chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:08:39 +08:00
commit a0df89c693
5252 changed files with 523444 additions and 0 deletions
+219
View File
@@ -0,0 +1,219 @@
package workflows
import (
"errors"
"fmt"
"slices"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
svc "github.com/portainer/portainer/api/gitops/workflows"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/set"
"github.com/rs/zerolog/log"
)
// ErrNotFound indicates a workflow or one of its artifacts is not visible to the requesting user,
// either because it does not exist or because access has been filtered out.
var ErrNotFound = errors.New("not found")
// ErrResourceNotAccessible indicates sc cannot access a resource that does exist. Callers
// resolving a single artifact translate this to ErrNotFound to avoid leaking its existence.
var ErrResourceNotAccessible = errors.New("resource not accessible")
// fetchWorkflowByID returns the detail view of a single workflow, resolving each of its
// Artifacts to its backing Stack or EdgeStack and filtering out any the user cannot access.
// A workflow with zero artifacts to begin with is a valid, visible state and is returned as-is.
// ErrNotFound is returned when the workflow itself does not exist, or when it had artifacts but
// every one of them was filtered out (existence-hiding for the common single-artifact case).
func fetchWorkflowByID(
tx dataservices.DataStoreTx,
k8sFactory *cli.ClientFactory,
sc *security.RestrictedRequestContext,
workflowID portainer.WorkflowID,
) (*WorkflowDetail, error) {
wf, err := tx.Workflow().Read(workflowID)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrNotFound, err)
}
sourceMap := map[portainer.SourceID]portainer.Source{}
if len(wf.Artifacts) > 0 {
userContext := source.NewUserContext(sc.User, sc.UserMemberships)
sourceMap, err = svc.LoadWorkflowSources(tx, userContext, wf)
if err != nil {
return nil, err
}
}
artifacts := make([]svc.ArtifactDetail, 0, len(wf.Artifacts))
for _, a := range wf.Artifacts {
detail, err := fetchArtifactDetail(tx, k8sFactory, sc, a, sourceMap)
if errors.Is(err, ErrNotFound) || errors.Is(err, ErrResourceNotAccessible) {
continue
}
if err != nil {
return nil, err
}
artifacts = append(artifacts, *detail)
}
if len(wf.Artifacts) > 0 && len(artifacts) == 0 {
return nil, ErrNotFound
}
return &WorkflowDetail{
ID: int(wf.ID),
Name: wf.Name,
Artifacts: artifacts,
}, nil
}
// fetchArtifactDetail resolves a single Artifact to its backing Stack or EdgeStack.
func fetchArtifactDetail(
tx dataservices.DataStoreTx,
k8sFactory *cli.ClientFactory,
sc *security.RestrictedRequestContext,
artifact portainer.Artifact,
sourceMap map[portainer.SourceID]portainer.Source,
) (*svc.ArtifactDetail, error) {
switch {
case artifact.StackID != 0:
stack, err := tx.Stack().Read(artifact.StackID)
if tx.IsErrObjectNotFound(err) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return fetchStackArtifact(tx, k8sFactory, sc, *stack, artifact, sourceMap)
case artifact.EdgeStackID != 0:
if !sc.IsAdmin {
return nil, ErrResourceNotAccessible
}
edgeStack, err := tx.EdgeStack().EdgeStack(artifact.EdgeStackID)
if tx.IsErrObjectNotFound(err) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return fetchEdgeStackArtifact(tx, *edgeStack, artifact, sourceMap)
default:
return nil, ErrNotFound
}
}
// fetchStackArtifact resolves a stack-backed Artifact, applying the same endpoint/type-match and
// access checks as FetchWorkflows, scoped to a single stack.
func fetchStackArtifact(
tx dataservices.DataStoreTx,
k8sFactory *cli.ClientFactory,
sc *security.RestrictedRequestContext,
stack portainer.Stack,
artifact portainer.Artifact,
sourceMap map[portainer.SourceID]portainer.Source,
) (*svc.ArtifactDetail, error) {
endpointMap, err := svc.BuildEndpointMap(tx, []portainer.Stack{stack})
if err != nil {
return nil, err
}
if err := checkStackAccessible(tx, k8sFactory, sc, stack, endpointMap, artifact, sourceMap); err != nil {
return nil, err
}
sourcePhase, artifactPhase := svc.ArtifactPhases(artifact.Files, sourceMap)
detail := svc.MapStackToArtifactDetail(stack, artifact.Files, sourcePhase, artifactPhase)
return &detail, nil
}
// checkStackAccessible returns ErrResourceNotAccessible if sc cannot access stack, either because
// its endpoint's Kubernetes namespace/source access is insufficient or because Docker's
// resource-control-based UAC filters it out.
func checkStackAccessible(
tx dataservices.DataStoreTx,
k8sFactory *cli.ClientFactory,
sc *security.RestrictedRequestContext,
stack portainer.Stack,
endpointMap map[portainer.EndpointID]portainer.Endpoint,
artifact portainer.Artifact,
sourceMap map[portainer.SourceID]portainer.Source,
) error {
if stack.Type != portainer.KubernetesStack {
accessible, err := svc.FilterDockerStacksByAccess(tx, []portainer.Stack{stack}, sc)
if err != nil {
return err
}
if len(accessible) == 0 {
return ErrResourceNotAccessible
}
return nil
}
ep, epOk := endpointMap[stack.EndpointID]
if !epOk {
return ErrResourceNotAccessible
}
access, err := svc.ResolveKubeAccess(k8sFactory, sc, &ep)
if err != nil {
log.Warn().Err(err).Str("context", "checkStackAccessible").Int("endpoint_id", int(ep.ID)).Msg("Failed to resolve kube access for endpoint, filtering artifact")
return ErrResourceNotAccessible
}
if (!access.IsKubeAdmin && !slices.Contains(access.NonAdminNamespaces, stack.Namespace)) || !HasAccessibleSource(artifact.Files, sourceMap) {
return ErrResourceNotAccessible
}
return nil
}
// fetchEdgeStackArtifact resolves an edge-stack-backed Artifact. The caller (fetchArtifactDetail)
// has already gated access via sc.IsAdmin, so edge stacks are visible to admins only.
func fetchEdgeStackArtifact(
tx dataservices.DataStoreTx,
edgeStack portainer.EdgeStack,
artifact portainer.Artifact,
sourceMap map[portainer.SourceID]portainer.Source,
) (*svc.ArtifactDetail, error) {
statuses, err := tx.EdgeStackStatus().ReadAll(edgeStack.ID)
if err != nil {
return nil, err
}
groupIDSet := set.ToSet(edgeStack.EdgeGroups)
edgeGroups, err := tx.EdgeGroup().ReadAll(func(g portainer.EdgeGroup) bool {
return groupIDSet.Contains(g.ID)
})
if err != nil {
return nil, err
}
groupEndpoints, err := svc.BuildGroupEndpoints(tx, edgeGroups)
if err != nil {
return nil, err
}
sourcePhase, artifactPhase := svc.ArtifactPhases(artifact.Files, sourceMap)
detail := svc.MapEdgeStackToArtifactDetail(edgeStack, artifact.Files, statuses, groupEndpoints, sourcePhase, artifactPhase)
return &detail, nil
}
// HasAccessibleSource reports whether any of the artifact's files has a source visible in
// sourceMap.
func HasAccessibleSource(files []portainer.ArtifactFile, sourceMap map[portainer.SourceID]portainer.Source) bool {
return slices.ContainsFunc(files, func(f portainer.ArtifactFile) bool {
_, ok := sourceMap[f.SourceID]
return ok
})
}
@@ -0,0 +1,279 @@
package workflows
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/datastore"
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/portainer/portainer/api/stacks/stackutils"
"github.com/stretchr/testify/require"
)
func adminContext() *security.RestrictedRequestContext {
return &security.RestrictedRequestContext{
IsAdmin: true,
UserID: 1,
User: &portainer.User{ID: 1, Role: portainer.AdministratorRole},
}
}
func nonAdminContext() *security.RestrictedRequestContext {
return &security.RestrictedRequestContext{
IsAdmin: false,
UserID: 2,
User: &portainer.User{ID: 2, Role: portainer.StandardUserRole},
}
}
func mustCreateGitWorkflow(t *testing.T, tx dataservices.DataStoreTx, stack *portainer.Stack) {
t.Helper()
cfg := stack.GitConfig
src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.GitSource{URL: cfg.URL, Authentication: cfg.Authentication, TLSSkipVerify: cfg.TLSSkipVerify}}
require.NoError(t, tx.Source().Create(source.InsecureNewAdminContext(), src))
wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{
StackID: stack.ID,
Files: []portainer.ArtifactFile{{SourceID: src.ID}},
}}}
require.NoError(t, tx.Workflow().Create(wf))
stack.WorkflowID = wf.ID
stack.GitConfig = nil
require.NoError(t, tx.Stack().Create(stack))
}
func TestFetchWorkflowByID_SingleStackArtifact(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var wfID portainer.WorkflowID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
stack := &portainer.Stack{ID: 1, Name: "gitops-stack", GitConfig: &gittypes.RepoConfig{URL: "https://github.com/x/repo", ConfigFilePath: "docker-compose.yml"}}
mustCreateGitWorkflow(t, tx, stack)
wfID = stack.WorkflowID
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
var detail *WorkflowDetail
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
detail, err = fetchWorkflowByID(tx, nil, adminContext(), wfID)
return err
}))
require.Len(t, detail.Artifacts, 1)
require.Equal(t, "gitops-stack", detail.Artifacts[0].Name)
require.Equal(t, ce.TypeStack, detail.Artifacts[0].Type)
require.Len(t, detail.Artifacts[0].Files, 1)
}
func TestFetchWorkflowByID_EdgeStackArtifact_Admin(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var wfID portainer.WorkflowID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{EdgeStackID: 1}}}
require.NoError(t, tx.Workflow().Create(wf))
wfID = wf.ID
require.NoError(t, tx.EdgeStack().Create(1, &portainer.EdgeStack{ID: 1, Name: "edge-stack"}))
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
var detail *WorkflowDetail
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
detail, err = fetchWorkflowByID(tx, nil, adminContext(), wfID)
return err
}))
require.Len(t, detail.Artifacts, 1)
require.Equal(t, "edge-stack", detail.Artifacts[0].Name)
require.Equal(t, ce.TypeEdgeStack, detail.Artifacts[0].Type)
}
func TestFetchWorkflowByID_EdgeStackArtifactFilteredForNonAdmin_SiblingStackSurvives(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var wfID portainer.WorkflowID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{StackID: 1}, {EdgeStackID: 1}}}
require.NoError(t, tx.Workflow().Create(wf))
wfID = wf.ID
require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 1, Name: "docker-stack", WorkflowID: wf.ID}))
require.NoError(t, tx.ResourceControl().Create(&portainer.ResourceControl{
ResourceID: stackutils.ResourceControlID(0, "docker-stack"),
Type: portainer.StackResourceControl,
Public: true,
}))
require.NoError(t, tx.EdgeStack().Create(1, &portainer.EdgeStack{ID: 1, Name: "edge-stack"}))
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
return tx.User().Create(&portainer.User{ID: 2, Role: portainer.StandardUserRole})
}))
var detail *WorkflowDetail
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
detail, err = fetchWorkflowByID(tx, nil, nonAdminContext(), wfID)
return err
}))
require.Len(t, detail.Artifacts, 1)
require.Equal(t, "docker-stack", detail.Artifacts[0].Name)
}
func TestFetchWorkflowByID_K8sStackWithNoAccessibleSourceIsFiltered(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var wfID portainer.WorkflowID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1, Type: portainer.KubernetesLocalEnvironment}))
wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{StackID: 1}}}
require.NoError(t, tx.Workflow().Create(wf))
wfID = wf.ID
require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 1, Name: "k8s-stack", Type: portainer.KubernetesStack, EndpointID: 1, WorkflowID: wf.ID}))
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
err := store.ViewTx(func(tx dataservices.DataStoreTx) error {
_, err := fetchWorkflowByID(tx, nil, adminContext(), wfID)
return err
})
require.ErrorIs(t, err, ErrNotFound)
}
func TestFetchWorkflowByID_K8sStackWithAccessibleSourceIsReturned(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var wfID portainer.WorkflowID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1, Type: portainer.KubernetesLocalEnvironment}))
src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.GitSource{URL: "https://github.com/x/repo"}}
require.NoError(t, tx.Source().Create(source.InsecureNewAdminContext(), src))
wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{
StackID: 1,
Files: []portainer.ArtifactFile{{SourceID: src.ID}},
}}}
require.NoError(t, tx.Workflow().Create(wf))
wfID = wf.ID
require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 1, Name: "k8s-stack", Type: portainer.KubernetesStack, EndpointID: 1, WorkflowID: wf.ID}))
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
var detail *WorkflowDetail
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
detail, err = fetchWorkflowByID(tx, nil, adminContext(), wfID)
return err
}))
require.Len(t, detail.Artifacts, 1)
require.Equal(t, "k8s-stack", detail.Artifacts[0].Name)
}
func TestFetchWorkflowByID_ZeroArtifactsIsNotAnError(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var wfID portainer.WorkflowID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
wf := &portainer.Workflow{Name: "empty-workflow"}
require.NoError(t, tx.Workflow().Create(wf))
wfID = wf.ID
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
var detail *WorkflowDetail
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
detail, err = fetchWorkflowByID(tx, nil, adminContext(), wfID)
return err
}))
require.Equal(t, "empty-workflow", detail.Name)
require.Empty(t, detail.Artifacts)
}
func TestFetchWorkflowByID_AllArtifactsFilteredOutReturnsNotFound(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var wfID portainer.WorkflowID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{EdgeStackID: 1}}}
require.NoError(t, tx.Workflow().Create(wf))
wfID = wf.ID
require.NoError(t, tx.EdgeStack().Create(1, &portainer.EdgeStack{ID: 1, Name: "edge-stack"}))
return tx.User().Create(&portainer.User{ID: 2, Role: portainer.StandardUserRole})
}))
err := store.ViewTx(func(tx dataservices.DataStoreTx) error {
_, err := fetchWorkflowByID(tx, nil, nonAdminContext(), wfID)
return err
})
require.ErrorIs(t, err, ErrNotFound)
}
func TestFetchWorkflowByID_StaleArtifactReferenceIsFiltered(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var wfID portainer.WorkflowID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{StackID: 999}}}
require.NoError(t, tx.Workflow().Create(wf))
wfID = wf.ID
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
err := store.ViewTx(func(tx dataservices.DataStoreTx) error {
_, err := fetchWorkflowByID(tx, nil, adminContext(), wfID)
return err
})
require.ErrorIs(t, err, ErrNotFound)
}
func TestFetchWorkflowByID_NotFoundWorkflowID(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
err := store.ViewTx(func(tx dataservices.DataStoreTx) error {
_, err := fetchWorkflowByID(tx, nil, adminContext(), 999)
return err
})
require.True(t, store.IsErrObjectNotFound(err))
require.ErrorIs(t, err, ErrNotFound)
}
@@ -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
createGitStack(t, store, &portainer.Stack{
ID: 1, Name: "no-rc-stack", EndpointID: 1,
GitConfig: gitConfig("https://github.com/x/no-rc"),
})
h := NewHandler(store, nil, nil)
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"
createGitStack(t, store, &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, nil, nil)
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)
}
+63
View File
@@ -0,0 +1,63 @@
package workflows
import (
"errors"
"net/http"
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"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
)
// WorkflowDetail is the response for GET /gitops/workflows/{id}
type WorkflowDetail struct {
ID int `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
Artifacts []svc.ArtifactDetail `json:"artifacts,omitempty"`
}
// @id GitOpsWorkflowGet
// @summary Get a GitOps workflow by ID
// @description Returns the detail view of a single GitOps workflow, with one entry per backing
// @description stack or edge stack artifact.
// @description **Access policy**: authenticated
// @tags gitops
// @security ApiKeyAuth
// @security jwt
// @produce json
// @param id path int true "Workflow identifier"
// @success 200 {object} WorkflowDetail
// @failure 400 "Invalid request"
// @failure 404 "Workflow not found"
// @failure 500 "Server error"
// @router /gitops/workflows/{id} [get]
func (h *Handler) get(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
id, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest("Invalid workflow identifier route variable", err)
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
var detail *WorkflowDetail
err = h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
detail, err = fetchWorkflowByID(tx, h.k8sFactory, securityContext, portainer.WorkflowID(id))
return err
})
if h.dataStore.IsErrObjectNotFound(err) || errors.Is(err, ErrNotFound) {
return httperror.NotFound("Workflow not found", err)
} else if err != nil {
return httperror.InternalServerError("Unable to retrieve workflow", err)
}
return response.JSON(w, detail)
}
@@ -0,0 +1,147 @@
package workflows
import (
"net/http"
"net/http/httptest"
"strconv"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/datastore"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// buildWorkflowGetReq creates an HTTP GET request for /gitops/workflows/{id} with a
// security context pre-populated.
func buildWorkflowGetReq(t *testing.T, userID portainer.UserID, role portainer.UserRole, id string) *http.Request {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/gitops/workflows/"+id, 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),
User: &portainer.User{ID: userID, Role: role},
})
return req.WithContext(ctx)
}
func decodeWorkflowDetail(t *testing.T, rr *httptest.ResponseRecorder) WorkflowDetail {
t.Helper()
require.Equal(t, http.StatusOK, rr.Code, "unexpected status: %s", rr.Body.String())
var detail WorkflowDetail
require.NoError(t, json.NewDecoder(rr.Body).Decode(&detail))
return detail
}
func TestWorkflowGet_MultiFileStackArtifact(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var wfID portainer.WorkflowID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.GitSource{URL: "https://github.com/x/repo"}}
require.NoError(t, tx.Source().Create(source.InsecureNewAdminContext(), src))
wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{
StackID: 1,
Files: []portainer.ArtifactFile{
{SourceID: src.ID, Path: "docker-compose.yml"},
{SourceID: src.ID, Path: "override.yml"},
},
}}}
require.NoError(t, tx.Workflow().Create(wf))
wfID = wf.ID
require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 1, Name: "gitops-stack", WorkflowID: wf.ID}))
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
h := NewHandler(store, nil, nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowGetReq(t, 1, portainer.AdministratorRole, strconv.Itoa(int(wfID))))
detail := decodeWorkflowDetail(t, rr)
assert.Equal(t, int(wfID), detail.ID)
require.Len(t, detail.Artifacts, 1)
assert.Equal(t, "gitops-stack", detail.Artifacts[0].Name)
assert.Len(t, detail.Artifacts[0].Files, 2)
}
func TestWorkflowGet_ZeroArtifactWorkflowReturnsEmptyArray(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var wfID portainer.WorkflowID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
wf := &portainer.Workflow{Name: "empty-workflow"}
require.NoError(t, tx.Workflow().Create(wf))
wfID = wf.ID
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
h := NewHandler(store, nil, nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowGetReq(t, 1, portainer.AdministratorRole, strconv.Itoa(int(wfID))))
detail := decodeWorkflowDetail(t, rr)
assert.Equal(t, "empty-workflow", detail.Name)
assert.Empty(t, detail.Artifacts)
}
func TestWorkflowGet_NonexistentWorkflowReturns404(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
h := NewHandler(store, nil, nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowGetReq(t, 1, portainer.AdministratorRole, "999"))
assert.Equal(t, http.StatusNotFound, rr.Code)
}
func TestWorkflowGet_AllArtifactsFilteredReturns404(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var wfID portainer.WorkflowID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{EdgeStackID: 1}}}
require.NoError(t, tx.Workflow().Create(wf))
wfID = wf.ID
require.NoError(t, tx.EdgeStack().Create(1, &portainer.EdgeStack{ID: 1, Name: "edge-stack"}))
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
return tx.User().Create(&portainer.User{ID: 2, Role: portainer.StandardUserRole})
}))
h := NewHandler(store, nil, nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowGetReq(t, 2, portainer.StandardUserRole, strconv.Itoa(int(wfID))))
assert.Equal(t, http.StatusNotFound, rr.Code)
}
func TestWorkflowGet_InvalidIDRouteVar(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
h := NewHandler(store, nil, nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowGetReq(t, 1, portainer.AdministratorRole, "not-a-number"))
assert.Equal(t, http.StatusBadRequest, rr.Code)
}
@@ -0,0 +1,43 @@
package workflows
import (
"net/http"
"time"
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"
)
const (
cacheTTL = 30 * time.Second
cacheCleanupInterval = 10 * time.Minute
)
type Handler struct {
*mux.Router
dataStore dataservices.DataStore
gitService portainer.GitService
cache *gocache.Cache
k8sFactory *cli.ClientFactory
}
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)
h.Handle("/gitops/workflows/summary", httperror.LoggerHandler(h.summary)).Methods(http.MethodGet)
h.Handle("/gitops/workflows/{id}", httperror.LoggerHandler(h.get)).Methods(http.MethodGet)
return h
}
@@ -0,0 +1,75 @@
package workflows
import (
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
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/portainer/portainer/pkg/fips"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
func init() {
fips.InitFIPS(false)
}
// 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),
User: &portainer.User{ID: userID, Role: 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"}
}
// createGitStack creates Source, Workflow, and Stack records with WorkflowID properly wired.
func createGitStack(t *testing.T, tx dataservices.DataStoreTx, stack *portainer.Stack) {
t.Helper()
if stack.GitConfig != nil {
src := &portainer.Source{Git: &gittypes.GitSource{URL: stack.GitConfig.URL, Authentication: stack.GitConfig.Authentication, TLSSkipVerify: stack.GitConfig.TLSSkipVerify}, Type: portainer.SourceTypeGit}
require.NoError(t, tx.Source().Create(source.InsecureNewAdminContext(), src))
wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{
StackID: stack.ID,
Files: []portainer.ArtifactFile{{
SourceID: src.ID,
Path: stack.GitConfig.ConfigFilePath,
Ref: stack.GitConfig.ReferenceName,
Hash: stack.GitConfig.ConfigHash,
}},
}}}
require.NoError(t, tx.Workflow().Create(wf))
stack.WorkflowID = wf.ID
}
require.NoError(t, tx.Stack().Create(stack))
}
+160
View File
@@ -0,0 +1,160 @@
package workflows
import (
"cmp"
"context"
"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(r.Context(), 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 svc.EffectiveStatus(i) == 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(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)) }},
},
})
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(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
}
var result []svc.Workflow
err := h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
result, err = svc.FetchWorkflows(tx, h.k8sFactory, sc, set.ToSet(endpointIDs))
return err
})
if err != nil {
return nil, err
}
h.cache.Set(key, result, gocache.DefaultExpiration)
return slices.Clone(result), nil
}
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,384 @@
package workflows
import (
"fmt"
"net/http"
"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"
gittypes "github.com/portainer/portainer/api/git/types"
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 {
createGitStack(t, tx, &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, nil, nil)
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++ {
createGitStack(t, tx, &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, nil, nil)
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++ {
createGitStack(t, tx, &portainer.Stack{
ID: portainer.StackID(i),
Name: fmt.Sprintf("stack-%d", i),
GitConfig: gitConfig(fmt.Sprintf("https://github.com/x/y-%d", i)),
})
}
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
return nil
}))
h := NewHandler(store, nil, nil)
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")},
} {
createGitStack(t, tx, s)
}
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
return nil
}))
h := NewHandler(store, nil, nil)
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 {
createGitStack(t, tx, &portainer.Stack{
ID: 1, Name: "stack-org1",
GitConfig: gitConfig("https://github.com/org1/repo"),
})
createGitStack(t, tx, &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, nil, nil)
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"} {
createGitStack(t, tx, &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, nil, nil)
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 {
createGitStack(t, tx, &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, nil, nil)
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.
createGitStack(t, store, &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 {
createGitStack(t, tx, &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, nil, nil)
// 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 {
createGitStack(t, tx, &portainer.Stack{
ID: 1, Name: "env1-stack", EndpointID: 1,
GitConfig: gitConfig("https://github.com/x/1"),
})
createGitStack(t, tx, &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, nil, nil)
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)
}
func TestWorkflowsList_StatusFilter(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
createGitStack(t, tx, &portainer.Stack{
ID: 1, Name: "healthy-stack",
GitConfig: gitConfig("https://github.com/x/1"),
})
createGitStack(t, tx, &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, 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, 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 {
createGitStack(t, tx, &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, 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)
}
@@ -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 gives healthy",
expectedStatus: ce.StatusHealthy,
},
{
name: "active gives healthy",
deployStatus: []portainer.StackDeploymentStatus{{Status: portainer.StackStatusActive}},
expectedStatus: ce.StatusHealthy,
},
{
name: "error gives error",
deployStatus: []portainer.StackDeploymentStatus{{Status: portainer.StackStatusError}},
expectedStatus: ce.StatusError,
},
{
name: "deploying gives syncing",
deployStatus: []portainer.StackDeploymentStatus{{Status: portainer.StackStatusDeploying}},
expectedStatus: ce.StatusSyncing,
},
{
name: "inactive gives 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 {
createGitStack(t, tx, &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, nil, 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.Target.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(r.Context(), cacheKey(securityContext, nil), securityContext, nil)
if err != nil {
return httperror.InternalServerError("Unable to retrieve workflows", err)
}
return response.JSON(w, svc.CountByStatus(items))
}
@@ -0,0 +1,93 @@
package workflows
import (
"net/http"
"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/portainer/portainer/api/http/security"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
func buildSummaryReq(t *testing.T, userID portainer.UserID, role portainer.UserRole) *http.Request {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/gitops/workflows/summary", 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),
User: &portainer.User{ID: userID, Role: role},
})
return req.WithContext(ctx)
}
func decodeSummary(t *testing.T, rr *httptest.ResponseRecorder) ce.StatusSummary {
t.Helper()
require.Equal(t, http.StatusOK, rr.Code, "unexpected status: %s", rr.Body.String())
var s ce.StatusSummary
require.NoError(t, json.NewDecoder(rr.Body).Decode(&s))
return s
}
func TestWorkflowsSummary_Empty(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
h := NewHandler(store, nil, nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildSummaryReq(t, 1, portainer.AdministratorRole))
s := decodeSummary(t, rr)
require.Equal(t, ce.StatusSummary{}, s)
}
func TestWorkflowsSummary_CountsHealthyAndError(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
// No deployment status, target healthy, effective status = healthy.
createGitStack(t, tx, &portainer.Stack{
ID: 1, Name: "healthy-stack",
GitConfig: gitConfig("https://github.com/x/1"),
})
// Error deployment status, target error, effective status = error.
createGitStack(t, tx, &portainer.Stack{
ID: 2, Name: "error-stack",
GitConfig: gitConfig("https://github.com/x/2"),
DeploymentStatus: []portainer.StackDeploymentStatus{{Status: portainer.StackStatusError}},
})
// Deploying deployment status, target syncing, effective status = syncing.
createGitStack(t, tx, &portainer.Stack{
ID: 3, Name: "syncing-stack",
GitConfig: gitConfig("https://github.com/x/3"),
DeploymentStatus: []portainer.StackDeploymentStatus{{Status: portainer.StackStatusDeploying}},
})
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
h := NewHandler(store, nil, nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildSummaryReq(t, 1, portainer.AdministratorRole))
s := decodeSummary(t, rr)
require.Equal(t, 1, s.Healthy)
require.Equal(t, 1, s.Error)
require.Equal(t, 1, s.Syncing)
require.Zero(t, s.Paused)
require.Zero(t, s.Unknown)
}