refactor(stacks): extract auto update logic [EE-4945] (#8545)

pull/8449/head^2
Chaim Lev-Ari 2023-03-02 17:07:50 +02:00 committed by GitHub
parent 085381e6fc
commit 6918da2414
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 410 additions and 166 deletions

View File

@ -68,13 +68,13 @@ func (b *stackBuilder) createNewStack(webhookID string) portainer.Stack {
if webhookID == "" { if webhookID == "" {
if b.count%2 == 0 { if b.count%2 == 0 {
stack.AutoUpdate = &portainer.StackAutoUpdate{ stack.AutoUpdate = &portainer.AutoUpdateSettings{
Interval: "", Interval: "",
Webhook: "", Webhook: "",
} }
} // else keep AutoUpdate nil } // else keep AutoUpdate nil
} else { } else {
stack.AutoUpdate = &portainer.StackAutoUpdate{Webhook: webhookID} stack.AutoUpdate = &portainer.AutoUpdateSettings{Webhook: webhookID}
} }
err := b.store.StackService.Create(&stack) err := b.store.StackService.Create(&stack)
@ -91,8 +91,8 @@ func Test_RefreshableStacks(t *testing.T) {
defer teardown() defer teardown()
staticStack := portainer.Stack{ID: 1} staticStack := portainer.Stack{ID: 1}
stackWithWebhook := portainer.Stack{ID: 2, AutoUpdate: &portainer.StackAutoUpdate{Webhook: "webhook"}} stackWithWebhook := portainer.Stack{ID: 2, AutoUpdate: &portainer.AutoUpdateSettings{Webhook: "webhook"}}
refreshableStack := portainer.Stack{ID: 3, AutoUpdate: &portainer.StackAutoUpdate{Interval: "1m"}} refreshableStack := portainer.Stack{ID: 3, AutoUpdate: &portainer.AutoUpdateSettings{Interval: "1m"}}
for _, stack := range []*portainer.Stack{&staticStack, &stackWithWebhook, &refreshableStack} { for _, stack := range []*portainer.Stack{&staticStack, &stackWithWebhook, &refreshableStack} {
err := store.Stack().Create(stack) err := store.Stack().Create(stack)

62
api/git/backup.go Normal file
View File

@ -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
}

13
api/git/credentials.go Normal file
View File

@ -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
}

94
api/git/update/update.go Normal file
View File

@ -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, "", "")
}

View File

@ -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
}

View File

@ -1,4 +1,4 @@
package stackutils package update
import ( import (
"testing" "testing"
@ -7,25 +7,25 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func Test_ValidateStackAutoUpdate(t *testing.T) { func Test_ValidateAutoUpdate(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
value *portainer.StackAutoUpdate value *portainer.AutoUpdateSettings
wantErr bool wantErr bool
}{ }{
{ {
name: "webhook is not a valid UUID", name: "webhook is not a valid UUID",
value: &portainer.StackAutoUpdate{Webhook: "fake-webhook"}, value: &portainer.AutoUpdateSettings{Webhook: "fake-webhook"},
wantErr: true, wantErr: true,
}, },
{ {
name: "incorrect interval value", name: "incorrect interval value",
value: &portainer.StackAutoUpdate{Interval: "1dd2hh3mm"}, value: &portainer.AutoUpdateSettings{Interval: "1dd2hh3mm"},
wantErr: true, wantErr: true,
}, },
{ {
name: "valid auto update", name: "valid auto update",
value: &portainer.StackAutoUpdate{ value: &portainer.AutoUpdateSettings{
Webhook: "8dce8c2f-9ca1-482b-ad20-271e86536ada", Webhook: "8dce8c2f-9ca1-482b-ad20-271e86536ada",
Interval: "5h30m40s10ms", Interval: "5h30m40s10ms",
}, },
@ -35,7 +35,7 @@ func Test_ValidateStackAutoUpdate(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { 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) assert.Equalf(t, tt.wantErr, err != nil, "received %+v", err)
}) })
} }

25
api/git/validate.go Normal file
View File

@ -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
}

View File

@ -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}
}

View File

@ -8,6 +8,7 @@ import (
"github.com/portainer/libhttp/request" "github.com/portainer/libhttp/request"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem" "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/http/security"
"github.com/portainer/portainer/api/stacks/deployments" "github.com/portainer/portainer/api/stacks/deployments"
"github.com/portainer/portainer/api/stacks/stackbuilders" "github.com/portainer/portainer/api/stacks/stackbuilders"
@ -156,14 +157,14 @@ type composeStackFromGitRepositoryPayload struct {
// Applicable when deploying with multiple stack files // Applicable when deploying with multiple stack files
AdditionalFiles []string `example:"[nz.compose.yml, uat.compose.yml]"` AdditionalFiles []string `example:"[nz.compose.yml, uat.compose.yml]"`
// Optional auto update configuration // Optional auto update configuration
AutoUpdate *portainer.StackAutoUpdate AutoUpdate *portainer.AutoUpdateSettings
// A list of environment(endpoint) variables used during stack deployment // A list of environment(endpoint) variables used during stack deployment
Env []portainer.Pair Env []portainer.Pair
// Whether the stack is from a app template // Whether the stack is from a app template
FromAppTemplate bool `example:"false"` 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{ return stackbuilders.StackPayload{
Name: name, Name: name,
RepositoryConfigPayload: stackbuilders.RepositoryConfigPayload{ RepositoryConfigPayload: stackbuilders.RepositoryConfigPayload{
@ -191,7 +192,7 @@ func (payload *composeStackFromGitRepositoryPayload) Validate(r *http.Request) e
if payload.RepositoryAuthentication && govalidator.IsNull(payload.RepositoryPassword) { if payload.RepositoryAuthentication && govalidator.IsNull(payload.RepositoryPassword) {
return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled") 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 err
} }
return nil return nil

View File

@ -11,6 +11,7 @@ import (
"github.com/portainer/libhttp/request" "github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response" "github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/git/update"
k "github.com/portainer/portainer/api/kubernetes" k "github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/api/stacks/deployments" "github.com/portainer/portainer/api/stacks/deployments"
"github.com/portainer/portainer/api/stacks/stackbuilders" "github.com/portainer/portainer/api/stacks/stackbuilders"
@ -44,10 +45,10 @@ type kubernetesGitDeploymentPayload struct {
RepositoryPassword string RepositoryPassword string
ManifestFile string ManifestFile string
AdditionalFiles []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{ return stackbuilders.StackPayload{
StackName: name, StackName: name,
RepositoryConfigPayload: stackbuilders.RepositoryConfigPayload{ RepositoryConfigPayload: stackbuilders.RepositoryConfigPayload{
@ -101,7 +102,7 @@ func (payload *kubernetesGitDeploymentPayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.ManifestFile) { if govalidator.IsNull(payload.ManifestFile) {
return errors.New("Invalid manifest file in repository") 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 return err
} }
if govalidator.IsNull(payload.StackName) { if govalidator.IsNull(payload.StackName) {

View File

@ -10,6 +10,7 @@ import (
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request" "github.com/portainer/libhttp/request"
portainer "github.com/portainer/portainer/api" 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/http/security"
"github.com/portainer/portainer/api/stacks/stackbuilders" "github.com/portainer/portainer/api/stacks/stackbuilders"
"github.com/portainer/portainer/api/stacks/stackutils" "github.com/portainer/portainer/api/stacks/stackutils"
@ -115,7 +116,7 @@ type swarmStackFromGitRepositoryPayload struct {
// Applicable when deploying with multiple stack files // Applicable when deploying with multiple stack files
AdditionalFiles []string `example:"[nz.compose.yml, uat.compose.yml]"` AdditionalFiles []string `example:"[nz.compose.yml, uat.compose.yml]"`
// Optional auto update configuration // Optional auto update configuration
AutoUpdate *portainer.StackAutoUpdate AutoUpdate *portainer.AutoUpdateSettings
} }
func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) error { 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) { if payload.RepositoryAuthentication && govalidator.IsNull(payload.RepositoryPassword) {
return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled") 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 err
} }
return nil 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{ return stackbuilders.StackPayload{
Name: name, Name: name,
SwarmID: swarmID, SwarmID: swarmID,

View File

@ -10,6 +10,7 @@ import (
"github.com/portainer/libhttp/response" "github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/git/update"
httperrors "github.com/portainer/portainer/api/http/errors" httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/stacks/deployments" "github.com/portainer/portainer/api/stacks/deployments"
@ -17,7 +18,7 @@ import (
) )
type stackGitUpdatePayload struct { type stackGitUpdatePayload struct {
AutoUpdate *portainer.StackAutoUpdate AutoUpdate *portainer.AutoUpdateSettings
Env []portainer.Pair Env []portainer.Pair
Prune bool Prune bool
RepositoryReferenceName string RepositoryReferenceName string
@ -27,7 +28,7 @@ type stackGitUpdatePayload struct {
} }
func (payload *stackGitUpdatePayload) Validate(r *http.Request) error { 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 err
} }
return nil return nil

View File

@ -11,13 +11,12 @@ import (
"github.com/portainer/libhttp/response" "github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/git"
httperrors "github.com/portainer/portainer/api/http/errors" httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
k "github.com/portainer/portainer/api/kubernetes" k "github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/api/stacks/deployments" "github.com/portainer/portainer/api/stacks/deployments"
"github.com/portainer/portainer/api/stacks/stackutils" "github.com/portainer/portainer/api/stacks/stackutils"
"github.com/rs/zerolog/log"
) )
type stackGitRedployPayload struct { type stackGitRedployPayload struct {
@ -154,22 +153,12 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
repositoryUsername = payload.RepositoryUsername 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 { if err != nil {
restoreError := filesystem.MoveDirectory(backupProjectPath, stack.ProjectPath) return httperror.InternalServerError("Unable to clone git repository directory", err)
if restoreError != nil {
log.Warn().Err(restoreError).Msg("failed restoring backup folder")
}
return httperror.InternalServerError("Unable to clone git repository", err)
} }
defer func() { defer clean()
err = handler.FileService.RemoveDirectory(backupProjectPath)
if err != nil {
log.Warn().Err(err).Msg("unable to remove git repository directory")
}
}()
httpErr := handler.deployStack(r, stack, payload.PullImage, endpoint) httpErr := handler.deployStack(r, stack, payload.PullImage, endpoint)
if httpErr != nil { if httpErr != nil {

View File

@ -11,10 +11,10 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
k "github.com/portainer/portainer/api/kubernetes" k "github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/api/stacks/deployments" "github.com/portainer/portainer/api/stacks/deployments"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/asaskevich/govalidator" "github.com/asaskevich/govalidator"
"github.com/pkg/errors" "github.com/pkg/errors"
@ -30,7 +30,7 @@ type kubernetesGitStackUpdatePayload struct {
RepositoryAuthentication bool RepositoryAuthentication bool
RepositoryUsername string RepositoryUsername string
RepositoryPassword string RepositoryPassword string
AutoUpdate *portainer.StackAutoUpdate AutoUpdate *portainer.AutoUpdateSettings
} }
func (payload *kubernetesFileStackUpdatePayload) Validate(r *http.Request) error { 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 { 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 err
} }
return nil return nil

View File

@ -9,7 +9,6 @@ import (
"github.com/portainer/portainer/api/stacks/deployments" "github.com/portainer/portainer/api/stacks/deployments"
"github.com/gofrs/uuid" "github.com/gofrs/uuid"
"github.com/rs/zerolog/log"
) )
// @id WebhookInvoke // @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} 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) return httperror.InternalServerError("Failed to update the stack", err)
} }

View File

@ -18,7 +18,8 @@ func TestHandler_webhookInvoke(t *testing.T) {
webhookID := newGuidString(t) webhookID := newGuidString(t)
store.StackService.Create(&portainer.Stack{ store.StackService.Create(&portainer.Stack{
AutoUpdate: &portainer.StackAutoUpdate{ ID: 1,
AutoUpdate: &portainer.AutoUpdateSettings{
Webhook: webhookID, Webhook: webhookID,
}, },
}) })

View File

@ -32,6 +32,20 @@ type (
// Authorizations represents a set of authorizations associated to a role // Authorizations represents a set of authorizations associated to a role
Authorizations map[Authorization]bool 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 // AzureCredentials represents the credentials used to connect to an Azure
// environment(endpoint). // environment(endpoint).
AzureCredentials struct { AzureCredentials struct {
@ -986,7 +1000,7 @@ type (
// Only applies when deploying stack with multiple files // Only applies when deploying stack with multiple files
AdditionalFiles []string `json:"AdditionalFiles"` AdditionalFiles []string `json:"AdditionalFiles"`
// The auto update settings of a git stack // The auto update settings of a git stack
AutoUpdate *StackAutoUpdate `json:"AutoUpdate"` AutoUpdate *AutoUpdateSettings `json:"AutoUpdate"`
// The stack deployment option // The stack deployment option
Option *StackOption `json:"Option"` Option *StackOption `json:"Option"`
// The git config of this stack // The git config of this stack
@ -999,16 +1013,6 @@ type (
IsComposeFormat bool `example:"false"` 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 represents the options for stack deployment
StackOption struct { StackOption struct {
// Prune services that are no longer referenced // Prune services that are no longer referenced

View File

@ -2,11 +2,11 @@ package deployments
import ( import (
"fmt" "fmt"
"strings"
"time" "time"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/pkg/errors" "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 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 author := stack.UpdatedBy
if author == "" { if author == "" {
author = stack.CreatedBy author = stack.CreatedBy
@ -53,39 +58,22 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
return &StackAuthorMissingErr{int(stack.ID), author} return &StackAuthorMissingErr{int(stack.ID), author}
} }
username, password := "", "" var gitCommitChangedOrForceUpdate bool
if stack.GitConfig.Authentication != nil { if !stack.FromAppTemplate {
username, password = stack.GitConfig.Authentication.Username, stack.GitConfig.Authentication.Password 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 updated {
if err != nil { stack.GitConfig.ConfigHash = newHash
return errors.WithMessagef(err, "failed to fetch latest commit id of the stack %v", stack.ID) stack.UpdateDate = time.Now().Unix()
} gitCommitChangedOrForceUpdate = updated
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 err := cloneGitRepository(gitService, cloneParams); err != nil { if !gitCommitChangedOrForceUpdate {
return errors.WithMessagef(err, "failed to do a fresh clone of the stack %v", stack.ID) return nil
}
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)
} }
registries, err := getUserRegistries(datastore, user, endpoint.ID) 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) 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 { if err := datastore.Stack().UpdateStack(stack.ID, stack); err != nil {
return errors.WithMessagef(err, "failed to update the stack %v", stack.ID) 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 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, "", "")
}

View File

@ -81,6 +81,11 @@ func Test_redeployWhenChanged_DoesNothingWhenNoGitChanges(t *testing.T) {
err := store.User().Create(admin) err := store.User().Create(admin)
assert.NoError(t, err, "error creating an 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{ err = store.Stack().Create(&portainer.Stack{
ID: 1, ID: 1,
CreatedBy: "admin", CreatedBy: "admin",
@ -105,6 +110,11 @@ func Test_redeployWhenChanged_FailsWhenCannotClone(t *testing.T) {
err := store.User().Create(admin) err := store.User().Create(admin)
assert.NoError(t, err, "error creating an 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{ err = store.Stack().Create(&portainer.Stack{
ID: 1, ID: 1,
CreatedBy: "admin", CreatedBy: "admin",
@ -142,7 +152,9 @@ func Test_redeployWhenChanged(t *testing.T) {
URL: "url", URL: "url",
ReferenceName: "ref", ReferenceName: "ref",
ConfigHash: "oldHash", ConfigHash: "oldHash",
}} },
}
err = store.Stack().Create(&stack) err = store.Stack().Create(&stack)
assert.NoError(t, err, "failed to create a test stack") assert.NoError(t, err, "failed to create a test stack")

View File

@ -18,7 +18,7 @@ type StackPayload struct {
// A list of environment(endpoint) variables used during stack deployment // A list of environment(endpoint) variables used during stack deployment
Env []portainer.Pair Env []portainer.Pair
// Optional auto update configuration // Optional auto update configuration
AutoUpdate *portainer.StackAutoUpdate AutoUpdate *portainer.AutoUpdateSettings
// Whether the stack is from a app template // Whether the stack is from a app template
FromAppTemplate bool `example:"false"` FromAppTemplate bool `example:"false"`
// Kubernetes stack name // Kubernetes stack name

View File

@ -1,9 +1,6 @@
package stackutils package stackutils
import ( import (
"time"
"github.com/asaskevich/govalidator"
"github.com/docker/cli/cli/compose/loader" "github.com/docker/cli/cli/compose/loader"
"github.com/docker/cli/cli/compose/types" "github.com/docker/cli/cli/compose/types"
"github.com/pkg/errors" "github.com/pkg/errors"
@ -67,21 +64,6 @@ func IsValidStackFile(stackFileContent []byte, securitySettings *portainer.Endpo
return nil 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 { func ValidateStackFiles(stack *portainer.Stack, securitySettings *portainer.EndpointSecuritySettings, fileService portainer.FileService) error {
for _, file := range GetStackFilePaths(stack, false) { for _, file := range GetStackFilePaths(stack, false) {
stackContent, err := fileService.GetFileContent(stack.ProjectPath, file) stackContent, err := fileService.GetFileContent(stack.ProjectPath, file)

View File

@ -199,7 +199,7 @@ class KubernetesCreateApplicationController {
} }
this.state.updateWebEditorInProgress = true; 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; this.state.isEditorDirty = false;
await this.$state.reload(this.$state.current); await this.$state.reload(this.$state.current);
} catch (err) { } catch (err) {

View File

@ -84,6 +84,8 @@
is-auth-explanation-visible="true" is-auth-explanation-visible="true"
deploy-method="{{ ctrl.state.DeployType === ctrl.ManifestDeployTypes.COMPOSE ? 'compose' : 'manifest' }}" deploy-method="{{ ctrl.state.DeployType === ctrl.ManifestDeployTypes.COMPOSE ? 'compose' : 'manifest' }}"
base-webhook-url="{{ ctrl.state.baseWebhookUrl }}" base-webhook-url="{{ ctrl.state.baseWebhookUrl }}"
webhook-id="{{ ctrl.state.webhookId }}"
webhooks-docs="https://docs.portainer.io/user/kubernetes/applications/webhooks"
></git-form> ></git-form>
<!-- !repository --> <!-- !repository -->

View File

@ -9,7 +9,7 @@ import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods'; import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods';
import { editor, git, customTemplate, url } from '@@/BoxSelector/common-options/build-methods'; import { editor, git, customTemplate, url } from '@@/BoxSelector/common-options/build-methods';
import { parseAutoUpdateResponse, transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils'; 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'; import { confirmWebEditorDiscard } from '@@/modals/confirm';
class KubernetesDeployController { class KubernetesDeployController {
@ -45,6 +45,7 @@ class KubernetesDeployController {
templateId: null, templateId: null,
template: null, template: null,
baseWebhookUrl: baseStackWebhookUrl(), baseWebhookUrl: baseStackWebhookUrl(),
webhookId: createWebhookId(),
}; };
this.formValues = { this.formValues = {
@ -256,7 +257,7 @@ class KubernetesDeployController {
} }
payload.ManifestFile = this.formValues.ComposeFilePathInRepository; payload.ManifestFile = this.formValues.ComposeFilePathInRepository;
payload.AdditionalFiles = this.formValues.AdditionalFiles; 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) { } else if (method === KubernetesDeployRequestMethods.STRING) {
payload.StackFileContent = this.formValues.EditorContent; payload.StackFileContent = this.formValues.EditorContent;
} else { } else {

View File

@ -10,6 +10,8 @@ export const gitFormAutoUpdate: IComponentOptions = {
environment-type="$ctrl.environmentType" environment-type="$ctrl.environmentType"
is-force-pull-visible="$ctrl.isForcePullVisible" is-force-pull-visible="$ctrl.isForcePullVisible"
base-webhook-url="$ctrl.baseWebhookUrl" base-webhook-url="$ctrl.baseWebhookUrl"
webhook-id="$ctrl.webhookId"
webhooks-docs="$ctrl.webhooksDocs"
errors="$ctrl.errors"> errors="$ctrl.errors">
</react-git-form-auto-update-fieldset> </react-git-form-auto-update-fieldset>
</ng-form>`, </ng-form>`,
@ -19,6 +21,8 @@ export const gitFormAutoUpdate: IComponentOptions = {
environmentType: '@', environmentType: '@',
isForcePullVisible: '<', isForcePullVisible: '<',
baseWebhookUrl: '@', baseWebhookUrl: '@',
webhookId: '@',
webhooksDocs: '@',
}, },
controller, controller,
}; };

View File

@ -11,10 +11,11 @@ export const gitForm: IComponentOptions = {
is-docker-standalone="$ctrl.isDockerStandalone" is-docker-standalone="$ctrl.isDockerStandalone"
deploy-method="$ctrl.deployMethod" deploy-method="$ctrl.deployMethod"
is-additional-files-field-visible="$ctrl.isAdditionalFilesFieldVisible" is-additional-files-field-visible="$ctrl.isAdditionalFilesFieldVisible"
is-auto-update-visible="$ctrl.isAutoUpdateVisible"
is-force-pull-visible="$ctrl.isForcePullVisible" is-force-pull-visible="$ctrl.isForcePullVisible"
is-auth-explanation-visible="$ctrl.isAuthExplanationVisible" is-auth-explanation-visible="$ctrl.isAuthExplanationVisible"
base-webhook-url="$ctrl.baseWebhookUrl" base-webhook-url="$ctrl.baseWebhookUrl"
webhook-id="$ctrl.webhookId"
webhooks-docs="$ctrl.webhooksDocs"
errors="$ctrl.errors"> errors="$ctrl.errors">
</react-git-form> </react-git-form>
</ng-form>`, </ng-form>`,
@ -25,9 +26,10 @@ export const gitForm: IComponentOptions = {
deployMethod: '@', deployMethod: '@',
baseWebhookUrl: '@', baseWebhookUrl: '@',
isAdditionalFilesFieldVisible: '<', isAdditionalFilesFieldVisible: '<',
isAutoUpdateVisible: '<',
isForcePullVisible: '<', isForcePullVisible: '<',
isAuthExplanationVisible: '<', isAuthExplanationVisible: '<',
webhookId: '@',
webhooksDocs: '@',
}, },
controller, controller,
}; };

View File

@ -3,7 +3,7 @@ import { confirm } from '@@/modals/confirm';
import { buildConfirmButton } from '@@/modals/utils'; import { buildConfirmButton } from '@@/modals/utils';
import { ModalType } from '@@/modals'; import { ModalType } from '@@/modals';
import { parseAutoUpdateResponse } from '@/react/portainer/gitops/AutoUpdateFieldset/utils'; import { parseAutoUpdateResponse } from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
import { baseStackWebhookUrl } from '@/portainer/helpers/webhookHelper'; import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper';
class KubernetesRedeployAppGitFormController { class KubernetesRedeployAppGitFormController {
/* @ngInject */ /* @ngInject */
@ -20,6 +20,7 @@ class KubernetesRedeployAppGitFormController {
isEdit: false, isEdit: false,
hasUnsavedChanges: false, hasUnsavedChanges: false,
baseWebhookUrl: baseStackWebhookUrl(), baseWebhookUrl: baseStackWebhookUrl(),
webhookId: createWebhookId(),
}; };
this.formValues = { this.formValues = {
@ -117,7 +118,7 @@ class KubernetesRedeployAppGitFormController {
return this.$async(async () => { return this.$async(async () => {
try { try {
this.state.saveGitSettingsInProgress = true; 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.savedFormValues = angular.copy(this.formValues);
this.state.hasUnsavedChanges = false; this.state.hasUnsavedChanges = false;
this.Notifications.success('Success', 'Save stack settings successfully'); this.Notifications.success('Success', 'Save stack settings successfully');
@ -138,6 +139,10 @@ class KubernetesRedeployAppGitFormController {
this.formValues.AutoUpdate = parseAutoUpdateResponse(this.stack.AutoUpdate); 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) { if (this.stack.GitConfig && this.stack.GitConfig.Authentication) {
this.formValues.RepositoryUsername = this.stack.GitConfig.Authentication.Username; this.formValues.RepositoryUsername = this.stack.GitConfig.Authentication.Username;
this.formValues.RepositoryAuthentication = true; this.formValues.RepositoryAuthentication = true;

View File

@ -10,6 +10,8 @@
on-change="($ctrl.onChangeAutoUpdate)" on-change="($ctrl.onChangeAutoUpdate)"
environment-type="KUBERNETES" environment-type="KUBERNETES"
base-webhook-url="{{ $ctrl.state.baseWebhookUrl }}" base-webhook-url="{{ $ctrl.state.baseWebhookUrl }}"
webhook-id="{{ $ctrl.state.webhookId }}"
webhooks-docs="https://docs.portainer.io/user/kubernetes/applications/webhooks"
></git-form-auto-update-fieldset> ></git-form-auto-update-fieldset>
<time-window-display></time-window-display> <time-window-display></time-window-display>

View File

@ -3,7 +3,7 @@ import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { confirmStackUpdate } from '@/react/docker/stacks/common/confirm-stack-update'; import { confirmStackUpdate } from '@/react/docker/stacks/common/confirm-stack-update';
import { parseAutoUpdateResponse } from '@/react/portainer/gitops/AutoUpdateFieldset/utils'; import { parseAutoUpdateResponse } from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
import { baseStackWebhookUrl } from '@/portainer/helpers/webhookHelper'; import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper';
class StackRedeployGitFormController { class StackRedeployGitFormController {
/* @ngInject */ /* @ngInject */
@ -23,6 +23,7 @@ class StackRedeployGitFormController {
isEdit: false, isEdit: false,
hasUnsavedChanges: false, hasUnsavedChanges: false,
baseWebhookUrl: baseStackWebhookUrl(), baseWebhookUrl: baseStackWebhookUrl(),
webhookId: createWebhookId(),
}; };
this.formValues = { this.formValues = {
@ -132,7 +133,8 @@ class StackRedeployGitFormController {
this.stack.Id, this.stack.Id,
this.stack.EndpointId, this.stack.EndpointId,
this.FormHelper.removeInvalidEnvVars(this.formValues.Env), this.FormHelper.removeInvalidEnvVars(this.formValues.Env),
this.formValues this.formValues,
this.state.webhookId
); );
this.savedFormValues = angular.copy(this.formValues); this.savedFormValues = angular.copy(this.formValues);
this.state.hasUnsavedChanges = false; this.state.hasUnsavedChanges = false;
@ -180,6 +182,10 @@ class StackRedeployGitFormController {
this.formValues.AutoUpdate = parseAutoUpdateResponse(this.stack.AutoUpdate); 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) { if (this.stack.GitConfig && this.stack.GitConfig.Authentication) {
this.formValues.RepositoryUsername = this.stack.GitConfig.Authentication.Username; this.formValues.RepositoryUsername = this.stack.GitConfig.Authentication.Username;
this.formValues.RepositoryAuthentication = true; this.formValues.RepositoryAuthentication = true;

View File

@ -14,6 +14,8 @@
environment-type="DOCKER" environment-type="DOCKER"
is-force-pull-visible="$ctrl.stack.Type !== 3" is-force-pull-visible="$ctrl.stack.Type !== 3"
base-webhook-url="{{ $ctrl.state.baseWebhookUrl }}" base-webhook-url="{{ $ctrl.state.baseWebhookUrl }}"
webhook-id="{{ $ctrl.state.webhookId }}"
webhooks-docs="https://docs.portainer.io/user/docker/stacks/webhooks"
></git-form-auto-update-fieldset> ></git-form-auto-update-fieldset>
<div class="form-group"> <div class="form-group">
<div class="col-sm-12"> <div class="col-sm-12">

View File

@ -1,3 +1,5 @@
import uuid from 'uuid';
import { API_ENDPOINT_STACKS, API_ENDPOINT_WEBHOOKS } from '@/constants'; import { API_ENDPOINT_STACKS, API_ENDPOINT_WEBHOOKS } from '@/constants';
import { baseHref } from './pathHelper'; import { baseHref } from './pathHelper';
@ -16,6 +18,10 @@ export function stackWebhookUrl(token: string) {
return `${baseStackWebhookUrl()}/${token}`; return `${baseStackWebhookUrl()}/${token}`;
} }
export function createWebhookId() {
return uuid();
}
/* @ngInject */ /* @ngInject */
export function WebhookHelperFactory() { export function WebhookHelperFactory() {
return { return {

View File

@ -25,6 +25,8 @@ export const gitFormModule = angular
'isAuthExplanationVisible', 'isAuthExplanationVisible',
'errors', 'errors',
'baseWebhookUrl', 'baseWebhookUrl',
'webhookId',
'webhooksDocs',
]) ])
) )
.component( .component(
@ -46,6 +48,8 @@ export const gitFormModule = angular
'isForcePullVisible', 'isForcePullVisible',
'errors', 'errors',
'baseWebhookUrl', 'baseWebhookUrl',
'webhookId',
'webhooksDocs',
]) ])
) )
.component( .component(

View File

@ -270,7 +270,7 @@ angular.module('portainer.app').factory('StackService', [
).$promise; ).$promise;
}; };
service.updateKubeStack = function (stack, stackFile, gitConfig) { service.updateKubeStack = function (stack, { stackFile, gitConfig, webhookId }) {
let payload = {}; let payload = {};
if (stackFile) { if (stackFile) {
@ -279,7 +279,7 @@ angular.module('portainer.app').factory('StackService', [
}; };
} else { } else {
payload = { payload = {
AutoUpdate: transformAutoUpdateViewModel(gitConfig.AutoUpdate), AutoUpdate: transformAutoUpdateViewModel(gitConfig.AutoUpdate, webhookId),
RepositoryReferenceName: gitConfig.RefName, RepositoryReferenceName: gitConfig.RefName,
RepositoryAuthentication: gitConfig.RepositoryAuthentication, RepositoryAuthentication: gitConfig.RepositoryAuthentication,
RepositoryUsername: gitConfig.RepositoryUsername, RepositoryUsername: gitConfig.RepositoryUsername,
@ -455,11 +455,11 @@ angular.module('portainer.app').factory('StackService', [
).$promise; ).$promise;
} }
service.updateGitStackSettings = function (id, endpointId, env, gitConfig) { service.updateGitStackSettings = function (id, endpointId, env, gitConfig, webhookId) {
return Stack.updateGitStackSettings( return Stack.updateGitStackSettings(
{ endpointId, id }, { endpointId, id },
{ {
AutoUpdate: transformAutoUpdateViewModel(gitConfig.AutoUpdate), AutoUpdate: transformAutoUpdateViewModel(gitConfig.AutoUpdate, webhookId),
Env: env, Env: env,
RepositoryReferenceName: gitConfig.RefName, RepositoryReferenceName: gitConfig.RefName,
RepositoryAuthentication: gitConfig.RepositoryAuthentication, RepositoryAuthentication: gitConfig.RepositoryAuthentication,

View File

@ -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 { editor, upload, git, customTemplate } from '@@/BoxSelector/common-options/build-methods';
import { confirmWebEditorDiscard } from '@@/modals/confirm'; import { confirmWebEditorDiscard } from '@@/modals/confirm';
import { parseAutoUpdateResponse, transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils'; import { parseAutoUpdateResponse, transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
import { baseStackWebhookUrl } from '@/portainer/helpers/webhookHelper'; import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper';
angular angular
.module('portainer.app') .module('portainer.app')
@ -70,6 +70,7 @@ angular
selectedTemplate: null, selectedTemplate: null,
selectedTemplateId: null, selectedTemplateId: null,
baseWebhookUrl: baseStackWebhookUrl(), baseWebhookUrl: baseStackWebhookUrl(),
webhookId: createWebhookId(),
}; };
$window.onbeforeunload = () => { $window.onbeforeunload = () => {
@ -153,6 +154,7 @@ angular
function createSwarmStack(name, method) { function createSwarmStack(name, method) {
var env = FormHelper.removeInvalidEnvVars($scope.formValues.Env); var env = FormHelper.removeInvalidEnvVars($scope.formValues.Env);
const endpointId = +$state.params.endpointId; const endpointId = +$state.params.endpointId;
if (method === 'template' || method === 'editor') { if (method === 'template' || method === 'editor') {
var stackFileContent = $scope.formValues.StackFileContent; var stackFileContent = $scope.formValues.StackFileContent;
return StackService.createSwarmStackFromFileContent(name, stackFileContent, env, endpointId); return StackService.createSwarmStackFromFileContent(name, stackFileContent, env, endpointId);
@ -172,7 +174,7 @@ angular
RepositoryAuthentication: $scope.formValues.RepositoryAuthentication, RepositoryAuthentication: $scope.formValues.RepositoryAuthentication,
RepositoryUsername: $scope.formValues.RepositoryUsername, RepositoryUsername: $scope.formValues.RepositoryUsername,
RepositoryPassword: $scope.formValues.RepositoryPassword, RepositoryPassword: $scope.formValues.RepositoryPassword,
AutoUpdate: transformAutoUpdateViewModel($scope.formValues.AutoUpdate), AutoUpdate: transformAutoUpdateViewModel($scope.formValues.AutoUpdate, $scope.state.webhookId),
}; };
return StackService.createSwarmStackFromGitRepository(name, repositoryOptions, env, endpointId); return StackService.createSwarmStackFromGitRepository(name, repositoryOptions, env, endpointId);
@ -198,7 +200,7 @@ angular
RepositoryAuthentication: $scope.formValues.RepositoryAuthentication, RepositoryAuthentication: $scope.formValues.RepositoryAuthentication,
RepositoryUsername: $scope.formValues.RepositoryUsername, RepositoryUsername: $scope.formValues.RepositoryUsername,
RepositoryPassword: $scope.formValues.RepositoryPassword, RepositoryPassword: $scope.formValues.RepositoryPassword,
AutoUpdate: transformAutoUpdateViewModel($scope.formValues.AutoUpdate), AutoUpdate: transformAutoUpdateViewModel($scope.formValues.AutoUpdate, $scope.state.webhookId),
}; };
return StackService.createComposeStackFromGitRepository(name, repositoryOptions, env, endpointId); return StackService.createComposeStackFromGitRepository(name, repositoryOptions, env, endpointId);

View File

@ -83,10 +83,11 @@
on-change="(onChangeFormValues)" on-change="(onChangeFormValues)"
is-docker-standalone="isDockerStandalone" is-docker-standalone="isDockerStandalone"
is-additional-files-field-visible="true" is-additional-files-field-visible="true"
is-auto-update-visible="true"
is-auth-explanation-visible="true" is-auth-explanation-visible="true"
is-force-pull-visible="true" is-force-pull-visible="true"
base-webhook-url="{{ state.baseWebhookUrl }}" base-webhook-url="{{ state.baseWebhookUrl }}"
webhook-id="{{ state.webhookId }}"
webhooks-docs="https://docs.portainer.io/user/docker/stacks/webhooks"
></git-form> ></git-form>
<div ng-show="state.Method === 'template'"> <div ng-show="state.Method === 'template'">

View File

@ -14,6 +14,8 @@ export function AutoUpdateFieldset({
isForcePullVisible = true, isForcePullVisible = true,
errors, errors,
baseWebhookUrl, baseWebhookUrl,
webhookId,
webhooksDocs,
}: { }: {
value: AutoUpdateModel; value: AutoUpdateModel;
onChange: (value: AutoUpdateModel) => void; onChange: (value: AutoUpdateModel) => void;
@ -21,6 +23,8 @@ export function AutoUpdateFieldset({
isForcePullVisible?: boolean; isForcePullVisible?: boolean;
errors?: FormikErrors<AutoUpdateModel>; errors?: FormikErrors<AutoUpdateModel>;
baseWebhookUrl: string; baseWebhookUrl: string;
webhookId: string;
webhooksDocs?: string;
}) { }) {
return ( return (
<> <>
@ -45,12 +49,14 @@ export function AutoUpdateFieldset({
{value.RepositoryAutomaticUpdates && ( {value.RepositoryAutomaticUpdates && (
<AutoUpdateSettings <AutoUpdateSettings
webhookId={webhookId}
baseWebhookUrl={baseWebhookUrl} baseWebhookUrl={baseWebhookUrl}
value={value} value={value}
onChange={handleChange} onChange={handleChange}
environmentType={environmentType} environmentType={environmentType}
showForcePullImage={isForcePullVisible} showForcePullImage={isForcePullVisible}
errors={errors} errors={errors}
webhookDocs={webhooksDocs}
/> />
)} )}
</> </>

View File

@ -19,6 +19,8 @@ export function AutoUpdateSettings({
showForcePullImage, showForcePullImage,
errors, errors,
baseWebhookUrl, baseWebhookUrl,
webhookId,
webhookDocs,
}: { }: {
value: AutoUpdateModel; value: AutoUpdateModel;
onChange: (value: Partial<AutoUpdateModel>) => void; onChange: (value: Partial<AutoUpdateModel>) => void;
@ -26,6 +28,8 @@ export function AutoUpdateSettings({
showForcePullImage: boolean; showForcePullImage: boolean;
errors?: FormikErrors<AutoUpdateModel>; errors?: FormikErrors<AutoUpdateModel>;
baseWebhookUrl: string; baseWebhookUrl: string;
webhookId: string;
webhookDocs?: string;
}) { }) {
return ( return (
<> <>
@ -50,12 +54,8 @@ export function AutoUpdateSettings({
{value.RepositoryMechanism === 'Webhook' && ( {value.RepositoryMechanism === 'Webhook' && (
<WebhookSettings <WebhookSettings
baseUrl={baseWebhookUrl} baseUrl={baseWebhookUrl}
value={value.RepositoryWebhookId || ''} value={webhookId}
docsLink={ docsLink={webhookDocs}
environmentType === 'KUBERNETES'
? 'https://docs.portainer.io/user/kubernetes/applications/webhooks'
: 'https://docs.portainer.io/user/docker/stacks/webhooks'
}
/> />
)} )}

View File

@ -8,7 +8,7 @@ export function WebhookSettings({
baseUrl, baseUrl,
docsLink, docsLink,
}: { }: {
docsLink: string; docsLink?: string;
value: string; value: string;
baseUrl: string; baseUrl: string;
}) { }) {
@ -18,13 +18,15 @@ export function WebhookSettings({
<FormControl <FormControl
label="Webhook" label="Webhook"
tooltip={ tooltip={
<> !!docsLink && (
See{' '} <>
<a href={docsLink} target="_blank" rel="noreferrer"> See{' '}
Portainer documentation on webhook usage <a href={docsLink} target="_blank" rel="noreferrer">
</a> Portainer documentation on webhook usage
. </a>
</> .
</>
)
} }
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@ -1,5 +1,3 @@
import { v4 as uuid } from 'uuid';
import { AutoUpdateResponse, AutoUpdateModel } from '../types'; import { AutoUpdateResponse, AutoUpdateModel } from '../types';
export function parseAutoUpdateResponse( export function parseAutoUpdateResponse(
@ -11,7 +9,6 @@ export function parseAutoUpdateResponse(
RepositoryAutomaticUpdatesForce: false, RepositoryAutomaticUpdatesForce: false,
RepositoryMechanism: 'Interval', RepositoryMechanism: 'Interval',
RepositoryFetchInterval: '5m', RepositoryFetchInterval: '5m',
RepositoryWebhookId: uuid(),
ForcePullImage: false, ForcePullImage: false,
}; };
} }
@ -20,28 +17,30 @@ export function parseAutoUpdateResponse(
RepositoryAutomaticUpdates: true, RepositoryAutomaticUpdates: true,
RepositoryMechanism: response.Interval ? 'Interval' : 'Webhook', RepositoryMechanism: response.Interval ? 'Interval' : 'Webhook',
RepositoryFetchInterval: response.Interval || '', RepositoryFetchInterval: response.Interval || '',
RepositoryWebhookId: response.Webhook || uuid(),
RepositoryAutomaticUpdatesForce: response.ForceUpdate, RepositoryAutomaticUpdatesForce: response.ForceUpdate,
ForcePullImage: response.ForcePullImage, ForcePullImage: response.ForcePullImage,
}; };
} }
export function transformAutoUpdateViewModel( export function transformAutoUpdateViewModel(
viewModel?: AutoUpdateModel viewModel?: AutoUpdateModel,
webhookId?: string
): AutoUpdateResponse | null { ): AutoUpdateResponse | null {
if (!viewModel || !viewModel.RepositoryAutomaticUpdates) { if (!viewModel || !viewModel.RepositoryAutomaticUpdates) {
return null; return null;
} }
if (viewModel.RepositoryMechanism === 'Webhook' && !webhookId) {
throw new Error('Webhook ID is required');
}
return { return {
Interval: Interval:
viewModel.RepositoryMechanism === 'Interval' viewModel.RepositoryMechanism === 'Interval'
? viewModel.RepositoryFetchInterval ? viewModel.RepositoryFetchInterval
: '', : '',
Webhook: Webhook:
viewModel.RepositoryMechanism === 'Webhook' viewModel.RepositoryMechanism === 'Webhook' && webhookId ? webhookId : '',
? viewModel.RepositoryWebhookId
: '',
ForceUpdate: viewModel.RepositoryAutomaticUpdatesForce, ForceUpdate: viewModel.RepositoryAutomaticUpdatesForce,
ForcePullImage: viewModel.ForcePullImage, ForcePullImage: viewModel.ForcePullImage,
}; };

View File

@ -90,6 +90,7 @@ export function Primary({
isForcePullVisible={isForcePullVisible} isForcePullVisible={isForcePullVisible}
deployMethod={deployMethod} deployMethod={deployMethod}
baseWebhookUrl="ws://localhost:9000" baseWebhookUrl="ws://localhost:9000"
webhookId="1234"
/> />
</Form> </Form>
)} )}

View File

@ -28,6 +28,8 @@ interface Props {
isAuthExplanationVisible?: boolean; isAuthExplanationVisible?: boolean;
errors: FormikErrors<GitFormModel>; errors: FormikErrors<GitFormModel>;
baseWebhookUrl: string; baseWebhookUrl: string;
webhookId: string;
webhooksDocs?: string;
} }
export function GitForm({ export function GitForm({
@ -40,6 +42,8 @@ export function GitForm({
isAuthExplanationVisible, isAuthExplanationVisible,
errors = {}, errors = {},
baseWebhookUrl, baseWebhookUrl,
webhookId,
webhooksDocs,
}: Props) { }: Props) {
return ( return (
<FormSection title="Git repository"> <FormSection title="Git repository">
@ -89,11 +93,13 @@ export function GitForm({
{value.AutoUpdate && ( {value.AutoUpdate && (
<AutoUpdateFieldset <AutoUpdateFieldset
webhookId={webhookId}
baseWebhookUrl={baseWebhookUrl} baseWebhookUrl={baseWebhookUrl}
value={value.AutoUpdate} value={value.AutoUpdate}
onChange={(value) => handleChange({ AutoUpdate: value })} onChange={(value) => handleChange({ AutoUpdate: value })}
isForcePullVisible={isForcePullVisible} isForcePullVisible={isForcePullVisible}
errors={errors.AutoUpdate as FormikErrors<GitFormModel['AutoUpdate']>} errors={errors.AutoUpdate as FormikErrors<GitFormModel['AutoUpdate']>}
webhooksDocs={webhooksDocs}
/> />
)} )}

View File

@ -31,7 +31,6 @@ export interface RepoConfigResponse {
export type AutoUpdateModel = { export type AutoUpdateModel = {
RepositoryAutomaticUpdates: boolean; RepositoryAutomaticUpdates: boolean;
RepositoryMechanism: AutoUpdateMechanism; RepositoryMechanism: AutoUpdateMechanism;
RepositoryWebhookId: string;
RepositoryFetchInterval: string; RepositoryFetchInterval: string;
ForcePullImage: boolean; ForcePullImage: boolean;
RepositoryAutomaticUpdatesForce: boolean; RepositoryAutomaticUpdatesForce: boolean;