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

feat(compose): check stack status [EE-5554] #35

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions compose/internal/composeplugin/status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package composeplugin

import (
"context"
"encoding/json"
"fmt"

libstack "github.com/portainer/docker-compose-wrapper"
"github.com/rs/zerolog/log"
)

type publisher struct {
URL string
TargetPort int
PublishedPort int
Protocol string
}

type service struct {
ID string
Name string
Image string
Command string
Project string
Service string
Created int64
State string
Status string
Health string
ExitCode int
Publishers []publisher
}

// docker container state can be one of "created", "running", "paused", "restarting", "removing", "exited", or "dead"
func getServiceStatus(service service) (libstack.Status, string) {
log.Debug().
Str("service", service.Name).
Str("state", service.State).
Int("exitCode", service.ExitCode).
Msg("getServiceStatus")

switch service.State {
case "created", "restarting", "paused":
return libstack.StatusStarting, ""
case "running":
return libstack.StatusRunning, ""
case "removing":
return libstack.StatusRemoving, ""
case "exited", "dead":
if service.ExitCode != 0 {
return libstack.StatusError, fmt.Sprintf("service %s exited with code %d", service.Name, service.ExitCode)
}

return libstack.StatusRemoved, ""
default:
return libstack.StatusUnknown, ""
}
}

func aggregateStatuses(services []service) (libstack.Status, string) {
servicesCount := len(services)

if servicesCount == 0 {
log.Debug().
Msg("no services found")

return libstack.StatusRemoved, ""
}

statusCounts := make(map[libstack.Status]int)
errorMessage := ""
for _, service := range services {
status, serviceError := getServiceStatus(service)
if serviceError != "" {
errorMessage = serviceError
}
statusCounts[status]++
}

log.Debug().
Interface("statusCounts", statusCounts).
Str("errorMessage", errorMessage).
Msg("check_status")

switch {
case errorMessage != "":
return libstack.StatusError, errorMessage
case statusCounts[libstack.StatusStarting] > 0:
return libstack.StatusStarting, ""
case statusCounts[libstack.StatusRemoving] > 0:
return libstack.StatusRemoving, ""
case statusCounts[libstack.StatusRunning] == servicesCount:
return libstack.StatusRunning, ""
case statusCounts[libstack.StatusStopped] == servicesCount:
return libstack.StatusStopped, ""
case statusCounts[libstack.StatusRemoved] == servicesCount:
return libstack.StatusRemoved, ""
default:
return libstack.StatusUnknown, ""
}

}

func (wrapper *PluginWrapper) Status(ctx context.Context, projectName string) (libstack.Status, string, error) {
output, err := wrapper.command(newCommand([]string{"ps", "-a", "--format", "json"}, nil), libstack.Options{
ProjectName: projectName,
})
if len(output) == 0 || err != nil {
return "", "", err
}

var services []service
err = json.Unmarshal(output, &services)
if err != nil {
return "", "", fmt.Errorf("failed to parse docker compose output: %w", err)
}

aggregateStatus, statusMessage := aggregateStatuses(services)
return aggregateStatus, statusMessage, nil

}
107 changes: 107 additions & 0 deletions compose/internal/composeplugin/status_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package composeplugin

import (
"context"
"testing"
"time"

libstack "github.com/portainer/docker-compose-wrapper"
)

/*

1. starting = docker compose file that runs several services, one of them should be with status starting
2. running = docker compose file that runs successfully and returns status running
3. removing = run docker compose config, remove the stack, and return removing status
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"]
5. removed = remove a compose stack and return status removed

*/

func TestComposeProjectStatus(t *testing.T) {
testCases := []struct {
TestName string
ComposeFile string
ExpectedStatus libstack.Status
ExpectedStatusMessage bool
}{
// {
// TestName: "starting",
// ComposeFile: "status_test_files/starting.yml",
// ExpectedStatus: libstack.StatusStarting,
// },
{
TestName: "running",
ComposeFile: "status_test_files/running.yml",
ExpectedStatus: libstack.StatusRunning,
},
// {
// TestName: "removing",
// ComposeFile: "status_test_files/removing.yml",
// ExpectedStatus: libstack.StatusRemoving,
// },
{
TestName: "failed",
ComposeFile: "status_test_files/failed.yml",
ExpectedStatus: libstack.StatusError,
ExpectedStatusMessage: true,
},
// {
// TestName: "removed",
// ComposeFile: "status_test_files/removed.yml",
// ExpectedStatus: libstack.StatusRemoved,
// },
}

w := setup(t)
ctx := context.Background()

for _, testCase := range testCases {
t.Run(testCase.ComposeFile, func(t *testing.T) {
projectName := testCase.TestName
err := w.Deploy(ctx, []string{testCase.ComposeFile}, libstack.DeployOptions{
Options: libstack.Options{
ProjectName: projectName,
},
})
if err != nil {
t.Fatalf("[test: %s] Failed to deploy compose file: %v", testCase.TestName, err)
}

time.Sleep(5 * time.Second)

status, statusMessage, err := w.Status(ctx, projectName)
if err != nil {
t.Fatalf("[test: %s] Failed to get compose project status: %v", testCase.TestName, err)
}

if status != testCase.ExpectedStatus {
t.Fatalf("[test: %s] Expected status: %s, got: %s", testCase.TestName, testCase.ExpectedStatus, status)
}

if testCase.ExpectedStatusMessage && statusMessage == "" {
t.Fatalf("[test: %s] Expected status message but got empty", testCase.TestName)
}

err = w.Remove(ctx, projectName, nil, libstack.Options{})
if err != nil {
t.Fatalf("[test: %s] Failed to remove compose project: %v", testCase.TestName, err)
}

time.Sleep(20 * time.Second)

status, statusMessage, err = w.Status(ctx, projectName)
if err != nil {
t.Fatalf("[test: %s] Failed to get compose project status: %v", testCase.TestName, err)
}

if status != libstack.StatusRemoved {
t.Fatalf("[test: %s] Expected stack to be removed, got %s", testCase.TestName, status)
}

if statusMessage != "" {
t.Fatalf("[test: %s] Expected empty status message: %s, got: %s", "", testCase.TestName, statusMessage)
}
})
}
}
7 changes: 7 additions & 0 deletions compose/internal/composeplugin/status_test_files/failed.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
version: '3'
services:
web:
image: nginx:latest
failing-service:
image: busybox
command: ["false"]
4 changes: 4 additions & 0 deletions compose/internal/composeplugin/status_test_files/running.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
version: '3'
services:
web:
image: nginx:latest
7 changes: 7 additions & 0 deletions compose/internal/composeplugin/status_test_files/starting.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
version: '3'
services:
web:
image: nginx:latest
slow-service:
image: busybox
command: sh -c "sleep 100s"
14 changes: 14 additions & 0 deletions libstack.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,22 @@ type Deployer interface {
Remove(ctx context.Context, projectName string, filePaths []string, options Options) error
Pull(ctx context.Context, filePaths []string, options Options) error
Validate(ctx context.Context, filePaths []string, options Options) error
// Status returns the status of the stack
Status(ctx context.Context, projectName string) (Status, string, error)
}

type Status string

const (
StatusUnknown Status = "unknown"
StatusStarting Status = "starting"
StatusRunning Status = "running"
StatusStopped Status = "stopped"
StatusError Status = "error"
StatusRemoving Status = "removing"
StatusRemoved Status = "removed"
)

type Options struct {
WorkingDir string
Host string
Expand Down