feat(gitops/workflows): introduce get item api [BE-13180] (#3111)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Chaim Lev-Ari
2026-07-07 12:53:03 +03:00
committed by GitHub
parent 902942c5e2
commit 6de77c92c4
23 changed files with 1305 additions and 144 deletions
+23
View File
@@ -0,0 +1,23 @@
package sources
import portainer "github.com/portainer/portainer/api"
// Status is the string representation of portainer.Status used in API responses.
type Status string
const (
SourceStatusUnknown Status = "unknown"
SourceStatusHealthy Status = "healthy"
SourceStatusError Status = "error"
)
func StatusString(s portainer.SourceStatus) Status {
switch s {
case portainer.SourceStatusHealthy:
return SourceStatusHealthy
case portainer.SourceStatusError:
return SourceStatusError
default:
return SourceStatusUnknown
}
}
+4 -4
View File
@@ -30,12 +30,12 @@ func FetchWorkflows(
return nil, err
}
endpointMap, err := buildEndpointMap(tx, stacks)
endpointMap, err := BuildEndpointMap(tx, stacks)
if err != nil {
return nil, err
}
stacks, err = filterDockerStacksByAccess(tx, stacks, sc)
stacks, err = FilterDockerStacksByAccess(tx, stacks, sc)
if err != nil {
return nil, err
}
@@ -137,12 +137,12 @@ func FetchSourceStats(
return nil, nil, err
}
endpointMap, err := buildEndpointMap(tx, allStacks)
endpointMap, err := BuildEndpointMap(tx, allStacks)
if err != nil {
return nil, nil, err
}
allStacks, err = filterDockerStacksByAccess(tx, allStacks, sc)
allStacks, err = FilterDockerStacksByAccess(tx, allStacks, sc)
if err != nil {
return nil, nil, err
}
+17 -11
View File
@@ -20,6 +20,7 @@ import (
"github.com/rs/zerolog/log"
)
// EndpointMatchesStackType reports whether ep is a valid target for stackType.
func EndpointMatchesStackType(ep portainer.Endpoint, stackType portainer.StackType) bool {
switch stackType {
case portainer.DockerSwarmStack:
@@ -33,7 +34,9 @@ func EndpointMatchesStackType(ep portainer.Endpoint, stackType portainer.StackTy
}
}
func buildEndpointMap(tx dataservices.DataStoreTx, stacks []portainer.Stack) (map[portainer.EndpointID]portainer.Endpoint, error) {
// BuildEndpointMap reads and returns the endpoints backing stacks, keyed by endpoint ID, with
// snapshot data filled in.
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] })
@@ -52,8 +55,8 @@ func buildEndpointMap(tx dataservices.DataStoreTx, stacks []portainer.Stack) (ma
return m, nil
}
// filterDockerStacksByAccess filters stacks to only those the current user can access.
func filterDockerStacksByAccess(tx dataservices.DataStoreTx, stacks []portainer.Stack, sc *security.RestrictedRequestContext) ([]portainer.Stack, error) {
// FilterDockerStacksByAccess filters stacks to only those the current user can access.
func FilterDockerStacksByAccess(tx dataservices.DataStoreTx, stacks []portainer.Stack, sc *security.RestrictedRequestContext) ([]portainer.Stack, error) {
if sc.IsAdmin {
return stacks, nil
}
@@ -79,9 +82,10 @@ func filterDockerStacksByAccess(tx dataservices.DataStoreTx, stacks []portainer.
return filtered, nil
}
func resolveKubeAccess(k8sFactory *cli.ClientFactory, sc *security.RestrictedRequestContext, ep *portainer.Endpoint) (endpointAccess, error) {
// ResolveKubeAccess determines sc's Kubernetes admin/namespace access on ep.
func ResolveKubeAccess(k8sFactory *cli.ClientFactory, sc *security.RestrictedRequestContext, ep *portainer.Endpoint) (endpointAccess, error) {
if sc.IsAdmin {
return endpointAccess{isKubeAdmin: true}, nil
return endpointAccess{IsKubeAdmin: true}, nil
}
pcli, err := k8sFactory.GetPrivilegedKubeClient(ep)
@@ -99,14 +103,16 @@ func resolveKubeAccess(k8sFactory *cli.ClientFactory, sc *security.RestrictedReq
return endpointAccess{}, fmt.Errorf("unable to retrieve non-admin namespaces for endpoint %d: %w", ep.ID, err)
}
return endpointAccess{isKubeAdmin: false, nonAdminNamespaces: nonAdminNamespaces}, nil
return endpointAccess{IsKubeAdmin: false, NonAdminNamespaces: nonAdminNamespaces}, nil
}
type endpointAccess struct {
isKubeAdmin bool
nonAdminNamespaces []string
IsKubeAdmin bool
NonAdminNamespaces []string
}
// buildEndpointAccessMap resolves sc's Kubernetes access for every Kubernetes endpoint in
// endpointMap, skipping (and logging) any endpoint whose access cannot be resolved.
func buildEndpointAccessMap(k8sFactory *cli.ClientFactory, sc *security.RestrictedRequestContext, endpointMap map[portainer.EndpointID]portainer.Endpoint) (map[portainer.EndpointID]endpointAccess, error) {
result := make(map[portainer.EndpointID]endpointAccess, len(endpointMap))
@@ -115,7 +121,7 @@ func buildEndpointAccessMap(k8sFactory *cli.ClientFactory, sc *security.Restrict
continue
}
access, err := resolveKubeAccess(k8sFactory, sc, &ep)
access, err := ResolveKubeAccess(k8sFactory, sc, &ep)
if err != nil {
log.Warn().Err(err).Str("context", "buildEndpointAccessMap").Int("endpoint_id", int(epID)).Msg("Failed to resolve kube access for endpoint, skipping")
continue
@@ -156,8 +162,8 @@ func filterK8SStacks(items []portainer.Stack, endpointMap map[portainer.Endpoint
}
access := accessMap[envID]
kcl.SetIsKubeAdmin(access.isKubeAdmin)
kcl.SetClientNonAdminNamespaces(access.nonAdminNamespaces)
kcl.SetIsKubeAdmin(access.IsKubeAdmin)
kcl.SetClientNonAdminNamespaces(access.NonAdminNamespaces)
apps, err := kcl.GetApplications("", "")
if err != nil {
+14 -14
View File
@@ -43,7 +43,7 @@ func TestFilterDockerStacksByAccess_KubeStacksPassThrough(t *testing.T) {
var result []portainer.Stack
err := store.ViewTx(func(tx dataservices.DataStoreTx) error {
var txErr error
result, txErr = filterDockerStacksByAccess(tx, stacks, sc)
result, txErr = FilterDockerStacksByAccess(tx, stacks, sc)
return txErr
})
require.NoError(t, err)
@@ -64,7 +64,7 @@ func TestFilterDockerStacksByAccess_AdminGetsAll(t *testing.T) {
{ID: 2, Name: "docker-stack", Type: portainer.DockerComposeStack},
}
result, err := filterDockerStacksByAccess(nil, stacks, sc)
result, err := FilterDockerStacksByAccess(nil, stacks, sc)
require.NoError(t, err)
require.Len(t, result, 2)
}
@@ -85,8 +85,8 @@ func TestBuildEndpointAccessMap_AdminIsKubeAdmin(t *testing.T) {
result, err := buildEndpointAccessMap(nil, sc, endpointMap)
require.NoError(t, err)
require.Len(t, result, 1)
require.True(t, result[1].isKubeAdmin)
require.Empty(t, result[1].nonAdminNamespaces)
require.True(t, result[1].IsKubeAdmin)
require.Empty(t, result[1].NonAdminNamespaces)
}
func TestFilterK8SStacks_IncludesMatchingStack(t *testing.T) {
@@ -122,7 +122,7 @@ func TestFilterK8SStacks_IncludesMatchingStack(t *testing.T) {
}
accessMap := map[portainer.EndpointID]endpointAccess{
1: {isKubeAdmin: true},
1: {IsKubeAdmin: true},
}
result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap)
@@ -148,7 +148,7 @@ func TestFilterK8SStacks_ExcludesStackWhenNoMatchingDeployment(t *testing.T) {
}
accessMap := map[portainer.EndpointID]endpointAccess{
1: {isKubeAdmin: true},
1: {IsKubeAdmin: true},
}
result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap)
@@ -189,7 +189,7 @@ func TestFilterK8SStacks_NonAdminWithNamespaceAccess(t *testing.T) {
}
accessMap := map[portainer.EndpointID]endpointAccess{
1: {isKubeAdmin: false, nonAdminNamespaces: []string{"ns1"}},
1: {IsKubeAdmin: false, NonAdminNamespaces: []string{"ns1"}},
}
result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap)
@@ -218,10 +218,10 @@ func TestResolveKubeAccess_NonAdminWithTeamMemberships(t *testing.T) {
},
}
access, err := resolveKubeAccess(factory, sc, ep)
access, err := ResolveKubeAccess(factory, sc, ep)
require.NoError(t, err)
require.False(t, access.isKubeAdmin)
require.Equal(t, []string{"default"}, access.nonAdminNamespaces)
require.False(t, access.IsKubeAdmin)
require.Equal(t, []string{"default"}, access.NonAdminNamespaces)
}
func TestResolveKubeAccess_NonAdmin(t *testing.T) {
@@ -241,10 +241,10 @@ func TestResolveKubeAccess_NonAdmin(t *testing.T) {
UserID: 1,
}
access, err := resolveKubeAccess(factory, sc, ep)
access, err := ResolveKubeAccess(factory, sc, ep)
require.NoError(t, err)
require.False(t, access.isKubeAdmin)
require.Equal(t, []string{"default"}, access.nonAdminNamespaces)
require.False(t, access.IsKubeAdmin)
require.Equal(t, []string{"default"}, access.NonAdminNamespaces)
}
func TestFilterK8SStacks_NonAdminWithoutNamespaceAccess(t *testing.T) {
@@ -280,7 +280,7 @@ func TestFilterK8SStacks_NonAdminWithoutNamespaceAccess(t *testing.T) {
}
accessMap := map[portainer.EndpointID]endpointAccess{
1: {isKubeAdmin: false, nonAdminNamespaces: []string{}},
1: {IsKubeAdmin: false, NonAdminNamespaces: []string{}},
}
result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap)
+84
View File
@@ -1,13 +1,35 @@
package workflows
import (
"fmt"
"slices"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/gitops/sources"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/set"
"github.com/portainer/portainer/api/slicesx"
)
// BuildGroupEndpoints builds a map between EdgeGroup id and its endpoints
func BuildGroupEndpoints(tx dataservices.DataStoreTx, groups []portainer.EdgeGroup) (map[portainer.EdgeGroupID][]portainer.EndpointID, error) {
m := make(map[portainer.EdgeGroupID][]portainer.EndpointID, len(groups))
for _, g := range groups {
if g.Dynamic {
ids, err := endpointutils.GetEndpointsByTags(tx, g.TagIDs, g.PartialMatch)
if err != nil {
return nil, fmt.Errorf("failed to resolve endpoints for dynamic edge group: %w", err)
}
m[g.ID] = ids
} else {
m[g.ID] = g.EndpointIDs.ToSlice()
}
}
return m, nil
}
// MapStackToWorkflow converts a stack to a Workflow. gitConfig is passed separately
// because EE embeds a different GitConfig type that shadows the CE field.
// source and artifact are the pre-computed git phase statuses from the caller.
@@ -62,6 +84,58 @@ func MapEdgeStackToWorkflow(es portainer.EdgeStack, gitConfig *gittypes.RepoConf
}
}
// MapStackToArtifactDetail converts a stack to an ArtifactDetail. source and artifact are the
// pre-computed git phase statuses from the caller; files are the artifact's resolved file refs.
func MapStackToArtifactDetail(stack portainer.Stack, files []portainer.ArtifactFile, source, artifact WorkflowPhaseStatus) ArtifactDetail {
return ArtifactDetail{
ID: int(stack.ID),
Type: TypeStack,
Name: stack.Name,
Platform: platformFromStackType(stack.Type),
Status: WorkflowStatusObject{
Source: source,
Artifact: artifact,
Target: deriveStackTargetState(stack),
},
AutoUpdate: stack.AutoUpdate,
Target: Target{
EndpointID: stack.EndpointID,
Namespace: stack.Namespace,
},
Files: mapFilesToFileDetails(files),
CreationDate: stack.CreationDate,
LastSyncDate: StackLastSyncDate(stack),
}
}
// MapEdgeStackToArtifactDetail converts an edge stack to an ArtifactDetail. source and artifact are
// the pre-computed git phase statuses from the caller; files are the artifact's resolved file refs.
func MapEdgeStackToArtifactDetail(es portainer.EdgeStack, files []portainer.ArtifactFile, statuses []portainer.EdgeStackStatusForEnv, groupEndpoints map[portainer.EdgeGroupID][]portainer.EndpointID, source, artifact WorkflowPhaseStatus) ArtifactDetail {
platform := DeploymentPlatformDockerStandalone
if es.DeploymentType == portainer.EdgeStackDeploymentKubernetes {
platform = DeploymentPlatformKubernetes
}
return ArtifactDetail{
ID: int(es.ID),
Type: TypeEdgeStack,
Name: es.Name,
Platform: platform,
Status: WorkflowStatusObject{
Source: source,
Artifact: artifact,
Target: deriveEdgeStackTargetState(statuses),
},
Target: Target{
EdgeGroupIDs: es.EdgeGroups,
GroupStatus: edgeStackTargetStatuses(es.EdgeGroups, statuses, groupEndpoints),
ResolvedEndpointIDs: resolveEdgeGroupEndpoints(es.EdgeGroups, groupEndpoints),
},
Files: mapFilesToFileDetails(files),
CreationDate: es.CreationDate,
LastSyncDate: edgeStackLastSyncDate(statuses),
}
}
func StackLastSyncDate(s portainer.Stack) int64 {
for _, ds := range slices.Backward(s.DeploymentStatus) {
if ds.Status == portainer.StackStatusActive {
@@ -150,3 +224,13 @@ func edgeStackTargetStatuses(
}
return result
}
func mapFilesToFileDetails(files []portainer.ArtifactFile) []ArtifactFileDetail {
return slicesx.Map(files, func(file portainer.ArtifactFile) ArtifactFileDetail {
return ArtifactFileDetail{
ArtifactFile: file,
RefStatus: sources.StatusString(file.RefStatus),
PathStatus: sources.StatusString(file.PathStatus),
}
})
}
+4 -4
View File
@@ -32,7 +32,7 @@ func GitSourceAndArtifactForStack(tx gitSourceStore, userContext source.UserCont
return nil, nil, err
}
sourceMap, err := loadWorkflowSources(tx, userContext, wf)
sourceMap, err := LoadWorkflowSources(tx, userContext, wf)
if err != nil {
return nil, nil, err
}
@@ -69,7 +69,7 @@ func GitSourceAndArtifactForEdgeStack(tx gitSourceStore, userContext source.User
return nil, nil, err
}
sourceMap, err := loadWorkflowSources(tx, userContext, wf)
sourceMap, err := LoadWorkflowSources(tx, userContext, wf)
if err != nil {
return nil, nil, err
}
@@ -373,9 +373,9 @@ func LoadWorkflowAndSourceMaps(tx gitSourceStore, userContext source.UserContext
return wfMap, srcMap, nil
}
// loadWorkflowSources collects all unique SourceIDs referenced by wf and returns them as a map.
// LoadWorkflowSources collects all unique SourceIDs referenced by wf and returns them as a map.
// This avoids reading the same Source record more than once when files share a SourceID.
func loadWorkflowSources(tx gitSourceStore, userContext source.UserContext, wf *portainer.Workflow) (map[portainer.SourceID]portainer.Source, error) {
func LoadWorkflowSources(tx gitSourceStore, userContext source.UserContext, wf *portainer.Workflow) (map[portainer.SourceID]portainer.Source, error) {
ids := make(set.Set[portainer.SourceID])
for _, as := range wf.Artifacts {
for _, f := range as.Files {
+28
View File
@@ -13,6 +13,34 @@ func SourceStatusToPhase(s portainer.SourceStatus, errMsg string) WorkflowPhaseS
}
}
// ArtifactPhases returns the source and artifact-path health phases for an artifact's files,
// aggregating the worst-priority status across all of its files. The source phase also folds in
// each file's Source connectivity status, since a broken source invalidates ref resolution
// regardless of the file's own cached RefStatus.
func ArtifactPhases(files []portainer.ArtifactFile, sourceMap map[portainer.SourceID]portainer.Source) (source, artifact WorkflowPhaseStatus) {
source = WorkflowPhaseStatus{Status: StatusUnknown}
artifact = WorkflowPhaseStatus{Status: StatusUnknown}
for _, f := range files {
src, ok := sourceMap[f.SourceID]
if !ok {
continue
}
if srcPhase := SourceStatusToPhase(src.Status, src.StatusError); statusPriority(srcPhase.Status) > statusPriority(source.Status) {
source = srcPhase
}
if refPhase := SourceStatusToPhase(f.RefStatus, f.RefError); statusPriority(refPhase.Status) > statusPriority(source.Status) {
source = refPhase
}
if artifactPhase := SourceStatusToPhase(f.PathStatus, f.PathError); statusPriority(artifactPhase.Status) > statusPriority(artifact.Status) {
artifact = artifactPhase
}
}
return source, artifact
}
func WorkflowPhaseToStatus(p WorkflowPhaseStatus) (portainer.SourceStatus, string) {
switch p.Status {
case StatusHealthy:
+138
View File
@@ -149,3 +149,141 @@ func TestDeriveEdgeStackTargetState(t *testing.T) {
})
}
}
func TestArtifactPhases(t *testing.T) {
t.Parallel()
src := func(id portainer.SourceID, status portainer.SourceStatus, statusError string) portainer.Source {
return portainer.Source{ID: id, Status: status, StatusError: statusError}
}
file := func(sourceID portainer.SourceID, refStatus portainer.SourceStatus, refError string, pathStatus portainer.SourceStatus, pathError string) portainer.ArtifactFile {
return portainer.ArtifactFile{SourceID: sourceID, RefStatus: refStatus, RefError: refError, PathStatus: pathStatus, PathError: pathError}
}
cases := []struct {
name string
files []portainer.ArtifactFile
sourceMap map[portainer.SourceID]portainer.Source
wantSource WorkflowPhaseStatus
wantArtifact WorkflowPhaseStatus
}{
{
name: "no files",
files: nil,
sourceMap: map[portainer.SourceID]portainer.Source{},
wantSource: WorkflowPhaseStatus{Status: StatusUnknown},
wantArtifact: WorkflowPhaseStatus{Status: StatusUnknown},
},
{
name: "file's source missing from sourceMap is skipped",
files: []portainer.ArtifactFile{file(1, portainer.SourceStatusError, "unreachable", portainer.SourceStatusError, "not found")},
sourceMap: map[portainer.SourceID]portainer.Source{
2: src(2, portainer.SourceStatusHealthy, ""),
},
wantSource: WorkflowPhaseStatus{Status: StatusUnknown},
wantArtifact: WorkflowPhaseStatus{Status: StatusUnknown},
},
{
name: "all healthy",
files: []portainer.ArtifactFile{file(1, portainer.SourceStatusHealthy, "", portainer.SourceStatusHealthy, "")},
sourceMap: map[portainer.SourceID]portainer.Source{
1: src(1, portainer.SourceStatusHealthy, ""),
},
wantSource: WorkflowPhaseStatus{Status: StatusHealthy},
wantArtifact: WorkflowPhaseStatus{Status: StatusHealthy},
},
{
name: "source-level error dominates a healthy ref",
files: []portainer.ArtifactFile{file(1, portainer.SourceStatusHealthy, "", portainer.SourceStatusHealthy, "")},
sourceMap: map[portainer.SourceID]portainer.Source{
1: src(1, portainer.SourceStatusError, "connection refused"),
},
wantSource: WorkflowPhaseStatus{Status: StatusError, Error: "connection refused"},
wantArtifact: WorkflowPhaseStatus{Status: StatusHealthy},
},
{
name: "file-level ref error dominates a healthy source",
files: []portainer.ArtifactFile{file(1, portainer.SourceStatusError, "ref not found", portainer.SourceStatusHealthy, "")},
sourceMap: map[portainer.SourceID]portainer.Source{
1: src(1, portainer.SourceStatusHealthy, ""),
},
wantSource: WorkflowPhaseStatus{Status: StatusError, Error: "ref not found"},
wantArtifact: WorkflowPhaseStatus{Status: StatusHealthy},
},
{
name: "path error is independent of a broken source",
files: []portainer.ArtifactFile{file(1, portainer.SourceStatusHealthy, "", portainer.SourceStatusHealthy, "")},
sourceMap: map[portainer.SourceID]portainer.Source{
1: src(1, portainer.SourceStatusError, "connection refused"),
},
wantSource: WorkflowPhaseStatus{Status: StatusError, Error: "connection refused"},
wantArtifact: WorkflowPhaseStatus{Status: StatusHealthy},
},
{
name: "path error surfaces on its own",
files: []portainer.ArtifactFile{file(1, portainer.SourceStatusHealthy, "", portainer.SourceStatusError, "path not found")},
sourceMap: map[portainer.SourceID]portainer.Source{
1: src(1, portainer.SourceStatusHealthy, ""),
},
wantSource: WorkflowPhaseStatus{Status: StatusHealthy},
wantArtifact: WorkflowPhaseStatus{Status: StatusError, Error: "path not found"},
},
{
name: "tie between source and ref error keeps the source-level message",
files: []portainer.ArtifactFile{file(1, portainer.SourceStatusError, "ref not found", portainer.SourceStatusHealthy, "")},
sourceMap: map[portainer.SourceID]portainer.Source{
1: src(1, portainer.SourceStatusError, "connection refused"),
},
wantSource: WorkflowPhaseStatus{Status: StatusError, Error: "connection refused"},
wantArtifact: WorkflowPhaseStatus{Status: StatusHealthy},
},
{
name: "worst artifact phase wins across multiple files",
files: []portainer.ArtifactFile{
file(1, portainer.SourceStatusHealthy, "", portainer.SourceStatusHealthy, ""),
file(2, portainer.SourceStatusHealthy, "", portainer.SourceStatusError, "path not found"),
},
sourceMap: map[portainer.SourceID]portainer.Source{
1: src(1, portainer.SourceStatusHealthy, ""),
2: src(2, portainer.SourceStatusHealthy, ""),
},
wantSource: WorkflowPhaseStatus{Status: StatusHealthy},
wantArtifact: WorkflowPhaseStatus{Status: StatusError, Error: "path not found"},
},
{
name: "worst source phase wins across multiple files",
files: []portainer.ArtifactFile{
file(1, portainer.SourceStatusHealthy, "", portainer.SourceStatusHealthy, ""),
file(2, portainer.SourceStatusError, "ref not found", portainer.SourceStatusHealthy, ""),
},
sourceMap: map[portainer.SourceID]portainer.Source{
1: src(1, portainer.SourceStatusHealthy, ""),
2: src(2, portainer.SourceStatusHealthy, ""),
},
wantSource: WorkflowPhaseStatus{Status: StatusError, Error: "ref not found"},
wantArtifact: WorkflowPhaseStatus{Status: StatusHealthy},
},
{
name: "mixed accessible and inaccessible files: only the accessible one drives the result",
files: []portainer.ArtifactFile{
file(1, portainer.SourceStatusError, "should be ignored", portainer.SourceStatusError, "should be ignored"),
file(2, portainer.SourceStatusHealthy, "", portainer.SourceStatusHealthy, ""),
},
sourceMap: map[portainer.SourceID]portainer.Source{
2: src(2, portainer.SourceStatusHealthy, ""),
},
wantSource: WorkflowPhaseStatus{Status: StatusHealthy},
wantArtifact: WorkflowPhaseStatus{Status: StatusHealthy},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
gotSource, gotArtifact := ArtifactPhases(tc.files, tc.sourceMap)
assert.Equal(t, tc.wantSource, gotSource)
assert.Equal(t, tc.wantArtifact, gotArtifact)
})
}
}
+23
View File
@@ -5,6 +5,7 @@ import (
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/gitops/sources"
)
type Status string
@@ -98,3 +99,25 @@ type StatusSummary struct {
Paused int `json:"paused"`
Unknown int `json:"unknown"`
}
// ArtifactDetail describes one Artifact's backing Stack or EdgeStack.
type ArtifactDetail struct {
ID int `json:"id" validate:"required"`
Type Type `json:"type" validate:"required"`
Name string `json:"name" validate:"required"`
Platform DeploymentPlatform `json:"platform"`
AutoUpdate *portainer.AutoUpdateSettings `json:"autoUpdate,omitempty"`
Target Target `json:"target"`
Status WorkflowStatusObject `json:"status"`
Files []ArtifactFileDetail `json:"files"`
CreationDate int64 `json:"creationDate"`
LastSyncDate int64 `json:"lastSyncDate"`
}
// ArtifactFileDetail describe the representation of portainer.ArtifactFile used in API responses.
type ArtifactFileDetail struct {
portainer.ArtifactFile
RefStatus sources.Status `json:"refStatus,omitempty"`
PathStatus sources.Status `json:"pathStatus,omitempty"`
}
@@ -3,53 +3,9 @@ package edgegroups
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/roar"
)
type endpointSetType map[portainer.EndpointID]bool
func GetEndpointsByTags(tx dataservices.DataStoreTx, tagIDs []portainer.TagID, partialMatch bool) ([]portainer.EndpointID, error) {
if len(tagIDs) == 0 {
return []portainer.EndpointID{}, nil
}
endpoints, err := tx.Endpoint().Endpoints()
if err != nil {
return nil, err
}
groupEndpoints := mapEndpointGroupToEndpoints(endpoints)
tags := []portainer.Tag{}
for _, tagID := range tagIDs {
tag, err := tx.Tag().Read(tagID)
if err != nil {
return nil, err
}
tags = append(tags, *tag)
}
setsOfEndpoints := mapTagsToEndpoints(tags, groupEndpoints)
var endpointSet endpointSetType
if partialMatch {
endpointSet = setsUnion(setsOfEndpoints)
} else {
endpointSet = setsIntersection(setsOfEndpoints)
}
results := []portainer.EndpointID{}
for _, endpoint := range endpoints {
if _, ok := endpointSet[endpoint.ID]; ok && endpointutils.IsEdgeEndpoint(&endpoint) && endpoint.UserTrusted {
results = append(results, endpoint.ID)
}
}
return results, nil
}
func getTrustedEndpoints(tx dataservices.DataStoreTx, endpointIDs roar.Roar[portainer.EndpointID]) ([]portainer.EndpointID, error) {
var innerErr error
@@ -74,66 +30,3 @@ func getTrustedEndpoints(tx dataservices.DataStoreTx, endpointIDs roar.Roar[port
return results, innerErr
}
func mapEndpointGroupToEndpoints(endpoints []portainer.Endpoint) map[portainer.EndpointGroupID]endpointSetType {
groupEndpoints := map[portainer.EndpointGroupID]endpointSetType{}
for _, endpoint := range endpoints {
groupID := endpoint.GroupID
if groupEndpoints[groupID] == nil {
groupEndpoints[groupID] = endpointSetType{}
}
groupEndpoints[groupID][endpoint.ID] = true
}
return groupEndpoints
}
func mapTagsToEndpoints(tags []portainer.Tag, groupEndpoints map[portainer.EndpointGroupID]endpointSetType) []endpointSetType {
sets := []endpointSetType{}
for _, tag := range tags {
set := tag.Endpoints
for groupID := range tag.EndpointGroups {
for endpointID := range groupEndpoints[groupID] {
set[endpointID] = true
}
}
sets = append(sets, set)
}
return sets
}
func setsIntersection(sets []endpointSetType) endpointSetType {
if len(sets) == 0 {
return endpointSetType{}
}
intersectionSet := sets[0]
for _, set := range sets {
for endpointID := range intersectionSet {
if !set[endpointID] {
delete(intersectionSet, endpointID)
}
}
}
return intersectionSet
}
func setsUnion(sets []endpointSetType) endpointSetType {
unionSet := endpointSetType{}
for _, set := range sets {
for endpointID := range set {
unionSet[endpointID] = true
}
}
return unionSet
}
@@ -5,6 +5,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/roar"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
@@ -55,7 +56,7 @@ func getEdgeGroup(tx dataservices.DataStoreTx, ID portainer.EdgeGroupID) (*porta
}
if edgeGroup.Dynamic {
endpoints, err := GetEndpointsByTags(tx, edgeGroup.TagIDs, edgeGroup.PartialMatch)
endpoints, err := endpointutils.GetEndpointsByTags(tx, edgeGroup.TagIDs, edgeGroup.PartialMatch)
if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve environments and environment groups for Edge group", err)
}
@@ -7,6 +7,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/roar"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -87,7 +88,7 @@ func getEdgeGroupList(tx dataservices.DataStoreTx) ([]decoratedEdgeGroup, error)
EndpointTypes: []portainer.EndpointType{},
}
if edgeGroup.Dynamic {
endpointIDs, err := GetEndpointsByTags(tx, edgeGroup.TagIDs, edgeGroup.PartialMatch)
endpointIDs, err := endpointutils.GetEndpointsByTags(tx, edgeGroup.TagIDs, edgeGroup.PartialMatch)
if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve environments and environment groups for Edge group", err)
}
+1 -2
View File
@@ -10,7 +10,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/handler/edgegroups"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/edge"
"github.com/portainer/portainer/api/internal/endpointutils"
@@ -324,7 +323,7 @@ func filterEndpointsByEdgeStack(endpoints []portainer.Endpoint, edgeStackId port
}
if edgeGroup.Dynamic {
endpointIDs, err := edgegroups.GetEndpointsByTags(tx, edgeGroup.TagIDs, edgeGroup.PartialMatch)
endpointIDs, err := endpointutils.GetEndpointsByTags(tx, edgeGroup.TagIDs, edgeGroup.PartialMatch)
if err != nil {
return errors.WithMessage(err, "Unable to retrieve environments and environment groups for Edge group")
}
+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)
}
+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)
}
@@ -37,6 +37,7 @@ func NewHandler(dataStore dataservices.DataStore, gitService portainer.GitServic
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
}
@@ -11,11 +11,16 @@ import (
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()
@@ -0,0 +1,83 @@
package endpointutils
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/set"
)
// GetEndpointsByTags returns the trusted edge endpoints matching tagIDs, unioned if partialMatch
// is set or intersected otherwise.
func GetEndpointsByTags(tx dataservices.DataStoreTx, tagIDs []portainer.TagID, partialMatch bool) ([]portainer.EndpointID, error) {
if len(tagIDs) == 0 {
return []portainer.EndpointID{}, nil
}
endpoints, err := tx.Endpoint().Endpoints()
if err != nil {
return nil, err
}
groupEndpoints := mapEndpointGroupToEndpoints(endpoints)
tags := []portainer.Tag{}
for _, tagID := range tagIDs {
tag, err := tx.Tag().Read(tagID)
if err != nil {
return nil, err
}
tags = append(tags, *tag)
}
setsOfEndpoints := mapTagsToEndpoints(tags, groupEndpoints)
var endpointSet set.Set[portainer.EndpointID]
if partialMatch {
endpointSet = set.Union(setsOfEndpoints...)
} else {
endpointSet = set.Intersection(setsOfEndpoints...)
}
results := []portainer.EndpointID{}
for _, endpoint := range endpoints {
if endpointSet.Contains(endpoint.ID) && IsEdgeEndpoint(&endpoint) && endpoint.UserTrusted {
results = append(results, endpoint.ID)
}
}
return results, nil
}
func mapEndpointGroupToEndpoints(endpoints []portainer.Endpoint) map[portainer.EndpointGroupID]set.Set[portainer.EndpointID] {
groupEndpoints := map[portainer.EndpointGroupID]set.Set[portainer.EndpointID]{}
for _, endpoint := range endpoints {
groupID := endpoint.GroupID
if groupEndpoints[groupID] == nil {
groupEndpoints[groupID] = set.Set[portainer.EndpointID]{}
}
groupEndpoints[groupID].Add(endpoint.ID)
}
return groupEndpoints
}
func mapTagsToEndpoints(tags []portainer.Tag, groupEndpoints map[portainer.EndpointGroupID]set.Set[portainer.EndpointID]) []set.Set[portainer.EndpointID] {
sets := make([]set.Set[portainer.EndpointID], 0, len(tags))
for _, tag := range tags {
s := set.Set[portainer.EndpointID](tag.Endpoints)
for groupID := range tag.EndpointGroups {
for endpointID := range groupEndpoints[groupID] {
s.Add(endpointID)
}
}
sets = append(sets, s)
}
return sets
}
@@ -465,6 +465,9 @@ import type {
GitOpsSourcesUpdateGitData,
GitOpsSourcesUpdateGitErrors,
GitOpsSourcesUpdateGitResponses,
GitOpsWorkflowGetData,
GitOpsWorkflowGetErrors,
GitOpsWorkflowGetResponses,
GitOpsWorkflowsListData,
GitOpsWorkflowsListErrors,
GitOpsWorkflowsListResponses,
@@ -1122,6 +1125,8 @@ import {
zGitOpsSourcesUpdateGitBody,
zGitOpsSourcesUpdateGitPath,
zGitOpsSourcesUpdateGitResponse,
zGitOpsWorkflowGetPath,
zGitOpsWorkflowGetResponse,
zGitOpsWorkflowsListQuery,
zGitOpsWorkflowsListResponse,
zGitOpsWorkflowsSummaryResponse,
@@ -4455,6 +4460,44 @@ export const gitOpsWorkflowsList = <ThrowOnError extends boolean = true>(
...options,
});
/**
* Get a GitOps workflow by ID
*
* Returns the detail view of a single GitOps workflow, with one entry per backing
* stack or edge stack artifact.
* **Access policy**: authenticated
*/
export const gitOpsWorkflowGet = <ThrowOnError extends boolean = true>(
options: Options<GitOpsWorkflowGetData, ThrowOnError>
): RequestResult<
GitOpsWorkflowGetResponses,
GitOpsWorkflowGetErrors,
ThrowOnError
> =>
(options.client ?? client).get<
GitOpsWorkflowGetResponses,
GitOpsWorkflowGetErrors,
ThrowOnError
>({
requestValidator: async (data) =>
await z
.object({
body: z.never().optional(),
path: zGitOpsWorkflowGetPath,
query: z.never().optional(),
})
.parseAsync(data),
responseType: 'json',
responseValidator: async (data) =>
await zGitOpsWorkflowGetResponse.parseAsync(data),
security: [
{ name: 'X-API-KEY', type: 'apiKey' },
{ name: 'Authorization', type: 'apiKey' },
],
url: '/gitops/workflows/{id}',
...options,
});
/**
* Summarize GitOps workflow status counts
*
@@ -4408,6 +4408,23 @@ export const SourcesSourceType = {
export type SourcesSourceType =
(typeof SourcesSourceType)[keyof typeof SourcesSourceType];
export const SourcesStatus = {
/**
* SourceStatusUnknown
*/
SOURCE_STATUS_UNKNOWN: 'unknown',
/**
* SourceStatusHealthy
*/
SOURCE_STATUS_HEALTHY: 'healthy',
/**
* SourceStatusError
*/
SOURCE_STATUS_ERROR: 'error',
} as const;
export type SourcesStatus = (typeof SourcesStatus)[keyof typeof SourcesStatus];
export type SourcesConnectionInfo = {
authentication?: SourcesGitAuthInfo;
tlsSkipVerify?: boolean;
@@ -8183,6 +8200,30 @@ export type WebhooksWebhookUpdatePayload = {
RegistryID?: number;
};
export type WorkflowsArtifactDetail = {
autoUpdate?: PortainerAutoUpdateSettings;
creationDate?: number;
files?: Array<WorkflowsArtifactFileDetail>;
id: number;
lastSyncDate?: number;
name: string;
platform?: WorkflowsDeploymentPlatform;
status?: WorkflowsWorkflowStatusObject;
target?: WorkflowsTarget;
type: WorkflowsType;
};
export type WorkflowsArtifactFileDetail = {
hash?: string;
path?: string;
pathError?: string;
pathStatus?: SourcesStatus;
ref?: string;
refError?: string;
refStatus?: SourcesStatus;
sourceId?: number;
};
export const WorkflowsDeploymentPlatform = {
/**
* DeploymentPlatformDockerStandalone
@@ -8270,6 +8311,12 @@ export type WorkflowsWorkflow = {
type: WorkflowsType;
};
export type WorkflowsWorkflowDetail = {
artifacts?: Array<WorkflowsArtifactDetail>;
id: number;
name: string;
};
export type WorkflowsWorkflowPhaseStatus = {
error?: string;
status?: WorkflowsStatus;
@@ -11796,6 +11843,43 @@ export type GitOpsWorkflowsListResponses = {
export type GitOpsWorkflowsListResponse =
GitOpsWorkflowsListResponses[keyof GitOpsWorkflowsListResponses];
export type GitOpsWorkflowGetData = {
body?: never;
path: {
/**
* Workflow identifier
*/
id: number;
};
query?: never;
url: '/gitops/workflows/{id}';
};
export type GitOpsWorkflowGetErrors = {
/**
* Invalid request
*/
400: unknown;
/**
* Workflow not found
*/
404: unknown;
/**
* Server error
*/
500: unknown;
};
export type GitOpsWorkflowGetResponses = {
/**
* OK
*/
200: WorkflowsWorkflowDetail;
};
export type GitOpsWorkflowGetResponse =
GitOpsWorkflowGetResponses[keyof GitOpsWorkflowGetResponses];
export type GitOpsWorkflowsSummaryData = {
body?: never;
path?: never;
@@ -2077,6 +2077,8 @@ export const zSourcesSourceAccessUpdatePayload = z.object({
export const zSourcesSourceType = z.enum(['git', 'helm', 'oci']);
export const zSourcesStatus = z.enum(['unknown', 'healthy', 'error']);
export const zSourcesGitAuthInfo = z.object({
username: z.string().optional(),
});
@@ -3285,6 +3287,17 @@ export const zWebhooksWebhookUpdatePayload = z.object({
RegistryID: z.int().optional(),
});
export const zWorkflowsArtifactFileDetail = z.object({
hash: z.string().optional(),
path: z.string().optional(),
pathError: z.string().optional(),
pathStatus: zSourcesStatus.optional(),
ref: z.string().optional(),
refError: z.string().optional(),
refStatus: zSourcesStatus.optional(),
sourceId: z.int().optional(),
});
export const zWorkflowsDeploymentPlatform = z.enum([
'dockerStandalone',
'dockerSwarm',
@@ -3340,6 +3353,19 @@ export const zWorkflowsWorkflowStatusObject = z.object({
target: zWorkflowsWorkflowPhaseStatus.optional(),
});
export const zWorkflowsArtifactDetail = z.object({
autoUpdate: zPortainerAutoUpdateSettings.optional(),
creationDate: z.int().optional(),
files: z.array(zWorkflowsArtifactFileDetail).optional(),
id: z.int(),
lastSyncDate: z.int().optional(),
name: z.string(),
platform: zWorkflowsDeploymentPlatform.optional(),
status: zWorkflowsWorkflowStatusObject.optional(),
target: zWorkflowsTarget.optional(),
type: zWorkflowsType,
});
export const zWorkflowsWorkflow = z.object({
autoUpdate: zPortainerAutoUpdateSettings.optional(),
creationDate: z.int().optional(),
@@ -3369,6 +3395,12 @@ export const zSourcesSourceDetail = z.object({
workflows: z.array(zWorkflowsWorkflow).optional(),
});
export const zWorkflowsWorkflowDetail = z.object({
artifacts: z.array(zWorkflowsArtifactDetail).optional(),
id: z.int(),
name: z.string(),
});
/**
* Ingress controllers
*/
@@ -4419,6 +4451,15 @@ export const zGitOpsWorkflowsListQuery = z.object({
*/
export const zGitOpsWorkflowsListResponse = z.array(zWorkflowsWorkflow);
export const zGitOpsWorkflowGetPath = z.object({
id: z.int(),
});
/**
* OK
*/
export const zGitOpsWorkflowGetResponse = zWorkflowsWorkflowDetail;
/**
* OK
*/