chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:08:39 +08:00
commit a0df89c693
5252 changed files with 523444 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
package sources
import "github.com/portainer/portainer/api/dataservices/source"
var adminUserContext = source.InsecureNewAdminContext()
+56
View File
@@ -0,0 +1,56 @@
package sources
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices/source"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/pkg/fips"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
)
// RepoConfigInput holds the raw payload fields needed to resolve a git RepoConfig.
// Set SourceID to resolve URL/auth from a stored source; otherwise provide the inline fields.
type RepoConfigInput struct {
SourceID portainer.SourceID
ReferenceName string
ConfigFilePath string
RepositoryURL string
TLSSkipVerify bool
RepositoryAuthentication bool
Username string
Password string
Provider gittypes.GitProvider
AuthorizationType gittypes.GitCredentialAuthType
}
// ResolveRepoConfig builds a RepoConfig from either a SourceID or inline URL/auth fields.
func ResolveRepoConfig(tx gitSourceStore, userContext source.UserContext, input RepoConfigInput) (gittypes.RepoConfig, *httperror.HandlerError) {
cfg := gittypes.RepoConfig{
ReferenceName: input.ReferenceName,
ConfigFilePath: input.ConfigFilePath,
}
if input.SourceID != 0 {
src, httpErr := ValidateGitSourceAccess(tx, userContext, input.SourceID)
if httpErr != nil {
return gittypes.RepoConfig{}, httpErr
}
cfg.URL = src.Git.URL
cfg.Authentication = src.Git.Authentication
cfg.TLSSkipVerify = src.Git.TLSSkipVerify
} else {
cfg.URL = input.RepositoryURL
cfg.TLSSkipVerify = input.TLSSkipVerify
if input.RepositoryAuthentication {
cfg.Authentication = &gittypes.GitAuthentication{
Username: input.Username,
Password: input.Password,
Provider: input.Provider,
AuthorizationType: input.AuthorizationType,
}
}
}
cfg.TLSSkipVerify = cfg.TLSSkipVerify && fips.CanTLSSkipVerify()
return cfg, nil
}
+70
View File
@@ -0,0 +1,70 @@
package sources
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/pkg/fips"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func init() {
fips.InitFIPS(false)
}
func TestResolveRepoConfig_WithSourceID_ReturnsSourceConfig(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, false)
src := &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.GitSource{
URL: "https://github.com/org/repo",
TLSSkipVerify: true,
Authentication: &gittypes.GitAuthentication{
Username: "user",
Password: "token",
},
},
}
require.NoError(t, store.Source().Create(adminUserContext, src))
cfg, httpErr := ResolveRepoConfig(store, adminUserContext, RepoConfigInput{
SourceID: src.ID,
ReferenceName: "refs/heads/main",
ConfigFilePath: "docker-compose.yml",
RepositoryURL: "https://ignored.example.com",
})
require.Nil(t, httpErr)
assert.Equal(t, src.Git.URL, cfg.URL)
assert.Equal(t, src.Git.Authentication, cfg.Authentication)
assert.Equal(t, src.Git.TLSSkipVerify, cfg.TLSSkipVerify)
assert.Equal(t, "refs/heads/main", cfg.ReferenceName)
assert.Equal(t, "docker-compose.yml", cfg.ConfigFilePath)
}
func TestResolveRepoConfig_WithInlineURL_ReturnsInlineConfig(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, false)
cfg, httpErr := ResolveRepoConfig(store, adminUserContext, RepoConfigInput{
ReferenceName: "refs/heads/main",
ConfigFilePath: "docker-compose.yml",
RepositoryURL: "https://github.com/org/repo",
TLSSkipVerify: true,
RepositoryAuthentication: true,
Username: "user",
Password: "pass",
})
require.Nil(t, httpErr)
assert.Equal(t, "https://github.com/org/repo", cfg.URL)
assert.True(t, cfg.TLSSkipVerify)
require.NotNil(t, cfg.Authentication)
assert.Equal(t, "user", cfg.Authentication.Username)
assert.Equal(t, "pass", cfg.Authentication.Password)
}
+38
View File
@@ -0,0 +1,38 @@
package sources
import (
"fmt"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
)
// 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 {
Source() dataservices.SourceService
IsErrObjectNotFound(err error) bool
}
// ValidateGitSourceAccess checks that the given Source exists and is a git Source, and returns it.
func ValidateGitSourceAccess(tx gitSourceStore, userContext source.UserContext, sourceID portainer.SourceID) (*portainer.Source, *httperror.HandlerError) {
src, err := tx.Source().Read(userContext, sourceID)
if err != nil {
if tx.IsErrObjectNotFound(err) {
return nil, httperror.NotFound("Source not found", err)
}
return nil, httperror.InternalServerError("Unable to read source", err)
}
if src.Type != portainer.SourceTypeGit {
return nil, httperror.BadRequest(fmt.Sprintf("source %d is not a git source", sourceID), nil)
}
if src.Git == nil {
return nil, httperror.BadRequest("Source has no git configuration", nil)
}
return src, nil
}
+35
View File
@@ -0,0 +1,35 @@
package sources
import (
"net/http"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValidateSourceForStack_ValidGitSource_ReturnsNil(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, false)
src := &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.GitSource{URL: "https://github.com/org/repo"},
}
require.NoError(t, store.Source().Create(adminUserContext, src))
_, httpErr := ValidateGitSourceAccess(store, adminUserContext, src.ID)
assert.Nil(t, httpErr)
}
func TestValidateSourceForStack_SourceNotFound_Returns404(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, false)
_, httpErr := ValidateGitSourceAccess(store, adminUserContext, portainer.SourceID(999))
require.NotNil(t, httpErr)
assert.Equal(t, http.StatusNotFound, httpErr.StatusCode)
}
+23
View File
@@ -0,0 +1,23 @@
package sources
import portainer "github.com/portainer/portainer/api"
// Status is the string representation of portainer.Status used in API responses.
type Status string
const (
SourceStatusUnknown Status = "unknown"
SourceStatusHealthy Status = "healthy"
SourceStatusError Status = "error"
)
func StatusString(s portainer.SourceStatus) Status {
switch s {
case portainer.SourceStatusHealthy:
return SourceStatusHealthy
case portainer.SourceStatusError:
return SourceStatusError
default:
return SourceStatusUnknown
}
}
+218
View File
@@ -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
}
}
+275
View File
@@ -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)
}
+189
View File
@@ -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
}
+289
View File
@@ -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)
}
+127
View File
@@ -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}
}
+162
View File
@@ -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")
}
+7
View File
@@ -0,0 +1,7 @@
package workflows
import (
"github.com/portainer/portainer/api/dataservices/source"
)
var adminUserContext = source.InsecureNewAdminContext()
+238
View File
@@ -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),
}
})
}
+268
View File
@@ -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))
}
}
+404
View File
@@ -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
+162
View File
@@ -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
}
}
+289
View File
@@ -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)
})
}
}
+124
View File
@@ -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"`
}
+65
View File
@@ -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)
})
}