feat(gitops): show live git validity status in workflow overview [BE-12885] (#2447)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// ListRefsFunc lists all git refs for a repository.
|
||||
type ListRefsFunc func(ctx context.Context) ([]string, error)
|
||||
|
||||
// ListFilesFunc lists files in a repository branch filtered by extension.
|
||||
type ListFilesFunc func(ctx context.Context, exts []string) ([]string, error)
|
||||
|
||||
// ComputeGitPhases checks source (ref reachability) and artifact (config file presence).
|
||||
// If source fails, artifact is returned as unknown without making a network call.
|
||||
func ComputeGitPhases(ctx context.Context, referenceName, configFilePath string, listRefs ListRefsFunc, listFiles ListFilesFunc) (source, artifact WorkflowPhaseStatus) {
|
||||
source = computeSourcePhase(ctx, referenceName, listRefs)
|
||||
if source.Status == StatusError {
|
||||
return source, WorkflowPhaseStatus{Status: StatusUnknown}
|
||||
}
|
||||
return source, computeArtifactPhase(ctx, configFilePath, listFiles)
|
||||
}
|
||||
|
||||
func computeSourcePhase(ctx context.Context, referenceName string, listRefs ListRefsFunc) WorkflowPhaseStatus {
|
||||
refs, err := listRefs(ctx)
|
||||
if err != nil {
|
||||
return WorkflowPhaseStatus{Status: StatusError, Error: err.Error()}
|
||||
}
|
||||
if referenceName == "" {
|
||||
return WorkflowPhaseStatus{Status: StatusHealthy}
|
||||
}
|
||||
if !slices.Contains(refs, referenceName) {
|
||||
return WorkflowPhaseStatus{Status: StatusError, Error: fmt.Sprintf("ref %q not found", referenceName)}
|
||||
}
|
||||
return WorkflowPhaseStatus{Status: StatusHealthy}
|
||||
}
|
||||
|
||||
func computeArtifactPhase(ctx context.Context, configFilePath string, listFiles ListFilesFunc) WorkflowPhaseStatus {
|
||||
if configFilePath == "" {
|
||||
return WorkflowPhaseStatus{Status: StatusError, Error: "no config file path specified"}
|
||||
}
|
||||
ext := path.Ext(configFilePath)
|
||||
var exts []string
|
||||
if len(ext) > 0 {
|
||||
ext = ext[1:]
|
||||
exts = []string{ext}
|
||||
}
|
||||
|
||||
files, err := listFiles(ctx, exts)
|
||||
if err != nil {
|
||||
return WorkflowPhaseStatus{Status: StatusError, Error: err.Error()}
|
||||
}
|
||||
if !slices.Contains(files, configFilePath) {
|
||||
return WorkflowPhaseStatus{Status: StatusError, Error: fmt.Sprintf("file %q not found", configFilePath)}
|
||||
}
|
||||
return WorkflowPhaseStatus{Status: StatusHealthy}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestComputeGitPhases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
okRefs := func(_ context.Context) ([]string, error) {
|
||||
return []string{"refs/heads/main"}, nil
|
||||
}
|
||||
okFiles := func(_ context.Context, _ []string) ([]string, error) {
|
||||
return []string{"docker-compose.yml"}, nil
|
||||
}
|
||||
errRefs := func(_ context.Context) ([]string, error) {
|
||||
return nil, errors.New("connection refused")
|
||||
}
|
||||
errFiles := func(_ context.Context, _ []string) ([]string, error) {
|
||||
return nil, errors.New("connection refused")
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
referenceName string
|
||||
configFilePath string
|
||||
listRefs ListRefsFunc
|
||||
listFiles ListFilesFunc
|
||||
expectedSource Status
|
||||
expectedArtifact Status
|
||||
}{
|
||||
{
|
||||
name: "listRefs errors → source error, artifact unknown",
|
||||
referenceName: "refs/heads/main",
|
||||
configFilePath: "docker-compose.yml",
|
||||
listRefs: errRefs,
|
||||
listFiles: okFiles,
|
||||
expectedSource: StatusError,
|
||||
expectedArtifact: StatusUnknown,
|
||||
},
|
||||
{
|
||||
name: "ref not in list → source error, artifact unknown",
|
||||
referenceName: "refs/heads/missing",
|
||||
configFilePath: "docker-compose.yml",
|
||||
listRefs: func(_ context.Context) ([]string, error) {
|
||||
return []string{"refs/heads/main"}, nil
|
||||
},
|
||||
listFiles: okFiles,
|
||||
expectedSource: StatusError,
|
||||
expectedArtifact: StatusUnknown,
|
||||
},
|
||||
{
|
||||
name: "empty configFilePath → artifact error",
|
||||
referenceName: "refs/heads/main",
|
||||
configFilePath: "",
|
||||
listRefs: okRefs,
|
||||
listFiles: okFiles,
|
||||
expectedSource: StatusHealthy,
|
||||
expectedArtifact: StatusError,
|
||||
},
|
||||
{
|
||||
name: "listFiles errors → artifact error",
|
||||
referenceName: "refs/heads/main",
|
||||
configFilePath: "docker-compose.yml",
|
||||
listRefs: okRefs,
|
||||
listFiles: errFiles,
|
||||
expectedSource: StatusHealthy,
|
||||
expectedArtifact: StatusError,
|
||||
},
|
||||
{
|
||||
name: "file not in list → artifact error",
|
||||
referenceName: "refs/heads/main",
|
||||
configFilePath: "docker-compose.yml",
|
||||
listRefs: okRefs,
|
||||
listFiles: func(_ context.Context, _ []string) ([]string, error) {
|
||||
return []string{"other.yml"}, nil
|
||||
},
|
||||
expectedSource: StatusHealthy,
|
||||
expectedArtifact: StatusError,
|
||||
},
|
||||
{
|
||||
name: "both healthy",
|
||||
referenceName: "refs/heads/main",
|
||||
configFilePath: "docker-compose.yml",
|
||||
listRefs: okRefs,
|
||||
listFiles: okFiles,
|
||||
expectedSource: StatusHealthy,
|
||||
expectedArtifact: StatusHealthy,
|
||||
},
|
||||
{
|
||||
name: "empty referenceName → source healthy (default HEAD)",
|
||||
referenceName: "",
|
||||
configFilePath: "docker-compose.yml",
|
||||
listRefs: okRefs,
|
||||
listFiles: okFiles,
|
||||
expectedSource: StatusHealthy,
|
||||
expectedArtifact: StatusHealthy,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
source, artifact := ComputeGitPhases(t.Context(), tc.referenceName, tc.configFilePath, tc.listRefs, tc.listFiles)
|
||||
assert.Equal(t, tc.expectedSource, source.Status)
|
||||
assert.Equal(t, tc.expectedArtifact, artifact.Status)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeArtifactPhase_ExtensionFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
configPath string
|
||||
wantExts []string
|
||||
}{
|
||||
{"docker-compose.yml", []string{"yml"}},
|
||||
{"stack.yaml", []string{"yaml"}},
|
||||
{"subdir/compose.yml", []string{"yml"}},
|
||||
{"Makefile", nil},
|
||||
{"archive.tar.gz", []string{"gz"}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.configPath, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var capturedExts []string
|
||||
ComputeGitPhases(
|
||||
t.Context(),
|
||||
"",
|
||||
tc.configPath,
|
||||
func(_ context.Context) ([]string, error) { return nil, nil },
|
||||
func(_ context.Context, exts []string) ([]string, error) {
|
||||
capturedExts = exts
|
||||
return []string{tc.configPath}, nil
|
||||
},
|
||||
)
|
||||
assert.Equal(t, tc.wantExts, capturedExts)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeGitPhases_ArtifactNotCalledOnSourceError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
listFilesCalled := false
|
||||
listRefs := func(_ context.Context) ([]string, error) {
|
||||
return nil, errors.New("repo unreachable")
|
||||
}
|
||||
listFiles := func(_ context.Context, _ []string) ([]string, error) {
|
||||
listFilesCalled = true
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ComputeGitPhases(t.Context(), "refs/heads/main", "docker-compose.yml", listRefs, listFiles)
|
||||
|
||||
assert.False(t, listFilesCalled, "listFiles must not be called when source fails")
|
||||
}
|
||||
@@ -7,16 +7,19 @@ import (
|
||||
|
||||
// MapStackToWorkflow converts a stack to a Workflow. gitConfig is passed separately
|
||||
// because EE embeds a different GitConfig type that shadows the CE field.
|
||||
func MapStackToWorkflow(s portainer.Stack, gitConfig *gittypes.RepoConfig) Workflow {
|
||||
status, msg := deriveStackStatus(s)
|
||||
// source and artifact are the pre-computed git phase statuses from the caller.
|
||||
func MapStackToWorkflow(s portainer.Stack, gitConfig *gittypes.RepoConfig, source, artifact WorkflowPhaseStatus) Workflow {
|
||||
return Workflow{
|
||||
ID: int(s.ID),
|
||||
Name: s.Name,
|
||||
Type: TypeStack,
|
||||
Platform: platformFromStackType(s.Type),
|
||||
Status: status,
|
||||
StatusMessage: msg,
|
||||
GitConfig: gitConfig,
|
||||
ID: int(s.ID),
|
||||
Name: s.Name,
|
||||
Type: TypeStack,
|
||||
Platform: platformFromStackType(s.Type),
|
||||
Status: WorkflowStatusObject{
|
||||
Source: source,
|
||||
Artifact: artifact,
|
||||
Target: deriveStackTargetState(s),
|
||||
},
|
||||
GitConfig: gitConfig,
|
||||
Target: Target{
|
||||
EndpointID: s.EndpointID,
|
||||
Namespace: s.Namespace,
|
||||
@@ -28,20 +31,23 @@ func MapStackToWorkflow(s portainer.Stack, gitConfig *gittypes.RepoConfig) Workf
|
||||
|
||||
// MapEdgeStackToWorkflow converts an edge stack to a Workflow. gitConfig is passed separately
|
||||
// because EE embeds a different GitConfig type that shadows the CE field.
|
||||
func MapEdgeStackToWorkflow(es portainer.EdgeStack, gitConfig *gittypes.RepoConfig, statuses []portainer.EdgeStackStatusForEnv, groupEndpoints map[portainer.EdgeGroupID][]portainer.EndpointID) Workflow {
|
||||
status, msg := deriveEdgeStackStatus(statuses)
|
||||
// source and artifact are the pre-computed git phase statuses from the caller.
|
||||
func MapEdgeStackToWorkflow(es portainer.EdgeStack, gitConfig *gittypes.RepoConfig, statuses []portainer.EdgeStackStatusForEnv, groupEndpoints map[portainer.EdgeGroupID][]portainer.EndpointID, source, artifact WorkflowPhaseStatus) Workflow {
|
||||
platform := DeploymentPlatformDockerStandalone
|
||||
if es.DeploymentType == portainer.EdgeStackDeploymentKubernetes {
|
||||
platform = DeploymentPlatformKubernetes
|
||||
}
|
||||
return Workflow{
|
||||
ID: int(es.ID),
|
||||
Name: es.Name,
|
||||
Type: TypeEdgeStack,
|
||||
Platform: platform,
|
||||
Status: status,
|
||||
StatusMessage: msg,
|
||||
GitConfig: gitConfig,
|
||||
ID: int(es.ID),
|
||||
Name: es.Name,
|
||||
Type: TypeEdgeStack,
|
||||
Platform: platform,
|
||||
Status: WorkflowStatusObject{
|
||||
Source: source,
|
||||
Artifact: artifact,
|
||||
Target: deriveEdgeStackTargetState(statuses),
|
||||
},
|
||||
GitConfig: gitConfig,
|
||||
Target: Target{
|
||||
EdgeGroupIDs: es.EdgeGroups,
|
||||
GroupStatus: edgeStackTargetStatuses(es.EdgeGroups, statuses, groupEndpoints),
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestStackLastSyncDate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("no deployment status", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equal(t, int64(0), stackLastSyncDate(portainer.Stack{}))
|
||||
})
|
||||
|
||||
t.Run("no active entry", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := portainer.Stack{DeploymentStatus: []portainer.StackDeploymentStatus{
|
||||
{Status: portainer.StackStatusDeploying, Time: 100},
|
||||
}}
|
||||
assert.Equal(t, int64(0), stackLastSyncDate(s))
|
||||
})
|
||||
|
||||
t.Run("last entry is active", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := portainer.Stack{DeploymentStatus: []portainer.StackDeploymentStatus{
|
||||
{Status: portainer.StackStatusDeploying, Time: 50},
|
||||
{Status: portainer.StackStatusActive, Time: 100},
|
||||
}}
|
||||
assert.Equal(t, int64(100), stackLastSyncDate(s))
|
||||
})
|
||||
|
||||
t.Run("active followed by non-active returns the active time", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := portainer.Stack{DeploymentStatus: []portainer.StackDeploymentStatus{
|
||||
{Status: portainer.StackStatusActive, Time: 100},
|
||||
{Status: portainer.StackStatusDeploying, Time: 200},
|
||||
}}
|
||||
assert.Equal(t, int64(100), stackLastSyncDate(s))
|
||||
})
|
||||
}
|
||||
|
||||
func TestEdgeStackLastSyncDate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("empty statuses", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equal(t, int64(0), edgeStackLastSyncDate(nil))
|
||||
})
|
||||
|
||||
t.Run("no healthy status for endpoint", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
statuses := []portainer.EdgeStackStatusForEnv{
|
||||
{EndpointID: 1, Status: []portainer.EdgeStackDeploymentStatus{
|
||||
{Type: portainer.EdgeStackStatusDeploying, Time: 100},
|
||||
}},
|
||||
}
|
||||
assert.Equal(t, int64(0), edgeStackLastSyncDate(statuses))
|
||||
})
|
||||
|
||||
t.Run("single endpoint with healthy status", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
statuses := []portainer.EdgeStackStatusForEnv{
|
||||
{EndpointID: 1, Status: []portainer.EdgeStackDeploymentStatus{
|
||||
{Type: portainer.EdgeStackStatusRunning, Time: 200},
|
||||
}},
|
||||
}
|
||||
assert.Equal(t, int64(200), edgeStackLastSyncDate(statuses))
|
||||
})
|
||||
|
||||
t.Run("returns minimum healthy time across endpoints", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
statuses := []portainer.EdgeStackStatusForEnv{
|
||||
{EndpointID: 1, Status: []portainer.EdgeStackDeploymentStatus{
|
||||
{Type: portainer.EdgeStackStatusRunning, Time: 300},
|
||||
}},
|
||||
{EndpointID: 2, Status: []portainer.EdgeStackDeploymentStatus{
|
||||
{Type: portainer.EdgeStackStatusRunning, Time: 100},
|
||||
}},
|
||||
}
|
||||
assert.Equal(t, int64(100), edgeStackLastSyncDate(statuses))
|
||||
})
|
||||
|
||||
t.Run("one endpoint not yet synced returns 0", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
statuses := []portainer.EdgeStackStatusForEnv{
|
||||
{EndpointID: 1, Status: []portainer.EdgeStackDeploymentStatus{
|
||||
{Type: portainer.EdgeStackStatusRunning, Time: 200},
|
||||
}},
|
||||
{EndpointID: 2, Status: []portainer.EdgeStackDeploymentStatus{
|
||||
{Type: portainer.EdgeStackStatusDeploying, Time: 100},
|
||||
}},
|
||||
}
|
||||
assert.Equal(t, int64(0), edgeStackLastSyncDate(statuses))
|
||||
})
|
||||
}
|
||||
|
||||
func TestEdgeStackTargetStatuses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ep := func(id portainer.EndpointID, typ portainer.EdgeStackStatusType) portainer.EdgeStackStatusForEnv {
|
||||
return portainer.EdgeStackStatusForEnv{
|
||||
EndpointID: id,
|
||||
Status: []portainer.EdgeStackDeploymentStatus{{Type: typ}},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("group with no endpoints is unknown", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := edgeStackTargetStatuses(
|
||||
[]portainer.EdgeGroupID{1},
|
||||
nil,
|
||||
map[portainer.EdgeGroupID][]portainer.EndpointID{1: {}},
|
||||
)
|
||||
assert.Equal(t, StatusUnknown, result[portainer.EdgeGroupID(1)])
|
||||
})
|
||||
|
||||
t.Run("group inherits highest-priority endpoint status", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := edgeStackTargetStatuses(
|
||||
[]portainer.EdgeGroupID{1},
|
||||
[]portainer.EdgeStackStatusForEnv{
|
||||
ep(1, portainer.EdgeStackStatusRunning),
|
||||
ep(2, portainer.EdgeStackStatusDeploying),
|
||||
},
|
||||
map[portainer.EdgeGroupID][]portainer.EndpointID{1: {1, 2}},
|
||||
)
|
||||
assert.Equal(t, StatusSyncing, result[portainer.EdgeGroupID(1)])
|
||||
})
|
||||
|
||||
t.Run("multiple groups tracked separately", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := edgeStackTargetStatuses(
|
||||
[]portainer.EdgeGroupID{10, 20},
|
||||
[]portainer.EdgeStackStatusForEnv{
|
||||
ep(1, portainer.EdgeStackStatusRunning),
|
||||
ep(2, portainer.EdgeStackStatusError),
|
||||
},
|
||||
map[portainer.EdgeGroupID][]portainer.EndpointID{
|
||||
10: {1},
|
||||
20: {2},
|
||||
},
|
||||
)
|
||||
assert.Equal(t, StatusHealthy, result[portainer.EdgeGroupID(10)])
|
||||
assert.Equal(t, StatusError, result[portainer.EdgeGroupID(20)])
|
||||
})
|
||||
}
|
||||
@@ -2,37 +2,37 @@ package workflows
|
||||
|
||||
import portainer "github.com/portainer/portainer/api"
|
||||
|
||||
func deriveStackStatus(s portainer.Stack) (Status, string) {
|
||||
func deriveStackTargetState(s portainer.Stack) WorkflowPhaseStatus {
|
||||
if len(s.DeploymentStatus) == 0 {
|
||||
return StatusHealthy, ""
|
||||
return WorkflowPhaseStatus{Status: StatusHealthy}
|
||||
}
|
||||
last := s.DeploymentStatus[len(s.DeploymentStatus)-1]
|
||||
switch last.Status {
|
||||
case portainer.StackStatusActive:
|
||||
return StatusHealthy, ""
|
||||
return WorkflowPhaseStatus{Status: StatusHealthy}
|
||||
case portainer.StackStatusError:
|
||||
return StatusError, last.Message
|
||||
return WorkflowPhaseStatus{Status: StatusError, Error: last.Message}
|
||||
case portainer.StackStatusDeploying:
|
||||
return StatusSyncing, ""
|
||||
return WorkflowPhaseStatus{Status: StatusSyncing}
|
||||
case portainer.StackStatusInactive:
|
||||
return StatusPaused, ""
|
||||
return WorkflowPhaseStatus{Status: StatusPaused}
|
||||
default:
|
||||
return StatusUnknown, ""
|
||||
return WorkflowPhaseStatus{Status: StatusUnknown}
|
||||
}
|
||||
}
|
||||
|
||||
func deriveEdgeStackStatus(statuses []portainer.EdgeStackStatusForEnv) (Status, string) {
|
||||
func deriveEdgeStackTargetState(statuses []portainer.EdgeStackStatusForEnv) WorkflowPhaseStatus {
|
||||
result := StatusUnknown
|
||||
for _, epStatus := range statuses {
|
||||
ws, msg := endpointWorkflowStatus(epStatus)
|
||||
if ws == StatusError {
|
||||
return ws, msg
|
||||
return WorkflowPhaseStatus{Status: ws, Error: msg}
|
||||
}
|
||||
if statusPriority(ws) > statusPriority(result) {
|
||||
result = ws
|
||||
}
|
||||
}
|
||||
return result, ""
|
||||
return WorkflowPhaseStatus{Status: result}
|
||||
}
|
||||
|
||||
func endpointWorkflowStatus(epStatus portainer.EdgeStackStatusForEnv) (Status, string) {
|
||||
@@ -64,11 +64,23 @@ func endpointWorkflowStatus(epStatus portainer.EdgeStackStatusForEnv) (Status, s
|
||||
}
|
||||
}
|
||||
|
||||
// CountByStatus counts workflows per status and returns a StatusSummary.
|
||||
// EffectiveStatus returns the highest-priority status across all three phases of a workflow.
|
||||
func EffectiveStatus(w Workflow) Status {
|
||||
s := w.Status.Target.Status
|
||||
if statusPriority(w.Status.Source.Status) > statusPriority(s) {
|
||||
s = w.Status.Source.Status
|
||||
}
|
||||
if statusPriority(w.Status.Artifact.Status) > statusPriority(s) {
|
||||
s = w.Status.Artifact.Status
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// CountByStatus counts workflows per effective status and returns a StatusSummary.
|
||||
func CountByStatus(workflows []Workflow) StatusSummary {
|
||||
var s StatusSummary
|
||||
for _, w := range workflows {
|
||||
switch w.Status {
|
||||
switch EffectiveStatus(w) {
|
||||
case StatusHealthy:
|
||||
s.Healthy++
|
||||
case StatusSyncing:
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestEffectiveStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
makeWorkflow := func(source, artifact, target Status) Workflow {
|
||||
return Workflow{
|
||||
Status: WorkflowStatusObject{
|
||||
Source: WorkflowPhaseStatus{Status: source},
|
||||
Artifact: WorkflowPhaseStatus{Status: artifact},
|
||||
Target: WorkflowPhaseStatus{Status: target},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
w Workflow
|
||||
want Status
|
||||
}{
|
||||
{"all healthy", makeWorkflow(StatusHealthy, StatusHealthy, StatusHealthy), StatusHealthy},
|
||||
{"all unknown", makeWorkflow(StatusUnknown, StatusUnknown, StatusUnknown), StatusUnknown},
|
||||
{"source error wins over syncing target", makeWorkflow(StatusError, StatusSyncing, StatusHealthy), StatusError},
|
||||
{"artifact error wins over syncing target", makeWorkflow(StatusHealthy, StatusError, StatusSyncing), StatusError},
|
||||
{"target error wins over healthy phases", makeWorkflow(StatusHealthy, StatusHealthy, StatusError), StatusError},
|
||||
{"syncing beats paused and healthy", makeWorkflow(StatusPaused, StatusSyncing, StatusHealthy), StatusSyncing},
|
||||
{"paused beats healthy", makeWorkflow(StatusHealthy, StatusPaused, StatusHealthy), StatusPaused},
|
||||
{"healthy beats unknown", makeWorkflow(StatusUnknown, StatusHealthy, StatusUnknown), StatusHealthy},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equal(t, tc.want, EffectiveStatus(tc.w))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountByStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
makeW := func(s Status) Workflow {
|
||||
return Workflow{
|
||||
Status: WorkflowStatusObject{
|
||||
Source: WorkflowPhaseStatus{Status: s},
|
||||
Artifact: WorkflowPhaseStatus{Status: s},
|
||||
Target: WorkflowPhaseStatus{Status: s},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("empty list", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equal(t, StatusSummary{}, CountByStatus(nil))
|
||||
})
|
||||
|
||||
t.Run("single healthy", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equal(t, StatusSummary{Healthy: 1}, CountByStatus([]Workflow{makeW(StatusHealthy)}))
|
||||
})
|
||||
|
||||
t.Run("mixed statuses", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
workflows := []Workflow{
|
||||
makeW(StatusHealthy),
|
||||
makeW(StatusError),
|
||||
makeW(StatusSyncing),
|
||||
makeW(StatusPaused),
|
||||
makeW(StatusUnknown),
|
||||
makeW(StatusError),
|
||||
}
|
||||
assert.Equal(t, StatusSummary{Healthy: 1, Error: 2, Syncing: 1, Paused: 1, Unknown: 1}, CountByStatus(workflows))
|
||||
})
|
||||
|
||||
t.Run("error phase overrides healthy target", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
w := Workflow{
|
||||
Status: WorkflowStatusObject{
|
||||
Source: WorkflowPhaseStatus{Status: StatusError},
|
||||
Artifact: WorkflowPhaseStatus{Status: StatusUnknown},
|
||||
Target: WorkflowPhaseStatus{Status: StatusHealthy},
|
||||
},
|
||||
}
|
||||
s := CountByStatus([]Workflow{w})
|
||||
assert.Equal(t, 1, s.Error)
|
||||
assert.Equal(t, 0, s.Healthy)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeriveEdgeStackTargetState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ep := func(id portainer.EndpointID, typ portainer.EdgeStackStatusType) portainer.EdgeStackStatusForEnv {
|
||||
return portainer.EdgeStackStatusForEnv{
|
||||
EndpointID: id,
|
||||
Status: []portainer.EdgeStackDeploymentStatus{{Type: typ}},
|
||||
}
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
statuses []portainer.EdgeStackStatusForEnv
|
||||
want Status
|
||||
}{
|
||||
{"empty", nil, StatusUnknown},
|
||||
{"all per-env status slices empty", []portainer.EdgeStackStatusForEnv{{EndpointID: 1}}, StatusUnknown},
|
||||
{"running → healthy", []portainer.EdgeStackStatusForEnv{ep(1, portainer.EdgeStackStatusRunning)}, StatusHealthy},
|
||||
{"deploying → syncing", []portainer.EdgeStackStatusForEnv{ep(1, portainer.EdgeStackStatusDeploying)}, StatusSyncing},
|
||||
{"paused deploying → paused", []portainer.EdgeStackStatusForEnv{ep(1, portainer.EdgeStackStatusPausedDeploying)}, StatusPaused},
|
||||
{"error short-circuits", []portainer.EdgeStackStatusForEnv{ep(1, portainer.EdgeStackStatusError)}, StatusError},
|
||||
{
|
||||
"error + running → error (short-circuit, order matters)",
|
||||
[]portainer.EdgeStackStatusForEnv{
|
||||
ep(1, portainer.EdgeStackStatusError),
|
||||
ep(2, portainer.EdgeStackStatusRunning),
|
||||
},
|
||||
StatusError,
|
||||
},
|
||||
{
|
||||
"syncing beats paused",
|
||||
[]portainer.EdgeStackStatusForEnv{
|
||||
ep(1, portainer.EdgeStackStatusPausedDeploying),
|
||||
ep(2, portainer.EdgeStackStatusDeploying),
|
||||
},
|
||||
StatusSyncing,
|
||||
},
|
||||
{
|
||||
"healthy does not downgrade syncing",
|
||||
[]portainer.EdgeStackStatusForEnv{
|
||||
ep(1, portainer.EdgeStackStatusDeploying),
|
||||
ep(2, portainer.EdgeStackStatusRunning),
|
||||
},
|
||||
StatusSyncing,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := deriveEdgeStackTargetState(tc.statuses)
|
||||
assert.Equal(t, tc.want, result.Status)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -63,17 +63,30 @@ type Target struct {
|
||||
GroupStatus map[portainer.EdgeGroupID]Status `json:"groupStatus,omitempty"`
|
||||
}
|
||||
|
||||
// WorkflowPhaseStatus represents the status of one phase (source, artifact, or target) of a workflow.
|
||||
// All three phases share the Status type; source and artifact only ever emit healthy, error, or unknown.
|
||||
type WorkflowPhaseStatus struct {
|
||||
Status Status `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// WorkflowStatusObject is the structured status reported for a workflow.
|
||||
type WorkflowStatusObject struct {
|
||||
Source WorkflowPhaseStatus `json:"source"`
|
||||
Artifact WorkflowPhaseStatus `json:"artifact"`
|
||||
Target WorkflowPhaseStatus `json:"target"`
|
||||
}
|
||||
|
||||
type Workflow struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type Type `json:"type"`
|
||||
Platform DeploymentPlatform `json:"platform"`
|
||||
Status Status `json:"status"`
|
||||
StatusMessage string `json:"statusMessage,omitempty"`
|
||||
GitConfig *gittypes.RepoConfig `json:"gitConfig,omitempty"`
|
||||
Target Target `json:"target"`
|
||||
CreationDate int64 `json:"creationDate"`
|
||||
LastSyncDate int64 `json:"lastSyncDate"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type Type `json:"type"`
|
||||
Platform DeploymentPlatform `json:"platform"`
|
||||
Status WorkflowStatusObject `json:"status"`
|
||||
GitConfig *gittypes.RepoConfig `json:"gitConfig,omitempty"`
|
||||
Target Target `json:"target"`
|
||||
CreationDate int64 `json:"creationDate"`
|
||||
LastSyncDate int64 `json:"lastSyncDate"`
|
||||
}
|
||||
|
||||
type StatusSummary struct {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, valid := range []string{"healthy", "error", "syncing", "paused", "unknown"} {
|
||||
t.Run(valid, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s, err := ParseStatus(valid)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Status(valid), s)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("invalid returns error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := ParseStatus("garbage")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, valid := range []string{"stack", "edgeStack"} {
|
||||
t.Run(valid, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
tp, err := ParseType(valid)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Type(valid), tp)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("invalid returns error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := ParseType("garbage")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestParsePlatform(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, valid := range []string{"dockerStandalone", "dockerSwarm", "kubernetes"} {
|
||||
t.Run(valid, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
p, err := ParsePlatform(valid)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, DeploymentPlatform(valid), p)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("invalid returns error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := ParsePlatform("garbage")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user