diff --git a/api/gitops/sources/status.go b/api/gitops/sources/status.go new file mode 100644 index 0000000000..de8aa9c08f --- /dev/null +++ b/api/gitops/sources/status.go @@ -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 + } +} diff --git a/api/gitops/workflows/fetch.go b/api/gitops/workflows/fetch.go index d805e8594f..9c0582b4ff 100644 --- a/api/gitops/workflows/fetch.go +++ b/api/gitops/workflows/fetch.go @@ -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 } diff --git a/api/gitops/workflows/filter.go b/api/gitops/workflows/filter.go index 1e87f280f6..3263f6152d 100644 --- a/api/gitops/workflows/filter.go +++ b/api/gitops/workflows/filter.go @@ -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 { diff --git a/api/gitops/workflows/filter_test.go b/api/gitops/workflows/filter_test.go index 7d7982084e..3693f856b8 100644 --- a/api/gitops/workflows/filter_test.go +++ b/api/gitops/workflows/filter_test.go @@ -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) diff --git a/api/gitops/workflows/mapping.go b/api/gitops/workflows/mapping.go index df0eca4bea..3d7c8f552b 100644 --- a/api/gitops/workflows/mapping.go +++ b/api/gitops/workflows/mapping.go @@ -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), + } + }) +} diff --git a/api/gitops/workflows/source_artifact.go b/api/gitops/workflows/source_artifact.go index 3257d84570..3df1825508 100644 --- a/api/gitops/workflows/source_artifact.go +++ b/api/gitops/workflows/source_artifact.go @@ -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 { diff --git a/api/gitops/workflows/status.go b/api/gitops/workflows/status.go index f106c99903..d08adc7dc2 100644 --- a/api/gitops/workflows/status.go +++ b/api/gitops/workflows/status.go @@ -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: diff --git a/api/gitops/workflows/status_test.go b/api/gitops/workflows/status_test.go index c86917dbd2..d15f4eea68 100644 --- a/api/gitops/workflows/status_test.go +++ b/api/gitops/workflows/status_test.go @@ -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) + }) + } +} diff --git a/api/gitops/workflows/types.go b/api/gitops/workflows/types.go index 12abab2e60..267ae11c95 100644 --- a/api/gitops/workflows/types.go +++ b/api/gitops/workflows/types.go @@ -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"` +} diff --git a/api/http/handler/edgegroups/associated_endpoints.go b/api/http/handler/edgegroups/associated_endpoints.go index b26e94d0c0..7310be777a 100644 --- a/api/http/handler/edgegroups/associated_endpoints.go +++ b/api/http/handler/edgegroups/associated_endpoints.go @@ -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 -} diff --git a/api/http/handler/edgegroups/edgegroup_inspect.go b/api/http/handler/edgegroups/edgegroup_inspect.go index 432747a7eb..2742801771 100644 --- a/api/http/handler/edgegroups/edgegroup_inspect.go +++ b/api/http/handler/edgegroups/edgegroup_inspect.go @@ -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) } diff --git a/api/http/handler/edgegroups/edgegroup_list.go b/api/http/handler/edgegroups/edgegroup_list.go index 610da85da3..24c58182eb 100644 --- a/api/http/handler/edgegroups/edgegroup_list.go +++ b/api/http/handler/edgegroups/edgegroup_list.go @@ -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) } diff --git a/api/http/handler/endpoints/filter.go b/api/http/handler/endpoints/filter.go index 6e886a7b48..2bacfe1d95 100644 --- a/api/http/handler/endpoints/filter.go +++ b/api/http/handler/endpoints/filter.go @@ -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") } diff --git a/api/http/handler/gitops/workflows/fetch.go b/api/http/handler/gitops/workflows/fetch.go new file mode 100644 index 0000000000..a3c054f89d --- /dev/null +++ b/api/http/handler/gitops/workflows/fetch.go @@ -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 + }) +} diff --git a/api/http/handler/gitops/workflows/fetch_test.go b/api/http/handler/gitops/workflows/fetch_test.go new file mode 100644 index 0000000000..f4dca87341 --- /dev/null +++ b/api/http/handler/gitops/workflows/fetch_test.go @@ -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) + +} diff --git a/api/http/handler/gitops/workflows/get.go b/api/http/handler/gitops/workflows/get.go new file mode 100644 index 0000000000..ff05f05015 --- /dev/null +++ b/api/http/handler/gitops/workflows/get.go @@ -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) +} diff --git a/api/http/handler/gitops/workflows/get_test.go b/api/http/handler/gitops/workflows/get_test.go new file mode 100644 index 0000000000..584108a365 --- /dev/null +++ b/api/http/handler/gitops/workflows/get_test.go @@ -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) +} diff --git a/api/http/handler/gitops/workflows/handler.go b/api/http/handler/gitops/workflows/handler.go index b64d3e182a..53adb7d8d6 100644 --- a/api/http/handler/gitops/workflows/handler.go +++ b/api/http/handler/gitops/workflows/handler.go @@ -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 } diff --git a/api/http/handler/gitops/workflows/helpers_test.go b/api/http/handler/gitops/workflows/helpers_test.go index 9c4b6ee203..7631bfd5e4 100644 --- a/api/http/handler/gitops/workflows/helpers_test.go +++ b/api/http/handler/gitops/workflows/helpers_test.go @@ -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() diff --git a/api/internal/endpointutils/endpoint_tags.go b/api/internal/endpointutils/endpoint_tags.go new file mode 100644 index 0000000000..ab7e8600f3 --- /dev/null +++ b/api/internal/endpointutils/endpoint_tags.go @@ -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 +} diff --git a/app/react/portainer/generated-api/portainer/sdk.gen.ts b/app/react/portainer/generated-api/portainer/sdk.gen.ts index aadaba1da1..a9f20ca32f 100644 --- a/app/react/portainer/generated-api/portainer/sdk.gen.ts +++ b/app/react/portainer/generated-api/portainer/sdk.gen.ts @@ -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 = ( ...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 = ( + options: Options +): 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 * diff --git a/app/react/portainer/generated-api/portainer/types.gen.ts b/app/react/portainer/generated-api/portainer/types.gen.ts index 8f6726c7bd..ff6d2551e4 100644 --- a/app/react/portainer/generated-api/portainer/types.gen.ts +++ b/app/react/portainer/generated-api/portainer/types.gen.ts @@ -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; + 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; + 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; diff --git a/app/react/portainer/generated-api/portainer/zod.gen.ts b/app/react/portainer/generated-api/portainer/zod.gen.ts index ef391fc145..bdfd94abe5 100644 --- a/app/react/portainer/generated-api/portainer/zod.gen.ts +++ b/app/react/portainer/generated-api/portainer/zod.gen.ts @@ -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 */