feat(gitops): add status persistence for sources and artifacts BE-13166 (#3113)

This commit is contained in:
andres-portainer
2026-07-06 11:23:58 -03:00
committed by GitHub
parent fca59638d8
commit 79d49ba0d6
21 changed files with 395 additions and 69 deletions
+7 -10
View File
@@ -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
}
}
+10 -21
View File
@@ -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)
+77
View File
@@ -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)
+22
View File
@@ -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)
-3
View File
@@ -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
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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)
+5 -15
View File
@@ -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,
}
}
+1 -1
View File
@@ -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 {
+7
View File
@@ -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
View File
@@ -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
+16
View File
@@ -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)
+13 -1
View File
@@ -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) {
+15 -3
View File
@@ -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")