diff --git a/api/dataservices/stack/tests/stack_test.go b/api/dataservices/stack/tests/stack_test.go
index e357dc388..4c15479f7 100644
--- a/api/dataservices/stack/tests/stack_test.go
+++ b/api/dataservices/stack/tests/stack_test.go
@@ -68,13 +68,13 @@ func (b *stackBuilder) createNewStack(webhookID string) portainer.Stack {
if webhookID == "" {
if b.count%2 == 0 {
- stack.AutoUpdate = &portainer.StackAutoUpdate{
+ stack.AutoUpdate = &portainer.AutoUpdateSettings{
Interval: "",
Webhook: "",
}
} // else keep AutoUpdate nil
} else {
- stack.AutoUpdate = &portainer.StackAutoUpdate{Webhook: webhookID}
+ stack.AutoUpdate = &portainer.AutoUpdateSettings{Webhook: webhookID}
}
err := b.store.StackService.Create(&stack)
@@ -91,8 +91,8 @@ func Test_RefreshableStacks(t *testing.T) {
defer teardown()
staticStack := portainer.Stack{ID: 1}
- stackWithWebhook := portainer.Stack{ID: 2, AutoUpdate: &portainer.StackAutoUpdate{Webhook: "webhook"}}
- refreshableStack := portainer.Stack{ID: 3, AutoUpdate: &portainer.StackAutoUpdate{Interval: "1m"}}
+ stackWithWebhook := portainer.Stack{ID: 2, AutoUpdate: &portainer.AutoUpdateSettings{Webhook: "webhook"}}
+ refreshableStack := portainer.Stack{ID: 3, AutoUpdate: &portainer.AutoUpdateSettings{Interval: "1m"}}
for _, stack := range []*portainer.Stack{&staticStack, &stackWithWebhook, &refreshableStack} {
err := store.Stack().Create(stack)
diff --git a/api/git/backup.go b/api/git/backup.go
new file mode 100644
index 000000000..90fef3fb8
--- /dev/null
+++ b/api/git/backup.go
@@ -0,0 +1,62 @@
+package git
+
+import (
+ "fmt"
+
+ "github.com/pkg/errors"
+ portainer "github.com/portainer/portainer/api"
+ "github.com/portainer/portainer/api/filesystem"
+ gittypes "github.com/portainer/portainer/api/git/types"
+ "github.com/rs/zerolog/log"
+)
+
+var (
+ ErrInvalidGitCredential = errors.New("Invalid git credential")
+)
+
+type CloneOptions struct {
+ ProjectPath string
+ URL string
+ ReferenceName string
+ Username string
+ Password string
+}
+
+func CloneWithBackup(gitService portainer.GitService, fileService portainer.FileService, options CloneOptions) (clean func(), err error) {
+ backupProjectPath := fmt.Sprintf("%s-old", options.ProjectPath)
+ cleanUp := false
+ cleanFn := func() {
+ if !cleanUp {
+ return
+ }
+
+ err = fileService.RemoveDirectory(backupProjectPath)
+ if err != nil {
+ log.Warn().Err(err).Msg("unable to remove git repository directory")
+ }
+ }
+
+ err = filesystem.MoveDirectory(options.ProjectPath, backupProjectPath)
+ if err != nil {
+ return cleanFn, errors.WithMessage(err, "Unable to move git repository directory")
+ }
+
+ cleanUp = true
+
+ err = gitService.CloneRepository(options.ProjectPath, options.URL, options.ReferenceName, options.Username, options.Password)
+ if err != nil {
+ cleanUp = false
+ restoreError := filesystem.MoveDirectory(backupProjectPath, options.ProjectPath)
+ if restoreError != nil {
+ log.Warn().Err(restoreError).Msg("failed restoring backup folder")
+ }
+
+ if err == gittypes.ErrAuthenticationFailure {
+ return cleanFn, errors.WithMessage(err, ErrInvalidGitCredential.Error())
+ }
+
+ return cleanFn, errors.WithMessage(err, "Unable to clone git repository")
+ }
+
+ return cleanFn, nil
+}
diff --git a/api/git/credentials.go b/api/git/credentials.go
new file mode 100644
index 000000000..d8ed1afc0
--- /dev/null
+++ b/api/git/credentials.go
@@ -0,0 +1,13 @@
+package git
+
+import (
+ gittypes "github.com/portainer/portainer/api/git/types"
+)
+
+func GetCredentials(auth *gittypes.GitAuthentication) (string, string, error) {
+ if auth == nil {
+ return "", "", nil
+ }
+
+ return auth.Username, auth.Password, nil
+}
diff --git a/api/git/update/update.go b/api/git/update/update.go
new file mode 100644
index 000000000..d43ef2102
--- /dev/null
+++ b/api/git/update/update.go
@@ -0,0 +1,94 @@
+package update
+
+import (
+ "strings"
+
+ "github.com/pkg/errors"
+
+ portainer "github.com/portainer/portainer/api"
+ "github.com/portainer/portainer/api/dataservices"
+ "github.com/portainer/portainer/api/git"
+ gittypes "github.com/portainer/portainer/api/git/types"
+ "github.com/rs/zerolog/log"
+)
+
+// UpdateGitObject updates a git object based on its config
+func UpdateGitObject(gitService portainer.GitService, dataStore dataservices.DataStore, objId string, gitConfig *gittypes.RepoConfig, autoUpdateConfig *portainer.AutoUpdateSettings, projectPath string) (bool, string, error) {
+ if gitConfig == nil {
+ return false, "", nil
+ }
+
+ log.Debug().
+ Str("url", gitConfig.URL).
+ Str("ref", gitConfig.ReferenceName).
+ Str("object", objId).
+ Msg("the object has a git config, try to poll from git repository")
+
+ username, password, err := git.GetCredentials(gitConfig.Authentication)
+ if err != nil {
+ return false, "", errors.WithMessagef(err, "failed to get credentials for %v", objId)
+ }
+
+ newHash, err := gitService.LatestCommitID(gitConfig.URL, gitConfig.ReferenceName, username, password)
+ if err != nil {
+ return false, "", errors.WithMessagef(err, "failed to fetch latest commit id of %v", objId)
+ }
+
+ hashChanged := !strings.EqualFold(newHash, string(gitConfig.ConfigHash))
+ forceUpdate := autoUpdateConfig != nil && autoUpdateConfig.ForceUpdate
+ if !hashChanged && !forceUpdate {
+ log.Debug().
+ Str("hash", newHash).
+ Str("url", gitConfig.URL).
+ Str("ref", gitConfig.ReferenceName).
+ Str("object", objId).
+ Msg("git repo is up to date")
+
+ return false, newHash, nil
+ }
+
+ cloneParams := &cloneRepositoryParameters{
+ url: gitConfig.URL,
+ ref: gitConfig.ReferenceName,
+ toDir: projectPath,
+ }
+ if gitConfig.Authentication != nil {
+ cloneParams.auth = &gitAuth{
+ username: username,
+ password: password,
+ }
+ }
+
+ if err := cloneGitRepository(gitService, cloneParams); err != nil {
+ return false, "", errors.WithMessagef(err, "failed to do a fresh clone of %v", objId)
+ }
+
+ log.Debug().
+ Str("hash", newHash).
+ Str("url", gitConfig.URL).
+ Str("ref", gitConfig.ReferenceName).
+ Str("object", objId).
+ Msg("git repo cloned updated")
+
+ return true, newHash, nil
+}
+
+type cloneRepositoryParameters struct {
+ url string
+ ref string
+ toDir string
+ auth *gitAuth
+}
+
+type gitAuth struct {
+ username string
+ password string
+}
+
+func cloneGitRepository(gitService portainer.GitService, cloneParams *cloneRepositoryParameters) error {
+ if cloneParams.auth != nil {
+ return gitService.CloneRepository(cloneParams.toDir, cloneParams.url, cloneParams.ref, cloneParams.auth.username, cloneParams.auth.password)
+ }
+
+ return gitService.CloneRepository(cloneParams.toDir, cloneParams.url, cloneParams.ref, "", "")
+}
diff --git a/api/git/update/validate.go b/api/git/update/validate.go
new file mode 100644
index 000000000..c1b7364ce
--- /dev/null
+++ b/api/git/update/validate.go
@@ -0,0 +1,31 @@
+package update
+
+import (
+ "time"
+
+ "github.com/asaskevich/govalidator"
+ portainer "github.com/portainer/portainer/api"
+ httperrors "github.com/portainer/portainer/api/http/errors"
+)
+
+func ValidateAutoUpdateSettings(autoUpdate *portainer.AutoUpdateSettings) error {
+ if autoUpdate == nil {
+ return nil
+ }
+
+ if autoUpdate.Webhook == "" && autoUpdate.Interval == "" {
+ return httperrors.NewInvalidPayloadError("Webhook or Interval must be provided")
+ }
+
+ if autoUpdate.Webhook != "" && !govalidator.IsUUID(autoUpdate.Webhook) {
+ return httperrors.NewInvalidPayloadError("invalid Webhook format")
+ }
+
+ if autoUpdate.Interval != "" {
+ if _, err := time.ParseDuration(autoUpdate.Interval); err != nil {
+ return httperrors.NewInvalidPayloadError("invalid Interval format")
+ }
+ }
+
+ return nil
+}
diff --git a/api/stacks/stackutils/validation_test.go b/api/git/update/validate_test.go
similarity index 65%
rename from api/stacks/stackutils/validation_test.go
rename to api/git/update/validate_test.go
index b256cbfb5..457cbc9fe 100644
--- a/api/stacks/stackutils/validation_test.go
+++ b/api/git/update/validate_test.go
@@ -1,4 +1,4 @@
-package stackutils
+package update
import (
"testing"
@@ -7,25 +7,25 @@ import (
"github.com/stretchr/testify/assert"
)
-func Test_ValidateStackAutoUpdate(t *testing.T) {
+func Test_ValidateAutoUpdate(t *testing.T) {
tests := []struct {
name string
- value *portainer.StackAutoUpdate
+ value *portainer.AutoUpdateSettings
wantErr bool
}{
{
name: "webhook is not a valid UUID",
- value: &portainer.StackAutoUpdate{Webhook: "fake-webhook"},
+ value: &portainer.AutoUpdateSettings{Webhook: "fake-webhook"},
wantErr: true,
},
{
name: "incorrect interval value",
- value: &portainer.StackAutoUpdate{Interval: "1dd2hh3mm"},
+ value: &portainer.AutoUpdateSettings{Interval: "1dd2hh3mm"},
wantErr: true,
},
{
name: "valid auto update",
- value: &portainer.StackAutoUpdate{
+ value: &portainer.AutoUpdateSettings{
Webhook: "8dce8c2f-9ca1-482b-ad20-271e86536ada",
Interval: "5h30m40s10ms",
},
@@ -35,7 +35,7 @@ func Test_ValidateStackAutoUpdate(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- err := ValidateStackAutoUpdate(tt.value)
+ err := ValidateAutoUpdateSettings(tt.value)
assert.Equalf(t, tt.wantErr, err != nil, "received %+v", err)
})
}
diff --git a/api/git/validate.go b/api/git/validate.go
new file mode 100644
index 000000000..2bf8375d7
--- /dev/null
+++ b/api/git/validate.go
@@ -0,0 +1,25 @@
+package git
+
+import (
+ "github.com/asaskevich/govalidator"
+
+ gittypes "github.com/portainer/portainer/api/git/types"
+ httperrors "github.com/portainer/portainer/api/http/errors"
+)
+
+func ValidateRepoConfig(repoConfig *gittypes.RepoConfig) error {
+ if govalidator.IsNull(repoConfig.URL) || !govalidator.IsURL(repoConfig.URL) {
+ return httperrors.NewInvalidPayloadError("Invalid repository URL. Must correspond to a valid URL format")
+ }
+
+ return ValidateRepoAuthentication(repoConfig.Authentication)
+
+}
+
+func ValidateRepoAuthentication(auth *gittypes.GitAuthentication) error {
+ if auth != nil && govalidator.IsNull(auth.Password) {
+ return httperrors.NewInvalidPayloadError("Invalid repository credentials. Password must be specified when authentication is enabled")
+ }
+
+ return nil
+}
diff --git a/api/http/errors/invalidpayload.go b/api/http/errors/invalidpayload.go
new file mode 100644
index 000000000..2ca040cfd
--- /dev/null
+++ b/api/http/errors/invalidpayload.go
@@ -0,0 +1,13 @@
+package errors
+
+type InvalidPayloadError struct {
+ msg string
+}
+
+func (e *InvalidPayloadError) Error() string {
+ return e.msg
+}
+
+func NewInvalidPayloadError(msg string) *InvalidPayloadError {
+ return &InvalidPayloadError{msg: msg}
+}
diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go
index 1ee9daaba..9cf7e304e 100644
--- a/api/http/handler/stacks/create_compose_stack.go
+++ b/api/http/handler/stacks/create_compose_stack.go
@@ -8,6 +8,7 @@ import (
"github.com/portainer/libhttp/request"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
+ "github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/stacks/deployments"
"github.com/portainer/portainer/api/stacks/stackbuilders"
@@ -156,14 +157,14 @@ type composeStackFromGitRepositoryPayload struct {
// Applicable when deploying with multiple stack files
AdditionalFiles []string `example:"[nz.compose.yml, uat.compose.yml]"`
// Optional auto update configuration
- AutoUpdate *portainer.StackAutoUpdate
+ AutoUpdate *portainer.AutoUpdateSettings
// A list of environment(endpoint) variables used during stack deployment
Env []portainer.Pair
// Whether the stack is from a app template
FromAppTemplate bool `example:"false"`
}
-func createStackPayloadFromComposeGitPayload(name, repoUrl, repoReference, repoUsername, repoPassword string, repoAuthentication bool, composeFile string, additionalFiles []string, autoUpdate *portainer.StackAutoUpdate, env []portainer.Pair, fromAppTemplate bool) stackbuilders.StackPayload {
+func createStackPayloadFromComposeGitPayload(name, repoUrl, repoReference, repoUsername, repoPassword string, repoAuthentication bool, composeFile string, additionalFiles []string, autoUpdate *portainer.AutoUpdateSettings, env []portainer.Pair, fromAppTemplate bool) stackbuilders.StackPayload {
return stackbuilders.StackPayload{
Name: name,
RepositoryConfigPayload: stackbuilders.RepositoryConfigPayload{
@@ -191,7 +192,7 @@ func (payload *composeStackFromGitRepositoryPayload) Validate(r *http.Request) e
if payload.RepositoryAuthentication && govalidator.IsNull(payload.RepositoryPassword) {
return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled")
}
- if err := stackutils.ValidateStackAutoUpdate(payload.AutoUpdate); err != nil {
+ if err := update.ValidateAutoUpdateSettings(payload.AutoUpdate); err != nil {
return err
}
return nil
diff --git a/api/http/handler/stacks/create_kubernetes_stack.go b/api/http/handler/stacks/create_kubernetes_stack.go
index 09cc8f04a..c3db2829c 100644
--- a/api/http/handler/stacks/create_kubernetes_stack.go
+++ b/api/http/handler/stacks/create_kubernetes_stack.go
@@ -11,6 +11,7 @@ import (
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
+ "github.com/portainer/portainer/api/git/update"
k "github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/api/stacks/deployments"
"github.com/portainer/portainer/api/stacks/stackbuilders"
@@ -44,10 +45,10 @@ type kubernetesGitDeploymentPayload struct {
RepositoryPassword string
ManifestFile string
AdditionalFiles []string
- AutoUpdate *portainer.StackAutoUpdate
+ AutoUpdate *portainer.AutoUpdateSettings
}
-func createStackPayloadFromK8sGitPayload(name, repoUrl, repoReference, repoUsername, repoPassword string, repoAuthentication, composeFormat bool, namespace, manifest string, additionalFiles []string, autoUpdate *portainer.StackAutoUpdate) stackbuilders.StackPayload {
+func createStackPayloadFromK8sGitPayload(name, repoUrl, repoReference, repoUsername, repoPassword string, repoAuthentication, composeFormat bool, namespace, manifest string, additionalFiles []string, autoUpdate *portainer.AutoUpdateSettings) stackbuilders.StackPayload {
return stackbuilders.StackPayload{
StackName: name,
RepositoryConfigPayload: stackbuilders.RepositoryConfigPayload{
@@ -101,7 +102,7 @@ func (payload *kubernetesGitDeploymentPayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.ManifestFile) {
return errors.New("Invalid manifest file in repository")
}
- if err := stackutils.ValidateStackAutoUpdate(payload.AutoUpdate); err != nil {
+ if err := update.ValidateAutoUpdateSettings(payload.AutoUpdate); err != nil {
return err
}
if govalidator.IsNull(payload.StackName) {
diff --git a/api/http/handler/stacks/create_swarm_stack.go b/api/http/handler/stacks/create_swarm_stack.go
index 08df67120..bcc71e14e 100644
--- a/api/http/handler/stacks/create_swarm_stack.go
+++ b/api/http/handler/stacks/create_swarm_stack.go
@@ -10,6 +10,7 @@ import (
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
portainer "github.com/portainer/portainer/api"
+ "github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/stacks/stackbuilders"
"github.com/portainer/portainer/api/stacks/stackutils"
@@ -115,7 +116,7 @@ type swarmStackFromGitRepositoryPayload struct {
// Applicable when deploying with multiple stack files
AdditionalFiles []string `example:"[nz.compose.yml, uat.compose.yml]"`
// Optional auto update configuration
- AutoUpdate *portainer.StackAutoUpdate
+ AutoUpdate *portainer.AutoUpdateSettings
}
func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) error {
@@ -131,13 +132,13 @@ func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) err
if payload.RepositoryAuthentication && govalidator.IsNull(payload.RepositoryPassword) {
return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled")
}
- if err := stackutils.ValidateStackAutoUpdate(payload.AutoUpdate); err != nil {
+ if err := update.ValidateAutoUpdateSettings(payload.AutoUpdate); err != nil {
return err
}
return nil
}
-func createStackPayloadFromSwarmGitPayload(name, swarmID, repoUrl, repoReference, repoUsername, repoPassword string, repoAuthentication bool, composeFile string, additionalFiles []string, autoUpdate *portainer.StackAutoUpdate, env []portainer.Pair, fromAppTemplate bool) stackbuilders.StackPayload {
+func createStackPayloadFromSwarmGitPayload(name, swarmID, repoUrl, repoReference, repoUsername, repoPassword string, repoAuthentication bool, composeFile string, additionalFiles []string, autoUpdate *portainer.AutoUpdateSettings, env []portainer.Pair, fromAppTemplate bool) stackbuilders.StackPayload {
return stackbuilders.StackPayload{
Name: name,
SwarmID: swarmID,
diff --git a/api/http/handler/stacks/stack_update_git.go b/api/http/handler/stacks/stack_update_git.go
index 02bc3702a..e35dd9df0 100644
--- a/api/http/handler/stacks/stack_update_git.go
+++ b/api/http/handler/stacks/stack_update_git.go
@@ -10,6 +10,7 @@ import (
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
+ "github.com/portainer/portainer/api/git/update"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/stacks/deployments"
@@ -17,7 +18,7 @@ import (
)
type stackGitUpdatePayload struct {
- AutoUpdate *portainer.StackAutoUpdate
+ AutoUpdate *portainer.AutoUpdateSettings
Env []portainer.Pair
Prune bool
RepositoryReferenceName string
@@ -27,7 +28,7 @@ type stackGitUpdatePayload struct {
}
func (payload *stackGitUpdatePayload) Validate(r *http.Request) error {
- if err := stackutils.ValidateStackAutoUpdate(payload.AutoUpdate); err != nil {
+ if err := update.ValidateAutoUpdateSettings(payload.AutoUpdate); err != nil {
return err
}
return nil
diff --git a/api/http/handler/stacks/stack_update_git_redeploy.go b/api/http/handler/stacks/stack_update_git_redeploy.go
index fbe69b0a1..3eb9d3a9b 100644
--- a/api/http/handler/stacks/stack_update_git_redeploy.go
+++ b/api/http/handler/stacks/stack_update_git_redeploy.go
@@ -11,13 +11,12 @@ import (
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
+ "github.com/portainer/portainer/api/git"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
k "github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/api/stacks/deployments"
"github.com/portainer/portainer/api/stacks/stackutils"
-
- "github.com/rs/zerolog/log"
)
type stackGitRedployPayload struct {
@@ -154,22 +153,12 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
repositoryUsername = payload.RepositoryUsername
}
- err = handler.GitService.CloneRepository(stack.ProjectPath, stack.GitConfig.URL, payload.RepositoryReferenceName, repositoryUsername, repositoryPassword)
+ clean, err := git.CloneWithBackup(handler.GitService, handler.FileService, git.CloneOptions{ProjectPath: stack.ProjectPath, URL: stack.GitConfig.URL, ReferenceName: stack.GitConfig.ReferenceName, Username: repositoryUsername, Password: repositoryPassword})
if err != nil {
- restoreError := filesystem.MoveDirectory(backupProjectPath, stack.ProjectPath)
- if restoreError != nil {
- log.Warn().Err(restoreError).Msg("failed restoring backup folder")
- }
-
- return httperror.InternalServerError("Unable to clone git repository", err)
+ return httperror.InternalServerError("Unable to clone git repository directory", err)
}
- defer func() {
- err = handler.FileService.RemoveDirectory(backupProjectPath)
- if err != nil {
- log.Warn().Err(err).Msg("unable to remove git repository directory")
- }
- }()
+ defer clean()
httpErr := handler.deployStack(r, stack, payload.PullImage, endpoint)
if httpErr != nil {
diff --git a/api/http/handler/stacks/update_kubernetes_stack.go b/api/http/handler/stacks/update_kubernetes_stack.go
index ed939addb..a541f666c 100644
--- a/api/http/handler/stacks/update_kubernetes_stack.go
+++ b/api/http/handler/stacks/update_kubernetes_stack.go
@@ -11,10 +11,10 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
+ "github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/http/security"
k "github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/api/stacks/deployments"
- "github.com/portainer/portainer/api/stacks/stackutils"
"github.com/asaskevich/govalidator"
"github.com/pkg/errors"
@@ -30,7 +30,7 @@ type kubernetesGitStackUpdatePayload struct {
RepositoryAuthentication bool
RepositoryUsername string
RepositoryPassword string
- AutoUpdate *portainer.StackAutoUpdate
+ AutoUpdate *portainer.AutoUpdateSettings
}
func (payload *kubernetesFileStackUpdatePayload) Validate(r *http.Request) error {
@@ -41,7 +41,7 @@ func (payload *kubernetesFileStackUpdatePayload) Validate(r *http.Request) error
}
func (payload *kubernetesGitStackUpdatePayload) Validate(r *http.Request) error {
- if err := stackutils.ValidateStackAutoUpdate(payload.AutoUpdate); err != nil {
+ if err := update.ValidateAutoUpdateSettings(payload.AutoUpdate); err != nil {
return err
}
return nil
diff --git a/api/http/handler/stacks/webhook_invoke.go b/api/http/handler/stacks/webhook_invoke.go
index 047f7aa45..77f64a0bc 100644
--- a/api/http/handler/stacks/webhook_invoke.go
+++ b/api/http/handler/stacks/webhook_invoke.go
@@ -9,7 +9,6 @@ import (
"github.com/portainer/portainer/api/stacks/deployments"
"github.com/gofrs/uuid"
- "github.com/rs/zerolog/log"
)
// @id WebhookInvoke
@@ -43,8 +42,6 @@ func (handler *Handler) webhookInvoke(w http.ResponseWriter, r *http.Request) *h
return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: "Autoupdate for the stack isn't available", Err: err}
}
- log.Error().Err(err).Msg("failed to update the stack")
-
return httperror.InternalServerError("Failed to update the stack", err)
}
diff --git a/api/http/handler/stacks/webhook_invoke_test.go b/api/http/handler/stacks/webhook_invoke_test.go
index 5314dce27..b81d2f395 100644
--- a/api/http/handler/stacks/webhook_invoke_test.go
+++ b/api/http/handler/stacks/webhook_invoke_test.go
@@ -18,7 +18,8 @@ func TestHandler_webhookInvoke(t *testing.T) {
webhookID := newGuidString(t)
store.StackService.Create(&portainer.Stack{
- AutoUpdate: &portainer.StackAutoUpdate{
+ ID: 1,
+ AutoUpdate: &portainer.AutoUpdateSettings{
Webhook: webhookID,
},
})
diff --git a/api/portainer.go b/api/portainer.go
index f9430a0f8..63f5f4557 100644
--- a/api/portainer.go
+++ b/api/portainer.go
@@ -32,6 +32,20 @@ type (
// Authorizations represents a set of authorizations associated to a role
Authorizations map[Authorization]bool
+ //AutoUpdateSettings represents the git auto sync config for stack deployment
+ AutoUpdateSettings struct {
+ // Auto update interval
+ Interval string `example:"1m30s"`
+ // A UUID generated from client
+ Webhook string `example:"05de31a2-79fa-4644-9c12-faa67e5c49f0"`
+ // Autoupdate job id
+ JobID string `example:"15"`
+ // Force update ignores repo changes
+ ForceUpdate bool `example:"false"`
+ // Pull latest image
+ ForcePullImage bool `example:"false"`
+ }
+
// AzureCredentials represents the credentials used to connect to an Azure
// environment(endpoint).
AzureCredentials struct {
@@ -986,7 +1000,7 @@ type (
// Only applies when deploying stack with multiple files
AdditionalFiles []string `json:"AdditionalFiles"`
// The auto update settings of a git stack
- AutoUpdate *StackAutoUpdate `json:"AutoUpdate"`
+ AutoUpdate *AutoUpdateSettings `json:"AutoUpdate"`
// The stack deployment option
Option *StackOption `json:"Option"`
// The git config of this stack
@@ -999,16 +1013,6 @@ type (
IsComposeFormat bool `example:"false"`
}
- //StackAutoUpdate represents the git auto sync config for stack deployment
- StackAutoUpdate struct {
- // Auto update interval
- Interval string `example:"1m30s"`
- // A UUID generated from client
- Webhook string `example:"05de31a2-79fa-4644-9c12-faa67e5c49f0"`
- // Autoupdate job id
- JobID string `example:"15"`
- }
-
// StackOption represents the options for stack deployment
StackOption struct {
// Prune services that are no longer referenced
diff --git a/api/stacks/deployments/deploy.go b/api/stacks/deployments/deploy.go
index 0759f59b2..292bdf086 100644
--- a/api/stacks/deployments/deploy.go
+++ b/api/stacks/deployments/deploy.go
@@ -2,11 +2,11 @@ package deployments
import (
"fmt"
- "strings"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
+ "github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/http/security"
"github.com/pkg/errors"
@@ -36,6 +36,11 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
return nil // do nothing if it isn't a git-based stack
}
+ endpoint, err := datastore.Endpoint().Endpoint(stack.EndpointID)
+ if err != nil {
+ return errors.WithMessagef(err, "failed to find the environment %v associated to the stack %v", stack.EndpointID, stack.ID)
+ }
+
author := stack.UpdatedBy
if author == "" {
author = stack.CreatedBy
@@ -53,39 +58,22 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
return &StackAuthorMissingErr{int(stack.ID), author}
}
- username, password := "", ""
- if stack.GitConfig.Authentication != nil {
- username, password = stack.GitConfig.Authentication.Username, stack.GitConfig.Authentication.Password
- }
+ var gitCommitChangedOrForceUpdate bool
+ if !stack.FromAppTemplate {
+ updated, newHash, err := update.UpdateGitObject(gitService, datastore, fmt.Sprintf("stack:%d", stackID), stack.GitConfig, stack.AutoUpdate, stack.ProjectPath)
+ if err != nil {
+ return err
+ }
- newHash, err := gitService.LatestCommitID(stack.GitConfig.URL, stack.GitConfig.ReferenceName, username, password)
- if err != nil {
- return errors.WithMessagef(err, "failed to fetch latest commit id of the stack %v", stack.ID)
- }
-
- if strings.EqualFold(newHash, string(stack.GitConfig.ConfigHash)) {
- return nil
- }
-
- cloneParams := &cloneRepositoryParameters{
- url: stack.GitConfig.URL,
- ref: stack.GitConfig.ReferenceName,
- toDir: stack.ProjectPath,
- }
- if stack.GitConfig.Authentication != nil {
- cloneParams.auth = &gitAuth{
- username: username,
- password: password,
+ if updated {
+ stack.GitConfig.ConfigHash = newHash
+ stack.UpdateDate = time.Now().Unix()
+ gitCommitChangedOrForceUpdate = updated
}
}
- if err := cloneGitRepository(gitService, cloneParams); err != nil {
- return errors.WithMessagef(err, "failed to do a fresh clone of the stack %v", stack.ID)
- }
-
- endpoint, err := datastore.Endpoint().Endpoint(stack.EndpointID)
- if err != nil {
- return errors.WithMessagef(err, "failed to find the environment %v associated to the stack %v", stack.EndpointID, stack.ID)
+ if !gitCommitChangedOrForceUpdate {
+ return nil
}
registries, err := getUserRegistries(datastore, user, endpoint.ID)
@@ -117,8 +105,6 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
return errors.Errorf("cannot update stack, type %v is unsupported", stack.Type)
}
- stack.UpdateDate = time.Now().Unix()
- stack.GitConfig.ConfigHash = newHash
if err := datastore.Stack().UpdateStack(stack.ID, stack); err != nil {
return errors.WithMessagef(err, "failed to update the stack %v", stack.ID)
}
@@ -150,22 +136,3 @@ func getUserRegistries(datastore dataservices.DataStore, user *portainer.User, e
return filteredRegistries, nil
}
-
-type cloneRepositoryParameters struct {
- url string
- ref string
- toDir string
- auth *gitAuth
-}
-
-type gitAuth struct {
- username string
- password string
-}
-
-func cloneGitRepository(gitService portainer.GitService, cloneParams *cloneRepositoryParameters) error {
- if cloneParams.auth != nil {
- return gitService.CloneRepository(cloneParams.toDir, cloneParams.url, cloneParams.ref, cloneParams.auth.username, cloneParams.auth.password)
- }
- return gitService.CloneRepository(cloneParams.toDir, cloneParams.url, cloneParams.ref, "", "")
-}
diff --git a/api/stacks/deployments/deploy_test.go b/api/stacks/deployments/deploy_test.go
index c697a248f..e644590f7 100644
--- a/api/stacks/deployments/deploy_test.go
+++ b/api/stacks/deployments/deploy_test.go
@@ -81,6 +81,11 @@ func Test_redeployWhenChanged_DoesNothingWhenNoGitChanges(t *testing.T) {
err := store.User().Create(admin)
assert.NoError(t, err, "error creating an admin")
+ err = store.Endpoint().Create(&portainer.Endpoint{
+ ID: 0,
+ })
+ assert.NoError(t, err, "error creating environment")
+
err = store.Stack().Create(&portainer.Stack{
ID: 1,
CreatedBy: "admin",
@@ -105,6 +110,11 @@ func Test_redeployWhenChanged_FailsWhenCannotClone(t *testing.T) {
err := store.User().Create(admin)
assert.NoError(t, err, "error creating an admin")
+ err = store.Endpoint().Create(&portainer.Endpoint{
+ ID: 0,
+ })
+ assert.NoError(t, err, "error creating environment")
+
err = store.Stack().Create(&portainer.Stack{
ID: 1,
CreatedBy: "admin",
@@ -142,7 +152,9 @@ func Test_redeployWhenChanged(t *testing.T) {
URL: "url",
ReferenceName: "ref",
ConfigHash: "oldHash",
- }}
+ },
+ }
+
err = store.Stack().Create(&stack)
assert.NoError(t, err, "failed to create a test stack")
diff --git a/api/stacks/stackbuilders/stack_payload.go b/api/stacks/stackbuilders/stack_payload.go
index 8720177d8..df5835190 100644
--- a/api/stacks/stackbuilders/stack_payload.go
+++ b/api/stacks/stackbuilders/stack_payload.go
@@ -18,7 +18,7 @@ type StackPayload struct {
// A list of environment(endpoint) variables used during stack deployment
Env []portainer.Pair
// Optional auto update configuration
- AutoUpdate *portainer.StackAutoUpdate
+ AutoUpdate *portainer.AutoUpdateSettings
// Whether the stack is from a app template
FromAppTemplate bool `example:"false"`
// Kubernetes stack name
diff --git a/api/stacks/stackutils/validation.go b/api/stacks/stackutils/validation.go
index daace6e60..83850497e 100644
--- a/api/stacks/stackutils/validation.go
+++ b/api/stacks/stackutils/validation.go
@@ -1,9 +1,6 @@
package stackutils
import (
- "time"
-
- "github.com/asaskevich/govalidator"
"github.com/docker/cli/cli/compose/loader"
"github.com/docker/cli/cli/compose/types"
"github.com/pkg/errors"
@@ -67,21 +64,6 @@ func IsValidStackFile(stackFileContent []byte, securitySettings *portainer.Endpo
return nil
}
-func ValidateStackAutoUpdate(autoUpdate *portainer.StackAutoUpdate) error {
- if autoUpdate == nil {
- return nil
- }
- if autoUpdate.Webhook != "" && !govalidator.IsUUID(autoUpdate.Webhook) {
- return errors.New("invalid Webhook format")
- }
- if autoUpdate.Interval != "" {
- if _, err := time.ParseDuration(autoUpdate.Interval); err != nil {
- return errors.New("invalid Interval format")
- }
- }
- return nil
-}
-
func ValidateStackFiles(stack *portainer.Stack, securitySettings *portainer.EndpointSecuritySettings, fileService portainer.FileService) error {
for _, file := range GetStackFilePaths(stack, false) {
stackContent, err := fileService.GetFileContent(stack.ProjectPath, file)
diff --git a/app/kubernetes/views/applications/create/createApplicationController.js b/app/kubernetes/views/applications/create/createApplicationController.js
index 22fcdef0b..34f9413dc 100644
--- a/app/kubernetes/views/applications/create/createApplicationController.js
+++ b/app/kubernetes/views/applications/create/createApplicationController.js
@@ -199,7 +199,7 @@ class KubernetesCreateApplicationController {
}
this.state.updateWebEditorInProgress = true;
- await this.StackService.updateKubeStack({ EndpointId: this.endpoint.Id, Id: this.application.StackId }, this.stackFileContent, null);
+ await this.StackService.updateKubeStack({ EndpointId: this.endpoint.Id, Id: this.application.StackId }, { stackFile: this.stackFileContent });
this.state.isEditorDirty = false;
await this.$state.reload(this.$state.current);
} catch (err) {
diff --git a/app/kubernetes/views/deploy/deploy.html b/app/kubernetes/views/deploy/deploy.html
index 50bd27ea8..b22a7f91c 100644
--- a/app/kubernetes/views/deploy/deploy.html
+++ b/app/kubernetes/views/deploy/deploy.html
@@ -84,6 +84,8 @@
is-auth-explanation-visible="true"
deploy-method="{{ ctrl.state.DeployType === ctrl.ManifestDeployTypes.COMPOSE ? 'compose' : 'manifest' }}"
base-webhook-url="{{ ctrl.state.baseWebhookUrl }}"
+ webhook-id="{{ ctrl.state.webhookId }}"
+ webhooks-docs="https://docs.portainer.io/user/kubernetes/applications/webhooks"
>
diff --git a/app/kubernetes/views/deploy/deployController.js b/app/kubernetes/views/deploy/deployController.js
index 6a9f6abb2..aa66134f9 100644
--- a/app/kubernetes/views/deploy/deployController.js
+++ b/app/kubernetes/views/deploy/deployController.js
@@ -9,7 +9,7 @@ import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods';
import { editor, git, customTemplate, url } from '@@/BoxSelector/common-options/build-methods';
import { parseAutoUpdateResponse, transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
-import { baseStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
+import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper';
import { confirmWebEditorDiscard } from '@@/modals/confirm';
class KubernetesDeployController {
@@ -45,6 +45,7 @@ class KubernetesDeployController {
templateId: null,
template: null,
baseWebhookUrl: baseStackWebhookUrl(),
+ webhookId: createWebhookId(),
};
this.formValues = {
@@ -256,7 +257,7 @@ class KubernetesDeployController {
}
payload.ManifestFile = this.formValues.ComposeFilePathInRepository;
payload.AdditionalFiles = this.formValues.AdditionalFiles;
- payload.AutoUpdate = transformAutoUpdateViewModel(this.formValues.AutoUpdate);
+ payload.AutoUpdate = transformAutoUpdateViewModel(this.formValues.AutoUpdate, this.state.webhookId);
} else if (method === KubernetesDeployRequestMethods.STRING) {
payload.StackFileContent = this.formValues.EditorContent;
} else {
diff --git a/app/portainer/components/forms/git-form/git-form-auto-update-fieldset.ts b/app/portainer/components/forms/git-form/git-form-auto-update-fieldset.ts
index a27e989cc..2cacd537f 100644
--- a/app/portainer/components/forms/git-form/git-form-auto-update-fieldset.ts
+++ b/app/portainer/components/forms/git-form/git-form-auto-update-fieldset.ts
@@ -10,6 +10,8 @@ export const gitFormAutoUpdate: IComponentOptions = {
environment-type="$ctrl.environmentType"
is-force-pull-visible="$ctrl.isForcePullVisible"
base-webhook-url="$ctrl.baseWebhookUrl"
+ webhook-id="$ctrl.webhookId"
+ webhooks-docs="$ctrl.webhooksDocs"
errors="$ctrl.errors">
`,
@@ -19,6 +21,8 @@ export const gitFormAutoUpdate: IComponentOptions = {
environmentType: '@',
isForcePullVisible: '<',
baseWebhookUrl: '@',
+ webhookId: '@',
+ webhooksDocs: '@',
},
controller,
};
diff --git a/app/portainer/components/forms/git-form/git-form.ts b/app/portainer/components/forms/git-form/git-form.ts
index e1c38322f..fb56e78e2 100644
--- a/app/portainer/components/forms/git-form/git-form.ts
+++ b/app/portainer/components/forms/git-form/git-form.ts
@@ -11,10 +11,11 @@ export const gitForm: IComponentOptions = {
is-docker-standalone="$ctrl.isDockerStandalone"
deploy-method="$ctrl.deployMethod"
is-additional-files-field-visible="$ctrl.isAdditionalFilesFieldVisible"
- is-auto-update-visible="$ctrl.isAutoUpdateVisible"
is-force-pull-visible="$ctrl.isForcePullVisible"
is-auth-explanation-visible="$ctrl.isAuthExplanationVisible"
base-webhook-url="$ctrl.baseWebhookUrl"
+ webhook-id="$ctrl.webhookId"
+ webhooks-docs="$ctrl.webhooksDocs"
errors="$ctrl.errors">
`,
@@ -25,9 +26,10 @@ export const gitForm: IComponentOptions = {
deployMethod: '@',
baseWebhookUrl: '@',
isAdditionalFilesFieldVisible: '<',
- isAutoUpdateVisible: '<',
isForcePullVisible: '<',
isAuthExplanationVisible: '<',
+ webhookId: '@',
+ webhooksDocs: '@',
},
controller,
};
diff --git a/app/portainer/components/forms/kubernetes-redeploy-app-git-form/kubernetes-redeploy-app-git-form.controller.js b/app/portainer/components/forms/kubernetes-redeploy-app-git-form/kubernetes-redeploy-app-git-form.controller.js
index 360b6cd42..d040fa015 100644
--- a/app/portainer/components/forms/kubernetes-redeploy-app-git-form/kubernetes-redeploy-app-git-form.controller.js
+++ b/app/portainer/components/forms/kubernetes-redeploy-app-git-form/kubernetes-redeploy-app-git-form.controller.js
@@ -3,7 +3,7 @@ import { confirm } from '@@/modals/confirm';
import { buildConfirmButton } from '@@/modals/utils';
import { ModalType } from '@@/modals';
import { parseAutoUpdateResponse } from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
-import { baseStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
+import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper';
class KubernetesRedeployAppGitFormController {
/* @ngInject */
@@ -20,6 +20,7 @@ class KubernetesRedeployAppGitFormController {
isEdit: false,
hasUnsavedChanges: false,
baseWebhookUrl: baseStackWebhookUrl(),
+ webhookId: createWebhookId(),
};
this.formValues = {
@@ -117,7 +118,7 @@ class KubernetesRedeployAppGitFormController {
return this.$async(async () => {
try {
this.state.saveGitSettingsInProgress = true;
- await this.StackService.updateKubeStack({ EndpointId: this.stack.EndpointId, Id: this.stack.Id }, null, this.formValues);
+ await this.StackService.updateKubeStack({ EndpointId: this.stack.EndpointId, Id: this.stack.Id }, { gitConfig: this.formValues, webhookId: this.state.webhookId });
this.savedFormValues = angular.copy(this.formValues);
this.state.hasUnsavedChanges = false;
this.Notifications.success('Success', 'Save stack settings successfully');
@@ -138,6 +139,10 @@ class KubernetesRedeployAppGitFormController {
this.formValues.AutoUpdate = parseAutoUpdateResponse(this.stack.AutoUpdate);
+ if (this.stack.AutoUpdate.Webhook) {
+ this.state.webhookId = this.stack.AutoUpdate.Webhook;
+ }
+
if (this.stack.GitConfig && this.stack.GitConfig.Authentication) {
this.formValues.RepositoryUsername = this.stack.GitConfig.Authentication.Username;
this.formValues.RepositoryAuthentication = true;
diff --git a/app/portainer/components/forms/kubernetes-redeploy-app-git-form/kubernetes-redeploy-app-git-form.html b/app/portainer/components/forms/kubernetes-redeploy-app-git-form/kubernetes-redeploy-app-git-form.html
index da5de262a..e2939dfc9 100644
--- a/app/portainer/components/forms/kubernetes-redeploy-app-git-form/kubernetes-redeploy-app-git-form.html
+++ b/app/portainer/components/forms/kubernetes-redeploy-app-git-form/kubernetes-redeploy-app-git-form.html
@@ -10,6 +10,8 @@
on-change="($ctrl.onChangeAutoUpdate)"
environment-type="KUBERNETES"
base-webhook-url="{{ $ctrl.state.baseWebhookUrl }}"
+ webhook-id="{{ $ctrl.state.webhookId }}"
+ webhooks-docs="https://docs.portainer.io/user/kubernetes/applications/webhooks"
>