mirror of
https://github.com/portainer/portainer.git
synced 2026-08-07 11:04:49 +00:00
feat(gitops): add status persistence for sources and artifacts BE-13166 (#3113)
This commit is contained in:
@@ -1,8 +1,6 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/dataservices/source"
|
||||
@@ -14,14 +12,14 @@ import (
|
||||
|
||||
// FetchWorkflows returns all GitOps workflows visible to the given user.
|
||||
func FetchWorkflows(
|
||||
ctx context.Context,
|
||||
tx dataservices.DataStoreTx,
|
||||
gitService portainer.GitService,
|
||||
k8sFactory *cli.ClientFactory,
|
||||
sc *security.RestrictedRequestContext,
|
||||
endpointIDSet set.Set[portainer.EndpointID],
|
||||
) ([]Workflow, error) {
|
||||
gitConfigs := map[portainer.StackID]*gittypes.RepoConfig{}
|
||||
sourcePhases := map[portainer.StackID]WorkflowPhaseStatus{}
|
||||
artifactPhases := map[portainer.StackID]WorkflowPhaseStatus{}
|
||||
|
||||
userContext := source.NewUserContext(sc.User, sc.UserMemberships)
|
||||
|
||||
@@ -80,6 +78,8 @@ func FetchWorkflows(
|
||||
|
||||
if src.Type == portainer.SourceTypeGit {
|
||||
gitConfigs[stack.ID] = MergeSourceAndFile(&src, &f)
|
||||
sourcePhases[stack.ID] = SourceStatusToPhase(f.RefStatus, f.RefError)
|
||||
artifactPhases[stack.ID] = SourceStatusToPhase(f.PathStatus, f.PathError)
|
||||
break outer
|
||||
}
|
||||
}
|
||||
@@ -106,8 +106,7 @@ func FetchWorkflows(
|
||||
items := make([]Workflow, 0, len(stacks))
|
||||
for _, stack := range stacks {
|
||||
gitConfig := gitConfigs[stack.ID]
|
||||
source, artifact := ComputeGitPhasesForConfig(ctx, gitService, gitConfig)
|
||||
items = append(items, MapStackToWorkflow(stack, gitConfig, source, artifact))
|
||||
items = append(items, MapStackToWorkflow(stack, gitConfig, sourcePhases[stack.ID], artifactPhases[stack.ID]))
|
||||
}
|
||||
|
||||
return items, nil
|
||||
@@ -117,7 +116,6 @@ func FetchWorkflows(
|
||||
type SourceStats struct {
|
||||
WorkflowCount int
|
||||
EndpointIDs set.Set[portainer.EndpointID]
|
||||
LastSync int64
|
||||
}
|
||||
|
||||
// FetchSourceStats returns all sources and per-source stats for sources accessible to the given user.
|
||||
@@ -197,13 +195,13 @@ func FetchSourceStats(
|
||||
if stack.EndpointID != 0 {
|
||||
epIDs = []portainer.EndpointID{stack.EndpointID}
|
||||
}
|
||||
addSourceStats(stats, stackSourceIDs[stack.ID], epIDs, StackLastSyncDate(stack))
|
||||
addSourceStats(stats, stackSourceIDs[stack.ID], epIDs)
|
||||
}
|
||||
|
||||
return sources, stats, nil
|
||||
}
|
||||
|
||||
func addSourceStats(result map[portainer.SourceID]SourceStats, srcIDs []portainer.SourceID, epIDs []portainer.EndpointID, lastSync int64) {
|
||||
func addSourceStats(result map[portainer.SourceID]SourceStats, srcIDs []portainer.SourceID, epIDs []portainer.EndpointID) {
|
||||
for _, srcID := range srcIDs {
|
||||
st := result[srcID]
|
||||
if st.EndpointIDs == nil {
|
||||
@@ -213,7 +211,6 @@ func addSourceStats(result map[portainer.SourceID]SourceStats, srcIDs []portaine
|
||||
for _, epID := range epIDs {
|
||||
st.EndpointIDs.Add(epID)
|
||||
}
|
||||
st.LastSync = max(lastSync, st.LastSync)
|
||||
result[srcID] = st
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestAddSourceStats_NoOp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := make(map[portainer.SourceID]SourceStats)
|
||||
addSourceStats(result, nil, nil, 0)
|
||||
addSourceStats(result, nil, nil)
|
||||
|
||||
require.Empty(t, result)
|
||||
}
|
||||
@@ -55,8 +55,8 @@ func TestAddSourceStats_AccumulatesWorkflowCount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := make(map[portainer.SourceID]SourceStats)
|
||||
addSourceStats(result, []portainer.SourceID{1}, nil, 0)
|
||||
addSourceStats(result, []portainer.SourceID{1}, nil, 0)
|
||||
addSourceStats(result, []portainer.SourceID{1}, nil)
|
||||
addSourceStats(result, []portainer.SourceID{1}, nil)
|
||||
|
||||
require.Equal(t, 2, result[1].WorkflowCount)
|
||||
}
|
||||
@@ -65,8 +65,8 @@ func TestAddSourceStats_CollectsUniqueEndpointIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := make(map[portainer.SourceID]SourceStats)
|
||||
addSourceStats(result, []portainer.SourceID{1}, []portainer.EndpointID{10, 20}, 0)
|
||||
addSourceStats(result, []portainer.SourceID{1}, []portainer.EndpointID{20, 30}, 0)
|
||||
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])
|
||||
@@ -74,22 +74,11 @@ func TestAddSourceStats_CollectsUniqueEndpointIDs(t *testing.T) {
|
||||
require.True(t, result[1].EndpointIDs[30])
|
||||
}
|
||||
|
||||
func TestAddSourceStats_MaxLastSync(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := make(map[portainer.SourceID]SourceStats)
|
||||
addSourceStats(result, []portainer.SourceID{1}, nil, 100)
|
||||
addSourceStats(result, []portainer.SourceID{1}, nil, 500)
|
||||
addSourceStats(result, []portainer.SourceID{1}, nil, 200)
|
||||
|
||||
require.Equal(t, int64(500), result[1].LastSync)
|
||||
}
|
||||
|
||||
func TestAddSourceStats_MultipleSourceIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := make(map[portainer.SourceID]SourceStats)
|
||||
addSourceStats(result, []portainer.SourceID{1, 2}, []portainer.EndpointID{10}, 100)
|
||||
addSourceStats(result, []portainer.SourceID{1, 2}, []portainer.EndpointID{10})
|
||||
|
||||
require.Equal(t, 1, result[1].WorkflowCount)
|
||||
require.Equal(t, 1, result[2].WorkflowCount)
|
||||
@@ -115,7 +104,7 @@ func TestFetchWorkflows_ReturnsOnlyGitopsStacks(t *testing.T) {
|
||||
var items []Workflow
|
||||
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
items, err = FetchWorkflows(t.Context(), tx, nil, nil, adminContext(), nil)
|
||||
items, err = FetchWorkflows(tx, nil, adminContext(), nil)
|
||||
return err
|
||||
}))
|
||||
require.Len(t, items, 1)
|
||||
@@ -142,7 +131,7 @@ func TestFetchWorkflows_FiltersByEndpointID(t *testing.T) {
|
||||
var items []Workflow
|
||||
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
items, err = FetchWorkflows(t.Context(), tx, nil, nil, adminContext(), set.ToSet([]portainer.EndpointID{1, 2}))
|
||||
items, err = FetchWorkflows(tx, nil, adminContext(), set.ToSet([]portainer.EndpointID{1, 2}))
|
||||
return err
|
||||
}))
|
||||
require.Len(t, items, 2)
|
||||
@@ -166,7 +155,7 @@ func TestFetchWorkflows_EmptyWhenNoGitopsStacks(t *testing.T) {
|
||||
var items []Workflow
|
||||
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
items, err = FetchWorkflows(t.Context(), tx, nil, nil, adminContext(), nil)
|
||||
items, err = FetchWorkflows(tx, nil, adminContext(), nil)
|
||||
return err
|
||||
}))
|
||||
require.Empty(t, items)
|
||||
@@ -192,7 +181,7 @@ func TestFetchWorkflows_NilEndpointSetReturnsAll(t *testing.T) {
|
||||
var items []Workflow
|
||||
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
items, err = FetchWorkflows(t.Context(), tx, nil, nil, adminContext(), nil)
|
||||
items, err = FetchWorkflows(tx, nil, adminContext(), nil)
|
||||
return err
|
||||
}))
|
||||
require.Len(t, items, 3)
|
||||
|
||||
@@ -2,6 +2,7 @@ package workflows
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
@@ -167,6 +168,78 @@ func UpdateArtifactFileForEdgeStack(tx gitSourceStore, workflowID portainer.Work
|
||||
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.
|
||||
@@ -246,6 +319,10 @@ func SaveWorkflowArtifact(tx gitSourceStore, workflowID portainer.WorkflowID, ma
|
||||
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
|
||||
}
|
||||
|
||||
@@ -985,6 +985,69 @@ func TestMergeSourceAndFile_ConfigHashComesFromFileNotSource(t *testing.T) {
|
||||
require.Equal(t, "artifact-hash", cfg.ConfigHash)
|
||||
}
|
||||
|
||||
func TestUpdateSourceSyncStatus_HealthyBumpsLastSyncAndClearsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
var sourceID portainer.SourceID
|
||||
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
src := &portainer.Source{
|
||||
Type: portainer.SourceTypeGit,
|
||||
Git: &gittypes.GitSource{URL: "https://example.com"},
|
||||
Status: portainer.SourceStatusError,
|
||||
StatusError: "previous failure",
|
||||
}
|
||||
err := tx.Source().Create(adminUserContext, src)
|
||||
require.NoError(t, err)
|
||||
sourceID = src.ID
|
||||
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return UpdateSourceSyncStatus(tx, adminUserContext, sourceID, portainer.SourceStatusHealthy, "")
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
src, err := store.Source().Read(adminUserContext, sourceID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, portainer.SourceStatusHealthy, src.Status)
|
||||
require.Empty(t, src.StatusError)
|
||||
require.NotZero(t, src.LastSync)
|
||||
}
|
||||
|
||||
func TestUpdateSourceSyncStatus_ErrorPreservesPriorLastSync(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
var sourceID portainer.SourceID
|
||||
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
src := &portainer.Source{
|
||||
Type: portainer.SourceTypeGit,
|
||||
Git: &gittypes.GitSource{URL: "https://example.com"},
|
||||
LastSync: 12345,
|
||||
}
|
||||
err := tx.Source().Create(adminUserContext, src)
|
||||
require.NoError(t, err)
|
||||
sourceID = src.ID
|
||||
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return UpdateSourceSyncStatus(tx, adminUserContext, sourceID, portainer.SourceStatusError, "git fetch failed")
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
src, err := store.Source().Read(adminUserContext, sourceID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, portainer.SourceStatusError, src.Status)
|
||||
require.Equal(t, "git fetch failed", src.StatusError)
|
||||
require.Equal(t, int64(12345), src.LastSync)
|
||||
}
|
||||
|
||||
func TestFindOrCreateGitSource_StripsEmbeddedCredentialsFromURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
@@ -2,6 +2,28 @@ 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}
|
||||
}
|
||||
}
|
||||
|
||||
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}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/api/gitops/sources"
|
||||
"github.com/portainer/portainer/api/gitops/workflows"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
@@ -155,11 +156,11 @@ func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dat
|
||||
stack.CreatedBy = stackutils.SanitizeLabel(tokenData.Username)
|
||||
|
||||
return handler.edgeStacksService.PersistEdgeStack(tx, stack, func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath string, manifestPath string, projectPath string, err error) {
|
||||
return handler.storeManifestFromGitRepository(context.TODO(), tx, stackFolder, relatedEndpointIds, payload.DeploymentType, tokenData.ID, repoConfig)
|
||||
return handler.storeManifestFromGitRepository(context.TODO(), tx, userContext, payload.SourceID, stackFolder, relatedEndpointIds, payload.DeploymentType, tokenData.ID, repoConfig)
|
||||
})
|
||||
}
|
||||
|
||||
func (handler *Handler) storeManifestFromGitRepository(ctx context.Context, tx dataservices.DataStoreTx, stackFolder string, relatedEndpointIds []portainer.EndpointID, deploymentType portainer.EdgeStackDeploymentType, currentUserID portainer.UserID, repositoryConfig gittypes.RepoConfig) (composePath, manifestPath, projectPath string, err error) {
|
||||
func (handler *Handler) storeManifestFromGitRepository(ctx context.Context, tx dataservices.DataStoreTx, userContext source.UserContext, sourceID portainer.SourceID, stackFolder string, relatedEndpointIds []portainer.EndpointID, deploymentType portainer.EdgeStackDeploymentType, currentUserID portainer.UserID, repositoryConfig gittypes.RepoConfig) (composePath, manifestPath, projectPath string, err error) {
|
||||
if hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, deploymentType); err != nil {
|
||||
return "", "", "", fmt.Errorf("unable to check for existence of non fitting environments: %w", err)
|
||||
} else if hasWrongType {
|
||||
@@ -183,9 +184,17 @@ func (handler *Handler) storeManifestFromGitRepository(ctx context.Context, tx d
|
||||
repositoryPassword,
|
||||
repositoryConfig.TLSSkipVerify,
|
||||
); err != nil {
|
||||
if statusErr := workflows.SaveSourceStatus(tx, userContext, sourceID, err); statusErr != nil {
|
||||
return "", "", "", fmt.Errorf("%w (and failed to persist status: %w)", err, statusErr)
|
||||
}
|
||||
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
if err := workflows.SaveSourceStatus(tx, userContext, sourceID, nil); err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to persist source sync status: %w", err)
|
||||
}
|
||||
|
||||
if deploymentType == portainer.EdgeStackDeploymentCompose {
|
||||
return repositoryConfig.ConfigFilePath, "", projectPath, nil
|
||||
}
|
||||
|
||||
@@ -80,8 +80,9 @@ func (h *Handler) gitSourceCreate(w http.ResponseWriter, r *http.Request) *httpe
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
|
||||
|
||||
if err := h.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
|
||||
return tx.Source().Create(userContext, src)
|
||||
}); errors.Is(err, source.ErrDuplicateSource) {
|
||||
return httperror.Conflict("A source with this URL and credentials already exists", err)
|
||||
@@ -89,6 +90,10 @@ func (h *Handler) gitSourceCreate(w http.ResponseWriter, r *http.Request) *httpe
|
||||
return httperror.InternalServerError("Unable to create source", err)
|
||||
}
|
||||
|
||||
if src, err = h.testAndSaveSourceConnection(r.Context(), userContext, src); err != nil {
|
||||
return httperror.InternalServerError("Unable to persist source status", err)
|
||||
}
|
||||
|
||||
h.invalidateCache()
|
||||
|
||||
src.Git = gittypes.SanitizeGitSource(src.Git)
|
||||
|
||||
@@ -71,9 +71,6 @@ func FetchSourceWorkflows(tx dataservices.DataStoreTx, src *portainer.Source) ([
|
||||
if stacks.EndpointID != 0 {
|
||||
stats.EndpointIDs.Add(stacks.EndpointID)
|
||||
}
|
||||
if lastSync := ce.StackLastSyncDate(stacks); lastSync > stats.LastSync {
|
||||
stats.LastSync = lastSync
|
||||
}
|
||||
}
|
||||
|
||||
return items, stats, nil
|
||||
|
||||
@@ -98,7 +98,7 @@ func (h *Handler) getSource(w http.ResponseWriter, r *http.Request) *httperror.H
|
||||
|
||||
access := BuildSourceAccess(source)
|
||||
|
||||
detail := BuildSourceDetail(h.buildSource(r.Context(), source, stats), source.Git, sourceWfs, access)
|
||||
detail := BuildSourceDetail(h.buildSource(source, stats), source.Git, sourceWfs, access)
|
||||
return response.JSON(w, detail)
|
||||
}
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ func (h *Handler) fetchSources(ctx context.Context, sc *security.RestrictedReque
|
||||
stat = workflows.SourceStats{}
|
||||
}
|
||||
|
||||
result = append(result, h.buildSource(ctx, &src, stat))
|
||||
result = append(result, h.buildSource(&src, stat))
|
||||
}
|
||||
|
||||
return result, nil
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"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/gitops/workflows"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
@@ -131,3 +132,31 @@ func testSourceConnection(ctx context.Context, gitService portainer.GitService,
|
||||
|
||||
return ConnectionTestResult{Success: true}
|
||||
}
|
||||
func (h *Handler) testAndSaveSourceConnection(ctx context.Context, userContext source.UserContext, src *portainer.Source) (*portainer.Source, error) {
|
||||
if h.gitService == nil || src.Git == nil {
|
||||
return src, nil
|
||||
}
|
||||
|
||||
result := testSourceConnection(ctx, h.gitService, src.Git)
|
||||
|
||||
var checkErr error
|
||||
if !result.Success {
|
||||
checkErr = errors.New(result.Error)
|
||||
}
|
||||
|
||||
var updated *portainer.Source
|
||||
if err := h.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
if err := workflows.SaveSourceStatus(tx, userContext, src.ID, checkErr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var readErr error
|
||||
updated, readErr = tx.Source().Read(userContext, src.ID)
|
||||
|
||||
return readErr
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
@@ -78,13 +78,13 @@ func (h *Handler) gitSourceUpdate(w http.ResponseWriter, r *http.Request) *httpe
|
||||
}
|
||||
|
||||
sourceID := portainer.SourceID(id)
|
||||
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
|
||||
|
||||
var src *portainer.Source
|
||||
|
||||
if err := h.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
|
||||
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
|
||||
if src, err = tx.Source().Read(userContext, sourceID); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -93,6 +93,9 @@ func (h *Handler) gitSourceUpdate(w http.ResponseWriter, r *http.Request) *httpe
|
||||
return err
|
||||
}
|
||||
|
||||
src.Status = portainer.SourceStatusUnknown
|
||||
src.StatusError = ""
|
||||
|
||||
return tx.Source().Update(userContext, src.ID, src)
|
||||
}); h.dataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find a source with the specified identifier", err)
|
||||
@@ -106,6 +109,10 @@ func (h *Handler) gitSourceUpdate(w http.ResponseWriter, r *http.Request) *httpe
|
||||
return httperror.InternalServerError("Unable to update source", err)
|
||||
}
|
||||
|
||||
if src, err = h.testAndSaveSourceConnection(r.Context(), userContext, src); err != nil {
|
||||
return httperror.InternalServerError("Unable to persist source status", err)
|
||||
}
|
||||
|
||||
h.invalidateCache()
|
||||
|
||||
src.Git = gittypes.SanitizeGitSource(src.Git)
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
package sources
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
ce "github.com/portainer/portainer/api/gitops/workflows"
|
||||
)
|
||||
|
||||
func (h *Handler) buildSource(ctx context.Context, src *portainer.Source, stats ce.SourceStats) Source {
|
||||
var status ce.Status
|
||||
var sourceErr string
|
||||
if src.Git != nil {
|
||||
phase, _ := ce.ComputeGitPhasesForConfig(ctx, h.gitService, src.Git.ToRepoConfig())
|
||||
status = phase.Status
|
||||
sourceErr = phase.Error
|
||||
} else {
|
||||
status = ce.StatusUnknown
|
||||
}
|
||||
func (h *Handler) buildSource(src *portainer.Source, stats ce.SourceStats) Source {
|
||||
phase := ce.SourceStatusToPhase(src.Status, src.StatusError)
|
||||
|
||||
url := ""
|
||||
if src.Git != nil {
|
||||
@@ -29,11 +19,11 @@ func (h *Handler) buildSource(ctx context.Context, src *portainer.Source, stats
|
||||
Name: src.Name,
|
||||
Type: sourceTypeString(src.Type),
|
||||
URL: url,
|
||||
Status: status,
|
||||
Error: sourceErr,
|
||||
Status: phase.Status,
|
||||
Error: phase.Error,
|
||||
UsedBy: stats.WorkflowCount,
|
||||
Environments: len(stats.EndpointIDs),
|
||||
LastSync: stats.LastSync,
|
||||
LastSync: src.LastSync,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ func (h *Handler) getWorkflows(ctx context.Context, key string, sc *security.Res
|
||||
var result []svc.Workflow
|
||||
err := h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
result, err = svc.FetchWorkflows(ctx, tx, h.gitService, h.k8sFactory, sc, set.ToSet(endpointIDs))
|
||||
result, err = svc.FetchWorkflows(tx, h.k8sFactory, sc, set.ToSet(endpointIDs))
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices/source"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/api/gitops/workflows"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
// stackResponse extends a Stack response with the git source identifier.
|
||||
@@ -45,6 +46,12 @@ func saveStackGitConfig(tx dataservices.DataStoreTx, userContext source.UserCont
|
||||
return workflows.SaveWorkflowGitConfig(tx, userContext, workflowID, matchArtifact, oldSourceID, cfg)
|
||||
}
|
||||
|
||||
func persistSourceSyncError(tx dataservices.DataStoreTx, securityContext *security.RestrictedRequestContext, sourceID portainer.SourceID, syncErr error) error {
|
||||
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
|
||||
|
||||
return workflows.SaveSourceStatus(tx, userContext, sourceID, syncErr)
|
||||
}
|
||||
|
||||
// newStackResponse fills stack.GitConfig and returns a response that also includes GitSourceId.
|
||||
func newStackResponse(tx dataservices.DataStoreTx, userContext source.UserContext, stack *portainer.Stack) (*stackResponse, error) {
|
||||
if stack.WorkflowID == 0 {
|
||||
|
||||
@@ -3,6 +3,7 @@ package stacks
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -190,6 +191,12 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
clean, err := git.CloneWithBackup(context.TODO(), handler.GitService, handler.FileService, cloneOptions)
|
||||
if err != nil {
|
||||
if persistErr := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return persistSourceSyncError(tx, securityContext, sourceID, err)
|
||||
}); persistErr != nil {
|
||||
return httperror.InternalServerError("Unable to clone git repository directory", fmt.Errorf("%w (and failed to persist status: %w)", err, persistErr))
|
||||
}
|
||||
|
||||
return httperror.InternalServerError("Unable to clone git repository directory", err)
|
||||
}
|
||||
|
||||
@@ -197,6 +204,12 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
newHash, err := handler.GitService.LatestCommitID(context.TODO(), gitConfig.URL, gitConfig.ReferenceName, auth.Username, auth.Password, gitConfig.TLSSkipVerify)
|
||||
if err != nil {
|
||||
if persistErr := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return persistSourceSyncError(tx, securityContext, sourceID, err)
|
||||
}); persistErr != nil {
|
||||
return httperror.InternalServerError("Unable get latest commit id", fmt.Errorf("%w (and failed to persist status: %w)", errors.WithMessagef(err, "failed to fetch latest commit id of the stack %v", stack.ID), persistErr))
|
||||
}
|
||||
|
||||
return httperror.InternalServerError("Unable get latest commit id", errors.WithMessagef(err, "failed to fetch latest commit id of the stack %v", stack.ID))
|
||||
}
|
||||
|
||||
@@ -261,6 +274,10 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := workflows.SaveSourceStatus(tx, userContext, sourceID, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return fillStackGitConfig(tx, userContext, stack)
|
||||
}); err != nil {
|
||||
deployGate.abortDeploy()
|
||||
|
||||
+26
-9
@@ -1329,13 +1329,17 @@ type (
|
||||
Registry *Registry `json:"registry,omitempty"`
|
||||
Helm *HelmConfig `json:"helm,omitempty"`
|
||||
|
||||
Public bool `json:"public"`
|
||||
AdministratorsOnly bool `json:"administratorsOnly"`
|
||||
UserAccesses []UserID `json:"userAccesses"`
|
||||
TeamAccesses []TeamID `json:"teamAccesses"`
|
||||
OwnerID UserID `json:"ownerID,omitempty"`
|
||||
Public bool `json:"public"`
|
||||
AdministratorsOnly bool `json:"administratorsOnly"`
|
||||
UserAccesses []UserID `json:"userAccesses"`
|
||||
TeamAccesses []TeamID `json:"teamAccesses"`
|
||||
OwnerID UserID `json:"ownerID,omitempty"`
|
||||
Status SourceStatus `json:"status,omitempty"`
|
||||
StatusError string `json:"statusError,omitempty"`
|
||||
}
|
||||
|
||||
SourceStatus int
|
||||
|
||||
// SourceID represents a source identifier
|
||||
SourceID int
|
||||
|
||||
@@ -1633,10 +1637,14 @@ type (
|
||||
|
||||
// ArtifactFile represents one file within an artifact, tied to a specific source and location within it
|
||||
ArtifactFile struct {
|
||||
SourceID SourceID `json:"sourceId"`
|
||||
Path string `json:"path,omitempty" example:"portainer.yaml"`
|
||||
Ref string `json:"ref,omitempty" example:"refs/heads/main"`
|
||||
Hash string `json:"hash,omitempty" example:"abc123"`
|
||||
SourceID SourceID `json:"sourceId"`
|
||||
Path string `json:"path,omitempty" example:"portainer.yaml"`
|
||||
Ref string `json:"ref,omitempty" example:"refs/heads/main"`
|
||||
Hash string `json:"hash,omitempty" example:"abc123"`
|
||||
RefStatus SourceStatus `json:"refStatus,omitempty"`
|
||||
RefError string `json:"refError,omitempty"`
|
||||
PathStatus SourceStatus `json:"pathStatus,omitempty"`
|
||||
PathError string `json:"pathError,omitempty"`
|
||||
}
|
||||
|
||||
// Workflow represents a GitOps workflow
|
||||
@@ -2301,6 +2309,15 @@ const (
|
||||
SourceTypeHelm
|
||||
)
|
||||
|
||||
const (
|
||||
// SourceStatusUnknown means the check has not been performed yet
|
||||
SourceStatusUnknown SourceStatus = iota
|
||||
// SourceStatusHealthy means the last check succeeded
|
||||
SourceStatusHealthy
|
||||
// SourceStatusError means the last check failed
|
||||
SourceStatusError
|
||||
)
|
||||
|
||||
const (
|
||||
_ RegistryType = iota
|
||||
// QuayRegistry represents a Quay.io registry
|
||||
|
||||
@@ -148,9 +148,21 @@ func redeployWhenChangedSecondStage(
|
||||
if !stack.FromAppTemplate {
|
||||
updated, newHash, err := update.UpdateGitObject(ctx, gitService, fmt.Sprintf("stack:%d", stack.ID), gitConfig, false, stack.ProjectPath)
|
||||
if err != nil {
|
||||
if txErr := datastore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return workflows.SaveStackStatus(tx, userContext, stack.WorkflowID, stack.ID, gitSrc.ID, err)
|
||||
}); txErr != nil {
|
||||
return fmt.Errorf("git check failed for stack %d: %w (and failed to persist status: %w)", stack.ID, err, txErr)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
if txErr := datastore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return workflows.SaveStackStatus(tx, userContext, stack.WorkflowID, stack.ID, gitSrc.ID, nil)
|
||||
}); txErr != nil {
|
||||
return fmt.Errorf("failed to persist git sync status for stack %d: %w", stack.ID, txErr)
|
||||
}
|
||||
|
||||
if updated {
|
||||
gitConfig.ConfigHash = newHash
|
||||
|
||||
@@ -239,6 +251,10 @@ func redeployWhenChangedSecondStage(
|
||||
|
||||
return workflows.UpdateArtifactFileForStack(tx, stack.WorkflowID, stack.ID, gitSrc.ID, func(a *portainer.ArtifactFile) {
|
||||
a.Hash = newHash
|
||||
a.RefStatus = portainer.SourceStatusHealthy
|
||||
a.RefError = ""
|
||||
a.PathStatus = portainer.SourceStatusHealthy
|
||||
a.PathError = ""
|
||||
})
|
||||
}); err != nil {
|
||||
return errors.WithMessagef(err, "failed to update the stack %v", stack.ID)
|
||||
|
||||
@@ -210,7 +210,7 @@ func Test_redeployWhenChanged_DoesNothingWhenNoGitChanges(t *testing.T) {
|
||||
err = store.Source().Create(adminUserContext, src)
|
||||
require.NoError(t, err, "failed to create source")
|
||||
|
||||
wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{Files: []portainer.ArtifactFile{{SourceID: src.ID}}}}}
|
||||
wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{StackID: 1, Files: []portainer.ArtifactFile{{SourceID: src.ID}}}}}
|
||||
err = store.Workflow().Create(wf)
|
||||
require.NoError(t, err, "failed to create workflow")
|
||||
|
||||
@@ -224,6 +224,12 @@ func Test_redeployWhenChanged_DoesNothingWhenNoGitChanges(t *testing.T) {
|
||||
|
||||
err = RedeployWhenChanged(t.Context(), 1, nil, store, testhelpers.NewGitService(nil, "oldHash"))
|
||||
require.NoError(t, err)
|
||||
|
||||
updatedSrc, err := store.Source().Read(adminUserContext, src.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, portainer.SourceStatusHealthy, updatedSrc.Status)
|
||||
require.Empty(t, updatedSrc.StatusError)
|
||||
require.NotZero(t, updatedSrc.LastSync)
|
||||
}
|
||||
|
||||
func Test_redeployWhenChanged_FailsWhenCannotClone(t *testing.T) {
|
||||
@@ -272,6 +278,12 @@ func Test_redeployWhenChanged_FailsWhenCannotClone(t *testing.T) {
|
||||
err = RedeployWhenChanged(t.Context(), 1, nil, store, testhelpers.NewGitService(cloneErr, "newHash"))
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, cloneErr, "should failed to clone but didn't, check test setup")
|
||||
|
||||
updatedSrc, err := store.Source().Read(adminUserContext, src.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, portainer.SourceStatusError, updatedSrc.Status)
|
||||
require.Contains(t, updatedSrc.StatusError, cloneErr.Error())
|
||||
require.Zero(t, updatedSrc.LastSync)
|
||||
}
|
||||
|
||||
func setupRedeployStore(t *testing.T, stackType portainer.StackType) (dataservices.DataStore, portainer.StackID) {
|
||||
|
||||
@@ -104,6 +104,12 @@ func (b *GitMethodStackBuilder) prepare(ctx context.Context, payload *StackPaylo
|
||||
|
||||
commitHash, err := stackutils.DownloadGitRepository(ctx, repoConfig, b.gitService, getProjectPath)
|
||||
if err != nil {
|
||||
if txErr := b.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return workflows.SaveSourceStatus(tx, userContext, sourceID, err)
|
||||
}); txErr != nil {
|
||||
return fmt.Errorf("failed to download git repository: %w (and failed to persist status: %w)", err, txErr)
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to download git repository: %w", err)
|
||||
}
|
||||
|
||||
@@ -114,9 +120,11 @@ func (b *GitMethodStackBuilder) prepare(ctx context.Context, payload *StackPaylo
|
||||
|
||||
if err := b.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
file := portainer.ArtifactFile{
|
||||
Path: repoConfig.ConfigFilePath,
|
||||
Ref: repoConfig.ReferenceName,
|
||||
Hash: repoConfig.ConfigHash,
|
||||
Path: repoConfig.ConfigFilePath,
|
||||
Ref: repoConfig.ReferenceName,
|
||||
Hash: repoConfig.ConfigHash,
|
||||
RefStatus: portainer.SourceStatusHealthy,
|
||||
PathStatus: portainer.SourceStatusHealthy,
|
||||
}
|
||||
|
||||
if sourceID != 0 {
|
||||
@@ -140,6 +148,10 @@ func (b *GitMethodStackBuilder) prepare(ctx context.Context, payload *StackPaylo
|
||||
file.SourceID = src.ID
|
||||
}
|
||||
|
||||
if err := workflows.SaveSourceStatus(tx, userContext, file.SourceID, nil); err != nil {
|
||||
return fmt.Errorf("failed to persist source sync status: %w", err)
|
||||
}
|
||||
|
||||
wf := &portainer.Workflow{
|
||||
Name: b.stack.Name,
|
||||
Artifacts: []portainer.Artifact{{
|
||||
|
||||
@@ -2,6 +2,7 @@ package stackbuilders
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -86,6 +87,65 @@ func TestGitMethodStackBuilder_WithSourceID_ReferencesExistingSource(t *testing.
|
||||
assert.Equal(t, "git-user", merged.Authentication.Username)
|
||||
}
|
||||
|
||||
func TestGitMethodStackBuilder_WithSourceID_PersistsHealthyStatusOnSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
builder := newGitMethodBuilder(t, "abc123")
|
||||
builder.stack.ID = 1
|
||||
|
||||
src := &portainer.Source{
|
||||
Name: "my-repo",
|
||||
Type: portainer.SourceTypeGit,
|
||||
Git: &gittypes.GitSource{URL: "https://github.com/org/private-repo"},
|
||||
}
|
||||
require.NoError(t, builder.dataStore.Source().Create(adminUserContext, src))
|
||||
|
||||
payload := &StackPayload{
|
||||
RepositoryConfigPayload: RepositoryConfigPayload{
|
||||
SourceID: src.ID,
|
||||
ReferenceName: "refs/heads/main",
|
||||
},
|
||||
}
|
||||
|
||||
err := builder.prepare(t.Context(), payload, portainer.UserID(1))
|
||||
require.NoError(t, err)
|
||||
|
||||
updatedSrc, err := builder.dataStore.Source().Read(adminUserContext, src.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, portainer.SourceStatusHealthy, updatedSrc.Status)
|
||||
assert.NotZero(t, updatedSrc.LastSync)
|
||||
}
|
||||
|
||||
func TestGitMethodStackBuilder_WithSourceID_PersistsErrorStatusOnCloneFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
builder := newGitMethodBuilder(t, "abc123")
|
||||
cloneErr := errors.New("failed to clone")
|
||||
builder.gitService = testhelpers.NewGitService(cloneErr, "abc123")
|
||||
builder.stack.ID = 1
|
||||
|
||||
src := &portainer.Source{
|
||||
Name: "my-repo",
|
||||
Type: portainer.SourceTypeGit,
|
||||
Git: &gittypes.GitSource{URL: "https://github.com/org/private-repo"},
|
||||
}
|
||||
require.NoError(t, builder.dataStore.Source().Create(adminUserContext, src))
|
||||
|
||||
payload := &StackPayload{
|
||||
RepositoryConfigPayload: RepositoryConfigPayload{
|
||||
SourceID: src.ID,
|
||||
ReferenceName: "refs/heads/main",
|
||||
},
|
||||
}
|
||||
|
||||
err := builder.prepare(t.Context(), payload, portainer.UserID(1))
|
||||
require.Error(t, err)
|
||||
|
||||
updatedSrc, err := builder.dataStore.Source().Read(adminUserContext, src.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, portainer.SourceStatusError, updatedSrc.Status)
|
||||
assert.Contains(t, updatedSrc.StatusError, cloneErr.Error())
|
||||
assert.Zero(t, updatedSrc.LastSync)
|
||||
}
|
||||
|
||||
func TestGitMethodStackBuilder_WithMissingSourceID_ReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
builder := newGitMethodBuilder(t, "abc123")
|
||||
|
||||
Reference in New Issue
Block a user