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" > diff --git a/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js b/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js index 1fe0bd2d6..3f90e5c13 100644 --- a/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js +++ b/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js @@ -3,7 +3,7 @@ import { FeatureId } from '@/react/portainer/feature-flags/enums'; import { confirmStackUpdate } from '@/react/docker/stacks/common/confirm-stack-update'; import { parseAutoUpdateResponse } from '@/react/portainer/gitops/AutoUpdateFieldset/utils'; -import { baseStackWebhookUrl } from '@/portainer/helpers/webhookHelper'; +import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper'; class StackRedeployGitFormController { /* @ngInject */ @@ -23,6 +23,7 @@ class StackRedeployGitFormController { isEdit: false, hasUnsavedChanges: false, baseWebhookUrl: baseStackWebhookUrl(), + webhookId: createWebhookId(), }; this.formValues = { @@ -132,7 +133,8 @@ class StackRedeployGitFormController { this.stack.Id, this.stack.EndpointId, this.FormHelper.removeInvalidEnvVars(this.formValues.Env), - this.formValues + this.formValues, + this.state.webhookId ); this.savedFormValues = angular.copy(this.formValues); this.state.hasUnsavedChanges = false; @@ -180,6 +182,10 @@ class StackRedeployGitFormController { 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/stack-redeploy-git-form/stack-redeploy-git-form.html b/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.html index 13a878776..ab4101e7e 100644 --- a/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.html +++ b/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.html @@ -14,6 +14,8 @@ environment-type="DOCKER" is-force-pull-visible="$ctrl.stack.Type !== 3" base-webhook-url="{{ $ctrl.state.baseWebhookUrl }}" + webhook-id="{{ $ctrl.state.webhookId }}" + webhooks-docs="https://docs.portainer.io/user/docker/stacks/webhooks" >
diff --git a/app/portainer/helpers/webhookHelper.ts b/app/portainer/helpers/webhookHelper.ts index 9286f8fd8..82fe54c86 100644 --- a/app/portainer/helpers/webhookHelper.ts +++ b/app/portainer/helpers/webhookHelper.ts @@ -1,3 +1,5 @@ +import uuid from 'uuid'; + import { API_ENDPOINT_STACKS, API_ENDPOINT_WEBHOOKS } from '@/constants'; import { baseHref } from './pathHelper'; @@ -16,6 +18,10 @@ export function stackWebhookUrl(token: string) { return `${baseStackWebhookUrl()}/${token}`; } +export function createWebhookId() { + return uuid(); +} + /* @ngInject */ export function WebhookHelperFactory() { return { diff --git a/app/portainer/react/components/git-form.ts b/app/portainer/react/components/git-form.ts index 98537ed36..c746f7665 100644 --- a/app/portainer/react/components/git-form.ts +++ b/app/portainer/react/components/git-form.ts @@ -25,6 +25,8 @@ export const gitFormModule = angular 'isAuthExplanationVisible', 'errors', 'baseWebhookUrl', + 'webhookId', + 'webhooksDocs', ]) ) .component( @@ -46,6 +48,8 @@ export const gitFormModule = angular 'isForcePullVisible', 'errors', 'baseWebhookUrl', + 'webhookId', + 'webhooksDocs', ]) ) .component( diff --git a/app/portainer/services/api/stackService.js b/app/portainer/services/api/stackService.js index f7b3722ad..2cc85260e 100644 --- a/app/portainer/services/api/stackService.js +++ b/app/portainer/services/api/stackService.js @@ -270,7 +270,7 @@ angular.module('portainer.app').factory('StackService', [ ).$promise; }; - service.updateKubeStack = function (stack, stackFile, gitConfig) { + service.updateKubeStack = function (stack, { stackFile, gitConfig, webhookId }) { let payload = {}; if (stackFile) { @@ -279,7 +279,7 @@ angular.module('portainer.app').factory('StackService', [ }; } else { payload = { - AutoUpdate: transformAutoUpdateViewModel(gitConfig.AutoUpdate), + AutoUpdate: transformAutoUpdateViewModel(gitConfig.AutoUpdate, webhookId), RepositoryReferenceName: gitConfig.RefName, RepositoryAuthentication: gitConfig.RepositoryAuthentication, RepositoryUsername: gitConfig.RepositoryUsername, @@ -455,11 +455,11 @@ angular.module('portainer.app').factory('StackService', [ ).$promise; } - service.updateGitStackSettings = function (id, endpointId, env, gitConfig) { + service.updateGitStackSettings = function (id, endpointId, env, gitConfig, webhookId) { return Stack.updateGitStackSettings( { endpointId, id }, { - AutoUpdate: transformAutoUpdateViewModel(gitConfig.AutoUpdate), + AutoUpdate: transformAutoUpdateViewModel(gitConfig.AutoUpdate, webhookId), Env: env, RepositoryReferenceName: gitConfig.RefName, RepositoryAuthentication: gitConfig.RepositoryAuthentication, diff --git a/app/portainer/views/stacks/create/createStackController.js b/app/portainer/views/stacks/create/createStackController.js index 18c3c10a6..4a08b7205 100644 --- a/app/portainer/views/stacks/create/createStackController.js +++ b/app/portainer/views/stacks/create/createStackController.js @@ -9,7 +9,7 @@ import { renderTemplate } from '@/react/portainer/custom-templates/components/ut import { editor, upload, git, customTemplate } from '@@/BoxSelector/common-options/build-methods'; import { confirmWebEditorDiscard } from '@@/modals/confirm'; import { parseAutoUpdateResponse, transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils'; -import { baseStackWebhookUrl } from '@/portainer/helpers/webhookHelper'; +import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper'; angular .module('portainer.app') @@ -70,6 +70,7 @@ angular selectedTemplate: null, selectedTemplateId: null, baseWebhookUrl: baseStackWebhookUrl(), + webhookId: createWebhookId(), }; $window.onbeforeunload = () => { @@ -153,6 +154,7 @@ angular function createSwarmStack(name, method) { var env = FormHelper.removeInvalidEnvVars($scope.formValues.Env); const endpointId = +$state.params.endpointId; + if (method === 'template' || method === 'editor') { var stackFileContent = $scope.formValues.StackFileContent; return StackService.createSwarmStackFromFileContent(name, stackFileContent, env, endpointId); @@ -172,7 +174,7 @@ angular RepositoryAuthentication: $scope.formValues.RepositoryAuthentication, RepositoryUsername: $scope.formValues.RepositoryUsername, RepositoryPassword: $scope.formValues.RepositoryPassword, - AutoUpdate: transformAutoUpdateViewModel($scope.formValues.AutoUpdate), + AutoUpdate: transformAutoUpdateViewModel($scope.formValues.AutoUpdate, $scope.state.webhookId), }; return StackService.createSwarmStackFromGitRepository(name, repositoryOptions, env, endpointId); @@ -198,7 +200,7 @@ angular RepositoryAuthentication: $scope.formValues.RepositoryAuthentication, RepositoryUsername: $scope.formValues.RepositoryUsername, RepositoryPassword: $scope.formValues.RepositoryPassword, - AutoUpdate: transformAutoUpdateViewModel($scope.formValues.AutoUpdate), + AutoUpdate: transformAutoUpdateViewModel($scope.formValues.AutoUpdate, $scope.state.webhookId), }; return StackService.createComposeStackFromGitRepository(name, repositoryOptions, env, endpointId); diff --git a/app/portainer/views/stacks/create/createstack.html b/app/portainer/views/stacks/create/createstack.html index 3520b20b7..4cbb8f19e 100644 --- a/app/portainer/views/stacks/create/createstack.html +++ b/app/portainer/views/stacks/create/createstack.html @@ -83,10 +83,11 @@ on-change="(onChangeFormValues)" is-docker-standalone="isDockerStandalone" is-additional-files-field-visible="true" - is-auto-update-visible="true" is-auth-explanation-visible="true" is-force-pull-visible="true" base-webhook-url="{{ state.baseWebhookUrl }}" + webhook-id="{{ state.webhookId }}" + webhooks-docs="https://docs.portainer.io/user/docker/stacks/webhooks" >
diff --git a/app/react/portainer/gitops/AutoUpdateFieldset/AutoUpdateFieldset.tsx b/app/react/portainer/gitops/AutoUpdateFieldset/AutoUpdateFieldset.tsx index 3ea1e7c74..638770f31 100644 --- a/app/react/portainer/gitops/AutoUpdateFieldset/AutoUpdateFieldset.tsx +++ b/app/react/portainer/gitops/AutoUpdateFieldset/AutoUpdateFieldset.tsx @@ -14,6 +14,8 @@ export function AutoUpdateFieldset({ isForcePullVisible = true, errors, baseWebhookUrl, + webhookId, + webhooksDocs, }: { value: AutoUpdateModel; onChange: (value: AutoUpdateModel) => void; @@ -21,6 +23,8 @@ export function AutoUpdateFieldset({ isForcePullVisible?: boolean; errors?: FormikErrors; baseWebhookUrl: string; + webhookId: string; + webhooksDocs?: string; }) { return ( <> @@ -45,12 +49,14 @@ export function AutoUpdateFieldset({ {value.RepositoryAutomaticUpdates && ( )} diff --git a/app/react/portainer/gitops/AutoUpdateFieldset/AutoUpdateSettings.tsx b/app/react/portainer/gitops/AutoUpdateFieldset/AutoUpdateSettings.tsx index 84104df61..426ca2c85 100644 --- a/app/react/portainer/gitops/AutoUpdateFieldset/AutoUpdateSettings.tsx +++ b/app/react/portainer/gitops/AutoUpdateFieldset/AutoUpdateSettings.tsx @@ -19,6 +19,8 @@ export function AutoUpdateSettings({ showForcePullImage, errors, baseWebhookUrl, + webhookId, + webhookDocs, }: { value: AutoUpdateModel; onChange: (value: Partial) => void; @@ -26,6 +28,8 @@ export function AutoUpdateSettings({ showForcePullImage: boolean; errors?: FormikErrors; baseWebhookUrl: string; + webhookId: string; + webhookDocs?: string; }) { return ( <> @@ -50,12 +54,8 @@ export function AutoUpdateSettings({ {value.RepositoryMechanism === 'Webhook' && ( )} diff --git a/app/react/portainer/gitops/AutoUpdateFieldset/WebhookSettings.tsx b/app/react/portainer/gitops/AutoUpdateFieldset/WebhookSettings.tsx index 8dcd24a61..baf4efa26 100644 --- a/app/react/portainer/gitops/AutoUpdateFieldset/WebhookSettings.tsx +++ b/app/react/portainer/gitops/AutoUpdateFieldset/WebhookSettings.tsx @@ -8,7 +8,7 @@ export function WebhookSettings({ baseUrl, docsLink, }: { - docsLink: string; + docsLink?: string; value: string; baseUrl: string; }) { @@ -18,13 +18,15 @@ export function WebhookSettings({ - See{' '} - - Portainer documentation on webhook usage - - . - + !!docsLink && ( + <> + See{' '} + + Portainer documentation on webhook usage + + . + + ) } >
diff --git a/app/react/portainer/gitops/AutoUpdateFieldset/utils.ts b/app/react/portainer/gitops/AutoUpdateFieldset/utils.ts index 70659b079..27b2b2c6a 100644 --- a/app/react/portainer/gitops/AutoUpdateFieldset/utils.ts +++ b/app/react/portainer/gitops/AutoUpdateFieldset/utils.ts @@ -1,5 +1,3 @@ -import { v4 as uuid } from 'uuid'; - import { AutoUpdateResponse, AutoUpdateModel } from '../types'; export function parseAutoUpdateResponse( @@ -11,7 +9,6 @@ export function parseAutoUpdateResponse( RepositoryAutomaticUpdatesForce: false, RepositoryMechanism: 'Interval', RepositoryFetchInterval: '5m', - RepositoryWebhookId: uuid(), ForcePullImage: false, }; } @@ -20,28 +17,30 @@ export function parseAutoUpdateResponse( RepositoryAutomaticUpdates: true, RepositoryMechanism: response.Interval ? 'Interval' : 'Webhook', RepositoryFetchInterval: response.Interval || '', - RepositoryWebhookId: response.Webhook || uuid(), RepositoryAutomaticUpdatesForce: response.ForceUpdate, ForcePullImage: response.ForcePullImage, }; } export function transformAutoUpdateViewModel( - viewModel?: AutoUpdateModel + viewModel?: AutoUpdateModel, + webhookId?: string ): AutoUpdateResponse | null { if (!viewModel || !viewModel.RepositoryAutomaticUpdates) { return null; } + if (viewModel.RepositoryMechanism === 'Webhook' && !webhookId) { + throw new Error('Webhook ID is required'); + } + return { Interval: viewModel.RepositoryMechanism === 'Interval' ? viewModel.RepositoryFetchInterval : '', Webhook: - viewModel.RepositoryMechanism === 'Webhook' - ? viewModel.RepositoryWebhookId - : '', + viewModel.RepositoryMechanism === 'Webhook' && webhookId ? webhookId : '', ForceUpdate: viewModel.RepositoryAutomaticUpdatesForce, ForcePullImage: viewModel.ForcePullImage, }; diff --git a/app/react/portainer/gitops/GitForm.stories.tsx b/app/react/portainer/gitops/GitForm.stories.tsx index 8e9c29035..d2a0aa39e 100644 --- a/app/react/portainer/gitops/GitForm.stories.tsx +++ b/app/react/portainer/gitops/GitForm.stories.tsx @@ -90,6 +90,7 @@ export function Primary({ isForcePullVisible={isForcePullVisible} deployMethod={deployMethod} baseWebhookUrl="ws://localhost:9000" + webhookId="1234" /> )} diff --git a/app/react/portainer/gitops/GitForm.tsx b/app/react/portainer/gitops/GitForm.tsx index ce3da1412..468a6dff8 100644 --- a/app/react/portainer/gitops/GitForm.tsx +++ b/app/react/portainer/gitops/GitForm.tsx @@ -28,6 +28,8 @@ interface Props { isAuthExplanationVisible?: boolean; errors: FormikErrors; baseWebhookUrl: string; + webhookId: string; + webhooksDocs?: string; } export function GitForm({ @@ -40,6 +42,8 @@ export function GitForm({ isAuthExplanationVisible, errors = {}, baseWebhookUrl, + webhookId, + webhooksDocs, }: Props) { return ( @@ -89,11 +93,13 @@ export function GitForm({ {value.AutoUpdate && ( handleChange({ AutoUpdate: value })} isForcePullVisible={isForcePullVisible} errors={errors.AutoUpdate as FormikErrors} + webhooksDocs={webhooksDocs} /> )} diff --git a/app/react/portainer/gitops/types.ts b/app/react/portainer/gitops/types.ts index 83b5b2370..ec20a93da 100644 --- a/app/react/portainer/gitops/types.ts +++ b/app/react/portainer/gitops/types.ts @@ -31,7 +31,6 @@ export interface RepoConfigResponse { export type AutoUpdateModel = { RepositoryAutomaticUpdates: boolean; RepositoryMechanism: AutoUpdateMechanism; - RepositoryWebhookId: string; RepositoryFetchInterval: string; ForcePullImage: boolean; RepositoryAutomaticUpdatesForce: boolean;