chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/dataservices/source"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
"github.com/portainer/portainer/api/set"
|
||||
)
|
||||
|
||||
// FetchWorkflows returns all GitOps workflows visible to the given user.
|
||||
func FetchWorkflows(
|
||||
tx dataservices.DataStoreTx,
|
||||
k8sFactory *cli.ClientFactory,
|
||||
sc *security.RestrictedRequestContext,
|
||||
endpointIDSet set.Set[portainer.EndpointID],
|
||||
) ([]Workflow, error) {
|
||||
gitConfigs := map[portainer.StackID]*gittypes.RepoConfig{}
|
||||
sourceIDs := map[portainer.StackID]portainer.SourceID{}
|
||||
sourcePhases := map[portainer.StackID]WorkflowPhaseStatus{}
|
||||
artifactPhases := map[portainer.StackID]WorkflowPhaseStatus{}
|
||||
|
||||
userContext := source.NewUserContext(sc.User, sc.UserMemberships)
|
||||
|
||||
stacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool {
|
||||
return s.WorkflowID != 0 && (len(endpointIDSet) == 0 || endpointIDSet.Contains(s.EndpointID))
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
endpointMap, err := BuildEndpointMap(tx, stacks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stacks, err = FilterDockerStacksByAccess(tx, stacks, sc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// First pass: filter by endpoint/stack-type match and collect workflow IDs.
|
||||
preFiltered := make([]portainer.Stack, 0, len(stacks))
|
||||
workflowIDSet := make(set.Set[portainer.WorkflowID], len(stacks))
|
||||
for _, stack := range stacks {
|
||||
if ep, ok := endpointMap[stack.EndpointID]; ok && !EndpointMatchesStackType(ep, stack.Type) {
|
||||
continue
|
||||
}
|
||||
preFiltered = append(preFiltered, stack)
|
||||
workflowIDSet.Add(stack.WorkflowID)
|
||||
}
|
||||
|
||||
workflowMap, sourceMap, err := LoadWorkflowAndSourceMaps(tx, userContext, workflowIDSet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Second pass: build filtered list using in-memory lookups.
|
||||
var filtered []portainer.Stack
|
||||
for _, stack := range preFiltered {
|
||||
wf := workflowMap[stack.WorkflowID]
|
||||
|
||||
hasAccessibleSource := false
|
||||
outer:
|
||||
for _, as := range wf.Artifacts {
|
||||
if as.StackID != stack.ID {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, f := range as.Files {
|
||||
src, ok := sourceMap[f.SourceID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
hasAccessibleSource = true
|
||||
|
||||
if src.Type == portainer.SourceTypeGit {
|
||||
gitConfigs[stack.ID] = MergeSourceAndFile(&src, &f)
|
||||
sourceIDs[stack.ID] = src.ID
|
||||
sourcePhases[stack.ID] = SourceStatusToPhase(f.RefStatus, f.RefError)
|
||||
artifactPhases[stack.ID] = SourceStatusToPhase(f.PathStatus, f.PathError)
|
||||
break outer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if stack.Type == portainer.KubernetesStack && !hasAccessibleSource {
|
||||
continue
|
||||
}
|
||||
|
||||
filtered = append(filtered, stack)
|
||||
}
|
||||
stacks = filtered
|
||||
|
||||
accessMap, err := buildEndpointAccessMap(k8sFactory, sc, endpointMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stacks, err = filterK8SStacks(stacks, endpointMap, k8sFactory, accessMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]Workflow, 0, len(stacks))
|
||||
for _, stack := range stacks {
|
||||
gitConfig := gitConfigs[stack.ID]
|
||||
items = append(items, MapStackToWorkflow(stack, sourceIDs[stack.ID], gitConfig, sourcePhases[stack.ID], artifactPhases[stack.ID]))
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// SourceStats holds aggregated statistics for a GitOps source.
|
||||
type SourceStats struct {
|
||||
WorkflowCount int
|
||||
EndpointIDs set.Set[portainer.EndpointID]
|
||||
}
|
||||
|
||||
// FetchSourceStats returns all sources and per-source stats for sources accessible to the given user.
|
||||
// It applies the same access control as FetchWorkflows but skips git phase checks.
|
||||
func FetchSourceStats(
|
||||
tx dataservices.DataStoreTx,
|
||||
k8sFactory *cli.ClientFactory,
|
||||
sc *security.RestrictedRequestContext,
|
||||
) ([]portainer.Source, map[portainer.SourceID]SourceStats, error) {
|
||||
userContext := source.NewUserContext(sc.User, sc.UserMemberships)
|
||||
|
||||
sources, err := tx.Source().ReadAll(userContext)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
allStacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool { return s.WorkflowID != 0 })
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
endpointMap, err := BuildEndpointMap(tx, allStacks)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
allStacks, err = FilterDockerStacksByAccess(tx, allStacks, sc)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
workflowIDSet := make(set.Set[portainer.WorkflowID], len(allStacks))
|
||||
preFiltered := make([]portainer.Stack, 0, len(allStacks))
|
||||
for _, stack := range allStacks {
|
||||
if ep, ok := endpointMap[stack.EndpointID]; ok && !EndpointMatchesStackType(ep, stack.Type) {
|
||||
continue
|
||||
}
|
||||
preFiltered = append(preFiltered, stack)
|
||||
workflowIDSet.Add(stack.WorkflowID)
|
||||
}
|
||||
|
||||
wfMap, err := LoadWorkflowMap(tx, workflowIDSet)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
wfSources := make(map[portainer.WorkflowID][]portainer.SourceID, len(wfMap))
|
||||
for id, wf := range wfMap {
|
||||
for _, as := range wf.Artifacts {
|
||||
for _, f := range as.Files {
|
||||
wfSources[id] = append(wfSources[id], f.SourceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stackSourceIDs := make(map[portainer.StackID][]portainer.SourceID)
|
||||
for _, stack := range preFiltered {
|
||||
if srcIDs := wfSources[stack.WorkflowID]; len(srcIDs) > 0 {
|
||||
stackSourceIDs[stack.ID] = srcIDs
|
||||
}
|
||||
}
|
||||
|
||||
accessMap, err := buildEndpointAccessMap(k8sFactory, sc, endpointMap)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
stacks, err := filterK8SStacks(preFiltered, endpointMap, k8sFactory, accessMap)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
stats := make(map[portainer.SourceID]SourceStats)
|
||||
|
||||
for _, stack := range stacks {
|
||||
var epIDs []portainer.EndpointID
|
||||
if stack.EndpointID != 0 {
|
||||
epIDs = []portainer.EndpointID{stack.EndpointID}
|
||||
}
|
||||
addSourceStats(stats, stackSourceIDs[stack.ID], epIDs)
|
||||
}
|
||||
|
||||
return sources, stats, nil
|
||||
}
|
||||
|
||||
func addSourceStats(result map[portainer.SourceID]SourceStats, srcIDs []portainer.SourceID, epIDs []portainer.EndpointID) {
|
||||
for _, srcID := range srcIDs {
|
||||
st := result[srcID]
|
||||
if st.EndpointIDs == nil {
|
||||
st.EndpointIDs = make(set.Set[portainer.EndpointID])
|
||||
}
|
||||
st.WorkflowCount++
|
||||
for _, epID := range epIDs {
|
||||
st.EndpointIDs.Add(epID)
|
||||
}
|
||||
result[srcID] = st
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/set"
|
||||
|
||||
"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 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(adminUserContext, 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 TestAddSourceStats_NoOp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := make(map[portainer.SourceID]SourceStats)
|
||||
addSourceStats(result, nil, nil)
|
||||
|
||||
require.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestAddSourceStats_AccumulatesWorkflowCount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := make(map[portainer.SourceID]SourceStats)
|
||||
addSourceStats(result, []portainer.SourceID{1}, nil)
|
||||
addSourceStats(result, []portainer.SourceID{1}, nil)
|
||||
|
||||
require.Equal(t, 2, result[1].WorkflowCount)
|
||||
}
|
||||
|
||||
func TestAddSourceStats_CollectsUniqueEndpointIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := make(map[portainer.SourceID]SourceStats)
|
||||
addSourceStats(result, []portainer.SourceID{1}, []portainer.EndpointID{10, 20})
|
||||
addSourceStats(result, []portainer.SourceID{1}, []portainer.EndpointID{20, 30})
|
||||
|
||||
require.Len(t, result[1].EndpointIDs, 3)
|
||||
require.True(t, result[1].EndpointIDs[10])
|
||||
require.True(t, result[1].EndpointIDs[20])
|
||||
require.True(t, result[1].EndpointIDs[30])
|
||||
}
|
||||
|
||||
func TestAddSourceStats_MultipleSourceIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := make(map[portainer.SourceID]SourceStats)
|
||||
addSourceStats(result, []portainer.SourceID{1, 2}, []portainer.EndpointID{10})
|
||||
|
||||
require.Equal(t, 1, result[1].WorkflowCount)
|
||||
require.Equal(t, 1, result[2].WorkflowCount)
|
||||
require.True(t, result[1].EndpointIDs[10])
|
||||
require.True(t, result[2].EndpointIDs[10])
|
||||
}
|
||||
|
||||
func TestFetchWorkflows_ReturnsOnlyGitopsStacks(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
mustCreateGitWorkflow(t, tx, &portainer.Stack{
|
||||
ID: 1,
|
||||
Name: "gitops-stack",
|
||||
GitConfig: &gittypes.RepoConfig{URL: "https://github.com/x/repo"},
|
||||
})
|
||||
require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 2, Name: "plain-stack"}))
|
||||
|
||||
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
|
||||
}))
|
||||
|
||||
var items []Workflow
|
||||
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
items, err = FetchWorkflows(tx, nil, adminContext(), nil)
|
||||
return err
|
||||
}))
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "gitops-stack", items[0].Name)
|
||||
}
|
||||
|
||||
func TestFetchWorkflows_FiltersByEndpointID(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
for i := 1; i <= 3; i++ {
|
||||
mustCreateGitWorkflow(t, tx, &portainer.Stack{
|
||||
ID: portainer.StackID(i),
|
||||
Name: "stack-" + strconv.Itoa(i),
|
||||
EndpointID: portainer.EndpointID(i),
|
||||
GitConfig: &gittypes.RepoConfig{URL: "https://github.com/x/" + strconv.Itoa(i)},
|
||||
})
|
||||
}
|
||||
|
||||
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
|
||||
}))
|
||||
|
||||
var items []Workflow
|
||||
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
items, err = FetchWorkflows(tx, nil, adminContext(), set.ToSet([]portainer.EndpointID{1, 2}))
|
||||
return err
|
||||
}))
|
||||
require.Len(t, items, 2)
|
||||
|
||||
names := []string{items[0].Name, items[1].Name}
|
||||
require.Contains(t, names, "stack-1")
|
||||
require.Contains(t, names, "stack-2")
|
||||
}
|
||||
|
||||
func TestFetchWorkflows_EmptyWhenNoGitopsStacks(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 1, Name: "plain-1"}))
|
||||
require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 2, Name: "plain-2"}))
|
||||
|
||||
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
|
||||
}))
|
||||
|
||||
var items []Workflow
|
||||
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
items, err = FetchWorkflows(tx, nil, adminContext(), nil)
|
||||
return err
|
||||
}))
|
||||
require.Empty(t, items)
|
||||
}
|
||||
|
||||
func TestFetchWorkflows_NilEndpointSetReturnsAll(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
for i := 1; i <= 3; i++ {
|
||||
mustCreateGitWorkflow(t, tx, &portainer.Stack{
|
||||
ID: portainer.StackID(i),
|
||||
Name: "stack-" + strconv.Itoa(i),
|
||||
EndpointID: portainer.EndpointID(i),
|
||||
GitConfig: &gittypes.RepoConfig{URL: "https://github.com/x/" + strconv.Itoa(i)},
|
||||
})
|
||||
}
|
||||
|
||||
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
|
||||
}))
|
||||
|
||||
var items []Workflow
|
||||
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
items, err = FetchWorkflows(tx, nil, adminContext(), nil)
|
||||
return err
|
||||
}))
|
||||
require.Len(t, items, 3)
|
||||
}
|
||||
|
||||
func TestFetchSourceStats_ReturnsAllSources(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
require.NoError(t, tx.Source().Create(adminUserContext, &portainer.Source{Name: "source-1", Type: portainer.SourceTypeGit, Git: &gittypes.GitSource{URL: "http://github.com/org/repo1"}}))
|
||||
require.NoError(t, tx.Source().Create(adminUserContext, &portainer.Source{Name: "source-2", Type: portainer.SourceTypeGit, Git: &gittypes.GitSource{URL: "http://github.com/org/repo2"}}))
|
||||
|
||||
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
|
||||
}))
|
||||
|
||||
var sources []portainer.Source
|
||||
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
sources, _, err = FetchSourceStats(tx, nil, adminContext())
|
||||
|
||||
return err
|
||||
}))
|
||||
|
||||
require.Len(t, sources, 2)
|
||||
}
|
||||
|
||||
func TestFetchSourceStats_TracksWorkflowCountAndEndpoints(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
var srcID portainer.SourceID
|
||||
|
||||
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
src := &portainer.Source{Name: "shared", Type: portainer.SourceTypeGit, Git: &gittypes.GitSource{URL: "http://github.com/org/repo"}}
|
||||
require.NoError(t, tx.Source().Create(adminUserContext, src))
|
||||
srcID = src.ID
|
||||
|
||||
for i := 1; i <= 2; i++ {
|
||||
wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{Files: []portainer.ArtifactFile{{SourceID: srcID}}}}}
|
||||
require.NoError(t, tx.Workflow().Create(wf))
|
||||
require.NoError(t, tx.Stack().Create(&portainer.Stack{
|
||||
ID: portainer.StackID(i),
|
||||
Name: "stack-" + strconv.Itoa(i),
|
||||
EndpointID: portainer.EndpointID(i),
|
||||
WorkflowID: wf.ID,
|
||||
}))
|
||||
}
|
||||
|
||||
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
|
||||
}))
|
||||
|
||||
var stats map[portainer.SourceID]SourceStats
|
||||
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
_, stats, err = FetchSourceStats(tx, nil, adminContext())
|
||||
|
||||
return err
|
||||
}))
|
||||
|
||||
st := stats[srcID]
|
||||
require.Equal(t, 2, st.WorkflowCount)
|
||||
require.Len(t, st.EndpointIDs, 2)
|
||||
}
|
||||
|
||||
func TestFetchSourceStats_UnusedSourceHasZeroStats(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
var unusedID portainer.SourceID
|
||||
|
||||
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
src := &portainer.Source{Name: "unused", Type: portainer.SourceTypeGit, Git: &gittypes.GitSource{URL: "http://github.com/org/repo"}}
|
||||
require.NoError(t, tx.Source().Create(adminUserContext, src))
|
||||
unusedID = src.ID
|
||||
|
||||
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
|
||||
}))
|
||||
|
||||
var stats map[portainer.SourceID]SourceStats
|
||||
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
_, stats, err = FetchSourceStats(tx, nil, adminContext())
|
||||
|
||||
return err
|
||||
}))
|
||||
|
||||
st := stats[unusedID]
|
||||
require.Zero(t, st.WorkflowCount)
|
||||
require.Empty(t, st.EndpointIDs)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
"github.com/portainer/portainer/api/internal/snapshot"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
"github.com/portainer/portainer/api/set"
|
||||
"github.com/portainer/portainer/api/slicesx"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
|
||||
"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:
|
||||
return len(ep.Snapshots) > 0 && ep.Snapshots[0].Swarm
|
||||
case portainer.DockerComposeStack:
|
||||
return len(ep.Snapshots) == 0 || !ep.Snapshots[0].Swarm
|
||||
case portainer.KubernetesStack:
|
||||
return endpointutils.IsKubernetesEndpoint(&ep)
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 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] })
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m := make(map[portainer.EndpointID]portainer.Endpoint, len(endpoints))
|
||||
for i := range endpoints {
|
||||
if err := snapshot.FillSnapshotData(tx, &endpoints[i], false); err != nil {
|
||||
return nil, fmt.Errorf("unable to fill snapshot data for endpoint %d: %w", endpoints[i].ID, err)
|
||||
}
|
||||
m[endpoints[i].ID] = endpoints[i]
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// do not try to check UAC on kube stacks
|
||||
filtered, dockerStacks := slicesx.Partition(stacks, func(s portainer.Stack) bool { return s.Type == portainer.KubernetesStack })
|
||||
|
||||
stackResourceIDSet := set.ToSet(slicesx.Map(dockerStacks, func(s portainer.Stack) string {
|
||||
return stackutils.ResourceControlID(s.EndpointID, s.Name)
|
||||
}))
|
||||
|
||||
resourceControls, err := tx.ResourceControl().ReadAll(func(rc portainer.ResourceControl) bool {
|
||||
return rc.Type == portainer.StackResourceControl && stackResourceIDSet[rc.ResourceID]
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dockerStacks = authorization.DecorateStacks(dockerStacks, resourceControls)
|
||||
|
||||
userTeamIDs := authorization.TeamIDs(sc.UserMemberships)
|
||||
filtered = append(filtered, authorization.FilterAuthorizedStacks(dockerStacks, sc.UserID, userTeamIDs)...)
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
pcli, err := k8sFactory.GetPrivilegedKubeClient(ep)
|
||||
if err != nil {
|
||||
return endpointAccess{}, fmt.Errorf("unable to get privileged kube client for endpoint %d: %w", ep.ID, err)
|
||||
}
|
||||
|
||||
teamIDs := make([]int, 0, len(sc.UserMemberships))
|
||||
for _, m := range sc.UserMemberships {
|
||||
teamIDs = append(teamIDs, int(m.TeamID))
|
||||
}
|
||||
|
||||
nonAdminNamespaces, err := pcli.GetNonAdminNamespaces(int(sc.UserID), teamIDs, ep.Kubernetes.Configuration.RestrictDefaultNamespace)
|
||||
if err != nil {
|
||||
return endpointAccess{}, fmt.Errorf("unable to retrieve non-admin namespaces for endpoint %d: %w", ep.ID, err)
|
||||
}
|
||||
|
||||
return endpointAccess{IsKubeAdmin: false, NonAdminNamespaces: nonAdminNamespaces}, nil
|
||||
}
|
||||
|
||||
type endpointAccess struct {
|
||||
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))
|
||||
|
||||
for epID, ep := range endpointMap {
|
||||
if !endpointutils.IsKubernetesEndpoint(&ep) {
|
||||
continue
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
result[epID] = access
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// lookup only if env is kube and either not edge or (edge + not async)
|
||||
func ShouldPerformEnvLookup(endpoint *portainer.Endpoint) bool {
|
||||
return endpointutils.IsKubernetesEndpoint(endpoint) &&
|
||||
(!endpointutils.IsEdgeEndpoint(endpoint) ||
|
||||
(endpointutils.IsEdgeEndpoint(endpoint) && !endpoint.Edge.AsyncMode))
|
||||
}
|
||||
|
||||
func filterK8SStacks(items []portainer.Stack, endpointMap map[portainer.EndpointID]portainer.Endpoint, k8sFactory *cli.ClientFactory, accessMap map[portainer.EndpointID]endpointAccess) ([]portainer.Stack, error) {
|
||||
k8sStacks, result := slicesx.Partition(items, func(s portainer.Stack) bool {
|
||||
return s.Type == portainer.KubernetesStack
|
||||
})
|
||||
|
||||
groupedByEnvId := slicesx.GroupBy(k8sStacks, func(s portainer.Stack) portainer.EndpointID {
|
||||
return s.EndpointID
|
||||
})
|
||||
|
||||
for envID, stacks := range groupedByEnvId {
|
||||
ep, ok := endpointMap[envID]
|
||||
if !ok || !ShouldPerformEnvLookup(&ep) {
|
||||
continue
|
||||
}
|
||||
|
||||
kcl, err := k8sFactory.GetPrivilegedKubeClient(&ep)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("context", "filterK8SStacks").Int("endpoint_id", int(envID)).Msg("Failed to get kube client for endpoint, skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
access := accessMap[envID]
|
||||
kcl.SetIsKubeAdmin(access.IsKubeAdmin)
|
||||
kcl.SetClientNonAdminNamespaces(access.NonAdminNamespaces)
|
||||
|
||||
apps, err := kcl.GetApplications("", "")
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("context", "filterK8SStacks").Int("endpoint_id", int(envID)).Msg("Failed to get kube applications for endpoint, skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
for _, s := range stacks {
|
||||
idx := slices.IndexFunc(apps, func(app kubernetes.K8sApplication) bool {
|
||||
return app.StackKind != "edge" && app.StackID == strconv.Itoa(int(s.ID))
|
||||
})
|
||||
if idx == -1 {
|
||||
continue
|
||||
}
|
||||
|
||||
app := apps[idx]
|
||||
s.Name = app.Name
|
||||
s.Namespace = app.ResourcePool
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
kfake "k8s.io/client-go/kubernetes/fake"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFilterDockerStacksByAccess_KubeStacksPassThrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
user := &portainer.User{
|
||||
ID: 1,
|
||||
Username: "standard",
|
||||
Role: portainer.StandardUserRole,
|
||||
PortainerAuthorizations: authorization.DefaultPortainerAuthorizations(),
|
||||
}
|
||||
require.NoError(t, store.User().Create(user))
|
||||
|
||||
sc := &security.RestrictedRequestContext{
|
||||
IsAdmin: false,
|
||||
UserID: 1,
|
||||
}
|
||||
|
||||
kubeStack := portainer.Stack{ID: 1, Name: "kube-stack", Type: portainer.KubernetesStack}
|
||||
dockerStack := portainer.Stack{ID: 2, Name: "docker-stack", Type: portainer.DockerComposeStack}
|
||||
|
||||
stacks := []portainer.Stack{kubeStack, dockerStack}
|
||||
|
||||
var result []portainer.Stack
|
||||
err := store.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var txErr error
|
||||
result, txErr = FilterDockerStacksByAccess(tx, stacks, sc)
|
||||
return txErr
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result, 1)
|
||||
require.Equal(t, "kube-stack", result[0].Name)
|
||||
}
|
||||
|
||||
func TestFilterDockerStacksByAccess_AdminGetsAll(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sc := &security.RestrictedRequestContext{
|
||||
IsAdmin: true,
|
||||
UserID: 1,
|
||||
}
|
||||
|
||||
stacks := []portainer.Stack{
|
||||
{ID: 1, Name: "kube-stack", Type: portainer.KubernetesStack},
|
||||
{ID: 2, Name: "docker-stack", Type: portainer.DockerComposeStack},
|
||||
}
|
||||
|
||||
result, err := FilterDockerStacksByAccess(nil, stacks, sc)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result, 2)
|
||||
}
|
||||
|
||||
func TestBuildEndpointAccessMap_AdminIsKubeAdmin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sc := &security.RestrictedRequestContext{
|
||||
IsAdmin: true,
|
||||
UserID: 1,
|
||||
}
|
||||
|
||||
endpointMap := map[portainer.EndpointID]portainer.Endpoint{
|
||||
1: {ID: 1, Type: portainer.KubernetesLocalEnvironment},
|
||||
2: {ID: 2, Type: portainer.DockerEnvironment},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func TestFilterK8SStacks_IncludesMatchingStack(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fakeKubeClient := kfake.NewSimpleClientset()
|
||||
|
||||
deployment := &appsv1.Deployment{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "my-app",
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{
|
||||
"io.portainer.kubernetes.application.stackid": "1",
|
||||
},
|
||||
},
|
||||
Spec: appsv1.DeploymentSpec{
|
||||
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "my-app"}},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := fakeKubeClient.AppsV1().Deployments("default").Create(t.Context(), deployment, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
kcl := cli.NewTestKubeClient(fakeKubeClient)
|
||||
factory := cli.NewTestClientFactory(1, kcl)
|
||||
|
||||
endpointMap := map[portainer.EndpointID]portainer.Endpoint{
|
||||
1: {ID: 1, Type: portainer.KubernetesLocalEnvironment},
|
||||
}
|
||||
|
||||
stacks := []portainer.Stack{
|
||||
{ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack},
|
||||
}
|
||||
|
||||
accessMap := map[portainer.EndpointID]endpointAccess{
|
||||
1: {IsKubeAdmin: true},
|
||||
}
|
||||
|
||||
result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result, 1)
|
||||
assert.Equal(t, "my-app", result[0].Name)
|
||||
assert.Equal(t, "default", result[0].Namespace)
|
||||
}
|
||||
|
||||
func TestFilterK8SStacks_ExcludesStackWhenNoMatchingDeployment(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fakeKubeClient := kfake.NewSimpleClientset()
|
||||
kcl := cli.NewTestKubeClient(fakeKubeClient)
|
||||
factory := cli.NewTestClientFactory(1, kcl)
|
||||
|
||||
endpointMap := map[portainer.EndpointID]portainer.Endpoint{
|
||||
1: {ID: 1, Type: portainer.KubernetesLocalEnvironment},
|
||||
}
|
||||
|
||||
stacks := []portainer.Stack{
|
||||
{ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack},
|
||||
}
|
||||
|
||||
accessMap := map[portainer.EndpointID]endpointAccess{
|
||||
1: {IsKubeAdmin: true},
|
||||
}
|
||||
|
||||
result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestFilterK8SStacks_NonAdminWithNamespaceAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fakeKubeClient := kfake.NewSimpleClientset()
|
||||
|
||||
deployment := &appsv1.Deployment{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "my-app",
|
||||
Namespace: "ns1",
|
||||
Labels: map[string]string{
|
||||
"io.portainer.kubernetes.application.stackid": "1",
|
||||
},
|
||||
},
|
||||
Spec: appsv1.DeploymentSpec{
|
||||
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "my-app"}},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := fakeKubeClient.AppsV1().Deployments("ns1").Create(t.Context(), deployment, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
kcl := cli.NewTestKubeClient(fakeKubeClient)
|
||||
factory := cli.NewTestClientFactory(1, kcl)
|
||||
|
||||
endpointMap := map[portainer.EndpointID]portainer.Endpoint{
|
||||
1: {ID: 1, Type: portainer.KubernetesLocalEnvironment},
|
||||
}
|
||||
|
||||
stacks := []portainer.Stack{
|
||||
{ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack},
|
||||
}
|
||||
|
||||
accessMap := map[portainer.EndpointID]endpointAccess{
|
||||
1: {IsKubeAdmin: false, NonAdminNamespaces: []string{"ns1"}},
|
||||
}
|
||||
|
||||
result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result, 1)
|
||||
assert.Equal(t, "my-app", result[0].Name)
|
||||
}
|
||||
|
||||
func TestResolveKubeAccess_NonAdminWithTeamMemberships(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fakeKubeClient := kfake.NewSimpleClientset()
|
||||
kcl := cli.NewTestKubeClient(fakeKubeClient)
|
||||
factory := cli.NewTestClientFactory(1, kcl)
|
||||
|
||||
ep := &portainer.Endpoint{
|
||||
ID: 1,
|
||||
Type: portainer.KubernetesLocalEnvironment,
|
||||
}
|
||||
|
||||
sc := &security.RestrictedRequestContext{
|
||||
IsAdmin: false,
|
||||
UserID: 1,
|
||||
UserMemberships: []portainer.TeamMembership{
|
||||
{TeamID: 5},
|
||||
},
|
||||
}
|
||||
|
||||
access, err := ResolveKubeAccess(factory, sc, ep)
|
||||
require.NoError(t, err)
|
||||
require.False(t, access.IsKubeAdmin)
|
||||
require.Equal(t, []string{"default"}, access.NonAdminNamespaces)
|
||||
}
|
||||
|
||||
func TestResolveKubeAccess_NonAdmin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fakeKubeClient := kfake.NewSimpleClientset()
|
||||
kcl := cli.NewTestKubeClient(fakeKubeClient)
|
||||
factory := cli.NewTestClientFactory(1, kcl)
|
||||
|
||||
ep := &portainer.Endpoint{
|
||||
ID: 1,
|
||||
Type: portainer.KubernetesLocalEnvironment,
|
||||
}
|
||||
|
||||
sc := &security.RestrictedRequestContext{
|
||||
IsAdmin: false,
|
||||
UserID: 1,
|
||||
}
|
||||
|
||||
access, err := ResolveKubeAccess(factory, sc, ep)
|
||||
require.NoError(t, err)
|
||||
require.False(t, access.IsKubeAdmin)
|
||||
require.Equal(t, []string{"default"}, access.NonAdminNamespaces)
|
||||
}
|
||||
|
||||
func TestFilterK8SStacks_NonAdminWithoutNamespaceAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fakeKubeClient := kfake.NewSimpleClientset()
|
||||
|
||||
deployment := &appsv1.Deployment{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "my-app",
|
||||
Namespace: "ns1",
|
||||
Labels: map[string]string{
|
||||
"io.portainer.kubernetes.application.stackid": "1",
|
||||
},
|
||||
},
|
||||
Spec: appsv1.DeploymentSpec{
|
||||
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "my-app"}},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := fakeKubeClient.AppsV1().Deployments("ns1").Create(t.Context(), deployment, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
kcl := cli.NewTestKubeClient(fakeKubeClient)
|
||||
factory := cli.NewTestClientFactory(1, kcl)
|
||||
|
||||
endpointMap := map[portainer.EndpointID]portainer.Endpoint{
|
||||
1: {ID: 1, Type: portainer.KubernetesLocalEnvironment},
|
||||
}
|
||||
|
||||
stacks := []portainer.Stack{
|
||||
{ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack},
|
||||
}
|
||||
|
||||
accessMap := map[portainer.EndpointID]endpointAccess{
|
||||
1: {IsKubeAdmin: false, NonAdminNamespaces: []string{}},
|
||||
}
|
||||
|
||||
result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, result)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path"
|
||||
"slices"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
)
|
||||
|
||||
// ListRefsFunc lists all git refs for a repository.
|
||||
type ListRefsFunc func(ctx context.Context) ([]string, error)
|
||||
|
||||
// ListFilesFunc lists files in a repository branch filtered by extension.
|
||||
type ListFilesFunc func(ctx context.Context, exts []string, dirOnly bool) ([]string, error)
|
||||
|
||||
// GitEntries represents a git entry which can be either a file or a directory.
|
||||
type GitEntries struct {
|
||||
Name string
|
||||
IsFile bool
|
||||
}
|
||||
|
||||
// ComputeGitPhasesForConfig computes source and artifact phases from a RepoConfig and a GitService.
|
||||
func ComputeGitPhasesForConfig(ctx context.Context, gitSvc portainer.GitService, cfg *gittypes.RepoConfig) (source, artifact WorkflowPhaseStatus) {
|
||||
if gitSvc == nil || cfg == nil {
|
||||
return WorkflowPhaseStatus{Status: StatusUnknown}, WorkflowPhaseStatus{Status: StatusUnknown}
|
||||
}
|
||||
|
||||
username, password := gitCredentials(cfg)
|
||||
return ComputeGitPhases(ctx, cfg.ReferenceName, []GitEntries{{Name: cfg.ConfigFilePath, IsFile: true}},
|
||||
func(ctx context.Context) ([]string, error) {
|
||||
return gitSvc.ListRefs(ctx, cfg.URL, username, password, false, cfg.TLSSkipVerify)
|
||||
},
|
||||
func(ctx context.Context, exts []string, dirOnly bool) ([]string, error) {
|
||||
return gitSvc.ListFiles(ctx, cfg.URL, cfg.ReferenceName, username, password, dirOnly, false, exts, cfg.TLSSkipVerify)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func gitCredentials(cfg *gittypes.RepoConfig) (username, password string) {
|
||||
if cfg.Authentication != nil {
|
||||
return cfg.Authentication.Username, cfg.Authentication.Password
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// ComputeGitPhases checks source (ref reachability) and artifact (config file presence).
|
||||
// If source fails, artifact is returned as unknown without making a network call.
|
||||
func ComputeGitPhases(ctx context.Context, referenceName string, configFilePath []GitEntries, listRefs ListRefsFunc, listFiles ListFilesFunc) (source, artifact WorkflowPhaseStatus) {
|
||||
source = computeSourcePhase(ctx, referenceName, listRefs)
|
||||
if source.Status == StatusError {
|
||||
return source, WorkflowPhaseStatus{Status: StatusUnknown}
|
||||
}
|
||||
return source, computeArtifactPhase(ctx, configFilePath, listFiles)
|
||||
}
|
||||
|
||||
func computeSourcePhase(ctx context.Context, referenceName string, listRefs ListRefsFunc) WorkflowPhaseStatus {
|
||||
refs, err := listRefs(ctx)
|
||||
if err != nil {
|
||||
return WorkflowPhaseStatus{Status: StatusError, Error: err.Error()}
|
||||
}
|
||||
if referenceName == "" {
|
||||
return WorkflowPhaseStatus{Status: StatusHealthy}
|
||||
}
|
||||
if !slices.Contains(refs, referenceName) {
|
||||
return WorkflowPhaseStatus{Status: StatusError, Error: fmt.Sprintf("ref %q not found", referenceName)}
|
||||
}
|
||||
return WorkflowPhaseStatus{Status: StatusHealthy}
|
||||
}
|
||||
|
||||
func computeArtifactPhase(ctx context.Context, gitEntries []GitEntries, listFiles ListFilesFunc) WorkflowPhaseStatus {
|
||||
if len(gitEntries) == 0 {
|
||||
return WorkflowPhaseStatus{Status: StatusError, Error: "no config file path specified"}
|
||||
}
|
||||
|
||||
var (
|
||||
exts []string
|
||||
fileEntries []string
|
||||
dirEntries []string
|
||||
)
|
||||
for _, gitEntry := range gitEntries {
|
||||
if gitEntry.IsFile {
|
||||
ext := path.Ext(gitEntry.Name)
|
||||
if len(ext) > 0 {
|
||||
ext = ext[1:]
|
||||
exts = append(exts, ext)
|
||||
}
|
||||
|
||||
fileEntries = append(fileEntries, gitEntry.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
dirEntries = append(dirEntries, gitEntry.Name)
|
||||
}
|
||||
|
||||
// Check file entries
|
||||
if len(fileEntries) > 0 {
|
||||
files, err := listFiles(ctx, exts, false)
|
||||
if err != nil {
|
||||
return WorkflowPhaseStatus{Status: StatusError, Error: err.Error()}
|
||||
}
|
||||
|
||||
for _, fileEntry := range fileEntries {
|
||||
if !slices.Contains(files, fileEntry) {
|
||||
return WorkflowPhaseStatus{Status: StatusError, Error: fmt.Sprintf("file %q not found", fileEntry)}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check directory entries
|
||||
if len(dirEntries) > 0 {
|
||||
dirs, err := listFiles(ctx, nil, true)
|
||||
if err != nil {
|
||||
return WorkflowPhaseStatus{Status: StatusError, Error: err.Error()}
|
||||
}
|
||||
|
||||
for _, dirEntry := range dirEntries {
|
||||
if !slices.Contains(dirs, dirEntry) {
|
||||
return WorkflowPhaseStatus{Status: StatusError, Error: fmt.Sprintf("directory %q not found", dirEntry)}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return WorkflowPhaseStatus{Status: StatusHealthy}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestComputeGitPhases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
okRefs := func(_ context.Context) ([]string, error) {
|
||||
return []string{"refs/heads/main"}, nil
|
||||
}
|
||||
okFiles := func(_ context.Context, _ []string, _ bool) ([]string, error) {
|
||||
return []string{"docker-compose.yml"}, nil
|
||||
}
|
||||
errRefs := func(_ context.Context) ([]string, error) {
|
||||
return nil, errors.New("connection refused")
|
||||
}
|
||||
errFiles := func(_ context.Context, _ []string, _ bool) ([]string, error) {
|
||||
return nil, errors.New("connection refused")
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
referenceName string
|
||||
configFilePath []GitEntries
|
||||
listRefs ListRefsFunc
|
||||
listFiles ListFilesFunc
|
||||
expectedSource Status
|
||||
expectedArtifact Status
|
||||
}{
|
||||
{
|
||||
name: "listRefs errors: source error, artifact unknown",
|
||||
referenceName: "refs/heads/main",
|
||||
configFilePath: []GitEntries{{Name: "docker-compose.yml", IsFile: true}},
|
||||
listRefs: errRefs,
|
||||
listFiles: okFiles,
|
||||
expectedSource: StatusError,
|
||||
expectedArtifact: StatusUnknown,
|
||||
},
|
||||
{
|
||||
name: "ref not in list: source error, artifact unknown",
|
||||
referenceName: "refs/heads/missing",
|
||||
configFilePath: []GitEntries{{Name: "docker-compose.yml", IsFile: true}},
|
||||
listRefs: func(_ context.Context) ([]string, error) {
|
||||
return []string{"refs/heads/main"}, nil
|
||||
},
|
||||
listFiles: okFiles,
|
||||
expectedSource: StatusError,
|
||||
expectedArtifact: StatusUnknown,
|
||||
},
|
||||
{
|
||||
name: "empty configFilePath: artifact error",
|
||||
referenceName: "refs/heads/main",
|
||||
configFilePath: []GitEntries{},
|
||||
listRefs: okRefs,
|
||||
listFiles: okFiles,
|
||||
expectedSource: StatusHealthy,
|
||||
expectedArtifact: StatusError,
|
||||
},
|
||||
{
|
||||
name: "listFiles errors: artifact error",
|
||||
referenceName: "refs/heads/main",
|
||||
configFilePath: []GitEntries{{Name: "docker-compose.yml", IsFile: true}},
|
||||
listRefs: okRefs,
|
||||
listFiles: errFiles,
|
||||
expectedSource: StatusHealthy,
|
||||
expectedArtifact: StatusError,
|
||||
},
|
||||
{
|
||||
name: "file not in list: artifact error",
|
||||
referenceName: "refs/heads/main",
|
||||
configFilePath: []GitEntries{{Name: "docker-compose.yml", IsFile: true}},
|
||||
listRefs: okRefs,
|
||||
listFiles: func(_ context.Context, _ []string, _ bool) ([]string, error) {
|
||||
return []string{"other.yml"}, nil
|
||||
},
|
||||
expectedSource: StatusHealthy,
|
||||
expectedArtifact: StatusError,
|
||||
},
|
||||
{
|
||||
name: "both healthy",
|
||||
referenceName: "refs/heads/main",
|
||||
configFilePath: []GitEntries{{Name: "docker-compose.yml", IsFile: true}},
|
||||
listRefs: okRefs,
|
||||
listFiles: okFiles,
|
||||
expectedSource: StatusHealthy,
|
||||
expectedArtifact: StatusHealthy,
|
||||
},
|
||||
{
|
||||
name: "empty referenceName: source healthy (default HEAD)",
|
||||
referenceName: "",
|
||||
configFilePath: []GitEntries{{Name: "docker-compose.yml", IsFile: true}},
|
||||
listRefs: okRefs,
|
||||
listFiles: okFiles,
|
||||
expectedSource: StatusHealthy,
|
||||
expectedArtifact: StatusHealthy,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
source, artifact := ComputeGitPhases(t.Context(), tc.referenceName, tc.configFilePath, tc.listRefs, tc.listFiles)
|
||||
assert.Equal(t, tc.expectedSource, source.Status)
|
||||
assert.Equal(t, tc.expectedArtifact, artifact.Status)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeArtifactPhase_ExtensionFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
configPath string
|
||||
wantExts []string
|
||||
}{
|
||||
{"docker-compose.yml", []string{"yml"}},
|
||||
{"stack.yaml", []string{"yaml"}},
|
||||
{"subdir/compose.yml", []string{"yml"}},
|
||||
{"Makefile", nil},
|
||||
{"archive.tar.gz", []string{"gz"}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.configPath, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var capturedExts []string
|
||||
ComputeGitPhases(
|
||||
t.Context(),
|
||||
"",
|
||||
[]GitEntries{{Name: tc.configPath, IsFile: true}},
|
||||
func(_ context.Context) ([]string, error) { return nil, nil },
|
||||
func(_ context.Context, exts []string, dirOnly bool) ([]string, error) {
|
||||
capturedExts = exts
|
||||
return []string{tc.configPath}, nil
|
||||
},
|
||||
)
|
||||
assert.Equal(t, tc.wantExts, capturedExts)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeGitPhases_ArtifactNotCalledOnSourceError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
listFilesCalled := false
|
||||
listRefs := func(_ context.Context) ([]string, error) {
|
||||
return nil, errors.New("repo unreachable")
|
||||
}
|
||||
listFiles := func(_ context.Context, _ []string, _ bool) ([]string, error) {
|
||||
listFilesCalled = true
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ComputeGitPhases(t.Context(), "refs/heads/main", []GitEntries{{Name: "docker-compose.yml", IsFile: true}}, listRefs, listFiles)
|
||||
|
||||
assert.False(t, listFilesCalled, "listFiles must not be called when source fails")
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer/api/dataservices/source"
|
||||
)
|
||||
|
||||
var adminUserContext = source.InsecureNewAdminContext()
|
||||
@@ -0,0 +1,238 @@
|
||||
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.
|
||||
func MapStackToWorkflow(s portainer.Stack, sourceID portainer.SourceID, gitConfig *gittypes.RepoConfig, source, artifact WorkflowPhaseStatus) Workflow {
|
||||
return Workflow{
|
||||
ID: s.WorkflowID,
|
||||
Name: s.Name,
|
||||
Type: TypeStack,
|
||||
Platform: platformFromStackType(s.Type),
|
||||
Status: WorkflowStatusObject{
|
||||
Source: source,
|
||||
Artifact: artifact,
|
||||
Target: deriveStackTargetState(s),
|
||||
},
|
||||
SourceID: sourceID,
|
||||
GitConfig: gitConfig,
|
||||
AutoUpdate: s.AutoUpdate,
|
||||
Target: Target{
|
||||
EndpointID: s.EndpointID,
|
||||
Namespace: s.Namespace,
|
||||
},
|
||||
CreationDate: s.CreationDate,
|
||||
LastSyncDate: StackLastSyncDate(s),
|
||||
}
|
||||
}
|
||||
|
||||
// MapEdgeStackToWorkflow converts an edge 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.
|
||||
func MapEdgeStackToWorkflow(wfID portainer.WorkflowID, es portainer.EdgeStack, sourceID portainer.SourceID, gitConfig *gittypes.RepoConfig, statuses []portainer.EdgeStackStatusForEnv, groupEndpoints map[portainer.EdgeGroupID][]portainer.EndpointID, source, artifact WorkflowPhaseStatus) Workflow {
|
||||
platform := DeploymentPlatformDockerStandalone
|
||||
if es.DeploymentType == portainer.EdgeStackDeploymentKubernetes {
|
||||
platform = DeploymentPlatformKubernetes
|
||||
}
|
||||
return Workflow{
|
||||
ID: wfID,
|
||||
Name: es.Name,
|
||||
Type: TypeEdgeStack,
|
||||
Platform: platform,
|
||||
Status: WorkflowStatusObject{
|
||||
Source: source,
|
||||
Artifact: artifact,
|
||||
Target: deriveEdgeStackTargetState(statuses),
|
||||
},
|
||||
SourceID: sourceID,
|
||||
GitConfig: gitConfig,
|
||||
Target: Target{
|
||||
EdgeGroupIDs: es.EdgeGroups,
|
||||
GroupStatus: edgeStackTargetStatuses(es.EdgeGroups, statuses, groupEndpoints),
|
||||
ResolvedEndpointIDs: resolveEdgeGroupEndpoints(es.EdgeGroups, groupEndpoints),
|
||||
},
|
||||
CreationDate: es.CreationDate,
|
||||
LastSyncDate: edgeStackLastSyncDate(statuses),
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return ds.Time
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func edgeStackLastSyncDate(statuses []portainer.EdgeStackStatusForEnv) int64 {
|
||||
var oldest int64
|
||||
for _, epStatus := range statuses {
|
||||
last := endpointLastSyncDate(epStatus)
|
||||
if last == 0 {
|
||||
return 0
|
||||
}
|
||||
if oldest == 0 || last < oldest {
|
||||
oldest = last
|
||||
}
|
||||
}
|
||||
return oldest
|
||||
}
|
||||
|
||||
func endpointLastSyncDate(epStatus portainer.EdgeStackStatusForEnv) int64 {
|
||||
for _, s := range slices.Backward(epStatus.Status) {
|
||||
if isEdgeStackHealthyStatus(s.Type) {
|
||||
return s.Time
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func platformFromStackType(t portainer.StackType) DeploymentPlatform {
|
||||
switch t {
|
||||
case portainer.KubernetesStack:
|
||||
return DeploymentPlatformKubernetes
|
||||
case portainer.DockerSwarmStack:
|
||||
return DeploymentPlatformDockerSwarm
|
||||
default:
|
||||
return DeploymentPlatformDockerStandalone
|
||||
}
|
||||
}
|
||||
|
||||
func isEdgeStackHealthyStatus(t portainer.EdgeStackStatusType) bool {
|
||||
switch t {
|
||||
case portainer.EdgeStackStatusRunning,
|
||||
portainer.EdgeStackStatusRolledBack,
|
||||
portainer.EdgeStackStatusCompleted,
|
||||
portainer.EdgeStackStatusRemoved,
|
||||
portainer.EdgeStackStatusRemoteUpdateSuccess:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func resolveEdgeGroupEndpoints(groups []portainer.EdgeGroupID, groupEndpoints map[portainer.EdgeGroupID][]portainer.EndpointID) []portainer.EndpointID {
|
||||
seen := set.Set[portainer.EndpointID]{}
|
||||
for _, gid := range groups {
|
||||
for _, epID := range groupEndpoints[gid] {
|
||||
seen.Add(epID)
|
||||
}
|
||||
}
|
||||
return seen.Keys()
|
||||
}
|
||||
|
||||
func edgeStackTargetStatuses(
|
||||
groups []portainer.EdgeGroupID,
|
||||
statuses []portainer.EdgeStackStatusForEnv,
|
||||
groupEndpoints map[portainer.EdgeGroupID][]portainer.EndpointID,
|
||||
) map[portainer.EdgeGroupID]Status {
|
||||
epMap := make(map[portainer.EndpointID]Status, len(statuses))
|
||||
for _, s := range statuses {
|
||||
ws, _ := endpointWorkflowStatus(s)
|
||||
epMap[s.EndpointID] = ws
|
||||
}
|
||||
|
||||
result := make(map[portainer.EdgeGroupID]Status, len(groups))
|
||||
for _, gid := range groups {
|
||||
gStatus := StatusUnknown
|
||||
for _, epID := range groupEndpoints[gid] {
|
||||
if ws := epMap[epID]; statusPriority(ws) > statusPriority(gStatus) {
|
||||
gStatus = ws
|
||||
}
|
||||
}
|
||||
result[gid] = gStatus
|
||||
}
|
||||
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),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStackLastSyncDate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("no deployment status", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equal(t, int64(0), StackLastSyncDate(portainer.Stack{}))
|
||||
})
|
||||
|
||||
t.Run("no active entry", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := portainer.Stack{DeploymentStatus: []portainer.StackDeploymentStatus{
|
||||
{Status: portainer.StackStatusDeploying, Time: 100},
|
||||
}}
|
||||
assert.Equal(t, int64(0), StackLastSyncDate(s))
|
||||
})
|
||||
|
||||
t.Run("last entry is active", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := portainer.Stack{DeploymentStatus: []portainer.StackDeploymentStatus{
|
||||
{Status: portainer.StackStatusDeploying, Time: 50},
|
||||
{Status: portainer.StackStatusActive, Time: 100},
|
||||
}}
|
||||
assert.Equal(t, int64(100), StackLastSyncDate(s))
|
||||
})
|
||||
|
||||
t.Run("active followed by non-active returns the active time", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := portainer.Stack{DeploymentStatus: []portainer.StackDeploymentStatus{
|
||||
{Status: portainer.StackStatusActive, Time: 100},
|
||||
{Status: portainer.StackStatusDeploying, Time: 200},
|
||||
}}
|
||||
assert.Equal(t, int64(100), StackLastSyncDate(s))
|
||||
})
|
||||
}
|
||||
|
||||
func TestEdgeStackLastSyncDate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("empty statuses", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equal(t, int64(0), edgeStackLastSyncDate(nil))
|
||||
})
|
||||
|
||||
t.Run("no healthy status for endpoint", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
statuses := []portainer.EdgeStackStatusForEnv{
|
||||
{EndpointID: 1, Status: []portainer.EdgeStackDeploymentStatus{
|
||||
{Type: portainer.EdgeStackStatusDeploying, Time: 100},
|
||||
}},
|
||||
}
|
||||
assert.Equal(t, int64(0), edgeStackLastSyncDate(statuses))
|
||||
})
|
||||
|
||||
t.Run("single endpoint with healthy status", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
statuses := []portainer.EdgeStackStatusForEnv{
|
||||
{EndpointID: 1, Status: []portainer.EdgeStackDeploymentStatus{
|
||||
{Type: portainer.EdgeStackStatusRunning, Time: 200},
|
||||
}},
|
||||
}
|
||||
assert.Equal(t, int64(200), edgeStackLastSyncDate(statuses))
|
||||
})
|
||||
|
||||
t.Run("returns minimum healthy time across endpoints", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
statuses := []portainer.EdgeStackStatusForEnv{
|
||||
{EndpointID: 1, Status: []portainer.EdgeStackDeploymentStatus{
|
||||
{Type: portainer.EdgeStackStatusRunning, Time: 300},
|
||||
}},
|
||||
{EndpointID: 2, Status: []portainer.EdgeStackDeploymentStatus{
|
||||
{Type: portainer.EdgeStackStatusRunning, Time: 100},
|
||||
}},
|
||||
}
|
||||
assert.Equal(t, int64(100), edgeStackLastSyncDate(statuses))
|
||||
})
|
||||
|
||||
t.Run("one endpoint not yet synced returns 0", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
statuses := []portainer.EdgeStackStatusForEnv{
|
||||
{EndpointID: 1, Status: []portainer.EdgeStackDeploymentStatus{
|
||||
{Type: portainer.EdgeStackStatusRunning, Time: 200},
|
||||
}},
|
||||
{EndpointID: 2, Status: []portainer.EdgeStackDeploymentStatus{
|
||||
{Type: portainer.EdgeStackStatusDeploying, Time: 100},
|
||||
}},
|
||||
}
|
||||
assert.Equal(t, int64(0), edgeStackLastSyncDate(statuses))
|
||||
})
|
||||
}
|
||||
|
||||
func TestEdgeStackTargetStatuses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ep := func(id portainer.EndpointID, typ portainer.EdgeStackStatusType) portainer.EdgeStackStatusForEnv {
|
||||
return portainer.EdgeStackStatusForEnv{
|
||||
EndpointID: id,
|
||||
Status: []portainer.EdgeStackDeploymentStatus{{Type: typ}},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("group with no endpoints is unknown", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := edgeStackTargetStatuses(
|
||||
[]portainer.EdgeGroupID{1},
|
||||
nil,
|
||||
map[portainer.EdgeGroupID][]portainer.EndpointID{1: {}},
|
||||
)
|
||||
assert.Equal(t, StatusUnknown, result[portainer.EdgeGroupID(1)])
|
||||
})
|
||||
|
||||
t.Run("group inherits highest-priority endpoint status", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := edgeStackTargetStatuses(
|
||||
[]portainer.EdgeGroupID{1},
|
||||
[]portainer.EdgeStackStatusForEnv{
|
||||
ep(1, portainer.EdgeStackStatusRunning),
|
||||
ep(2, portainer.EdgeStackStatusDeploying),
|
||||
},
|
||||
map[portainer.EdgeGroupID][]portainer.EndpointID{1: {1, 2}},
|
||||
)
|
||||
assert.Equal(t, StatusSyncing, result[portainer.EdgeGroupID(1)])
|
||||
})
|
||||
|
||||
t.Run("multiple groups tracked separately", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := edgeStackTargetStatuses(
|
||||
[]portainer.EdgeGroupID{10, 20},
|
||||
[]portainer.EdgeStackStatusForEnv{
|
||||
ep(1, portainer.EdgeStackStatusRunning),
|
||||
ep(2, portainer.EdgeStackStatusError),
|
||||
},
|
||||
map[portainer.EdgeGroupID][]portainer.EndpointID{
|
||||
10: {1},
|
||||
20: {2},
|
||||
},
|
||||
)
|
||||
assert.Equal(t, StatusHealthy, result[portainer.EdgeGroupID(10)])
|
||||
assert.Equal(t, StatusError, result[portainer.EdgeGroupID(20)])
|
||||
})
|
||||
}
|
||||
|
||||
func TestMapEdgeStackToWorkflow_DockerPlatform(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
es := portainer.EdgeStack{
|
||||
ID: 1,
|
||||
Name: "docker-edge",
|
||||
DeploymentType: portainer.EdgeStackDeploymentCompose,
|
||||
EdgeGroups: []portainer.EdgeGroupID{1},
|
||||
CreationDate: 1587399600,
|
||||
}
|
||||
cfg := &gittypes.RepoConfig{URL: "https://github.com/x/repo"}
|
||||
|
||||
w := MapEdgeStackToWorkflow(2, es, 7, cfg, nil, map[portainer.EdgeGroupID][]portainer.EndpointID{1: {10}}, WorkflowPhaseStatus{Status: StatusHealthy}, WorkflowPhaseStatus{Status: StatusHealthy})
|
||||
|
||||
require.Equal(t, portainer.WorkflowID(2), w.ID)
|
||||
require.Equal(t, es.Name, w.Name)
|
||||
require.Equal(t, TypeEdgeStack, w.Type)
|
||||
require.Equal(t, DeploymentPlatformDockerStandalone, w.Platform)
|
||||
require.Equal(t, es.CreationDate, w.CreationDate)
|
||||
require.Equal(t, cfg, w.GitConfig)
|
||||
require.Equal(t, portainer.SourceID(7), w.SourceID)
|
||||
require.Equal(t, []portainer.EdgeGroupID{1}, w.Target.EdgeGroupIDs)
|
||||
}
|
||||
|
||||
func TestMapEdgeStackToWorkflow_KubernetesPlatform(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
es := portainer.EdgeStack{
|
||||
ID: 2,
|
||||
Name: "kube-edge",
|
||||
DeploymentType: portainer.EdgeStackDeploymentKubernetes,
|
||||
EdgeGroups: []portainer.EdgeGroupID{1},
|
||||
}
|
||||
|
||||
w := MapEdgeStackToWorkflow(1, es, 0, nil, nil, map[portainer.EdgeGroupID][]portainer.EndpointID{}, WorkflowPhaseStatus{Status: StatusUnknown}, WorkflowPhaseStatus{Status: StatusUnknown})
|
||||
|
||||
require.Equal(t, DeploymentPlatformKubernetes, w.Platform)
|
||||
}
|
||||
|
||||
func TestMapEdgeStackToWorkflow_GroupStatusesAndResolvedEndpoints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
statuses := []portainer.EdgeStackStatusForEnv{
|
||||
{EndpointID: 10, Status: []portainer.EdgeStackDeploymentStatus{{Type: portainer.EdgeStackStatusRunning}}},
|
||||
{EndpointID: 20, Status: []portainer.EdgeStackDeploymentStatus{{Type: portainer.EdgeStackStatusError, Error: "boom"}}},
|
||||
}
|
||||
groupEndpoints := map[portainer.EdgeGroupID][]portainer.EndpointID{
|
||||
1: {10},
|
||||
2: {20},
|
||||
}
|
||||
es := portainer.EdgeStack{
|
||||
ID: 3,
|
||||
Name: "multi-group",
|
||||
EdgeGroups: []portainer.EdgeGroupID{1, 2},
|
||||
}
|
||||
|
||||
w := MapEdgeStackToWorkflow(5, es, 0, nil, statuses, groupEndpoints, WorkflowPhaseStatus{Status: StatusUnknown}, WorkflowPhaseStatus{Status: StatusUnknown})
|
||||
|
||||
require.Equal(t, StatusHealthy, w.Target.GroupStatus[1])
|
||||
require.Equal(t, StatusError, w.Target.GroupStatus[2])
|
||||
require.Len(t, w.Target.ResolvedEndpointIDs, 2)
|
||||
}
|
||||
|
||||
func TestPlatformFromStackType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Equal(t, DeploymentPlatformKubernetes, platformFromStackType(portainer.KubernetesStack))
|
||||
require.Equal(t, DeploymentPlatformDockerSwarm, platformFromStackType(portainer.DockerSwarmStack))
|
||||
require.Equal(t, DeploymentPlatformDockerStandalone, platformFromStackType(portainer.DockerComposeStack))
|
||||
require.Equal(t, DeploymentPlatformDockerStandalone, platformFromStackType(portainer.StackType(99)))
|
||||
}
|
||||
|
||||
func TestResolveEdgeGroupEndpoints_Empty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := resolveEdgeGroupEndpoints(nil, map[portainer.EdgeGroupID][]portainer.EndpointID{})
|
||||
require.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestResolveEdgeGroupEndpoints_DeduplicatesAcrossGroups(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
groupEndpoints := map[portainer.EdgeGroupID][]portainer.EndpointID{
|
||||
1: {10, 20},
|
||||
2: {20, 30},
|
||||
}
|
||||
|
||||
result := resolveEdgeGroupEndpoints([]portainer.EdgeGroupID{1, 2}, groupEndpoints)
|
||||
|
||||
require.Len(t, result, 3)
|
||||
}
|
||||
|
||||
func TestIsEdgeStackHealthyStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
healthyTypes := []portainer.EdgeStackStatusType{
|
||||
portainer.EdgeStackStatusRunning,
|
||||
portainer.EdgeStackStatusRolledBack,
|
||||
portainer.EdgeStackStatusCompleted,
|
||||
portainer.EdgeStackStatusRemoved,
|
||||
portainer.EdgeStackStatusRemoteUpdateSuccess,
|
||||
}
|
||||
for _, typ := range healthyTypes {
|
||||
require.True(t, isEdgeStackHealthyStatus(typ))
|
||||
}
|
||||
|
||||
unhealthyTypes := []portainer.EdgeStackStatusType{
|
||||
portainer.EdgeStackStatusError,
|
||||
portainer.EdgeStackStatusDeploying,
|
||||
portainer.EdgeStackStatusPending,
|
||||
}
|
||||
for _, typ := range unhealthyTypes {
|
||||
require.False(t, isEdgeStackHealthyStatus(typ))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/dataservices/source"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/api/set"
|
||||
)
|
||||
|
||||
// gitSourceStore is the minimal intersection of CE and EE DataStoreTx that these functions need.
|
||||
// Both EE and CE DataStoreTx satisfy it, even though they are incompatible as full interface types.
|
||||
type gitSourceStore interface {
|
||||
Workflow() dataservices.WorkflowService
|
||||
Source() dataservices.SourceService
|
||||
}
|
||||
|
||||
// GitSourceAndArtifactForStack returns the git Source and the ArtifactFile matching stackID
|
||||
// from the workflow identified by workflowID.
|
||||
// Source carries the shared fields (URL, auth, TLS); ArtifactFile carries the file-specific fields (ref, path, hash).
|
||||
// Returns nil, nil, nil when workflowID is 0 or no matching entry is found.
|
||||
func GitSourceAndArtifactForStack(tx gitSourceStore, userContext source.UserContext, workflowID portainer.WorkflowID, stackID portainer.StackID) (*portainer.Source, *portainer.ArtifactFile, error) {
|
||||
if workflowID == 0 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
wf, err := tx.Workflow().Read(workflowID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
sourceMap, err := LoadWorkflowSources(tx, userContext, wf)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
for i, as := range wf.Artifacts {
|
||||
if as.StackID != stackID {
|
||||
continue
|
||||
}
|
||||
|
||||
for j, file := range as.Files {
|
||||
src, ok := sourceMap[file.SourceID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if src.Type == portainer.SourceTypeGit {
|
||||
return &src, &wf.Artifacts[i].Files[j], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// GitSourceAndArtifactForEdgeStack returns the git Source and the ArtifactFile matching edgeStackID.
|
||||
// Returns nil, nil, nil when workflowID is 0 or no matching entry is found.
|
||||
func GitSourceAndArtifactForEdgeStack(tx gitSourceStore, userContext source.UserContext, workflowID portainer.WorkflowID, edgeStackID portainer.EdgeStackID) (*portainer.Source, *portainer.ArtifactFile, error) {
|
||||
if workflowID == 0 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
wf, err := tx.Workflow().Read(workflowID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
sourceMap, err := LoadWorkflowSources(tx, userContext, wf)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
for i, as := range wf.Artifacts {
|
||||
if as.EdgeStackID != edgeStackID {
|
||||
continue
|
||||
}
|
||||
|
||||
for j, file := range as.Files {
|
||||
src, ok := sourceMap[file.SourceID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if src.Type == portainer.SourceTypeGit {
|
||||
return &src, &wf.Artifacts[i].Files[j], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// MergeSourceAndFile builds a RepoConfig by combining shared fields from src (URL, auth, TLS)
|
||||
// with file-specific fields from file (ref, path, hash).
|
||||
func MergeSourceAndFile(src *portainer.Source, file *portainer.ArtifactFile) *gittypes.RepoConfig {
|
||||
if src == nil || src.Git == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := &gittypes.RepoConfig{
|
||||
URL: src.Git.URL,
|
||||
Authentication: src.Git.Authentication,
|
||||
TLSSkipVerify: src.Git.TLSSkipVerify,
|
||||
}
|
||||
|
||||
if file != nil {
|
||||
cfg.ReferenceName = file.Ref
|
||||
cfg.ConfigFilePath = file.Path
|
||||
cfg.ConfigHash = file.Hash
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// UpdateArtifactFileForStack finds the ArtifactFile matching stackID and sourceID in the workflow
|
||||
// and applies fn to it, then persists the updated Workflow.
|
||||
// A no-op if no matching artifact or file is found.
|
||||
func UpdateArtifactFileForStack(tx gitSourceStore, workflowID portainer.WorkflowID, stackID portainer.StackID, sourceID portainer.SourceID, fn func(*portainer.ArtifactFile)) error {
|
||||
wf, err := tx.Workflow().Read(workflowID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i, as := range wf.Artifacts {
|
||||
if as.StackID != stackID {
|
||||
continue
|
||||
}
|
||||
|
||||
for j, file := range as.Files {
|
||||
if file.SourceID == sourceID {
|
||||
fn(&wf.Artifacts[i].Files[j])
|
||||
|
||||
return tx.Workflow().Update(workflowID, wf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateArtifactFileForEdgeStack finds the ArtifactFile matching edgeStackID and sourceID in the workflow
|
||||
// and applies fn to it, then persists the updated Workflow.
|
||||
// A no-op if no matching artifact or file is found.
|
||||
func UpdateArtifactFileForEdgeStack(tx gitSourceStore, workflowID portainer.WorkflowID, edgeStackID portainer.EdgeStackID, sourceID portainer.SourceID, fn func(*portainer.ArtifactFile)) error {
|
||||
wf, err := tx.Workflow().Read(workflowID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i, as := range wf.Artifacts {
|
||||
if as.EdgeStackID != edgeStackID {
|
||||
continue
|
||||
}
|
||||
|
||||
for j, file := range as.Files {
|
||||
if file.SourceID == sourceID {
|
||||
fn(&wf.Artifacts[i].Files[j])
|
||||
|
||||
return tx.Workflow().Update(workflowID, wf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func UpdateSourceSyncStatus(tx gitSourceStore, userContext source.UserContext, sourceID portainer.SourceID, status portainer.SourceStatus, statusError string) error {
|
||||
if sourceID == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
src, err := tx.Source().Read(userContext, sourceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read source: %w", err)
|
||||
}
|
||||
|
||||
src.Status = status
|
||||
src.StatusError = statusError
|
||||
|
||||
if status == portainer.SourceStatusHealthy {
|
||||
src.LastSync = time.Now().Unix()
|
||||
}
|
||||
|
||||
return tx.Source().Update(userContext, src.ID, src)
|
||||
}
|
||||
|
||||
func checkResultStatus(checkErr error) (portainer.SourceStatus, string) {
|
||||
if checkErr != nil {
|
||||
return portainer.SourceStatusError, checkErr.Error()
|
||||
}
|
||||
|
||||
return portainer.SourceStatusHealthy, ""
|
||||
}
|
||||
|
||||
func SaveSourceStatus(tx gitSourceStore, userContext source.UserContext, sourceID portainer.SourceID, checkErr error) error {
|
||||
status, statusError := checkResultStatus(checkErr)
|
||||
|
||||
return UpdateSourceSyncStatus(tx, userContext, sourceID, status, statusError)
|
||||
}
|
||||
|
||||
func SaveStackStatus(tx gitSourceStore, userContext source.UserContext, workflowID portainer.WorkflowID, stackID portainer.StackID, sourceID portainer.SourceID, checkErr error) error {
|
||||
if workflowID == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
status, statusError := checkResultStatus(checkErr)
|
||||
|
||||
if err := UpdateArtifactFileForStack(tx, workflowID, stackID, sourceID, func(a *portainer.ArtifactFile) {
|
||||
a.RefStatus = status
|
||||
a.RefError = statusError
|
||||
a.PathStatus = status
|
||||
a.PathError = statusError
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return UpdateSourceSyncStatus(tx, userContext, sourceID, status, statusError)
|
||||
}
|
||||
|
||||
func SaveEdgeStackStatus(tx gitSourceStore, userContext source.UserContext, workflowID portainer.WorkflowID, edgeStackID portainer.EdgeStackID, sourceID portainer.SourceID, checkErr error) error {
|
||||
if workflowID == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
status, statusError := checkResultStatus(checkErr)
|
||||
|
||||
if err := UpdateArtifactFileForEdgeStack(tx, workflowID, edgeStackID, sourceID, func(a *portainer.ArtifactFile) {
|
||||
a.RefStatus = status
|
||||
a.RefError = statusError
|
||||
a.PathStatus = status
|
||||
a.PathError = statusError
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return UpdateSourceSyncStatus(tx, userContext, sourceID, status, statusError)
|
||||
}
|
||||
|
||||
// FindOrCreateGitSource returns an existing Source whose URL and authentication match cfg,
|
||||
// or creates a new one. Only URL, authentication, and TLSSkipVerify are stored on the Source;
|
||||
// per-stack fields (ReferenceName, ConfigFilePath, ConfigHash) belong in the Artifact.
|
||||
func FindOrCreateGitSource(tx gitSourceStore, userContext source.UserContext, src *portainer.Source) (*portainer.Source, error) {
|
||||
return tx.Source().FindOrCreateGitSource(userContext, src)
|
||||
}
|
||||
|
||||
// SaveWorkflowGitConfig persists URL/auth/TLS on the Source and ref/path/hash on the Artifact
|
||||
// matched by matchArtifact. When the URL changes, an existing or new Source is located via
|
||||
// FindOrCreateGitSource and the Workflow's SourceID is updated atomically alongside the Artifact fields.
|
||||
func SaveWorkflowGitConfig(tx gitSourceStore, userContext source.UserContext, workflowID portainer.WorkflowID, matchArtifact func(portainer.Artifact) bool, oldSourceID portainer.SourceID, cfg *gittypes.RepoConfig) error {
|
||||
src, err := tx.Source().Read(userContext, oldSourceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read source: %w", err)
|
||||
}
|
||||
|
||||
if src.Git == nil {
|
||||
return fmt.Errorf("source %d has no git configuration", oldSourceID)
|
||||
}
|
||||
|
||||
newSourceID := oldSourceID
|
||||
|
||||
if cfg.URL != src.Git.URL {
|
||||
newSrc, err := FindOrCreateGitSource(tx, userContext, &portainer.Source{
|
||||
Name: gittypes.RepoName(cfg.URL),
|
||||
Type: portainer.SourceTypeGit,
|
||||
Git: &gittypes.GitSource{
|
||||
URL: cfg.URL,
|
||||
Authentication: cfg.Authentication,
|
||||
TLSSkipVerify: cfg.TLSSkipVerify,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find or create source: %w", err)
|
||||
}
|
||||
|
||||
newSourceID = newSrc.ID
|
||||
} else {
|
||||
src.Git.Authentication = cfg.Authentication
|
||||
src.Git.TLSSkipVerify = cfg.TLSSkipVerify
|
||||
|
||||
if err := tx.Source().Update(userContext, src.ID, src); err != nil {
|
||||
return fmt.Errorf("failed to update source: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return SaveWorkflowArtifact(tx, workflowID, matchArtifact, oldSourceID, portainer.ArtifactFile{
|
||||
SourceID: newSourceID,
|
||||
Ref: cfg.ReferenceName,
|
||||
Path: cfg.ConfigFilePath,
|
||||
Hash: cfg.ConfigHash,
|
||||
})
|
||||
}
|
||||
|
||||
// SaveWorkflowArtifact replaces the ArtifactFile referencing oldSourceID on the Artifact matched by
|
||||
// matchArtifact with update (its SourceID may repoint the Artifact to a different Source). It does not
|
||||
// modify any Source's git config — the caller is responsible for ensuring update.SourceID
|
||||
// references a valid existing Source.
|
||||
func SaveWorkflowArtifact(tx gitSourceStore, workflowID portainer.WorkflowID, matchArtifact func(portainer.Artifact) bool, oldSourceID portainer.SourceID, update portainer.ArtifactFile) error {
|
||||
wf, err := tx.Workflow().Read(workflowID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read workflow: %w", err)
|
||||
}
|
||||
|
||||
for i, as := range wf.Artifacts {
|
||||
if !matchArtifact(as) {
|
||||
continue
|
||||
}
|
||||
|
||||
for j, file := range as.Files {
|
||||
if file.SourceID != oldSourceID {
|
||||
continue
|
||||
}
|
||||
|
||||
f := &wf.Artifacts[i].Files[j]
|
||||
f.SourceID = update.SourceID
|
||||
f.Ref = update.Ref
|
||||
f.Path = update.Path
|
||||
f.Hash = update.Hash
|
||||
f.RefStatus = portainer.SourceStatusUnknown
|
||||
f.RefError = ""
|
||||
f.PathStatus = portainer.SourceStatusUnknown
|
||||
f.PathError = ""
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
return tx.Workflow().Update(workflowID, wf)
|
||||
}
|
||||
|
||||
// LoadWorkflowMap fetches workflows by their IDs and returns them keyed by ID.
|
||||
func LoadWorkflowMap(tx gitSourceStore, ids set.Set[portainer.WorkflowID]) (map[portainer.WorkflowID]portainer.Workflow, error) {
|
||||
result := make(map[portainer.WorkflowID]portainer.Workflow, len(ids))
|
||||
for id := range ids {
|
||||
wf, err := tx.Workflow().Read(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[id] = *wf
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// LoadWorkflowAndSourceMaps fetches workflows by their IDs and the sources they reference,
|
||||
// collecting source IDs in a single pass over the workflows.
|
||||
func LoadWorkflowAndSourceMaps(tx gitSourceStore, userContext source.UserContext, ids set.Set[portainer.WorkflowID]) (map[portainer.WorkflowID]portainer.Workflow, map[portainer.SourceID]portainer.Source, error) {
|
||||
wfMap := make(map[portainer.WorkflowID]portainer.Workflow, len(ids))
|
||||
sourceIDs := make(set.Set[portainer.SourceID])
|
||||
for id := range ids {
|
||||
wf, err := tx.Workflow().Read(id)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
wfMap[id] = *wf
|
||||
for _, as := range wf.Artifacts {
|
||||
for _, f := range as.Files {
|
||||
sourceIDs.Add(f.SourceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
srcMap, err := loadSourceMap(tx, userContext, sourceIDs)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return wfMap, srcMap, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
ids := make(set.Set[portainer.SourceID])
|
||||
for _, as := range wf.Artifacts {
|
||||
for _, f := range as.Files {
|
||||
ids.Add(f.SourceID)
|
||||
}
|
||||
}
|
||||
|
||||
return loadSourceMap(tx, userContext, ids)
|
||||
}
|
||||
|
||||
// loadSourceMap fetches sources by their IDs and returns them keyed by ID.
|
||||
func loadSourceMap(tx gitSourceStore, userContext source.UserContext, ids set.Set[portainer.SourceID]) (map[portainer.SourceID]portainer.Source, error) {
|
||||
sources, err := tx.Source().ReadAll(userContext, func(s portainer.Source) bool {
|
||||
return ids.Contains(s.ID)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[portainer.SourceID]portainer.Source, len(ids))
|
||||
for _, src := range sources {
|
||||
result[src.ID] = src
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,162 @@
|
||||
package workflows
|
||||
|
||||
import portainer "github.com/portainer/portainer/api"
|
||||
|
||||
func SourceStatusToPhase(s portainer.SourceStatus, errMsg string) WorkflowPhaseStatus {
|
||||
switch s {
|
||||
case portainer.SourceStatusHealthy:
|
||||
return WorkflowPhaseStatus{Status: StatusHealthy}
|
||||
case portainer.SourceStatusError:
|
||||
return WorkflowPhaseStatus{Status: StatusError, Error: errMsg}
|
||||
default:
|
||||
return WorkflowPhaseStatus{Status: StatusUnknown}
|
||||
}
|
||||
}
|
||||
|
||||
// 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:
|
||||
return portainer.SourceStatusHealthy, ""
|
||||
case StatusError:
|
||||
return portainer.SourceStatusError, p.Error
|
||||
default:
|
||||
return portainer.SourceStatusUnknown, ""
|
||||
}
|
||||
}
|
||||
|
||||
func deriveStackTargetState(s portainer.Stack) WorkflowPhaseStatus {
|
||||
if len(s.DeploymentStatus) == 0 {
|
||||
return WorkflowPhaseStatus{Status: StatusHealthy}
|
||||
}
|
||||
last := s.DeploymentStatus[len(s.DeploymentStatus)-1]
|
||||
switch last.Status {
|
||||
case portainer.StackStatusActive:
|
||||
return WorkflowPhaseStatus{Status: StatusHealthy}
|
||||
case portainer.StackStatusError:
|
||||
return WorkflowPhaseStatus{Status: StatusError, Error: last.Message}
|
||||
case portainer.StackStatusDeploying:
|
||||
return WorkflowPhaseStatus{Status: StatusSyncing}
|
||||
case portainer.StackStatusInactive:
|
||||
return WorkflowPhaseStatus{Status: StatusPaused}
|
||||
default:
|
||||
return WorkflowPhaseStatus{Status: StatusUnknown}
|
||||
}
|
||||
}
|
||||
|
||||
func deriveEdgeStackTargetState(statuses []portainer.EdgeStackStatusForEnv) WorkflowPhaseStatus {
|
||||
result := StatusUnknown
|
||||
for _, epStatus := range statuses {
|
||||
ws, msg := endpointWorkflowStatus(epStatus)
|
||||
if ws == StatusError {
|
||||
return WorkflowPhaseStatus{Status: ws, Error: msg}
|
||||
}
|
||||
if statusPriority(ws) > statusPriority(result) {
|
||||
result = ws
|
||||
}
|
||||
}
|
||||
return WorkflowPhaseStatus{Status: result}
|
||||
}
|
||||
|
||||
func endpointWorkflowStatus(epStatus portainer.EdgeStackStatusForEnv) (Status, string) {
|
||||
if len(epStatus.Status) == 0 {
|
||||
return StatusUnknown, ""
|
||||
}
|
||||
last := epStatus.Status[len(epStatus.Status)-1]
|
||||
switch last.Type {
|
||||
case portainer.EdgeStackStatusError:
|
||||
return StatusError, last.Error
|
||||
case portainer.EdgeStackStatusDeploying,
|
||||
portainer.EdgeStackStatusRollingBack,
|
||||
portainer.EdgeStackStatusRemoving,
|
||||
portainer.EdgeStackStatusPending,
|
||||
portainer.EdgeStackStatusDeploymentReceived,
|
||||
portainer.EdgeStackStatusAcknowledged,
|
||||
portainer.EdgeStackStatusImagesPulled:
|
||||
return StatusSyncing, ""
|
||||
case portainer.EdgeStackStatusPausedDeploying:
|
||||
return StatusPaused, ""
|
||||
case portainer.EdgeStackStatusRunning,
|
||||
portainer.EdgeStackStatusRolledBack,
|
||||
portainer.EdgeStackStatusCompleted,
|
||||
portainer.EdgeStackStatusRemoved,
|
||||
portainer.EdgeStackStatusRemoteUpdateSuccess:
|
||||
return StatusHealthy, ""
|
||||
default:
|
||||
return StatusUnknown, ""
|
||||
}
|
||||
}
|
||||
|
||||
// EffectiveStatus returns the highest-priority status across all three phases of a workflow.
|
||||
func EffectiveStatus(w Workflow) Status {
|
||||
s := w.Status.Target.Status
|
||||
if statusPriority(w.Status.Source.Status) > statusPriority(s) {
|
||||
s = w.Status.Source.Status
|
||||
}
|
||||
if statusPriority(w.Status.Artifact.Status) > statusPriority(s) {
|
||||
s = w.Status.Artifact.Status
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// CountByStatus counts workflows per effective status and returns a StatusSummary.
|
||||
func CountByStatus(workflows []Workflow) StatusSummary {
|
||||
var s StatusSummary
|
||||
for _, w := range workflows {
|
||||
switch EffectiveStatus(w) {
|
||||
case StatusHealthy:
|
||||
s.Healthy++
|
||||
case StatusSyncing:
|
||||
s.Syncing++
|
||||
case StatusError:
|
||||
s.Error++
|
||||
case StatusPaused:
|
||||
s.Paused++
|
||||
default:
|
||||
s.Unknown++
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func statusPriority(s Status) int {
|
||||
switch s {
|
||||
case StatusError:
|
||||
return 4
|
||||
case StatusSyncing:
|
||||
return 3
|
||||
case StatusPaused:
|
||||
return 2
|
||||
case StatusHealthy:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestEffectiveStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
makeWorkflow := func(source, artifact, target Status) Workflow {
|
||||
return Workflow{
|
||||
Status: WorkflowStatusObject{
|
||||
Source: WorkflowPhaseStatus{Status: source},
|
||||
Artifact: WorkflowPhaseStatus{Status: artifact},
|
||||
Target: WorkflowPhaseStatus{Status: target},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
w Workflow
|
||||
want Status
|
||||
}{
|
||||
{"all healthy", makeWorkflow(StatusHealthy, StatusHealthy, StatusHealthy), StatusHealthy},
|
||||
{"all unknown", makeWorkflow(StatusUnknown, StatusUnknown, StatusUnknown), StatusUnknown},
|
||||
{"source error wins over syncing target", makeWorkflow(StatusError, StatusSyncing, StatusHealthy), StatusError},
|
||||
{"artifact error wins over syncing target", makeWorkflow(StatusHealthy, StatusError, StatusSyncing), StatusError},
|
||||
{"target error wins over healthy phases", makeWorkflow(StatusHealthy, StatusHealthy, StatusError), StatusError},
|
||||
{"syncing beats paused and healthy", makeWorkflow(StatusPaused, StatusSyncing, StatusHealthy), StatusSyncing},
|
||||
{"paused beats healthy", makeWorkflow(StatusHealthy, StatusPaused, StatusHealthy), StatusPaused},
|
||||
{"healthy beats unknown", makeWorkflow(StatusUnknown, StatusHealthy, StatusUnknown), StatusHealthy},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equal(t, tc.want, EffectiveStatus(tc.w))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountByStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
makeW := func(s Status) Workflow {
|
||||
return Workflow{
|
||||
Status: WorkflowStatusObject{
|
||||
Source: WorkflowPhaseStatus{Status: s},
|
||||
Artifact: WorkflowPhaseStatus{Status: s},
|
||||
Target: WorkflowPhaseStatus{Status: s},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("empty list", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equal(t, StatusSummary{}, CountByStatus(nil))
|
||||
})
|
||||
|
||||
t.Run("single healthy", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equal(t, StatusSummary{Healthy: 1}, CountByStatus([]Workflow{makeW(StatusHealthy)}))
|
||||
})
|
||||
|
||||
t.Run("mixed statuses", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
workflows := []Workflow{
|
||||
makeW(StatusHealthy),
|
||||
makeW(StatusError),
|
||||
makeW(StatusSyncing),
|
||||
makeW(StatusPaused),
|
||||
makeW(StatusUnknown),
|
||||
makeW(StatusError),
|
||||
}
|
||||
assert.Equal(t, StatusSummary{Healthy: 1, Error: 2, Syncing: 1, Paused: 1, Unknown: 1}, CountByStatus(workflows))
|
||||
})
|
||||
|
||||
t.Run("error phase overrides healthy target", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
w := Workflow{
|
||||
Status: WorkflowStatusObject{
|
||||
Source: WorkflowPhaseStatus{Status: StatusError},
|
||||
Artifact: WorkflowPhaseStatus{Status: StatusUnknown},
|
||||
Target: WorkflowPhaseStatus{Status: StatusHealthy},
|
||||
},
|
||||
}
|
||||
s := CountByStatus([]Workflow{w})
|
||||
assert.Equal(t, 1, s.Error)
|
||||
assert.Equal(t, 0, s.Healthy)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeriveEdgeStackTargetState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ep := func(id portainer.EndpointID, typ portainer.EdgeStackStatusType) portainer.EdgeStackStatusForEnv {
|
||||
return portainer.EdgeStackStatusForEnv{
|
||||
EndpointID: id,
|
||||
Status: []portainer.EdgeStackDeploymentStatus{{Type: typ}},
|
||||
}
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
statuses []portainer.EdgeStackStatusForEnv
|
||||
want Status
|
||||
}{
|
||||
{"empty", nil, StatusUnknown},
|
||||
{"all per-env status slices empty", []portainer.EdgeStackStatusForEnv{{EndpointID: 1}}, StatusUnknown},
|
||||
{"running: healthy", []portainer.EdgeStackStatusForEnv{ep(1, portainer.EdgeStackStatusRunning)}, StatusHealthy},
|
||||
{"deploying: syncing", []portainer.EdgeStackStatusForEnv{ep(1, portainer.EdgeStackStatusDeploying)}, StatusSyncing},
|
||||
{"paused deploying: paused", []portainer.EdgeStackStatusForEnv{ep(1, portainer.EdgeStackStatusPausedDeploying)}, StatusPaused},
|
||||
{"error short-circuits", []portainer.EdgeStackStatusForEnv{ep(1, portainer.EdgeStackStatusError)}, StatusError},
|
||||
{
|
||||
"error + running gives error (short-circuit, order matters)",
|
||||
[]portainer.EdgeStackStatusForEnv{
|
||||
ep(1, portainer.EdgeStackStatusError),
|
||||
ep(2, portainer.EdgeStackStatusRunning),
|
||||
},
|
||||
StatusError,
|
||||
},
|
||||
{
|
||||
"syncing beats paused",
|
||||
[]portainer.EdgeStackStatusForEnv{
|
||||
ep(1, portainer.EdgeStackStatusPausedDeploying),
|
||||
ep(2, portainer.EdgeStackStatusDeploying),
|
||||
},
|
||||
StatusSyncing,
|
||||
},
|
||||
{
|
||||
"healthy does not downgrade syncing",
|
||||
[]portainer.EdgeStackStatusForEnv{
|
||||
ep(1, portainer.EdgeStackStatusDeploying),
|
||||
ep(2, portainer.EdgeStackStatusRunning),
|
||||
},
|
||||
StatusSyncing,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := deriveEdgeStackTargetState(tc.statuses)
|
||||
assert.Equal(t, tc.want, result.Status)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/api/gitops/sources"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusHealthy Status = "healthy"
|
||||
StatusSyncing Status = "syncing"
|
||||
StatusError Status = "error"
|
||||
StatusPaused Status = "paused"
|
||||
StatusUnknown Status = "unknown"
|
||||
)
|
||||
|
||||
type Type string
|
||||
|
||||
const (
|
||||
TypeStack Type = "stack"
|
||||
TypeEdgeStack Type = "edgeStack"
|
||||
)
|
||||
|
||||
type DeploymentPlatform string
|
||||
|
||||
const (
|
||||
DeploymentPlatformDockerStandalone DeploymentPlatform = "dockerStandalone"
|
||||
DeploymentPlatformDockerSwarm DeploymentPlatform = "dockerSwarm"
|
||||
DeploymentPlatformKubernetes DeploymentPlatform = "kubernetes"
|
||||
)
|
||||
|
||||
func ParseStatus(s string) (Status, error) {
|
||||
switch Status(s) {
|
||||
case StatusHealthy, StatusSyncing, StatusError, StatusPaused, StatusUnknown:
|
||||
return Status(s), nil
|
||||
}
|
||||
return "", fmt.Errorf("unknown status %q", s)
|
||||
}
|
||||
|
||||
func ParseType(s string) (Type, error) {
|
||||
switch Type(s) {
|
||||
case TypeStack, TypeEdgeStack:
|
||||
return Type(s), nil
|
||||
}
|
||||
return "", fmt.Errorf("unknown type %q", s)
|
||||
}
|
||||
|
||||
func ParsePlatform(s string) (DeploymentPlatform, error) {
|
||||
switch DeploymentPlatform(s) {
|
||||
case DeploymentPlatformDockerStandalone, DeploymentPlatformDockerSwarm, DeploymentPlatformKubernetes:
|
||||
return DeploymentPlatform(s), nil
|
||||
}
|
||||
return "", fmt.Errorf("unknown platform %q", s)
|
||||
}
|
||||
|
||||
type Target struct {
|
||||
EndpointID portainer.EndpointID `json:"endpointId,omitempty"`
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
EdgeGroupIDs []portainer.EdgeGroupID `json:"edgeGroupIds,omitempty"`
|
||||
GroupStatus map[portainer.EdgeGroupID]Status `json:"groupStatus,omitempty"`
|
||||
ResolvedEndpointIDs []portainer.EndpointID `json:"resolvedEndpointIds,omitempty"`
|
||||
}
|
||||
|
||||
// WorkflowPhaseStatus represents the status of one phase (source, artifact, or target) of a workflow.
|
||||
// All three phases share the Status type; source and artifact only ever emit healthy, error, or unknown.
|
||||
type WorkflowPhaseStatus struct {
|
||||
Status Status `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// WorkflowStatusObject is the structured status reported for a workflow.
|
||||
type WorkflowStatusObject struct {
|
||||
Source WorkflowPhaseStatus `json:"source"`
|
||||
Artifact WorkflowPhaseStatus `json:"artifact"`
|
||||
Target WorkflowPhaseStatus `json:"target"`
|
||||
}
|
||||
|
||||
type Workflow struct {
|
||||
ID portainer.WorkflowID `json:"id" validate:"required"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
Type Type `json:"type" validate:"required"`
|
||||
Platform DeploymentPlatform `json:"platform" validate:"required"`
|
||||
Status WorkflowStatusObject `json:"status" validate:"required"`
|
||||
SourceID portainer.SourceID `json:"sourceId,omitempty"`
|
||||
GitConfig *gittypes.RepoConfig `json:"gitConfig,omitempty"`
|
||||
AutoUpdate *portainer.AutoUpdateSettings `json:"autoUpdate,omitempty"`
|
||||
Target Target `json:"target" validate:"required"`
|
||||
CreationDate int64 `json:"creationDate"`
|
||||
LastSyncDate int64 `json:"lastSyncDate"`
|
||||
}
|
||||
|
||||
type StatusSummary struct {
|
||||
Healthy int `json:"healthy"`
|
||||
Syncing int `json:"syncing"`
|
||||
Error int `json:"error"`
|
||||
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"`
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, valid := range []string{"healthy", "error", "syncing", "paused", "unknown"} {
|
||||
t.Run(valid, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s, err := ParseStatus(valid)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Status(valid), s)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("invalid returns error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := ParseStatus("garbage")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, valid := range []string{"stack", "edgeStack"} {
|
||||
t.Run(valid, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
tp, err := ParseType(valid)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Type(valid), tp)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("invalid returns error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := ParseType("garbage")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestParsePlatform(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, valid := range []string{"dockerStandalone", "dockerSwarm", "kubernetes"} {
|
||||
t.Run(valid, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
p, err := ParsePlatform(valid)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, DeploymentPlatform(valid), p)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("invalid returns error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := ParsePlatform("garbage")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user