) {
+ const value = getValue();
+ if (!value || value === '0') {
+ return (
+
+
+
+ );
}
- if (!labels.length) {
- labels.push('Pending');
+ let statusIcon = ;
+ if (
+ (row.original.TargetCommitHash &&
+ row.original.TargetCommitHash.slice(0, 7) !== value) ||
+ (!row.original.TargetCommitHash && row.original.TargetFileVersion !== value)
+ ) {
+ statusIcon = ;
+ }
+
+ return (
+ <>
+ {row.original.TargetCommitHash ? (
+
+ ) : (
+
+ {statusIcon}
+ {value}
+
+ )}
+ >
+ );
+}
+
+function endpointDeployedVersionLabel(status: EdgeStackStatus) {
+ if (status.DeploymentInfo?.ConfigHash) {
+ return status.DeploymentInfo?.ConfigHash.slice(0, 7).toString();
}
+ return status.DeploymentInfo?.FileVersion.toString() || '';
+}
+
+function Status({ value }: { value: StatusType }) {
+ const color = getStateColor(value);
+
+ return (
+
+
- return labels.join(', ');
+ {_.startCase(StatusType[value])}
+
+ );
+}
+
+function getStateColor(type: StatusType): 'orange' | 'green' | 'red' {
+ switch (type) {
+ case StatusType.Acknowledged:
+ case StatusType.ImagesPulled:
+ case StatusType.DeploymentReceived:
+ case StatusType.Running:
+ case StatusType.RemoteUpdateSuccess:
+ case StatusType.Removed:
+ return 'green';
+ case StatusType.Error:
+ return 'red';
+ case StatusType.Pending:
+ case StatusType.Deploying:
+ case StatusType.Removing:
+ default:
+ return 'orange';
+ }
}
diff --git a/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/types.ts b/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/types.ts
index 65597781a..b56a2b116 100644
--- a/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/types.ts
+++ b/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/types.ts
@@ -4,4 +4,7 @@ import { EdgeStackStatus } from '../../types';
export type EdgeStackEnvironment = Environment & {
StackStatus: EdgeStackStatus;
+ TargetFileVersion: string;
+ GitConfigURL: string;
+ TargetCommitHash: string;
};
diff --git a/app/react/edge/edge-stacks/ListView/EdgeStackStatus.tsx b/app/react/edge/edge-stacks/ListView/EdgeStackStatus.tsx
new file mode 100644
index 000000000..803584405
--- /dev/null
+++ b/app/react/edge/edge-stacks/ListView/EdgeStackStatus.tsx
@@ -0,0 +1,87 @@
+import _ from 'lodash';
+import {
+ AlertTriangle,
+ CheckCircle,
+ type Icon as IconType,
+ Loader2,
+ XCircle,
+} from 'lucide-react';
+
+import { Icon, IconMode } from '@@/Icon';
+
+import { DeploymentStatus, EdgeStack, StatusType } from '../types';
+
+export function EdgeStackStatus({ edgeStack }: { edgeStack: EdgeStack }) {
+ const status = Object.values(edgeStack.Status);
+ const lastStatus = _.compact(status.map((s) => _.last(s.Status)));
+
+ const { icon, label, mode, spin } = getStatus(
+ edgeStack.NumDeployments,
+ lastStatus
+ );
+
+ return (
+
+ {icon && }
+ {label}
+
+ );
+}
+
+function getStatus(
+ numDeployments: number,
+ envStatus: Array
+): {
+ label: string;
+ icon?: IconType;
+ spin?: boolean;
+ mode?: IconMode;
+} {
+ if (envStatus.length < numDeployments) {
+ return {
+ label: 'Deploying',
+ icon: Loader2,
+ spin: true,
+ mode: 'primary',
+ };
+ }
+
+ const allFailed = envStatus.every((s) => s.Type === StatusType.Error);
+
+ if (allFailed) {
+ return {
+ label: 'Failed',
+ icon: XCircle,
+ mode: 'danger',
+ };
+ }
+
+ const allRunning = envStatus.every((s) => s.Type === StatusType.Running);
+
+ if (allRunning) {
+ return {
+ label: 'Running',
+ icon: CheckCircle,
+ mode: 'success',
+ };
+ }
+
+ const hasDeploying = envStatus.some((s) => s.Type === StatusType.Deploying);
+ const hasRunning = envStatus.some((s) => s.Type === StatusType.Running);
+ const hasFailed = envStatus.some((s) => s.Type === StatusType.Error);
+
+ if (hasRunning && hasFailed && !hasDeploying) {
+ return {
+ label: 'Partially Running',
+ icon: AlertTriangle,
+ mode: 'warning',
+ };
+ }
+
+ return {
+ label: 'Deploying',
+ icon: Loader2,
+ spin: true,
+ mode: 'primary',
+ };
+}
diff --git a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/DeploymentCounter.tsx b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/DeploymentCounter.tsx
index 1618fd673..ec25cb777 100644
--- a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/DeploymentCounter.tsx
+++ b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/DeploymentCounter.tsx
@@ -38,10 +38,10 @@ export function DeploymentCounter({
return (
diff --git a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/EdgeStacksDatatable.tsx b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/EdgeStacksDatatable.tsx
index 1bca53d4f..2e908a45f 100644
--- a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/EdgeStacksDatatable.tsx
+++ b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/EdgeStacksDatatable.tsx
@@ -4,7 +4,7 @@ import { Datatable } from '@@/datatables';
import { useTableState } from '@@/datatables/useTableState';
import { useEdgeStacks } from '../../queries/useEdgeStacks';
-import { EdgeStack } from '../../types';
+import { EdgeStack, StatusType } from '../../types';
import { createStore } from './store';
import { columns } from './columns';
@@ -51,11 +51,16 @@ export function EdgeStacksDatatable() {
function aggregateStackStatus(stackStatus: EdgeStack['Status']) {
const aggregateStatus = { ok: 0, error: 0, acknowledged: 0, imagesPulled: 0 };
- return Object.values(stackStatus).reduce((acc, envStatus) => {
- acc.ok += Number(envStatus.Details.Ok);
- acc.error += Number(envStatus.Details.Error);
- acc.acknowledged += Number(envStatus.Details.Acknowledged);
- acc.imagesPulled += Number(envStatus.Details.ImagesPulled);
- return acc;
- }, aggregateStatus);
+ return Object.values(stackStatus).reduce(
+ (acc, envStatus) =>
+ envStatus.Status.reduce((acc, status) => {
+ const { Type } = status;
+ acc.ok += Number(Type === StatusType.Running);
+ acc.error += Number(Type === StatusType.Error);
+ acc.acknowledged += Number(Type === StatusType.Acknowledged);
+ acc.imagesPulled += Number(Type === StatusType.ImagesPulled);
+ return acc;
+ }, acc),
+ aggregateStatus
+ );
}
diff --git a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/EdgeStacksStatus.tsx b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/EdgeStacksStatus.tsx
new file mode 100644
index 000000000..99ab8ee23
--- /dev/null
+++ b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/EdgeStacksStatus.tsx
@@ -0,0 +1,87 @@
+import _ from 'lodash';
+import {
+ AlertTriangle,
+ CheckCircle,
+ type Icon as IconType,
+ Loader2,
+ XCircle,
+} from 'lucide-react';
+
+import { Icon, IconMode } from '@@/Icon';
+
+import { DeploymentStatus, EdgeStack, StatusType } from '../../types';
+
+export function EdgeStackStatus({ edgeStack }: { edgeStack: EdgeStack }) {
+ const status = Object.values(edgeStack.Status);
+ const lastStatus = _.compact(status.map((s) => _.last(s.Status)));
+
+ const { icon, label, mode, spin } = getStatus(
+ edgeStack.NumDeployments,
+ lastStatus
+ );
+
+ return (
+
+ {icon && }
+ {label}
+
+ );
+}
+
+function getStatus(
+ numDeployments: number,
+ envStatus: Array
+): {
+ label: string;
+ icon?: IconType;
+ spin?: boolean;
+ mode?: IconMode;
+} {
+ if (envStatus.length < numDeployments) {
+ return {
+ label: 'Deploying',
+ icon: Loader2,
+ spin: true,
+ mode: 'primary',
+ };
+ }
+
+ const allFailed = envStatus.every((s) => s.Type === StatusType.Error);
+
+ if (allFailed) {
+ return {
+ label: 'Failed',
+ icon: XCircle,
+ mode: 'danger',
+ };
+ }
+
+ const allRunning = envStatus.every((s) => s.Type === StatusType.Running);
+
+ if (allRunning) {
+ return {
+ label: 'Running',
+ icon: CheckCircle,
+ mode: 'success',
+ };
+ }
+
+ const hasDeploying = envStatus.some((s) => s.Type === StatusType.Deploying);
+ const hasRunning = envStatus.some((s) => s.Type === StatusType.Running);
+ const hasFailed = envStatus.some((s) => s.Type === StatusType.Error);
+
+ if (hasRunning && hasFailed && !hasDeploying) {
+ return {
+ label: 'Partially Running',
+ icon: AlertTriangle,
+ mode: 'warning',
+ };
+ }
+
+ return {
+ label: 'Deploying',
+ icon: Loader2,
+ spin: true,
+ mode: 'primary',
+ };
+}
diff --git a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/columns.tsx b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/columns.tsx
index 859c515f4..041076dbe 100644
--- a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/columns.tsx
+++ b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/columns.tsx
@@ -6,6 +6,9 @@ import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { buildNameColumn } from '@@/datatables/NameCell';
+import { StatusType } from '../../types';
+import { EdgeStackStatus } from '../EdgeStackStatus';
+
import { DecoratedEdgeStack } from './types';
import { DeploymentCounter, DeploymentCounterLink } from './DeploymentCounter';
@@ -25,7 +28,7 @@ export const columns = _.compact([
cell: ({ getValue, row }) => (
),
@@ -39,7 +42,7 @@ export const columns = _.compact([
cell: ({ getValue, row }) => (
),
@@ -54,7 +57,7 @@ export const columns = _.compact([
cell: ({ getValue, row }) => (
),
@@ -69,7 +72,7 @@ export const columns = _.compact([
cell: ({ getValue, row }) => (
),
@@ -79,6 +82,19 @@ export const columns = _.compact([
className: '[&>*]:justify-center',
},
}),
+ columnHelper.accessor('Status', {
+ header: 'Status',
+ cell: ({ row }) => (
+
+
+
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ meta: {
+ className: '[&>*]:justify-center',
+ },
+ }),
columnHelper.accessor('NumDeployments', {
header: 'Deployments',
cell: ({ getValue }) => (
diff --git a/app/react/edge/edge-stacks/queries/useEdgeStack.ts b/app/react/edge/edge-stacks/queries/useEdgeStack.ts
index 6aabfb26e..53ef6660c 100644
--- a/app/react/edge/edge-stacks/queries/useEdgeStack.ts
+++ b/app/react/edge/edge-stacks/queries/useEdgeStack.ts
@@ -8,10 +8,24 @@ import { EdgeStack } from '../types';
import { buildUrl } from './buildUrl';
import { queryKeys } from './query-keys';
-export function useEdgeStack(id?: EdgeStack['Id']) {
+export function useEdgeStack(
+ id?: EdgeStack['Id'],
+ {
+ refetchInterval,
+ }: {
+ /**
+ * If set to a number, the query will continuously refetch at this frequency in milliseconds. If set to a function, the function will be executed with the latest data and query to compute a frequency Defaults to false.
+ */
+ refetchInterval?:
+ | number
+ | false
+ | ((data?: Awaited>) => false | number);
+ } = {}
+) {
return useQuery(id ? queryKeys.item(id) : [], () => getEdgeStack(id), {
...withError('Failed loading Edge stack'),
enabled: !!id,
+ refetchInterval,
});
}
diff --git a/app/react/edge/edge-stacks/queries/useEdgeStacks.ts b/app/react/edge/edge-stacks/queries/useEdgeStacks.ts
index f420febd1..e03da2b15 100644
--- a/app/react/edge/edge-stacks/queries/useEdgeStacks.ts
+++ b/app/react/edge/edge-stacks/queries/useEdgeStacks.ts
@@ -6,6 +6,7 @@ import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EdgeStack } from '../types';
import { buildUrl } from './buildUrl';
+import { queryKeys } from './query-keys';
export function useEdgeStacks>({
select,
@@ -19,7 +20,7 @@ export function useEdgeStacks>({
select?: (stacks: EdgeStack[]) => T;
refetchInterval?: number | false | ((data?: T) => false | number);
} = {}) {
- return useQuery(['edge_stacks'], () => getEdgeStacks(), {
+ return useQuery(queryKeys.base(), () => getEdgeStacks(), {
...withError('Failed loading Edge stack'),
select,
refetchInterval,
diff --git a/app/react/edge/edge-stacks/types.ts b/app/react/edge/edge-stacks/types.ts
index 9ffe137e4..42319d341 100644
--- a/app/react/edge/edge-stacks/types.ts
+++ b/app/react/edge/edge-stacks/types.ts
@@ -9,22 +9,44 @@ import { EnvVar } from '@@/form-components/EnvironmentVariablesFieldset/types';
import { EdgeGroup } from '../edge-groups/types';
-interface EdgeStackStatusDetails {
- Pending: boolean;
- Ok: boolean;
- Error: boolean;
- Acknowledged: boolean;
- Remove: boolean;
- RemoteUpdateSuccess: boolean;
- ImagesPulled: boolean;
+export enum StatusType {
+ /** Pending represents a pending edge stack */
+ Pending,
+ /** DeploymentReceived represents an edge environment which received the edge stack deployment */
+ DeploymentReceived,
+ /** Error represents an edge environment which failed to deploy its edge stack */
+ Error,
+ /** Acknowledged represents an acknowledged edge stack */
+ Acknowledged,
+ /** Removed represents a removed edge stack */
+ Removed,
+ /** StatusRemoteUpdateSuccess represents a successfully updated edge stack */
+ RemoteUpdateSuccess,
+ /** ImagesPulled represents a successfully images-pulling */
+ ImagesPulled,
+ /** Running represents a running Edge stack */
+ Running,
+ /** Deploying represents an Edge stack which is being deployed */
+ Deploying,
+ /** Removing represents an Edge stack which is being removed */
+ Removing,
}
-export type StatusType = keyof EdgeStackStatusDetails;
+export interface DeploymentStatus {
+ Type: StatusType;
+ Error: string;
+ Time: number;
+}
+
+interface EdgeStackDeploymentInfo {
+ FileVersion: number;
+ ConfigHash: string;
+}
export interface EdgeStackStatus {
- Details: EdgeStackStatusDetails;
- Error: string;
+ Status: Array;
EndpointID: EnvironmentId;
+ DeploymentInfo?: EdgeStackDeploymentInfo;
}
export enum DeploymentType {
diff --git a/app/react/edge/edge-stacks/utils/uniqueStatus.ts b/app/react/edge/edge-stacks/utils/uniqueStatus.ts
new file mode 100644
index 000000000..b6d8fc2ae
--- /dev/null
+++ b/app/react/edge/edge-stacks/utils/uniqueStatus.ts
@@ -0,0 +1,16 @@
+import { DeploymentStatus } from '../types';
+
+/**
+ * returns the latest status object of each type
+ */
+export function uniqueStatus(statusArray: Array = []) {
+ // keep only the last status object of each type, assume that the last status is the most recent
+ return statusArray.reduce((acc, status) => {
+ const index = acc.findIndex((s) => s.Type === status.Type);
+ if (index === -1) {
+ return [...acc, status];
+ }
+
+ return [...acc.slice(0, index), ...acc.slice(index + 1), status];
+ }, [] as Array);
+}
diff --git a/pkg/libstack/compose/internal/composeplugin/composeplugin.go b/pkg/libstack/compose/internal/composeplugin/composeplugin.go
index 73d230f16..ca98426c0 100644
--- a/pkg/libstack/compose/internal/composeplugin/composeplugin.go
+++ b/pkg/libstack/compose/internal/composeplugin/composeplugin.go
@@ -3,6 +3,7 @@ package composeplugin
import (
"bytes"
"context"
+ "fmt"
"os"
"os/exec"
"strings"
@@ -160,7 +161,11 @@ func (wrapper *PluginWrapper) command(command composeCommand, options libstack.O
Err(err).
Msg("docker compose command failed")
- return nil, errors.New(errOutput)
+ if errOutput != "" {
+ return nil, errors.New(errOutput)
+ }
+
+ return nil, fmt.Errorf("docker compose command failed: %w", err)
}
return output, nil
diff --git a/pkg/libstack/compose/internal/composeplugin/composeplugin_test.go b/pkg/libstack/compose/internal/composeplugin/composeplugin_test.go
index b979b6cb6..450d1e81a 100644
--- a/pkg/libstack/compose/internal/composeplugin/composeplugin_test.go
+++ b/pkg/libstack/compose/internal/composeplugin/composeplugin_test.go
@@ -2,7 +2,6 @@ package composeplugin
import (
"context"
- "errors"
"fmt"
"log"
"os"
@@ -16,9 +15,9 @@ import (
)
func checkPrerequisites(t *testing.T) {
- if _, err := os.Stat("docker-compose"); errors.Is(err, os.ErrNotExist) {
- t.Fatal("docker-compose binary not found, please run download.sh and re-run this test suite")
- }
+ // if _, err := os.Stat("docker-compose"); errors.Is(err, os.ErrNotExist) {
+ // t.Fatal("docker-compose binary not found, please run download.sh and re-run this test suite")
+ // }
}
func setup(t *testing.T) libstack.Deployer {
@@ -118,7 +117,11 @@ func createFile(dir, fileName, content string) (string, error) {
return "", err
}
- f.WriteString(content)
+ _, err = f.WriteString(content)
+ if err != nil {
+ return "", err
+ }
+
f.Close()
return filePath, nil
diff --git a/pkg/libstack/compose/internal/composeplugin/status.go b/pkg/libstack/compose/internal/composeplugin/status.go
new file mode 100644
index 000000000..4b6d9299d
--- /dev/null
+++ b/pkg/libstack/compose/internal/composeplugin/status.go
@@ -0,0 +1,171 @@
+package composeplugin
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "github.com/portainer/portainer/pkg/libstack"
+ "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) WaitForStatus(ctx context.Context, name string, status libstack.Status) <-chan string {
+ errorMessageCh := make(chan string)
+
+ go func() {
+ for {
+ select {
+ case <-ctx.Done():
+ errorMessageCh <- fmt.Sprintf("failed to wait for status: %s", ctx.Err().Error())
+ default:
+ }
+
+ time.Sleep(1 * time.Second)
+
+ output, err := wrapper.command(newCommand([]string{"ps", "-a", "--format", "json"}, nil), libstack.Options{
+ ProjectName: name,
+ })
+ if len(output) == 0 {
+ log.Debug().
+ Str("project_name", name).
+ Msg("no output from docker compose ps")
+ continue
+ }
+
+ if err != nil {
+ log.Debug().
+ Str("project_name", name).
+ Err(err).
+ Msg("error from docker compose ps")
+ continue
+ }
+
+ var services []service
+ err = json.Unmarshal(output, &services)
+ if err != nil {
+ log.Debug().
+ Str("project_name", name).
+ Err(err).
+ Msg("failed to parse docker compose output")
+ continue
+ }
+
+ if len(services) == 0 && status == libstack.StatusRemoved {
+ errorMessageCh <- ""
+ return
+ }
+
+ aggregateStatus, errorMessage := aggregateStatuses(services)
+ if aggregateStatus == status {
+ errorMessageCh <- ""
+ return
+ }
+
+ if errorMessage != "" {
+ errorMessageCh <- errorMessage
+ return
+ }
+
+ log.Debug().
+ Str("project_name", name).
+ Str("status", string(aggregateStatus)).
+ Msg("waiting for status")
+
+ }
+ }()
+
+ return errorMessageCh
+}
diff --git a/pkg/libstack/compose/internal/composeplugin/status_integration_test.go b/pkg/libstack/compose/internal/composeplugin/status_integration_test.go
new file mode 100644
index 000000000..29cce2eec
--- /dev/null
+++ b/pkg/libstack/compose/internal/composeplugin/status_integration_test.go
@@ -0,0 +1,116 @@
+package composeplugin
+
+import (
+ "context"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/portainer/portainer/pkg/libstack"
+)
+
+/*
+
+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 ensureIntegrationTest(t *testing.T) {
+ if _, ok := os.LookupEnv("INTEGRATION_TEST"); !ok {
+ t.Skip("skip an integration test")
+ }
+}
+
+func TestComposeProjectStatus(t *testing.T) {
+ ensureIntegrationTest(t)
+
+ testCases := []struct {
+ TestName string
+ ComposeFile string
+ ExpectedStatus libstack.Status
+ ExpectedStatusMessage bool
+ }{
+
+ {
+ TestName: "running",
+ ComposeFile: "status_test_files/running.yml",
+ ExpectedStatus: libstack.StatusRunning,
+ },
+
+ {
+ TestName: "failed",
+ ComposeFile: "status_test_files/failed.yml",
+ ExpectedStatus: libstack.StatusError,
+ ExpectedStatusMessage: true,
+ },
+ }
+
+ w := setup(t)
+ ctx := context.Background()
+
+ for _, testCase := range testCases {
+ t.Run(testCase.TestName, 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 := waitForStatus(w, ctx, projectName, libstack.StatusRunning)
+ 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 = waitForStatus(w, ctx, projectName, libstack.StatusRemoved)
+ 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)
+ }
+ })
+ }
+}
+
+func waitForStatus(deployer libstack.Deployer, ctx context.Context, stackName string, requiredStatus libstack.Status) (libstack.Status, string, error) {
+ ctx, cancel := context.WithTimeout(ctx, 1*time.Minute)
+ defer cancel()
+
+ statusCh := deployer.WaitForStatus(ctx, stackName, requiredStatus)
+ result := <-statusCh
+ if result == "" {
+ return requiredStatus, "", nil
+ }
+
+ return libstack.StatusError, result, nil
+}
diff --git a/pkg/libstack/compose/internal/composeplugin/status_test_files/failed.yml b/pkg/libstack/compose/internal/composeplugin/status_test_files/failed.yml
new file mode 100644
index 000000000..a57699b35
--- /dev/null
+++ b/pkg/libstack/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/pkg/libstack/compose/internal/composeplugin/status_test_files/running.yml b/pkg/libstack/compose/internal/composeplugin/status_test_files/running.yml
new file mode 100644
index 000000000..1950aaa58
--- /dev/null
+++ b/pkg/libstack/compose/internal/composeplugin/status_test_files/running.yml
@@ -0,0 +1,4 @@
+version: '3'
+services:
+ web:
+ image: nginx:latest
diff --git a/pkg/libstack/libstack.go b/pkg/libstack/libstack.go
index d51dae8be..294eb1c94 100644
--- a/pkg/libstack/libstack.go
+++ b/pkg/libstack/libstack.go
@@ -13,8 +13,21 @@ 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
+ WaitForStatus(ctx context.Context, name string, status Status) <-chan string
}
+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