mirror of
https://github.com/portainer/portainer.git
synced 2026-08-07 08:54:48 +00:00
feat(gitops/workflows): introduce get item api [BE-13180] (#3111)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user