diff --git a/compose/internal/composeplugin/status.go b/compose/internal/composeplugin/status.go new file mode 100644 index 0000000..5384596 --- /dev/null +++ b/compose/internal/composeplugin/status.go @@ -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 + +} diff --git a/compose/internal/composeplugin/status_test.go b/compose/internal/composeplugin/status_test.go new file mode 100644 index 0000000..9151347 --- /dev/null +++ b/compose/internal/composeplugin/status_test.go @@ -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) + } + }) + } +} diff --git a/compose/internal/composeplugin/status_test_files/failed.yml b/compose/internal/composeplugin/status_test_files/failed.yml new file mode 100644 index 0000000..a57699b --- /dev/null +++ b/compose/internal/composeplugin/status_test_files/failed.yml @@ -0,0 +1,7 @@ +version: '3' +services: + web: + image: nginx:latest + failing-service: + image: busybox + command: ["false"] \ No newline at end of file diff --git a/compose/internal/composeplugin/status_test_files/running.yml b/compose/internal/composeplugin/status_test_files/running.yml new file mode 100644 index 0000000..1950aaa --- /dev/null +++ b/compose/internal/composeplugin/status_test_files/running.yml @@ -0,0 +1,4 @@ +version: '3' +services: + web: + image: nginx:latest diff --git a/compose/internal/composeplugin/status_test_files/starting.yml b/compose/internal/composeplugin/status_test_files/starting.yml new file mode 100644 index 0000000..851013d --- /dev/null +++ b/compose/internal/composeplugin/status_test_files/starting.yml @@ -0,0 +1,7 @@ +version: '3' +services: + web: + image: nginx:latest + slow-service: + image: busybox + command: sh -c "sleep 100s" \ No newline at end of file diff --git a/libstack.go b/libstack.go index d51dae8..a144955 100644 --- a/libstack.go +++ b/libstack.go @@ -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