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:
@@ -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, ""))
|
||||
|
||||
|
||||
@@ -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 "", ""
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user