diff --git a/api/gitops/workflows/git_phases.go b/api/gitops/workflows/git_phases.go new file mode 100644 index 000000000..4bf5fa728 --- /dev/null +++ b/api/gitops/workflows/git_phases.go @@ -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} +} diff --git a/api/gitops/workflows/git_phases_test.go b/api/gitops/workflows/git_phases_test.go new file mode 100644 index 000000000..6db54dceb --- /dev/null +++ b/api/gitops/workflows/git_phases_test.go @@ -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") +} diff --git a/api/gitops/workflows/mapping.go b/api/gitops/workflows/mapping.go index ffde946a5..4ac28cf9f 100644 --- a/api/gitops/workflows/mapping.go +++ b/api/gitops/workflows/mapping.go @@ -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), diff --git a/api/gitops/workflows/mapping_test.go b/api/gitops/workflows/mapping_test.go new file mode 100644 index 000000000..ae11bc490 --- /dev/null +++ b/api/gitops/workflows/mapping_test.go @@ -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)]) + }) +} diff --git a/api/gitops/workflows/status.go b/api/gitops/workflows/status.go index c77343281..f70eaecbc 100644 --- a/api/gitops/workflows/status.go +++ b/api/gitops/workflows/status.go @@ -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: diff --git a/api/gitops/workflows/status_test.go b/api/gitops/workflows/status_test.go new file mode 100644 index 000000000..1a87d65c5 --- /dev/null +++ b/api/gitops/workflows/status_test.go @@ -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) + }) + } +} diff --git a/api/gitops/workflows/types.go b/api/gitops/workflows/types.go index d310619c6..16df512c7 100644 --- a/api/gitops/workflows/types.go +++ b/api/gitops/workflows/types.go @@ -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 { diff --git a/api/gitops/workflows/types_test.go b/api/gitops/workflows/types_test.go new file mode 100644 index 000000000..27fc3c321 --- /dev/null +++ b/api/gitops/workflows/types_test.go @@ -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) + }) +} diff --git a/api/http/handler/gitops/handler.go b/api/http/handler/gitops/handler.go index d3eccfb87..dbea2adb6 100644 --- a/api/http/handler/gitops/handler.go +++ b/api/http/handler/gitops/handler.go @@ -34,7 +34,7 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor authenticatedRouter.Handle("/gitops/repo/file/preview", httperror.LoggerHandler(h.gitOperationRepoFilePreview)).Methods(http.MethodPost) - workflowsHandler := workflows.NewHandler(dataStore) + workflowsHandler := workflows.NewHandler(dataStore, gitService) authenticatedRouter.PathPrefix("/gitops/workflows").Handler(workflowsHandler) return h diff --git a/api/http/handler/gitops/workflows/filter_test.go b/api/http/handler/gitops/workflows/filter_test.go index 7aaa13adf..c5ae0e733 100644 --- a/api/http/handler/gitops/workflows/filter_test.go +++ b/api/http/handler/gitops/workflows/filter_test.go @@ -33,7 +33,7 @@ func TestWorkflowsList_RBAC_NonAdminNoAccess(t *testing.T) { GitConfig: gitConfig("https://github.com/x/no-rc"), })) - h := NewHandler(store) + h := NewHandler(store, nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.StandardUserRole, "")) @@ -70,7 +70,7 @@ func TestWorkflowsList_RBAC_NonAdminWithAccess(t *testing.T) { }, })) - h := NewHandler(store) + h := NewHandler(store, nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.StandardUserRole, "")) diff --git a/api/http/handler/gitops/workflows/git_phases.go b/api/http/handler/gitops/workflows/git_phases.go new file mode 100644 index 000000000..59dd6757a --- /dev/null +++ b/api/http/handler/gitops/workflows/git_phases.go @@ -0,0 +1,32 @@ +package workflows + +import ( + "context" + + portainer "github.com/portainer/portainer/api" + gittypes "github.com/portainer/portainer/api/git/types" + wf "github.com/portainer/portainer/api/gitops/workflows" +) + +func computeGitPhases(ctx context.Context, gitSvc portainer.GitService, cfg *gittypes.RepoConfig) (source, artifact wf.WorkflowPhaseStatus) { + if gitSvc == nil || cfg == nil { + return wf.WorkflowPhaseStatus{Status: wf.StatusUnknown}, wf.WorkflowPhaseStatus{Status: wf.StatusUnknown} + } + + username, password := gitCredentials(cfg) + return wf.ComputeGitPhases(ctx, cfg.ReferenceName, cfg.ConfigFilePath, + func(ctx context.Context) ([]string, error) { + return gitSvc.ListRefs(ctx, cfg.URL, username, password, false, cfg.TLSSkipVerify) + }, + func(ctx context.Context, exts []string) ([]string, error) { + return gitSvc.ListFiles(ctx, cfg.URL, cfg.ReferenceName, username, password, false, false, exts, cfg.TLSSkipVerify) + }, + ) +} + +func gitCredentials(cfg *gittypes.RepoConfig) (username, password string) { + if cfg.Authentication != nil { + return cfg.Authentication.Username, cfg.Authentication.Password + } + return "", "" +} diff --git a/api/http/handler/gitops/workflows/handler.go b/api/http/handler/gitops/workflows/handler.go index 3433b656e..be0fde78e 100644 --- a/api/http/handler/gitops/workflows/handler.go +++ b/api/http/handler/gitops/workflows/handler.go @@ -5,6 +5,7 @@ import ( "time" gocache "github.com/patrickmn/go-cache" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" httperror "github.com/portainer/portainer/pkg/libhttp/error" @@ -18,15 +19,17 @@ const ( type Handler struct { *mux.Router - dataStore dataservices.DataStore - cache *gocache.Cache + dataStore dataservices.DataStore + gitService portainer.GitService + cache *gocache.Cache } -func NewHandler(dataStore dataservices.DataStore) *Handler { +func NewHandler(dataStore dataservices.DataStore, gitService portainer.GitService) *Handler { h := &Handler{ - Router: mux.NewRouter(), - dataStore: dataStore, - cache: gocache.New(cacheTTL, cacheCleanupInterval), + Router: mux.NewRouter(), + dataStore: dataStore, + gitService: gitService, + cache: gocache.New(cacheTTL, cacheCleanupInterval), } h.Handle("/gitops/workflows", httperror.LoggerHandler(h.list)).Methods(http.MethodGet) diff --git a/api/http/handler/gitops/workflows/list.go b/api/http/handler/gitops/workflows/list.go index fb4e8216e..e4fabcb25 100644 --- a/api/http/handler/gitops/workflows/list.go +++ b/api/http/handler/gitops/workflows/list.go @@ -2,6 +2,7 @@ package workflows import ( "cmp" + "context" "net/http" "slices" "strconv" @@ -55,7 +56,7 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) *httperror.Handle key := cacheKey(securityContext, endpointIDs) - items, err := h.getWorkflows(key, securityContext, endpointIDs) + items, err := h.getWorkflows(r.Context(), key, securityContext, endpointIDs) if err != nil { return httperror.InternalServerError("Unable to retrieve workflows", err) } @@ -65,7 +66,7 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) *httperror.Handle if err != nil { return httperror.BadRequest("Invalid status parameter", err) } - items = slicesx.FilterInPlace(items, func(i svc.Workflow) bool { return i.Status == s }) + items = slicesx.FilterInPlace(items, func(i svc.Workflow) bool { return svc.EffectiveStatus(i) == s }) } if workflowType, _ := request.RetrieveQueryParameter(r, "type", true); workflowType != "" { @@ -97,7 +98,9 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) *httperror.Handle SortBindings: []filters.SortBinding[svc.Workflow]{ {Key: "name", Fn: func(a, b svc.Workflow) int { return strings.Compare(a.Name, b.Name) }}, {Key: "type", Fn: func(a, b svc.Workflow) int { return strings.Compare(string(a.Type), string(b.Type)) }}, - {Key: "status", Fn: func(a, b svc.Workflow) int { return strings.Compare(string(a.Status), string(b.Status)) }}, + {Key: "status", Fn: func(a, b svc.Workflow) int { + return strings.Compare(string(svc.EffectiveStatus(a)), string(svc.EffectiveStatus(b))) + }}, {Key: "creationDate", Fn: func(a, b svc.Workflow) int { return cmp.Compare(a.CreationDate, b.CreationDate) }}, {Key: "lastSyncDate", Fn: func(a, b svc.Workflow) int { return cmp.Compare(a.LastSyncDate, b.LastSyncDate) }, NullsLast: func(i svc.Workflow) bool { return i.LastSyncDate == 0 }}, {Key: "platform", Fn: func(a, b svc.Workflow) int { return strings.Compare(string(a.Platform), string(b.Platform)) }}, @@ -121,12 +124,12 @@ func redactWorkflowCredentials(items []svc.Workflow) []svc.Workflow { return items } -func (h *Handler) getWorkflows(key string, sc *security.RestrictedRequestContext, endpointIDs []portainer.EndpointID) ([]svc.Workflow, error) { +func (h *Handler) getWorkflows(ctx context.Context, key string, sc *security.RestrictedRequestContext, endpointIDs []portainer.EndpointID) ([]svc.Workflow, error) { if cached, ok := h.cache.Get(key); ok { return slices.Clone(cached.([]svc.Workflow)), nil } - result, err := h.fetchWorkflows(sc, set.ToSet(endpointIDs)) + result, err := h.fetchWorkflows(ctx, sc, set.ToSet(endpointIDs)) if err != nil { return nil, err } @@ -134,8 +137,8 @@ func (h *Handler) getWorkflows(key string, sc *security.RestrictedRequestContext return slices.Clone(result), nil } -func (h *Handler) fetchWorkflows(sc *security.RestrictedRequestContext, endpointIDSet set.Set[portainer.EndpointID]) ([]svc.Workflow, error) { - var items []svc.Workflow +func (h *Handler) fetchWorkflows(ctx context.Context, sc *security.RestrictedRequestContext, endpointIDSet set.Set[portainer.EndpointID]) ([]svc.Workflow, error) { + var entries []portainer.Stack err := h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error { stacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool { return s.GitConfig != nil && (len(endpointIDSet) == 0 || endpointIDSet.Contains(s.EndpointID)) @@ -165,13 +168,22 @@ func (h *Handler) fetchWorkflows(sc *security.RestrictedRequestContext, endpoint if ep, ok := endpointMap[s.EndpointID]; ok && !endpointMatchesStackType(ep, s.Type) { continue } - items = append(items, svc.MapStackToWorkflow(s, s.GitConfig)) + entries = append(entries, s) } return nil }) + if err != nil { + return nil, err + } - return items, err + items := make([]svc.Workflow, 0, len(entries)) + for _, s := range entries { + source, artifact := computeGitPhases(ctx, h.gitService, s.GitConfig) + items = append(items, svc.MapStackToWorkflow(s, s.GitConfig, source, artifact)) + } + + return items, nil } func cacheKey(sc *security.RestrictedRequestContext, endpointIDs []portainer.EndpointID) string { diff --git a/api/http/handler/gitops/workflows/list_test.go b/api/http/handler/gitops/workflows/list_test.go index d6f5ec635..bb10ac335 100644 --- a/api/http/handler/gitops/workflows/list_test.go +++ b/api/http/handler/gitops/workflows/list_test.go @@ -2,6 +2,7 @@ package workflows import ( "fmt" + "net/http" "net/http/httptest" "testing" "testing/synctest" @@ -10,6 +11,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/datastore" + gittypes "github.com/portainer/portainer/api/git/types" ce "github.com/portainer/portainer/api/gitops/workflows" "github.com/stretchr/testify/assert" @@ -30,7 +32,7 @@ func TestWorkflowsList_GitConfigFilter(t *testing.T) { return nil })) - h := NewHandler(store) + h := NewHandler(store, nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "")) @@ -59,7 +61,7 @@ func TestWorkflowsList_EndpointIDsFilter(t *testing.T) { return nil })) - h := NewHandler(store) + h := NewHandler(store, nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "endpointIds[]=1&endpointIds[]=2")) @@ -87,7 +89,7 @@ func TestWorkflowsList_Pagination(t *testing.T) { return nil })) - h := NewHandler(store) + h := NewHandler(store, nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "start=0&limit=2")) @@ -112,7 +114,7 @@ func TestWorkflowsList_Search(t *testing.T) { return nil })) - h := NewHandler(store) + h := NewHandler(store, nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "search=alpha")) @@ -139,7 +141,7 @@ func TestWorkflowsList_SearchByURL(t *testing.T) { return nil })) - h := NewHandler(store) + h := NewHandler(store, nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "search=org1")) @@ -164,7 +166,7 @@ func TestWorkflowsList_Sort(t *testing.T) { return nil })) - h := NewHandler(store) + h := NewHandler(store, nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "sort=name&order=desc")) @@ -197,7 +199,7 @@ func TestWorkflowsList_Cache(t *testing.T) { // Create the handler outside the bubble so the go-cache cleanup goroutine // is not part of the bubble and does not block synctest.Test from returning. - h := NewHandler(store) + h := NewHandler(store, nil) synctest.Test(t, func(t *testing.T) { // First request at fake T=0: populates cache. @@ -246,7 +248,7 @@ func TestWorkflowsList_CacheImmutableAfterSort(t *testing.T) { })) } - h := NewHandler(store) + h := NewHandler(store, nil) // First request: no sort — cache miss, populates cache as [alpha, beta, gamma]. rr := httptest.NewRecorder() @@ -288,7 +290,7 @@ func TestWorkflowsList_CacheSeparateKeys(t *testing.T) { return nil })) - h := NewHandler(store) + h := NewHandler(store, nil) rr1 := httptest.NewRecorder() h.ServeHTTP(rr1, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "endpointIds[]=1")) @@ -302,3 +304,83 @@ func TestWorkflowsList_CacheSeparateKeys(t *testing.T) { require.Len(t, items2, 1) assert.Equal(t, "env2-stack", items2[0].Name) } + +func TestWorkflowsList_StatusFilter(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + require.NoError(t, tx.Stack().Create(&portainer.Stack{ + ID: 1, Name: "healthy-stack", + GitConfig: gitConfig("https://github.com/x/1"), + })) + require.NoError(t, tx.Stack().Create(&portainer.Stack{ + ID: 2, Name: "error-stack", + GitConfig: gitConfig("https://github.com/x/2"), + DeploymentStatus: []portainer.StackDeploymentStatus{{Status: portainer.StackStatusError}}, + })) + require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})) + return nil + })) + + h := NewHandler(store, nil) + + t.Run("status=healthy returns only healthy workflows", func(t *testing.T) { + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "status=healthy")) + items := decodeWorkflows(t, rr) + require.Len(t, items, 1) + assert.Equal(t, "healthy-stack", items[0].Name) + }) + + t.Run("status=error returns only error workflows", func(t *testing.T) { + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "status=error")) + items := decodeWorkflows(t, rr) + require.Len(t, items, 1) + assert.Equal(t, "error-stack", items[0].Name) + }) +} + +func TestWorkflowsList_InvalidFilterParams(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + require.NoError(t, store.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})) + h := NewHandler(store, nil) + + for _, query := range []string{"status=garbage", "type=garbage", "platform=garbage"} { + t.Run(query, func(t *testing.T) { + t.Parallel() + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, query)) + assert.Equal(t, http.StatusBadRequest, rr.Code) + }) + } +} + +func TestWorkflowsList_RedactsCredentials(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + cfg := gitConfig("https://github.com/x/secure") + cfg.Authentication = &gittypes.GitAuthentication{Username: "user", Password: "s3cr3t"} + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + require.NoError(t, tx.Stack().Create(&portainer.Stack{ + ID: 1, Name: "secure-stack", GitConfig: cfg, + })) + require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})) + return nil + })) + + h := NewHandler(store, nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "")) + + items := decodeWorkflows(t, rr) + require.Len(t, items, 1) + require.NotNil(t, items[0].GitConfig) + require.NotNil(t, items[0].GitConfig.Authentication) + assert.Equal(t, "user", items[0].GitConfig.Authentication.Username) + assert.Empty(t, items[0].GitConfig.Authentication.Password) +} diff --git a/api/http/handler/gitops/workflows/status_test.go b/api/http/handler/gitops/workflows/status_test.go index 2b3f0c7e4..11e049de7 100644 --- a/api/http/handler/gitops/workflows/status_test.go +++ b/api/http/handler/gitops/workflows/status_test.go @@ -72,13 +72,13 @@ func TestWorkflowsList_StackStatusDerivation(t *testing.T) { return nil })) - h := NewHandler(store) + h := NewHandler(store, nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "")) items := decodeWorkflows(t, rr) require.Len(t, items, 1) - assert.Equal(t, tc.expectedStatus, items[0].Status, tc.name) + assert.Equal(t, tc.expectedStatus, items[0].Status.Target.Status, tc.name) }) } } diff --git a/api/http/handler/gitops/workflows/summary.go b/api/http/handler/gitops/workflows/summary.go index 66ccf3eb8..b46a77c69 100644 --- a/api/http/handler/gitops/workflows/summary.go +++ b/api/http/handler/gitops/workflows/summary.go @@ -26,7 +26,7 @@ func (h *Handler) summary(w http.ResponseWriter, r *http.Request) *httperror.Han return httperror.InternalServerError("Unable to retrieve info from request context", err) } - items, err := h.getWorkflows(cacheKey(securityContext, nil), securityContext, nil) + items, err := h.getWorkflows(r.Context(), cacheKey(securityContext, nil), securityContext, nil) if err != nil { return httperror.InternalServerError("Unable to retrieve workflows", err) } diff --git a/app/react/components/datatables/useTableStateFromUrl.ts b/app/react/components/datatables/useTableStateFromUrl.ts index b6b23da00..1f922be7d 100644 --- a/app/react/components/datatables/useTableStateFromUrl.ts +++ b/app/react/components/datatables/useTableStateFromUrl.ts @@ -43,7 +43,7 @@ export function useTableStateFromUrl< const [urlState, setUrlState] = useParamsState((params) => ({ search: params.search ?? '', sort: params.sort ?? defaultSort, - order: (params.order ?? 'asc') as 'asc' | 'desc', + order: (params.order === 'desc' ? 'desc' : 'asc') as 'asc' | 'desc', page: Math.max(0, parseIntOrDefault(params.page, 0)), pageSize: parsePositiveIntOrNull(params.pageSize), ...(parseExtra ? parseExtra(params) : ({} as TParsed)), diff --git a/app/react/portainer/gitops/WorkflowsView/WorkflowCard.tsx b/app/react/portainer/gitops/WorkflowsView/WorkflowCard.tsx index 6ee020593..bdeb7db71 100644 --- a/app/react/portainer/gitops/WorkflowsView/WorkflowCard.tsx +++ b/app/react/portainer/gitops/WorkflowsView/WorkflowCard.tsx @@ -7,13 +7,16 @@ import { Icon } from '@@/Icon'; import { Link } from '@@/Link'; import { SortableListItem } from '@@/SortableList/SortableListItem'; -import { Workflow, WorkflowType } from './types'; +import { effectiveWorkflowStatus, Workflow, WorkflowType } from './types'; import { StatusBadge, TypeBadge } from './WorkflowBadges'; import { WorkflowSubRow } from './WorkflowSubRow/WorkflowSubRow'; export function WorkflowCard({ item }: { item: Workflow }) { const { to, params } = getStackLink(item); + const { status: effectiveStatus, error: errorMessage } = + effectiveWorkflowStatus(item); + return (
@@ -31,16 +34,16 @@ export function WorkflowCard({ item }: { item: Workflow }) { > {item.name} - +
- {item.statusMessage && ( + {errorMessage && (
- {item.statusMessage} + {errorMessage}
)} diff --git a/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/WorkflowSubRow.tsx b/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/WorkflowSubRow.tsx index 6eeb4babc..95887941d 100644 --- a/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/WorkflowSubRow.tsx +++ b/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/WorkflowSubRow.tsx @@ -5,11 +5,8 @@ import { Workflow, WorkflowStatus } from '../types'; import { Block, Dot } from './Block'; import { TargetCell } from './TargetCell'; -import { deriveSubRowStatuses } from './status'; export function WorkflowSubRow({ item }: { item: Workflow }) { - const status = deriveSubRowStatuses(item); - return (
@@ -27,7 +24,7 @@ export function WorkflowSubRow({ item }: { item: Workflow }) { )} @@ -35,7 +32,7 @@ export function WorkflowSubRow({ item }: { item: Workflow }) { {item.gitConfig && ( )} @@ -43,7 +40,7 @@ export function WorkflowSubRow({ item }: { item: Workflow }) { diff --git a/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/status.test.ts b/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/status.test.ts deleted file mode 100644 index 7d7397308..000000000 --- a/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/status.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { Workflow, WorkflowStatus } from '../types'; - -import { deriveSubRowStatuses } from './status'; - -function makeItem(status: WorkflowStatus, statusMessage?: string): Workflow { - return { - id: 1, - name: 'test', - type: 'stack', - platform: 'dockerStandalone', - status, - statusMessage, - target: { endpointId: 1 }, - creationDate: 0, - lastSyncDate: 0, - }; -} - -describe('deriveSubRowStatuses', () => { - describe('paused', () => { - it('returns healthy source/artifact and paused target', () => { - expect(deriveSubRowStatuses(makeItem('paused'))).toEqual({ - source: 'healthy', - artifact: 'healthy', - target: 'paused', - }); - }); - }); - - describe('non-error statuses', () => { - it.each(['healthy', 'syncing', 'unknown'])( - 'propagates %s to all three phases', - (status) => { - expect(deriveSubRowStatuses(makeItem(status))).toEqual({ - source: status, - artifact: status, - target: status, - }); - } - ); - }); - - describe('error with git-related message', () => { - it.each([ - 'failed to clone repository', - 'could not fetch remote', - 'pull failed', - 'repository not found', - 'authentication failed', - 'invalid credential', - 'ssh: connect to host', - 'unable to access https://github.com', - 'could not read from remote repository', - ])('classifies "%s" as source error', (msg) => { - expect(deriveSubRowStatuses(makeItem('error', msg))).toEqual({ - source: 'error', - artifact: 'unknown', - target: 'unknown', - }); - }); - }); - - describe('error with artifact message', () => { - it.each([ - 'yaml: unmarshal errors', - 'stack config file is invalid: services must be a mapping', - 'failed to get stack file content', - 'bind-mount disabled for non administrator users', - 'privileged mode disabled for non administrator users', - 'pid host disabled for non administrator users', - 'device mapping disabled for non administrator users', - 'sysctl setting disabled for non administrator users', - 'security-opt setting disabled for non administrator users', - 'container capabilities disabled for non administrator users', - 'failed to parse compose file', - ])('classifies "%s" as artifact error', (msg) => { - expect(deriveSubRowStatuses(makeItem('error', msg))).toEqual({ - source: 'healthy', - artifact: 'error', - target: 'unknown', - }); - }); - }); - - describe('error with target message', () => { - it.each([ - 'container failed to start', - 'exit code 1', - 'out of memory', - 'OOMKilled', - undefined, - ])('classifies "%s" as target error', (msg) => { - expect(deriveSubRowStatuses(makeItem('error', msg))).toEqual({ - source: 'healthy', - artifact: 'healthy', - target: 'error', - }); - }); - }); -}); diff --git a/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/status.ts b/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/status.ts deleted file mode 100644 index aea2c3cdd..000000000 --- a/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/status.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Workflow, WorkflowStatus } from '../types'; - -const GIT_ERROR_PATTERNS = [ - /clone/i, - /fetch/i, - /pull/i, - /repository/i, - /authentication/i, - /credential/i, - /ssh/i, - /unable to access/i, - /could not read/i, -]; - -const ARTIFACT_ERROR_PATTERNS = [ - /parse/i, - /yaml/i, - /stack config file/i, - /failed to get stack file/i, - /bind-mount/i, - /privileged mode/i, - /pid host/i, - /device mapping/i, - /sysctl/i, - /security-opt/i, - /container capabilities/i, -]; - -function classifyErrorPhase(msg: string): 'source' | 'artifact' | 'target' { - if (GIT_ERROR_PATTERNS.some((p) => p.test(msg))) return 'source'; - if (ARTIFACT_ERROR_PATTERNS.some((p) => p.test(msg))) return 'artifact'; - return 'target'; -} - -export function deriveSubRowStatuses(item: Workflow): { - source: WorkflowStatus; - artifact: WorkflowStatus; - target: WorkflowStatus; -} { - if (item.status === 'paused') { - return { source: 'healthy', artifact: 'healthy', target: 'paused' }; - } - if (item.status !== 'error') { - return { source: item.status, artifact: item.status, target: item.status }; - } - const phase = classifyErrorPhase(item.statusMessage ?? ''); - if (phase === 'source') { - return { source: 'error', artifact: 'unknown', target: 'unknown' }; - } - if (phase === 'artifact') { - return { source: 'healthy', artifact: 'error', target: 'unknown' }; - } - return { source: 'healthy', artifact: 'healthy', target: 'error' }; -} diff --git a/app/react/portainer/gitops/WorkflowsView/WorkflowsView.tsx b/app/react/portainer/gitops/WorkflowsView/WorkflowsView.tsx index ce1b4a2ac..9c1cafa22 100644 --- a/app/react/portainer/gitops/WorkflowsView/WorkflowsView.tsx +++ b/app/react/portainer/gitops/WorkflowsView/WorkflowsView.tsx @@ -12,8 +12,8 @@ import { useWorkflows } from '../queries/useWorkflows'; import { useWorkflowsSummary } from '../queries/useWorkflowsSummary'; import { WorkflowCard } from './WorkflowCard'; -import { Workflow, WorkflowStatus } from './types'; import { useListState } from './useListState'; +import { effectiveWorkflowStatus, Workflow, WorkflowStatus } from './types'; const STATUS_CONFIG: Array<{ key: WorkflowStatus; @@ -49,7 +49,7 @@ const GROUP_OPTIONS: Record> = { }; const GROUP_FIELD: Record string> = { - status: (item) => item.status, + status: (item: Workflow) => effectiveWorkflowStatus(item).status, type: (item) => item.type, platform: (item) => item.platform, }; diff --git a/app/react/portainer/gitops/WorkflowsView/types.test.ts b/app/react/portainer/gitops/WorkflowsView/types.test.ts new file mode 100644 index 000000000..15ad75487 --- /dev/null +++ b/app/react/portainer/gitops/WorkflowsView/types.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'vitest'; + +import { + effectiveWorkflowStatus, + Workflow, + WorkflowPhaseStatus, + WorkflowStatus, +} from './types'; + +function makePhase( + status: WorkflowStatus, + error?: string +): WorkflowPhaseStatus { + return { status, error }; +} + +function makeWorkflow( + source: WorkflowPhaseStatus, + artifact: WorkflowPhaseStatus, + target: WorkflowPhaseStatus +): Workflow { + return { + id: 1, + name: 'test', + type: 'stack', + platform: 'dockerStandalone', + status: { source, artifact, target }, + target: { endpointId: 1 }, + creationDate: 0, + lastSyncDate: 0, + }; +} + +describe('effectiveWorkflowStatus', () => { + describe('uniform phases', () => { + it.each([ + 'error', + 'syncing', + 'paused', + 'healthy', + 'unknown', + ])('all phases %s → %s', (status) => { + const item = makeWorkflow( + makePhase(status), + makePhase(status), + makePhase(status) + ); + expect(effectiveWorkflowStatus(item).status).toBe(status); + }); + }); + + describe('priority order', () => { + it('error beats syncing and healthy', () => { + const item = makeWorkflow( + makePhase('error'), + makePhase('syncing'), + makePhase('healthy') + ); + expect(effectiveWorkflowStatus(item).status).toBe('error'); + }); + + it('syncing beats paused and healthy', () => { + const item = makeWorkflow( + makePhase('paused'), + makePhase('syncing'), + makePhase('healthy') + ); + expect(effectiveWorkflowStatus(item).status).toBe('syncing'); + }); + + it('paused beats healthy and unknown', () => { + const item = makeWorkflow( + makePhase('healthy'), + makePhase('unknown'), + makePhase('paused') + ); + expect(effectiveWorkflowStatus(item).status).toBe('paused'); + }); + }); + + describe('error message', () => { + it('includes error from the winning phase', () => { + const item = makeWorkflow( + makePhase('error', 'git clone failed'), + makePhase('healthy'), + makePhase('healthy') + ); + expect(effectiveWorkflowStatus(item)).toEqual({ + status: 'error', + error: 'git clone failed', + }); + }); + + it('no error when winning phase has no error', () => { + const item = makeWorkflow( + makePhase('syncing'), + makePhase('healthy'), + makePhase('healthy') + ); + expect(effectiveWorkflowStatus(item).error).toBeUndefined(); + }); + }); +}); diff --git a/app/react/portainer/gitops/WorkflowsView/types.ts b/app/react/portainer/gitops/WorkflowsView/types.ts index 481513dca..c812298c7 100644 --- a/app/react/portainer/gitops/WorkflowsView/types.ts +++ b/app/react/portainer/gitops/WorkflowsView/types.ts @@ -12,6 +12,17 @@ export type DeploymentPlatform = | 'dockerSwarm' | 'kubernetes'; +export interface WorkflowPhaseStatus { + status: WorkflowStatus; + error?: string; +} + +export interface WorkflowStatusObject { + source: WorkflowPhaseStatus; + artifact: WorkflowPhaseStatus; + target: WorkflowPhaseStatus; +} + export interface WorkflowTarget { endpointId?: number; namespace?: string; @@ -24,10 +35,28 @@ export interface Workflow { name: string; type: WorkflowType; platform: DeploymentPlatform; - status: WorkflowStatus; - statusMessage?: string; + status: WorkflowStatusObject; gitConfig?: RepoConfigResponse; target: WorkflowTarget; creationDate: number; lastSyncDate: number; } + +const STATUS_PRIORITY: Record = { + error: 4, + syncing: 3, + paused: 2, + healthy: 1, + unknown: 0, +}; + +export function effectiveWorkflowStatus(item: Workflow): { + status: WorkflowStatus; + error?: string; +} { + const phases = [item.status.source, item.status.artifact, item.status.target]; + const winning = phases.reduce((best, phase) => + STATUS_PRIORITY[phase.status] > STATUS_PRIORITY[best.status] ? phase : best + ); + return { status: winning.status, error: winning.error }; +} diff --git a/app/react/portainer/gitops/WorkflowsView/useListState.ts b/app/react/portainer/gitops/WorkflowsView/useListState.ts index c357243ba..1afd7e6b4 100644 --- a/app/react/portainer/gitops/WorkflowsView/useListState.ts +++ b/app/react/portainer/gitops/WorkflowsView/useListState.ts @@ -60,7 +60,7 @@ export function useListState() { sort: id ?? DEFAULT_SORT, order: desc ? 'desc' : 'asc', // Clear status filter only if was previously grouped by status - ...(sortKey === 'status' ? { status: null } : {}), + ...(id === 'status' ? { status: null } : {}), type: null, platform: null, page: 0, diff --git a/app/react/portainer/gitops/queries/useGitRefs.ts b/app/react/portainer/gitops/queries/useGitRefs.ts index effbbc6f6..cd7fc92bb 100644 --- a/app/react/portainer/gitops/queries/useGitRefs.ts +++ b/app/react/portainer/gitops/queries/useGitRefs.ts @@ -28,12 +28,14 @@ export function useGitRefs( onSuccess, onSettled, suppressError, + cacheTime = 0, }: { enabled?: boolean; select?: (data: string[]) => T; onSuccess?(data: T): void; onSettled?(data: T | undefined, error: unknown): void; suppressError?: boolean; + cacheTime?: number; } = {} ) { return useQuery({ @@ -41,7 +43,7 @@ export function useGitRefs( queryFn: () => listRefs(payload), enabled: isBE && enabled, retry: false, - cacheTime: 0, + cacheTime, select, onSuccess, onSettled,