Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com> Co-authored-by: Chaim Lev-Ari <chaim.lev-ari@portainer.io>
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"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/snapshot"
|
||||
"github.com/portainer/portainer/api/set"
|
||||
"github.com/portainer/portainer/api/slicesx"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
)
|
||||
|
||||
func endpointMatchesStackType(ep portainer.Endpoint, stackType portainer.StackType) bool {
|
||||
switch stackType {
|
||||
case portainer.DockerSwarmStack:
|
||||
return len(ep.Snapshots) > 0 && ep.Snapshots[0].Swarm
|
||||
case portainer.DockerComposeStack:
|
||||
return len(ep.Snapshots) == 0 || !ep.Snapshots[0].Swarm
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func buildEndpointMap(tx dataservices.DataStoreTx, stacks []portainer.Stack) (map[portainer.EndpointID]portainer.Endpoint, error) {
|
||||
ids := set.ToSet(slicesx.Map(stacks, func(s portainer.Stack) portainer.EndpointID { return s.EndpointID }))
|
||||
|
||||
endpoints, err := tx.Endpoint().ReadAll(func(ep portainer.Endpoint) bool { return ids[ep.ID] })
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m := make(map[portainer.EndpointID]portainer.Endpoint, len(endpoints))
|
||||
for i := range endpoints {
|
||||
if err := snapshot.FillSnapshotData(tx, &endpoints[i], false); err != nil {
|
||||
return nil, fmt.Errorf("unable to fill snapshot data for endpoint %d: %w", endpoints[i].ID, err)
|
||||
}
|
||||
m[endpoints[i].ID] = endpoints[i]
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func filterStacksByAccess(tx dataservices.DataStoreTx, stacks []portainer.Stack, sc *security.RestrictedRequestContext) ([]portainer.Stack, error) {
|
||||
if sc.IsAdmin {
|
||||
return stacks, nil
|
||||
}
|
||||
|
||||
stackResourceIDSet := set.ToSet(slicesx.Map(stacks, func(s portainer.Stack) string {
|
||||
return stackutils.ResourceControlID(s.EndpointID, s.Name)
|
||||
}))
|
||||
|
||||
resourceControls, err := tx.ResourceControl().ReadAll(func(rc portainer.ResourceControl) bool {
|
||||
return rc.Type == portainer.StackResourceControl && stackResourceIDSet[rc.ResourceID]
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stacks = authorization.DecorateStacks(stacks, resourceControls)
|
||||
|
||||
userTeamIDs := authorization.TeamIDs(sc.UserMemberships)
|
||||
return authorization.FilterAuthorizedStacks(stacks, sc.UserID, userTeamIDs), nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWorkflowsList_RBAC_NonAdminNoAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
user := &portainer.User{
|
||||
ID: 1,
|
||||
Username: "standard",
|
||||
Role: portainer.StandardUserRole,
|
||||
PortainerAuthorizations: authorization.DefaultPortainerAuthorizations(),
|
||||
}
|
||||
require.NoError(t, store.User().Create(user))
|
||||
|
||||
require.NoError(t, store.Endpoint().Create(&portainer.Endpoint{ID: 1, Name: "test-env"}))
|
||||
|
||||
// Stack on endpoint 1 WITHOUT resource control — non-admin cannot see it
|
||||
require.NoError(t, store.StackService.Create(&portainer.Stack{
|
||||
ID: 1, Name: "no-rc-stack", EndpointID: 1,
|
||||
GitConfig: gitConfig("https://github.com/x/no-rc"),
|
||||
}))
|
||||
|
||||
h := NewHandler(store)
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.StandardUserRole, ""))
|
||||
|
||||
items := decodeWorkflows(t, rr)
|
||||
assert.Empty(t, items, "non-admin without resource control access should see no stacks")
|
||||
}
|
||||
|
||||
func TestWorkflowsList_RBAC_NonAdminWithAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
user := &portainer.User{
|
||||
ID: 1,
|
||||
Username: "standard",
|
||||
Role: portainer.StandardUserRole,
|
||||
PortainerAuthorizations: authorization.DefaultPortainerAuthorizations(),
|
||||
}
|
||||
require.NoError(t, store.User().Create(user))
|
||||
|
||||
require.NoError(t, store.Endpoint().Create(&portainer.Endpoint{ID: 1, Name: "test-env"}))
|
||||
|
||||
const stackName = "rc-stack"
|
||||
require.NoError(t, store.StackService.Create(&portainer.Stack{
|
||||
ID: 1, Name: stackName, EndpointID: 1,
|
||||
GitConfig: gitConfig("https://github.com/x/rc"),
|
||||
}))
|
||||
|
||||
require.NoError(t, store.ResourceControl().Create(&portainer.ResourceControl{
|
||||
ID: 1,
|
||||
ResourceID: stackutils.ResourceControlID(1, stackName),
|
||||
Type: portainer.StackResourceControl,
|
||||
UserAccesses: []portainer.UserResourceAccess{
|
||||
{UserID: 1, AccessLevel: portainer.ReadWriteAccessLevel},
|
||||
},
|
||||
}))
|
||||
|
||||
h := NewHandler(store)
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.StandardUserRole, ""))
|
||||
|
||||
items := decodeWorkflows(t, rr)
|
||||
require.Len(t, items, 1)
|
||||
assert.Equal(t, stackName, items[0].Name)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
gocache "github.com/patrickmn/go-cache"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
const (
|
||||
cacheTTL = 30 * time.Second
|
||||
cacheCleanupInterval = 10 * time.Minute
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
dataStore dataservices.DataStore
|
||||
cache *gocache.Cache
|
||||
}
|
||||
|
||||
func NewHandler(dataStore dataservices.DataStore) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
dataStore: dataStore,
|
||||
cache: gocache.New(cacheTTL, cacheCleanupInterval),
|
||||
}
|
||||
|
||||
h.Handle("/gitops/workflows", httperror.LoggerHandler(h.list)).Methods(http.MethodGet)
|
||||
h.Handle("/gitops/workflows/summary", httperror.LoggerHandler(h.summary)).Methods(http.MethodGet)
|
||||
|
||||
return h
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
ce "github.com/portainer/portainer/api/gitops/workflows"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
|
||||
"github.com/segmentio/encoding/json"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// buildWorkflowsReq creates an HTTP GET request with security context pre-populated.
|
||||
func buildWorkflowsReq(t *testing.T, userID portainer.UserID, role portainer.UserRole, query string) *http.Request {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, "/gitops/workflows?"+query, nil)
|
||||
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: userID})
|
||||
req = req.WithContext(ctx)
|
||||
ctx = security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{
|
||||
UserID: userID,
|
||||
IsAdmin: security.IsAdminRole(role),
|
||||
})
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
// decodeWorkflows decodes a 200 JSON response into a slice of ce.Workflow.
|
||||
func decodeWorkflows(t *testing.T, rr *httptest.ResponseRecorder) []ce.Workflow {
|
||||
t.Helper()
|
||||
require.Equal(t, http.StatusOK, rr.Code, "unexpected status: %s", rr.Body.String())
|
||||
var items []ce.Workflow
|
||||
require.NoError(t, json.NewDecoder(rr.Body).Decode(&items))
|
||||
return items
|
||||
}
|
||||
|
||||
// gitConfig is a convenience constructor for test RepoConfigs.
|
||||
func gitConfig(url string) *gittypes.RepoConfig {
|
||||
return &gittypes.RepoConfig{URL: url, ConfigFilePath: "docker-compose.yml"}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
gocache "github.com/patrickmn/go-cache"
|
||||
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/security"
|
||||
"github.com/portainer/portainer/api/http/utils/filters"
|
||||
"github.com/portainer/portainer/api/set"
|
||||
"github.com/portainer/portainer/api/slicesx"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
// @id GitOpsWorkflowsList
|
||||
// @summary List all GitOps workflows
|
||||
// @description Returns a unified list of all stacks that have GitOps (GitConfig) configured.
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags gitops
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @param search query string false "Search term (matches name or repository URL)"
|
||||
// @param sort query string false "Sort field: name | type | status | creationDate | lastSyncDate"
|
||||
// @param order query string false "Sort order: asc or desc"
|
||||
// @param start query int false "Pagination start index"
|
||||
// @param limit query int false "Pagination limit (0 = unlimited)"
|
||||
// @param endpointIds query []int false "Filter by environment IDs (e.g. endpointIds[]=1&endpointIds[]=2)"
|
||||
// @param status query string false "Filter by status: healthy | syncing | error | paused | unknown"
|
||||
// @param type query string false "Filter by type: stack"
|
||||
// @param platform query string false "Filter by platform: dockerStandalone | dockerSwarm | kubernetes"
|
||||
// @success 200 {array} svc.Workflow
|
||||
// @failure 500 "Server error"
|
||||
// @router /gitops/workflows [get]
|
||||
func (h *Handler) list(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
params := filters.ExtractListModifiersQueryParams(r)
|
||||
|
||||
endpointIDs, err := request.RetrieveNumberArrayQueryParameter[portainer.EndpointID](r, "endpointIds")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid endpointIds parameter", err)
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve info from request context", err)
|
||||
}
|
||||
|
||||
key := cacheKey(securityContext, endpointIDs)
|
||||
|
||||
items, err := h.getWorkflows(key, securityContext, endpointIDs)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve workflows", err)
|
||||
}
|
||||
|
||||
if status, _ := request.RetrieveQueryParameter(r, "status", true); status != "" {
|
||||
s, err := svc.ParseStatus(status)
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid status parameter", err)
|
||||
}
|
||||
items = slicesx.FilterInPlace(items, func(i svc.Workflow) bool { return i.Status == s })
|
||||
}
|
||||
|
||||
if workflowType, _ := request.RetrieveQueryParameter(r, "type", true); workflowType != "" {
|
||||
t, err := svc.ParseType(workflowType)
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid type parameter", err)
|
||||
}
|
||||
items = slicesx.FilterInPlace(items, func(i svc.Workflow) bool { return i.Type == t })
|
||||
}
|
||||
|
||||
if platform, _ := request.RetrieveQueryParameter(r, "platform", true); platform != "" {
|
||||
p, err := svc.ParsePlatform(platform)
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid platform parameter", err)
|
||||
}
|
||||
items = slicesx.FilterInPlace(items, func(i svc.Workflow) bool { return i.Platform == p })
|
||||
}
|
||||
|
||||
results := filters.SearchOrderAndPaginate(items, params, filters.Config[svc.Workflow]{
|
||||
SearchAccessors: []filters.SearchAccessor[svc.Workflow]{
|
||||
func(i svc.Workflow) (string, error) { return i.Name, nil },
|
||||
func(i svc.Workflow) (string, error) {
|
||||
if i.GitConfig == nil {
|
||||
return "", nil
|
||||
}
|
||||
return i.GitConfig.URL, nil
|
||||
},
|
||||
},
|
||||
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: "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)) }},
|
||||
},
|
||||
})
|
||||
|
||||
filters.ApplyFilterResultsHeaders(&w, results)
|
||||
return response.JSON(w, redactWorkflowCredentials(results.Items))
|
||||
}
|
||||
|
||||
func redactWorkflowCredentials(items []svc.Workflow) []svc.Workflow {
|
||||
for i := range items {
|
||||
if items[i].GitConfig != nil && items[i].GitConfig.Authentication != nil {
|
||||
gc := *items[i].GitConfig
|
||||
auth := *gc.Authentication
|
||||
auth.Password = ""
|
||||
gc.Authentication = &auth
|
||||
items[i].GitConfig = &gc
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (h *Handler) getWorkflows(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))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.cache.Set(key, result, gocache.DefaultExpiration)
|
||||
return slices.Clone(result), nil
|
||||
}
|
||||
|
||||
func (h *Handler) fetchWorkflows(sc *security.RestrictedRequestContext, endpointIDSet set.Set[portainer.EndpointID]) ([]svc.Workflow, error) {
|
||||
var items []svc.Workflow
|
||||
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))
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
endpointMap, err := buildEndpointMap(tx, stacks)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stacks, err = filterStacksByAccess(tx, stacks, sc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
items = append(items, svc.MapStackToWorkflow(s, s.GitConfig))
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return items, err
|
||||
}
|
||||
|
||||
func cacheKey(sc *security.RestrictedRequestContext, endpointIDs []portainer.EndpointID) string {
|
||||
ids := make([]string, len(endpointIDs))
|
||||
for i, id := range endpointIDs {
|
||||
ids[i] = strconv.Itoa(int(id))
|
||||
}
|
||||
slices.Sort(ids)
|
||||
|
||||
teamIDs := make([]string, len(sc.UserMemberships))
|
||||
for i, membership := range sc.UserMemberships {
|
||||
teamIDs[i] = strconv.Itoa(int(membership.TeamID))
|
||||
}
|
||||
slices.Sort(teamIDs)
|
||||
|
||||
return strconv.Itoa(int(sc.UserID)) + ":" + strconv.FormatBool(sc.IsAdmin) + ":" + strings.Join(ids, ",") + ":" + strings.Join(teamIDs, ",")
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
ce "github.com/portainer/portainer/api/gitops/workflows"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWorkflowsList_GitConfigFilter(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: "gitops-stack",
|
||||
GitConfig: gitConfig("https://github.com/example/repo"),
|
||||
}))
|
||||
require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 2, Name: "plain-stack"}))
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
|
||||
return nil
|
||||
}))
|
||||
|
||||
h := NewHandler(store)
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, ""))
|
||||
|
||||
items := decodeWorkflows(t, rr)
|
||||
require.Len(t, items, 1)
|
||||
assert.Equal(t, "gitops-stack", items[0].Name)
|
||||
assert.Equal(t, ce.TypeStack, items[0].Type)
|
||||
assert.Equal(t, "https://github.com/example/repo", items[0].GitConfig.URL)
|
||||
assert.Equal(t, "docker-compose.yml", items[0].GitConfig.ConfigFilePath)
|
||||
}
|
||||
|
||||
func TestWorkflowsList_EndpointIDsFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
for i := 1; i <= 3; i++ {
|
||||
require.NoError(t, tx.Stack().Create(&portainer.Stack{
|
||||
ID: portainer.StackID(i),
|
||||
Name: fmt.Sprintf("env%d-stack", i),
|
||||
EndpointID: portainer.EndpointID(i),
|
||||
GitConfig: gitConfig(fmt.Sprintf("https://github.com/x/%d", i)),
|
||||
}))
|
||||
}
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
|
||||
return nil
|
||||
}))
|
||||
|
||||
h := NewHandler(store)
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "endpointIds[]=1&endpointIds[]=2"))
|
||||
|
||||
items := decodeWorkflows(t, rr)
|
||||
require.Len(t, items, 2)
|
||||
names := []string{items[0].Name, items[1].Name}
|
||||
assert.Contains(t, names, "env1-stack")
|
||||
assert.Contains(t, names, "env2-stack")
|
||||
}
|
||||
|
||||
func TestWorkflowsList_Pagination(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
for i := 1; i <= 5; i++ {
|
||||
require.NoError(t, tx.Stack().Create(&portainer.Stack{
|
||||
ID: portainer.StackID(i),
|
||||
Name: fmt.Sprintf("stack-%d", i),
|
||||
GitConfig: gitConfig("https://github.com/x/y"),
|
||||
}))
|
||||
}
|
||||
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
|
||||
return nil
|
||||
}))
|
||||
|
||||
h := NewHandler(store)
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "start=0&limit=2"))
|
||||
|
||||
items := decodeWorkflows(t, rr)
|
||||
assert.Len(t, items, 2)
|
||||
assert.Equal(t, "5", rr.Header().Get("X-Total-Count"))
|
||||
}
|
||||
|
||||
func TestWorkflowsList_Search(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
for _, s := range []*portainer.Stack{
|
||||
{ID: 1, Name: "alpha", GitConfig: gitConfig("https://github.com/org/alpha")},
|
||||
{ID: 2, Name: "beta", GitConfig: gitConfig("https://github.com/org/beta")},
|
||||
} {
|
||||
require.NoError(t, tx.Stack().Create(s))
|
||||
}
|
||||
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
|
||||
return nil
|
||||
}))
|
||||
|
||||
h := NewHandler(store)
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "search=alpha"))
|
||||
|
||||
items := decodeWorkflows(t, rr)
|
||||
require.Len(t, items, 1)
|
||||
assert.Equal(t, "alpha", items[0].Name)
|
||||
}
|
||||
|
||||
func TestWorkflowsList_SearchByURL(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: "stack-org1",
|
||||
GitConfig: gitConfig("https://github.com/org1/repo"),
|
||||
}))
|
||||
require.NoError(t, tx.Stack().Create(&portainer.Stack{
|
||||
ID: 2, Name: "stack-org2",
|
||||
GitConfig: gitConfig("https://github.com/org2/repo"),
|
||||
}))
|
||||
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
|
||||
return nil
|
||||
}))
|
||||
|
||||
h := NewHandler(store)
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "search=org1"))
|
||||
|
||||
items := decodeWorkflows(t, rr)
|
||||
require.Len(t, items, 1)
|
||||
assert.Equal(t, "stack-org1", items[0].Name)
|
||||
}
|
||||
|
||||
func TestWorkflowsList_Sort(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
for i, name := range []string{"gamma", "alpha", "beta"} {
|
||||
require.NoError(t, tx.Stack().Create(&portainer.Stack{
|
||||
ID: portainer.StackID(i + 1),
|
||||
Name: name,
|
||||
GitConfig: gitConfig("https://github.com/x/" + name),
|
||||
}))
|
||||
}
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
|
||||
return nil
|
||||
}))
|
||||
|
||||
h := NewHandler(store)
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "sort=name&order=desc"))
|
||||
|
||||
items := decodeWorkflows(t, rr)
|
||||
require.Len(t, items, 3)
|
||||
assert.Equal(t, "gamma", items[0].Name)
|
||||
assert.Equal(t, "beta", items[1].Name)
|
||||
assert.Equal(t, "alpha", items[2].Name)
|
||||
}
|
||||
|
||||
// Uses testing/synctest to control time.Now() without real sleeps.
|
||||
// The Handler is created outside the bubble so its go-cache cleanup goroutine
|
||||
// does not join the bubble. Inside the bubble all time.Now() calls return
|
||||
// fake time, so cache.Set stores a fake expiry and cache.Get compares
|
||||
// against the same fake clock — consistent without touching real time.
|
||||
|
||||
func TestWorkflowsList_Cache(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: "initial-stack",
|
||||
GitConfig: gitConfig("https://github.com/x/initial"),
|
||||
}))
|
||||
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
|
||||
return nil
|
||||
}))
|
||||
|
||||
// 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)
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
// First request at fake T=0: populates cache.
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, ""))
|
||||
require.Len(t, decodeWorkflows(t, rr), 1)
|
||||
|
||||
// Mutate the store while cache is still warm.
|
||||
require.NoError(t, store.StackService.Create(&portainer.Stack{
|
||||
ID: 2, Name: "new-stack",
|
||||
GitConfig: gitConfig("https://github.com/x/new"),
|
||||
}))
|
||||
|
||||
// Second request — same cache key, should return stale cached result.
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, ""))
|
||||
assert.Len(t, decodeWorkflows(t, rr), 1, "cache hit: new stack should not appear yet")
|
||||
|
||||
// Advance fake clock past the cache TTL. synctest unblocks immediately
|
||||
// since no other goroutines are in the bubble.
|
||||
time.Sleep(cacheTTL + time.Second)
|
||||
|
||||
// Third request — cache expired, should now fetch fresh data.
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, ""))
|
||||
assert.Len(t, decodeWorkflows(t, rr), 2, "after TTL expiry: both stacks should appear")
|
||||
})
|
||||
}
|
||||
|
||||
func TestWorkflowsList_CacheImmutableAfterSort(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
for i, name := range []string{"alpha", "beta", "gamma"} {
|
||||
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
require.NoError(t, tx.Stack().Create(
|
||||
&portainer.Stack{
|
||||
ID: portainer.StackID(i + 1),
|
||||
Name: name,
|
||||
GitConfig: gitConfig("https://github.com/x/" + name),
|
||||
},
|
||||
))
|
||||
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
|
||||
return nil
|
||||
}))
|
||||
}
|
||||
|
||||
h := NewHandler(store)
|
||||
|
||||
// First request: no sort — cache miss, populates cache as [alpha, beta, gamma].
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, ""))
|
||||
items := decodeWorkflows(t, rr)
|
||||
require.Len(t, items, 3)
|
||||
require.Equal(t, "alpha", items[0].Name)
|
||||
|
||||
// Second request: sort desc — cache hit, sorts the shared slice in-place to [gamma, beta, alpha].
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "sort=name&order=desc"))
|
||||
items = decodeWorkflows(t, rr)
|
||||
require.Len(t, items, 3)
|
||||
require.Equal(t, "gamma", items[0].Name)
|
||||
|
||||
// Third request: no sort — should still return insertion order [alpha, beta, gamma],
|
||||
// but without a defensive clone the mutated cache returns [gamma, beta, alpha].
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, ""))
|
||||
items = decodeWorkflows(t, rr)
|
||||
require.Len(t, items, 3)
|
||||
assert.Equal(t, "alpha", items[0].Name, "sort must not mutate the cached slice")
|
||||
}
|
||||
|
||||
func TestWorkflowsList_CacheSeparateKeys(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: "env1-stack", EndpointID: 1,
|
||||
GitConfig: gitConfig("https://github.com/x/1"),
|
||||
}))
|
||||
require.NoError(t, tx.Stack().Create(&portainer.Stack{
|
||||
ID: 2, Name: "env2-stack", EndpointID: 2,
|
||||
GitConfig: gitConfig("https://github.com/x/2"),
|
||||
}))
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
|
||||
return nil
|
||||
}))
|
||||
|
||||
h := NewHandler(store)
|
||||
|
||||
rr1 := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr1, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "endpointIds[]=1"))
|
||||
items1 := decodeWorkflows(t, rr1)
|
||||
require.Len(t, items1, 1)
|
||||
assert.Equal(t, "env1-stack", items1[0].Name)
|
||||
|
||||
rr2 := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr2, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "endpointIds[]=2"))
|
||||
items2 := decodeWorkflows(t, rr2)
|
||||
require.Len(t, items2, 1)
|
||||
assert.Equal(t, "env2-stack", items2[0].Name)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
ce "github.com/portainer/portainer/api/gitops/workflows"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWorkflowsList_StackStatusDerivation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
deployStatus []portainer.StackDeploymentStatus
|
||||
expectedStatus ce.Status
|
||||
}{
|
||||
{
|
||||
name: "no deployment status → healthy",
|
||||
expectedStatus: ce.StatusHealthy,
|
||||
},
|
||||
{
|
||||
name: "active → healthy",
|
||||
deployStatus: []portainer.StackDeploymentStatus{{Status: portainer.StackStatusActive}},
|
||||
expectedStatus: ce.StatusHealthy,
|
||||
},
|
||||
{
|
||||
name: "error → error",
|
||||
deployStatus: []portainer.StackDeploymentStatus{{Status: portainer.StackStatusError}},
|
||||
expectedStatus: ce.StatusError,
|
||||
},
|
||||
{
|
||||
name: "deploying → syncing",
|
||||
deployStatus: []portainer.StackDeploymentStatus{{Status: portainer.StackStatusDeploying}},
|
||||
expectedStatus: ce.StatusSyncing,
|
||||
},
|
||||
{
|
||||
name: "inactive → paused",
|
||||
deployStatus: []portainer.StackDeploymentStatus{{Status: portainer.StackStatusInactive}},
|
||||
expectedStatus: ce.StatusPaused,
|
||||
},
|
||||
{
|
||||
name: "last entry wins",
|
||||
deployStatus: []portainer.StackDeploymentStatus{
|
||||
{Status: portainer.StackStatusDeploying},
|
||||
{Status: portainer.StackStatusActive},
|
||||
},
|
||||
expectedStatus: ce.StatusHealthy,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(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: "status-stack",
|
||||
DeploymentStatus: tc.deployStatus,
|
||||
GitConfig: gitConfig("https://github.com/x/y"),
|
||||
}))
|
||||
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
|
||||
return nil
|
||||
}))
|
||||
|
||||
h := NewHandler(store)
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
svc "github.com/portainer/portainer/api/gitops/workflows"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
// @id GitOpsWorkflowsSummary
|
||||
// @summary Summarize GitOps workflow status counts
|
||||
// @description Returns a count of workflows per status across all environments.
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags gitops
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @success 200 {object} svc.StatusSummary
|
||||
// @failure 500 "Server error"
|
||||
// @router /gitops/workflows/summary [get]
|
||||
func (h *Handler) summary(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve info from request context", err)
|
||||
}
|
||||
|
||||
items, err := h.getWorkflows(cacheKey(securityContext, nil), securityContext, nil)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve workflows", err)
|
||||
}
|
||||
|
||||
return response.JSON(w, svc.CountByStatus(items))
|
||||
}
|
||||
Reference in New Issue
Block a user