feat(api/gitops): list and filter kubernetes git workflows (#2474)

This commit is contained in:
LP B
2026-04-27 20:24:39 +02:00
committed by GitHub
parent 1c55555ad0
commit bc6c5da2dc
16 changed files with 173 additions and 30 deletions

View File

@@ -7,6 +7,7 @@ import (
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/internal/snapshot"
"github.com/portainer/portainer/api/set"
"github.com/portainer/portainer/api/slicesx"
@@ -19,6 +20,8 @@ func endpointMatchesStackType(ep portainer.Endpoint, stackType portainer.StackTy
return len(ep.Snapshots) > 0 && ep.Snapshots[0].Swarm
case portainer.DockerComposeStack:
return len(ep.Snapshots) == 0 || !ep.Snapshots[0].Swarm
case portainer.KubernetesStack:
return endpointutils.IsKubernetesEndpoint(&ep)
default:
return true
}

View File

@@ -33,7 +33,7 @@ func TestWorkflowsList_RBAC_NonAdminNoAccess(t *testing.T) {
GitConfig: gitConfig("https://github.com/x/no-rc"),
}))
h := NewHandler(store, nil)
h := NewHandler(store, nil, 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, nil)
h := NewHandler(store, nil, nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.StandardUserRole, ""))

View File

@@ -7,6 +7,7 @@ import (
gocache "github.com/patrickmn/go-cache"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/kubernetes/cli"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/gorilla/mux"
@@ -22,14 +23,16 @@ type Handler struct {
dataStore dataservices.DataStore
gitService portainer.GitService
cache *gocache.Cache
k8sFactory *cli.ClientFactory
}
func NewHandler(dataStore dataservices.DataStore, gitService portainer.GitService) *Handler {
func NewHandler(dataStore dataservices.DataStore, gitService portainer.GitService, k8sFactory *cli.ClientFactory) *Handler {
h := &Handler{
Router: mux.NewRouter(),
dataStore: dataStore,
gitService: gitService,
cache: gocache.New(cacheTTL, cacheCleanupInterval),
k8sFactory: k8sFactory,
}
h.Handle("/gitops/workflows", httperror.LoggerHandler(h.list)).Methods(http.MethodGet)

View File

@@ -12,8 +12,11 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
svc "github.com/portainer/portainer/api/gitops/workflows"
"github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/http/utils/filters"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/set"
"github.com/portainer/portainer/api/slicesx"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
@@ -160,17 +163,17 @@ func (h *Handler) fetchWorkflows(ctx context.Context, sc *security.RestrictedReq
for i := range stacks {
s := stacks[i]
// TODO show kube stacks when there's a kube stacks view [BE-12867]
if s.Type == portainer.KubernetesStack {
continue
}
if ep, ok := endpointMap[s.EndpointID]; ok && !endpointMatchesStackType(ep, s.Type) {
continue
}
entries = append(entries, s)
}
entries, err = filterK8SStacks(entries, endpointMap, h.k8sFactory, sc.UserID)
if err != nil {
return err
}
return nil
})
if err != nil {
@@ -186,6 +189,58 @@ func (h *Handler) fetchWorkflows(ctx context.Context, sc *security.RestrictedReq
return items, nil
}
// lookup only if env is kube and either not edge or (edge + not async)
func shouldPerformEnvLookup(endpoint *portainer.Endpoint) bool {
return endpointutils.IsKubernetesEndpoint(endpoint) &&
(!endpointutils.IsEdgeEndpoint(endpoint) ||
(endpointutils.IsEdgeEndpoint(endpoint) && !endpoint.Edge.AsyncMode))
}
func filterK8SStacks(items []portainer.Stack, endpointMap map[portainer.EndpointID]portainer.Endpoint, k8sFactory *cli.ClientFactory, userId portainer.UserID) ([]portainer.Stack, error) {
k8sStacks, result := slicesx.Partition(items, func(s portainer.Stack) bool {
return s.Type == portainer.KubernetesStack
})
groupedByEnvId := slicesx.GroupBy(k8sStacks, func(s portainer.Stack) portainer.EndpointID {
return s.EndpointID
})
for envID, stacks := range groupedByEnvId {
ep, ok := endpointMap[envID]
if !ok || !shouldPerformEnvLookup(&ep) {
continue
}
kcl, err := k8sFactory.GetPrivilegedUserKubeClient(&ep, userId)
if err != nil {
return nil, err
}
apps, err := kcl.GetApplications("", "")
if err != nil {
return nil, err
}
for _, s := range stacks {
idx := slices.IndexFunc(apps, func(app kubernetes.K8sApplication) bool {
return app.StackKind != "edge" && app.StackID == strconv.Itoa(int(s.ID))
})
if idx == -1 {
// if we don't find a matching application (deployment/statefulset/daemonset) in the environment workloads
// this workflow (stack) wouldn't show in the Applications list, so we don't keep it
continue
}
app := apps[idx]
s.Name = app.Name
s.Namespace = app.ResourcePool
result = append(result, s)
}
}
return result, nil
}
func cacheKey(sc *security.RestrictedRequestContext, endpointIDs []portainer.EndpointID) string {
ids := make([]string, len(endpointIDs))
for i, id := range endpointIDs {

View File

@@ -32,7 +32,7 @@ func TestWorkflowsList_GitConfigFilter(t *testing.T) {
return nil
}))
h := NewHandler(store, nil)
h := NewHandler(store, nil, nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, ""))
@@ -61,7 +61,7 @@ func TestWorkflowsList_EndpointIDsFilter(t *testing.T) {
return nil
}))
h := NewHandler(store, nil)
h := NewHandler(store, nil, nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "endpointIds[]=1&endpointIds[]=2"))
@@ -89,7 +89,7 @@ func TestWorkflowsList_Pagination(t *testing.T) {
return nil
}))
h := NewHandler(store, nil)
h := NewHandler(store, nil, nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "start=0&limit=2"))
@@ -114,7 +114,7 @@ func TestWorkflowsList_Search(t *testing.T) {
return nil
}))
h := NewHandler(store, nil)
h := NewHandler(store, nil, nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "search=alpha"))
@@ -141,7 +141,7 @@ func TestWorkflowsList_SearchByURL(t *testing.T) {
return nil
}))
h := NewHandler(store, nil)
h := NewHandler(store, nil, nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "search=org1"))
@@ -166,7 +166,7 @@ func TestWorkflowsList_Sort(t *testing.T) {
return nil
}))
h := NewHandler(store, nil)
h := NewHandler(store, nil, nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "sort=name&order=desc"))
@@ -199,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, nil)
h := NewHandler(store, nil, nil)
synctest.Test(t, func(t *testing.T) {
// First request at fake T=0: populates cache.
@@ -248,7 +248,7 @@ func TestWorkflowsList_CacheImmutableAfterSort(t *testing.T) {
}))
}
h := NewHandler(store, nil)
h := NewHandler(store, nil, nil)
// First request: no sort — cache miss, populates cache as [alpha, beta, gamma].
rr := httptest.NewRecorder()
@@ -290,7 +290,7 @@ func TestWorkflowsList_CacheSeparateKeys(t *testing.T) {
return nil
}))
h := NewHandler(store, nil)
h := NewHandler(store, nil, nil)
rr1 := httptest.NewRecorder()
h.ServeHTTP(rr1, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "endpointIds[]=1"))
@@ -323,7 +323,7 @@ func TestWorkflowsList_StatusFilter(t *testing.T) {
return nil
}))
h := NewHandler(store, nil)
h := NewHandler(store, nil, nil)
t.Run("status=healthy returns only healthy workflows", func(t *testing.T) {
rr := httptest.NewRecorder()
@@ -346,7 +346,7 @@ 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)
h := NewHandler(store, nil, nil)
for _, query := range []string{"status=garbage", "type=garbage", "platform=garbage"} {
t.Run(query, func(t *testing.T) {
@@ -373,7 +373,7 @@ func TestWorkflowsList_RedactsCredentials(t *testing.T) {
return nil
}))
h := NewHandler(store, nil)
h := NewHandler(store, nil, nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, ""))

View File

@@ -72,7 +72,7 @@ func TestWorkflowsList_StackStatusDerivation(t *testing.T) {
return nil
}))
h := NewHandler(store, nil)
h := NewHandler(store, nil, nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, ""))