diff --git a/api/filesystem/filesystem.go b/api/filesystem/filesystem.go index 62dac019d..778c979d8 100644 --- a/api/filesystem/filesystem.go +++ b/api/filesystem/filesystem.go @@ -279,13 +279,7 @@ func (service *Service) WriteJSONToFile(path string, content interface{}) error // FileExists checks for the existence of the specified file. func (service *Service) FileExists(filePath string) (bool, error) { - if _, err := os.Stat(filePath); err != nil { - if os.IsNotExist(err) { - return false, nil - } - return false, err - } - return true, nil + return FileExists(filePath) } // KeyPairFilesExist checks for the existence of the key files. @@ -510,3 +504,31 @@ func (service *Service) GetTemporaryPath() (string, error) { func (service *Service) GetDatastorePath() string { return service.dataStorePath } + +// FileExists checks for the existence of the specified file. +func FileExists(filePath string) (bool, error) { + if _, err := os.Stat(filePath); err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + return true, nil +} + +func MoveDirectory(originalPath, newPath string) error { + if _, err := os.Stat(originalPath); err != nil { + return err + } + + alreadyExists, err := FileExists(newPath) + if err != nil { + return err + } + + if alreadyExists { + return errors.New("Target path already exists") + } + + return os.Rename(originalPath, newPath) +} diff --git a/api/filesystem/filesystem_fileexists_test.go b/api/filesystem/filesystem_fileexists_test.go new file mode 100644 index 000000000..f0f203151 --- /dev/null +++ b/api/filesystem/filesystem_fileexists_test.go @@ -0,0 +1,55 @@ +package filesystem + +import ( + "fmt" + "math/rand" + "os" + "path" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_fileSystemService_FileExists_whenFileExistsShouldReturnTrue(t *testing.T) { + service := createService(t) + testHelperFileExists_fileExists(t, service.FileExists) +} + +func Test_fileSystemService_FileExists_whenFileNotExistsShouldReturnFalse(t *testing.T) { + service := createService(t) + testHelperFileExists_fileNotExists(t, service.FileExists) +} + +func Test_FileExists_whenFileExistsShouldReturnTrue(t *testing.T) { + testHelperFileExists_fileExists(t, FileExists) +} + +func Test_FileExists_whenFileNotExistsShouldReturnFalse(t *testing.T) { + testHelperFileExists_fileNotExists(t, FileExists) +} + +func testHelperFileExists_fileExists(t *testing.T, checker func(path string) (bool, error)) { + file, err := os.CreateTemp("", t.Name()) + assert.NoError(t, err, "CreateTemp should not fail") + + t.Cleanup(func() { + os.RemoveAll(file.Name()) + }) + + exists, err := checker(file.Name()) + assert.NoError(t, err, "FileExists should not fail") + + assert.True(t, exists) +} + +func testHelperFileExists_fileNotExists(t *testing.T, checker func(path string) (bool, error)) { + filePath := path.Join(os.TempDir(), fmt.Sprintf("%s%d", t.Name(), rand.Int())) + + err := os.RemoveAll(filePath) + assert.NoError(t, err, "RemoveAll should not fail") + + exists, err := checker(filePath) + assert.NoError(t, err, "FileExists should not fail") + + assert.False(t, exists) +} diff --git a/api/filesystem/filesystem_move_test.go b/api/filesystem/filesystem_move_test.go new file mode 100644 index 000000000..863075399 --- /dev/null +++ b/api/filesystem/filesystem_move_test.go @@ -0,0 +1,49 @@ +package filesystem + +import ( + "fmt" + "os" + "path" + "testing" + + "github.com/stretchr/testify/assert" +) + +// temporary function until upgrade to 1.16 +func tempDir(t *testing.T) string { + tmpDir, err := os.MkdirTemp("", "dir") + assert.NoError(t, err, "MkdirTemp should not fail") + + return tmpDir +} + +func Test_movePath_shouldFailIfOriginalPathDoesntExist(t *testing.T) { + tmpDir := tempDir(t) + missingPath := path.Join(tmpDir, "missing") + targetPath := path.Join(tmpDir, "target") + + defer os.RemoveAll(tmpDir) + + err := MoveDirectory(missingPath, targetPath) + assert.Error(t, err, "move directory should fail when target path exists") +} + +func Test_movePath_shouldFailIfTargetPathDoesExist(t *testing.T) { + originalPath := tempDir(t) + missingPath := tempDir(t) + + defer os.RemoveAll(originalPath) + defer os.RemoveAll(missingPath) + + err := MoveDirectory(originalPath, missingPath) + assert.Error(t, err, "move directory should fail when target path exists") +} + +func Test_movePath_success(t *testing.T) { + originalPath := tempDir(t) + + defer os.RemoveAll(originalPath) + + err := MoveDirectory(originalPath, fmt.Sprintf("%s-old", originalPath)) + assert.NoError(t, err) +} diff --git a/api/filesystem/filesystem_test.go b/api/filesystem/filesystem_test.go new file mode 100644 index 000000000..f2b46d8a3 --- /dev/null +++ b/api/filesystem/filesystem_test.go @@ -0,0 +1,22 @@ +package filesystem + +import ( + "os" + "path" + "testing" + + "github.com/stretchr/testify/assert" +) + +func createService(t *testing.T) *Service { + dataStorePath := path.Join(os.TempDir(), t.Name()) + + service, err := NewService(dataStorePath, "") + assert.NoError(t, err, "NewService should not fail") + + t.Cleanup(func() { + os.RemoveAll(dataStorePath) + }) + + return service +} diff --git a/api/git/azure_integration_test.go b/api/git/azure_integration_test.go index 85ee8f707..6d684d877 100644 --- a/api/git/azure_integration_test.go +++ b/api/git/azure_integration_test.go @@ -2,12 +2,13 @@ package git import ( "fmt" - "github.com/docker/docker/pkg/ioutils" - _ "github.com/joho/godotenv/autoload" - "github.com/stretchr/testify/assert" "os" "path/filepath" "testing" + + "github.com/docker/docker/pkg/ioutils" + _ "github.com/joho/godotenv/autoload" + "github.com/stretchr/testify/assert" ) func TestService_ClonePublicRepository_Azure(t *testing.T) { @@ -54,7 +55,7 @@ func TestService_ClonePublicRepository_Azure(t *testing.T) { assert.NoError(t, err) defer os.RemoveAll(dst) repositoryUrl := fmt.Sprintf(tt.args.repositoryURLFormat, tt.args.password) - err = service.ClonePublicRepository(repositoryUrl, tt.args.referenceName, dst) + err = service.CloneRepository(dst, repositoryUrl, tt.args.referenceName, "", "") assert.NoError(t, err) assert.FileExists(t, filepath.Join(dst, "README.md")) }) @@ -72,7 +73,7 @@ func TestService_ClonePrivateRepository_Azure(t *testing.T) { defer os.RemoveAll(dst) repositoryUrl := "https://portainer.visualstudio.com/Playground/_git/dev_integration" - err = service.ClonePrivateRepositoryWithBasicAuth(repositoryUrl, "refs/heads/main", dst, "", pat) + err = service.CloneRepository(dst, repositoryUrl, "refs/heads/main", "", pat) assert.NoError(t, err) assert.FileExists(t, filepath.Join(dst, "README.md")) } diff --git a/api/git/git.go b/api/git/git.go index 51eeac898..7887f7d95 100644 --- a/api/git/git.go +++ b/api/git/git.go @@ -3,12 +3,13 @@ package git import ( "context" "crypto/tls" - "github.com/pkg/errors" "net/http" "os" "path/filepath" "time" + "github.com/pkg/errors" + "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/transport/client" @@ -27,7 +28,7 @@ type downloader interface { download(ctx context.Context, dst string, opt cloneOptions) error } -type gitClient struct{ +type gitClient struct { preserveGitDirectory bool } @@ -86,26 +87,18 @@ func NewService() *Service { } } -// ClonePublicRepository clones a public git repository using the specified URL in the specified +// CloneRepository clones a git repository using the specified URL in the specified // destination folder. -func (service *Service) ClonePublicRepository(repositoryURL, referenceName, destination string) error { - return service.cloneRepository(destination, cloneOptions{ - repositoryUrl: repositoryURL, - referenceName: referenceName, - depth: 1, - }) -} - -// ClonePrivateRepositoryWithBasicAuth clones a private git repository using the specified URL in the specified -// destination folder. It will use the specified Username and Password for basic HTTP authentication. -func (service *Service) ClonePrivateRepositoryWithBasicAuth(repositoryURL, referenceName, destination, username, password string) error { - return service.cloneRepository(destination, cloneOptions{ +func (service *Service) CloneRepository(destination, repositoryURL, referenceName, username, password string) error { + options := cloneOptions{ repositoryUrl: repositoryURL, username: username, password: password, referenceName: referenceName, depth: 1, - }) + } + + return service.cloneRepository(destination, options) } func (service *Service) cloneRepository(destination string, options cloneOptions) error { diff --git a/api/git/git_integration_test.go b/api/git/git_integration_test.go index 0249a629a..d35ba8d52 100644 --- a/api/git/git_integration_test.go +++ b/api/git/git_integration_test.go @@ -1,11 +1,12 @@ package git import ( - "github.com/docker/docker/pkg/ioutils" - "github.com/stretchr/testify/assert" "os" "path/filepath" "testing" + + "github.com/docker/docker/pkg/ioutils" + "github.com/stretchr/testify/assert" ) func TestService_ClonePrivateRepository_GitHub(t *testing.T) { @@ -20,7 +21,7 @@ func TestService_ClonePrivateRepository_GitHub(t *testing.T) { defer os.RemoveAll(dst) repositoryUrl := "https://github.com/portainer/private-test-repository.git" - err = service.ClonePrivateRepositoryWithBasicAuth(repositoryUrl, "refs/heads/main", dst, username, pat) + err = service.CloneRepository(dst, repositoryUrl, "refs/heads/main", username, pat) assert.NoError(t, err) assert.FileExists(t, filepath.Join(dst, "README.md")) } diff --git a/api/git/git_test.go b/api/git/git_test.go index 8276a0925..14878b304 100644 --- a/api/git/git_test.go +++ b/api/git/git_test.go @@ -2,16 +2,17 @@ package git import ( "context" - "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing/object" - "github.com/pkg/errors" - "github.com/portainer/portainer/api/archive" - "github.com/stretchr/testify/assert" "io/ioutil" "log" "os" "path/filepath" "testing" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/pkg/errors" + "github.com/portainer/portainer/api/archive" + "github.com/stretchr/testify/assert" ) var bareRepoDir string @@ -59,7 +60,7 @@ func Test_ClonePublicRepository_Shallow(t *testing.T) { } defer os.RemoveAll(dir) t.Logf("Cloning into %s", dir) - err = service.ClonePublicRepository(repositoryURL, referenceName, dir) + err = service.CloneRepository(dir, repositoryURL, referenceName, "", "") assert.NoError(t, err) assert.Equal(t, 1, getCommitHistoryLength(t, err, dir), "cloned repo has incorrect depth") } @@ -74,9 +75,11 @@ func Test_ClonePublicRepository_NoGitDirectory(t *testing.T) { if err != nil { t.Fatalf("failed to create a temp dir") } + defer os.RemoveAll(dir) + t.Logf("Cloning into %s", dir) - err = service.ClonePublicRepository(repositoryURL, referenceName, dir) + err = service.CloneRepository(dir, repositoryURL, referenceName, "", "") assert.NoError(t, err) assert.NoDirExists(t, filepath.Join(dir, ".git")) } diff --git a/api/git/types/types.go b/api/git/types/types.go new file mode 100644 index 000000000..2a91f61b6 --- /dev/null +++ b/api/git/types/types.go @@ -0,0 +1,7 @@ +package gittypes + +type RepoConfig struct { + URL string + ReferenceName string + ConfigFilePath string +} diff --git a/api/http/handler/customtemplates/customtemplate_create.go b/api/http/handler/customtemplates/customtemplate_create.go index b8768b74c..9433affa2 100644 --- a/api/http/handler/customtemplates/customtemplate_create.go +++ b/api/http/handler/customtemplates/customtemplate_create.go @@ -236,16 +236,7 @@ func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) ( projectPath := handler.FileService.GetCustomTemplateProjectPath(strconv.Itoa(customTemplateID)) customTemplate.ProjectPath = projectPath - gitCloneParams := &cloneRepositoryParameters{ - url: payload.RepositoryURL, - referenceName: payload.RepositoryReferenceName, - path: projectPath, - authentication: payload.RepositoryAuthentication, - username: payload.RepositoryUsername, - password: payload.RepositoryPassword, - } - - err = handler.cloneGitRepository(gitCloneParams) + err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryUsername, payload.RepositoryPassword) if err != nil { return nil, err } diff --git a/api/http/handler/customtemplates/git.go b/api/http/handler/customtemplates/git.go deleted file mode 100644 index b4f3e3211..000000000 --- a/api/http/handler/customtemplates/git.go +++ /dev/null @@ -1,17 +0,0 @@ -package customtemplates - -type cloneRepositoryParameters struct { - url string - referenceName string - path string - authentication bool - username string - password string -} - -func (handler *Handler) cloneGitRepository(parameters *cloneRepositoryParameters) error { - if parameters.authentication { - return handler.GitService.ClonePrivateRepositoryWithBasicAuth(parameters.url, parameters.referenceName, parameters.path, parameters.username, parameters.password) - } - return handler.GitService.ClonePublicRepository(parameters.url, parameters.referenceName, parameters.path) -} diff --git a/api/http/handler/edgestacks/edgestack_create.go b/api/http/handler/edgestacks/edgestack_create.go index 0e5a74d7c..b0b904faa 100644 --- a/api/http/handler/edgestacks/edgestack_create.go +++ b/api/http/handler/edgestacks/edgestack_create.go @@ -212,16 +212,7 @@ func (handler *Handler) createSwarmStackFromGitRepository(r *http.Request) (*por projectPath := handler.FileService.GetEdgeStackProjectPath(strconv.Itoa(int(stack.ID))) stack.ProjectPath = projectPath - gitCloneParams := &cloneRepositoryParameters{ - url: payload.RepositoryURL, - referenceName: payload.RepositoryReferenceName, - path: projectPath, - authentication: payload.RepositoryAuthentication, - username: payload.RepositoryUsername, - password: payload.RepositoryPassword, - } - - err = handler.cloneGitRepository(gitCloneParams) + err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryUsername, payload.RepositoryPassword) if err != nil { return nil, err } diff --git a/api/http/handler/edgestacks/git.go b/api/http/handler/edgestacks/git.go deleted file mode 100644 index 855fa72bc..000000000 --- a/api/http/handler/edgestacks/git.go +++ /dev/null @@ -1,17 +0,0 @@ -package edgestacks - -type cloneRepositoryParameters struct { - url string - referenceName string - path string - authentication bool - username string - password string -} - -func (handler *Handler) cloneGitRepository(parameters *cloneRepositoryParameters) error { - if parameters.authentication { - return handler.GitService.ClonePrivateRepositoryWithBasicAuth(parameters.url, parameters.referenceName, parameters.path, parameters.username, parameters.password) - } - return handler.GitService.ClonePublicRepository(parameters.url, parameters.referenceName, parameters.path) -} diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go index 2c78ee0f9..4f8e38c50 100644 --- a/api/http/handler/stacks/create_compose_stack.go +++ b/api/http/handler/stacks/create_compose_stack.go @@ -169,19 +169,10 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID))) stack.ProjectPath = projectPath - gitCloneParams := &cloneRepositoryParameters{ - url: payload.RepositoryURL, - referenceName: payload.RepositoryReferenceName, - path: projectPath, - authentication: payload.RepositoryAuthentication, - username: payload.RepositoryUsername, - password: payload.RepositoryPassword, - } - doCleanUp := true defer handler.cleanUp(stack, &doCleanUp) - err = handler.cloneGitRepository(gitCloneParams) + err = handler.cloneAndSaveConfig(stack, projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, payload.ComposeFilePathInRepository, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword) if err != nil { return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to clone git repository", Err: err} } diff --git a/api/http/handler/stacks/create_swarm_stack.go b/api/http/handler/stacks/create_swarm_stack.go index e34df227e..747f1b456 100644 --- a/api/http/handler/stacks/create_swarm_stack.go +++ b/api/http/handler/stacks/create_swarm_stack.go @@ -173,21 +173,12 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID))) stack.ProjectPath = projectPath - gitCloneParams := &cloneRepositoryParameters{ - url: payload.RepositoryURL, - referenceName: payload.RepositoryReferenceName, - path: projectPath, - authentication: payload.RepositoryAuthentication, - username: payload.RepositoryUsername, - password: payload.RepositoryPassword, - } - doCleanUp := true defer handler.cleanUp(stack, &doCleanUp) - err = handler.cloneGitRepository(gitCloneParams) + err = handler.cloneAndSaveConfig(stack, projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, payload.ComposeFilePathInRepository, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clone git repository", err} + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to clone git repository", Err: err} } config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false) diff --git a/api/http/handler/stacks/git.go b/api/http/handler/stacks/git.go deleted file mode 100644 index b3e2448f7..000000000 --- a/api/http/handler/stacks/git.go +++ /dev/null @@ -1,17 +0,0 @@ -package stacks - -type cloneRepositoryParameters struct { - url string - referenceName string - path string - authentication bool - username string - password string -} - -func (handler *Handler) cloneGitRepository(parameters *cloneRepositoryParameters) error { - if parameters.authentication { - return handler.GitService.ClonePrivateRepositoryWithBasicAuth(parameters.url, parameters.referenceName, parameters.path, parameters.username, parameters.password) - } - return handler.GitService.ClonePublicRepository(parameters.url, parameters.referenceName, parameters.path) -} diff --git a/api/http/handler/stacks/handler.go b/api/http/handler/stacks/handler.go index ed5fe27a1..dcdfce41b 100644 --- a/api/http/handler/stacks/handler.go +++ b/api/http/handler/stacks/handler.go @@ -56,6 +56,8 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.AdminAccess(httperror.LoggerHandler(h.stackAssociate))).Methods(http.MethodPut) h.Handle("/stacks/{id}", bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackUpdate))).Methods(http.MethodPut) + h.Handle("/stacks/{id}/git", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackUpdateGit))).Methods(http.MethodPut) h.Handle("/stacks/{id}/file", bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackFile))).Methods(http.MethodGet) h.Handle("/stacks/{id}/migrate", diff --git a/api/http/handler/stacks/stack_create.go b/api/http/handler/stacks/stack_create.go index 7da0fd386..eae955cf5 100644 --- a/api/http/handler/stacks/stack_create.go +++ b/api/http/handler/stacks/stack_create.go @@ -2,6 +2,7 @@ package stacks import ( "errors" + "fmt" "log" "net/http" @@ -12,6 +13,7 @@ import ( "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" + gittypes "github.com/portainer/portainer/api/git/types" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" @@ -226,3 +228,18 @@ func (handler *Handler) decorateStackResponse(w http.ResponseWriter, stack *port stack.ResourceControl = resourceControl return response.JSON(w, stack) } + +func (handler *Handler) cloneAndSaveConfig(stack *portainer.Stack, projectPath, repositoryURL, refName, configFilePath string, auth bool, username, password string) error { + + err := handler.GitService.CloneRepository(projectPath, repositoryURL, refName, username, password) + if err != nil { + return fmt.Errorf("unable to clone git repository: %w", err) + } + + stack.GitConfig = &gittypes.RepoConfig{ + URL: repositoryURL, + ReferenceName: refName, + ConfigFilePath: configFilePath, + } + return nil +} diff --git a/api/http/handler/stacks/stack_create_test.go b/api/http/handler/stacks/stack_create_test.go new file mode 100644 index 000000000..414948378 --- /dev/null +++ b/api/http/handler/stacks/stack_create_test.go @@ -0,0 +1,29 @@ +package stacks + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/stretchr/testify/assert" +) + +func Test_stackHandler_cloneAndSaveConfig_shouldCallGitCloneAndSaveConfigOnStack(t *testing.T) { + handler := NewHandler(&security.RequestBouncer{}) + handler.GitService = testhelpers.NewGitService() + + url := "url" + refName := "ref" + configPath := "path" + stack := &portainer.Stack{} + err := handler.cloneAndSaveConfig(stack, "", url, refName, configPath, false, "", "") + assert.NoError(t, err, "clone and save should not fail") + + assert.Equal(t, gittypes.RepoConfig{ + URL: url, + ReferenceName: refName, + ConfigFilePath: configPath, + }, *stack.GitConfig) +} diff --git a/api/http/handler/stacks/stack_update.go b/api/http/handler/stacks/stack_update.go index ea012382a..34c64129c 100644 --- a/api/http/handler/stacks/stack_update.go +++ b/api/http/handler/stacks/stack_update.go @@ -191,6 +191,7 @@ func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack stack.UpdateDate = time.Now().Unix() stack.UpdatedBy = config.user.Username + stack.Status = portainer.StackStatusActive err = handler.deploySwarmStack(config) if err != nil { diff --git a/api/http/handler/stacks/stack_update_git.go b/api/http/handler/stacks/stack_update_git.go new file mode 100644 index 000000000..1daefec19 --- /dev/null +++ b/api/http/handler/stacks/stack_update_git.go @@ -0,0 +1,171 @@ +package stacks + +import ( + "errors" + "fmt" + "log" + "net/http" + "time" + + "github.com/asaskevich/govalidator" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/filesystem" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/stackutils" +) + +type updateStackGitPayload struct { + RepositoryReferenceName string + RepositoryAuthentication bool + RepositoryUsername string + RepositoryPassword string +} + +func (payload *updateStackGitPayload) Validate(r *http.Request) error { + if payload.RepositoryAuthentication && (govalidator.IsNull(payload.RepositoryUsername) || govalidator.IsNull(payload.RepositoryPassword)) { + return errors.New("Invalid repository credentials. Username and password must be specified when authentication is enabled") + } + return nil +} + +// PUT request on /api/stacks/:id/git?endpointId= +func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} + } + + stack, err := handler.DataStore.Stack().Stack(portainer.StackID(stackID)) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} + } + + if stack.GitConfig == nil { + return &httperror.HandlerError{http.StatusBadRequest, "Stack is not created from git", err} + } + + // TODO: this is a work-around for stacks created with Portainer version >= 1.17.1 + // The EndpointID property is not available for these stacks, this API endpoint + // can use the optional EndpointID query parameter to associate a valid endpoint identifier to the stack. + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} + } + if endpointID != int(stack.EndpointID) { + stack.EndpointID = portainer.EndpointID(endpointID) + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} + } + + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + } + + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} + } + if !access { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} + } + + var payload updateStackGitPayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + stack.GitConfig.ReferenceName = payload.RepositoryReferenceName + + backupProjectPath := fmt.Sprintf("%s-old", stack.ProjectPath) + err = filesystem.MoveDirectory(stack.ProjectPath, backupProjectPath) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to move git repository directory", err} + } + + err = handler.GitService.CloneRepository(stack.ProjectPath, stack.GitConfig.URL, payload.RepositoryReferenceName, payload.RepositoryUsername, payload.RepositoryPassword) + if err != nil { + restoreError := filesystem.MoveDirectory(backupProjectPath, stack.ProjectPath) + if restoreError != nil { + log.Printf("[WARN] [http,stacks,git] [error: %s] [message: failed restoring backup folder]", restoreError) + } + + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clone git repository", err} + } + + httpErr := handler.deployStack(r, stack, endpoint) + if httpErr != nil { + return httpErr + } + + err = handler.DataStore.Stack().UpdateStack(stack.ID, stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack changes inside the database", err} + } + + err = handler.FileService.RemoveDirectory(backupProjectPath) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove git repository directory", err} + } + + return response.JSON(w, stack) +} + +func (handler *Handler) deployStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError { + if stack.Type == portainer.DockerSwarmStack { + config, httpErr := handler.createSwarmDeployConfig(r, stack, endpoint, false) + if httpErr != nil { + return httpErr + } + + err := handler.deploySwarmStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + stack.UpdateDate = time.Now().Unix() + stack.UpdatedBy = config.user.Username + stack.Status = portainer.StackStatusActive + + return nil + } + + config, httpErr := handler.createComposeDeployConfig(r, stack, endpoint) + if httpErr != nil { + return httpErr + } + + err := handler.deployComposeStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + stack.UpdateDate = time.Now().Unix() + stack.UpdatedBy = config.user.Username + stack.Status = portainer.StackStatusActive + + return nil +} diff --git a/api/http/handler/templates/git.go b/api/http/handler/templates/git.go deleted file mode 100644 index cc94668cd..000000000 --- a/api/http/handler/templates/git.go +++ /dev/null @@ -1,17 +0,0 @@ -package templates - -type cloneRepositoryParameters struct { - url string - referenceName string - path string - authentication bool - username string - password string -} - -func (handler *Handler) cloneGitRepository(parameters *cloneRepositoryParameters) error { - if parameters.authentication { - return handler.GitService.ClonePrivateRepositoryWithBasicAuth(parameters.url, parameters.referenceName, parameters.path, parameters.username, parameters.password) - } - return handler.GitService.ClonePublicRepository(parameters.url, parameters.referenceName, parameters.path) -} diff --git a/api/http/handler/templates/template_file.go b/api/http/handler/templates/template_file.go index 359471ce6..12f6b5b70 100644 --- a/api/http/handler/templates/template_file.go +++ b/api/http/handler/templates/template_file.go @@ -63,12 +63,7 @@ func (handler *Handler) templateFile(w http.ResponseWriter, r *http.Request) *ht defer handler.cleanUp(projectPath) - gitCloneParams := &cloneRepositoryParameters{ - url: payload.RepositoryURL, - path: projectPath, - } - - err = handler.cloneGitRepository(gitCloneParams) + err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, "", "", "") if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clone git repository", err} } diff --git a/api/internal/testhelpers/git_service.go b/api/internal/testhelpers/git_service.go new file mode 100644 index 000000000..8dca32cd1 --- /dev/null +++ b/api/internal/testhelpers/git_service.go @@ -0,0 +1,12 @@ +package testhelpers + +type gitService struct{} + +// NewGitService creates new mock for portainer.GitService. +func NewGitService() *gitService { + return &gitService{} +} + +func (service *gitService) CloneRepository(destination string, repositoryURL, referenceName string, username, password string) error { + return nil +} diff --git a/api/portainer.go b/api/portainer.go index 6567420ad..512cb8ae0 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -3,6 +3,8 @@ package portainer import ( "io" "time" + + gittypes "github.com/portainer/portainer/api/git/types" ) type ( @@ -699,6 +701,8 @@ type ( UpdateDate int64 `example:"1587399600"` // The username which last updated this stack UpdatedBy string `example:"bob"` + // The git config of this stack + GitConfig *gittypes.RepoConfig } // StackID represents a stack identifier (it must be composed of Name + "_" + SwarmID to create a unique identifier) @@ -1140,8 +1144,7 @@ type ( // GitService represents a service for managing Git GitService interface { - ClonePublicRepository(repositoryURL, referenceName string, destination string) error - ClonePrivateRepositoryWithBasicAuth(repositoryURL, referenceName string, destination, username, password string) error + CloneRepository(destination string, repositoryURL, referenceName, username, password string) error } // JWTService represents a service for managing JWT tokens diff --git a/app/assets/css/app.css b/app/assets/css/app.css index e41e1b01c..1989727d0 100644 --- a/app/assets/css/app.css +++ b/app/assets/css/app.css @@ -1023,3 +1023,8 @@ json-tree .branch-preview { overflow-y: auto; } /* !uib-typeahead override */ + +.my-8 { + margin-top: 2rem; + margin-bottom: 2rem; +} diff --git a/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackView.html b/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackView.html index 025071149..c0a4b3332 100644 --- a/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackView.html +++ b/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackView.html @@ -136,78 +136,7 @@ -
-
- Git repository -
-
- - You can use the URL of a git repository. - -
-
- -
- -
-
-
- - Specify a reference of the repository using the following syntax: branches with - refs/heads/branch_name or tags with refs/tags/tag_name. If not specified, will use the default HEAD reference normally the - master branch. - -
-
- -
- -
-
-
- - Indicate the path to the Compose file from the root of your repository. - -
-
- -
- -
-
-
-
- - -
-
-
- - If your git account has 2FA enabled, you may receive an - authentication required error when deploying your stack. In this case, you will need to provide a personal-access token instead of your password. - -
-
- -
- -
- -
- -
-
-
+
diff --git a/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackViewController.js b/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackViewController.js index 7636f2bf0..aaf10b64b 100644 --- a/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackViewController.js +++ b/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackViewController.js @@ -40,6 +40,7 @@ export class CreateEdgeStackViewController { this.onChangeTemplate = this.onChangeTemplate.bind(this); this.onChangeTemplateAsync = this.onChangeTemplateAsync.bind(this); this.onChangeMethod = this.onChangeMethod.bind(this); + this.onChangeFormValues = this.onChangeFormValues.bind(this); } async uiCanExit() { @@ -161,6 +162,10 @@ export class CreateEdgeStackViewController { return this.EdgeStackService.createStackFromGitRepository(name, repositoryOptions, this.formValues.Groups); } + onChangeFormValues(values) { + this.formValues = values; + } + editorUpdate(cm) { this.formValues.StackFileContent = cm.getValue(); this.state.isEditorDirty = true; diff --git a/app/portainer/__module.js b/app/portainer/__module.js index b8f01310c..21e14120b 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -1,5 +1,7 @@ import _ from 'lodash-es'; +import componentsModule from './components'; + async function initAuthentication(authManager, Authentication, $rootScope, $state) { authManager.checkAuthOnRefresh(); // The unauthenticated event is broadcasted by the jwtInterceptor when @@ -15,7 +17,7 @@ async function initAuthentication(authManager, Authentication, $rootScope, $stat return await Authentication.init(); } -angular.module('portainer.app', ['portainer.oauth']).config([ +angular.module('portainer.app', ['portainer.oauth', componentsModule]).config([ '$stateRegistryProvider', function ($stateRegistryProvider) { 'use strict'; diff --git a/app/portainer/components/forms/git-form/git-form-auth-fieldset/git-form-auth-fieldset.controller.js b/app/portainer/components/forms/git-form/git-form-auth-fieldset/git-form-auth-fieldset.controller.js new file mode 100644 index 000000000..1fac1f4fd --- /dev/null +++ b/app/portainer/components/forms/git-form/git-form-auth-fieldset/git-form-auth-fieldset.controller.js @@ -0,0 +1,20 @@ +class GitFormComposeAuthFieldsetController { + /* @ngInject */ + constructor() { + this.onChangeField = this.onChangeField.bind(this); + this.onChangeAuth = this.onChangeField('RepositoryAuthentication'); + this.onChangeUsername = this.onChangeField('RepositoryUsername'); + this.onChangePassword = this.onChangeField('RepositoryPassword'); + } + + onChangeField(field) { + return (value) => { + this.onChange({ + ...this.model, + [field]: value, + }); + }; + } +} + +export default GitFormComposeAuthFieldsetController; diff --git a/app/portainer/components/forms/git-form/git-form-auth-fieldset/git-form-auth-fieldset.html b/app/portainer/components/forms/git-form/git-form-auth-fieldset/git-form-auth-fieldset.html new file mode 100644 index 000000000..b884c248d --- /dev/null +++ b/app/portainer/components/forms/git-form/git-form-auth-fieldset/git-form-auth-fieldset.html @@ -0,0 +1,39 @@ +
+
+ +
+
+
+
+ + If your git account has 2FA enabled, you may receive an authentication required error when deploying your stack. In this case, you will need to provide a + personal-access token instead of your password. + +
+
+ +
+ +
+ +
+ +
+
+
diff --git a/app/portainer/components/forms/git-form/git-form-auth-fieldset/index.js b/app/portainer/components/forms/git-form/git-form-auth-fieldset/index.js new file mode 100644 index 000000000..5c2373387 --- /dev/null +++ b/app/portainer/components/forms/git-form/git-form-auth-fieldset/index.js @@ -0,0 +1,10 @@ +import controller from './git-form-auth-fieldset.controller.js'; + +export const gitFormAuthFieldset = { + templateUrl: './git-form-auth-fieldset.html', + controller, + bindings: { + model: '<', + onChange: '<', + }, +}; diff --git a/app/portainer/components/forms/git-form/git-form-compose-path-field/git-form-compose-path-field.html b/app/portainer/components/forms/git-form/git-form-compose-path-field/git-form-compose-path-field.html new file mode 100644 index 000000000..541023c49 --- /dev/null +++ b/app/portainer/components/forms/git-form/git-form-compose-path-field/git-form-compose-path-field.html @@ -0,0 +1,11 @@ +
+ + Indicate the path to the Compose file from the root of your repository. + +
+
+ +
+ +
+
diff --git a/app/portainer/components/forms/git-form/git-form-compose-path-field/index.js b/app/portainer/components/forms/git-form/git-form-compose-path-field/index.js new file mode 100644 index 000000000..7906d0e85 --- /dev/null +++ b/app/portainer/components/forms/git-form/git-form-compose-path-field/index.js @@ -0,0 +1,7 @@ +export const gitFormComposePathField = { + templateUrl: './git-form-compose-path-field.html', + bindings: { + value: '<', + onChange: '<', + }, +}; diff --git a/app/portainer/components/forms/git-form/git-form-ref-field/git-form-ref-field.html b/app/portainer/components/forms/git-form/git-form-ref-field/git-form-ref-field.html new file mode 100644 index 000000000..a12639d24 --- /dev/null +++ b/app/portainer/components/forms/git-form/git-form-ref-field/git-form-ref-field.html @@ -0,0 +1,12 @@ +
+ + Specify a reference of the repository using the following syntax: branches with refs/heads/branch_name or tags with refs/tags/tag_name. If not + specified, will use the default HEAD reference normally the master branch. + +
+
+ +
+ +
+
diff --git a/app/portainer/components/forms/git-form/git-form-ref-field/index.js b/app/portainer/components/forms/git-form/git-form-ref-field/index.js new file mode 100644 index 000000000..df8145061 --- /dev/null +++ b/app/portainer/components/forms/git-form/git-form-ref-field/index.js @@ -0,0 +1,7 @@ +export const gitFormRefField = { + templateUrl: './git-form-ref-field.html', + bindings: { + value: '<', + onChange: '<', + }, +}; diff --git a/app/portainer/components/forms/git-form/git-form-url-field/git-form-url-field.html b/app/portainer/components/forms/git-form/git-form-url-field/git-form-url-field.html new file mode 100644 index 000000000..c9083ce60 --- /dev/null +++ b/app/portainer/components/forms/git-form/git-form-url-field/git-form-url-field.html @@ -0,0 +1,18 @@ +
+ + You can use the URL of a git repository. + +
+
+ +
+ +
+
diff --git a/app/portainer/components/forms/git-form/git-form-url-field/index.js b/app/portainer/components/forms/git-form/git-form-url-field/index.js new file mode 100644 index 000000000..972df9aaa --- /dev/null +++ b/app/portainer/components/forms/git-form/git-form-url-field/index.js @@ -0,0 +1,7 @@ +export const gitFormUrlField = { + templateUrl: './git-form-url-field.html', + bindings: { + value: '<', + onChange: '<', + }, +}; diff --git a/app/portainer/components/forms/git-form/git-form.controller.js b/app/portainer/components/forms/git-form/git-form.controller.js new file mode 100644 index 000000000..795e9a7ba --- /dev/null +++ b/app/portainer/components/forms/git-form/git-form.controller.js @@ -0,0 +1,18 @@ +export default class GitFormController { + /* @ngInject */ + constructor() { + this.onChangeField = this.onChangeField.bind(this); + this.onChangeURL = this.onChangeField('RepositoryURL'); + this.onChangeRefName = this.onChangeField('RepositoryReferenceName'); + this.onChangeComposePath = this.onChangeField('ComposeFilePathInRepository'); + } + + onChangeField(field) { + return (value) => { + this.onChange({ + ...this.model, + [field]: value, + }); + }; + } +} diff --git a/app/portainer/components/forms/git-form/git-form.html b/app/portainer/components/forms/git-form/git-form.html new file mode 100644 index 000000000..16b131008 --- /dev/null +++ b/app/portainer/components/forms/git-form/git-form.html @@ -0,0 +1,9 @@ +
+
+ Git repository +
+ + + + +
diff --git a/app/portainer/components/forms/git-form/git-form.js b/app/portainer/components/forms/git-form/git-form.js new file mode 100644 index 000000000..4a1c5733e --- /dev/null +++ b/app/portainer/components/forms/git-form/git-form.js @@ -0,0 +1,10 @@ +import controller from './git-form.controller.js'; + +export const gitForm = { + templateUrl: './git-form.html', + controller, + bindings: { + model: '<', + onChange: '<', + }, +}; diff --git a/app/portainer/components/forms/git-form/index.js b/app/portainer/components/forms/git-form/index.js new file mode 100644 index 000000000..323c1d271 --- /dev/null +++ b/app/portainer/components/forms/git-form/index.js @@ -0,0 +1,15 @@ +import angular from 'angular'; + +import { gitForm } from './git-form'; +import { gitFormAuthFieldset } from './git-form-auth-fieldset'; +import { gitFormComposePathField } from './git-form-compose-path-field'; +import { gitFormRefField } from './git-form-ref-field'; +import { gitFormUrlField } from './git-form-url-field'; + +export default angular + .module('portainer.app.components.forms.git', []) + .component('gitFormComposePathField', gitFormComposePathField) + .component('gitFormRefField', gitFormRefField) + .component('gitForm', gitForm) + .component('gitFormUrlField', gitFormUrlField) + .component('gitFormAuthFieldset', gitFormAuthFieldset).name; diff --git a/app/portainer/components/forms/stack-redeploy-git-form/index.js b/app/portainer/components/forms/stack-redeploy-git-form/index.js new file mode 100644 index 000000000..2372e8b58 --- /dev/null +++ b/app/portainer/components/forms/stack-redeploy-git-form/index.js @@ -0,0 +1,13 @@ +import angular from 'angular'; +import controller from './stack-redeploy-git-form.controller.js'; + +export const stackRedeployGitForm = { + templateUrl: './stack-redeploy-git-form.html', + controller, + bindings: { + model: '<', + stack: '<', + }, +}; + +angular.module('portainer.app').component('stackRedeployGitForm', stackRedeployGitForm); 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 new file mode 100644 index 000000000..f489cc2d9 --- /dev/null +++ b/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js @@ -0,0 +1,75 @@ +class StackRedeployGitFormController { + /* @ngInject */ + constructor($async, $state, StackService, ModalService, Notifications) { + this.$async = $async; + this.$state = $state; + this.StackService = StackService; + this.ModalService = ModalService; + this.Notifications = Notifications; + + this.state = { + inProgress: false, + }; + + this.formValues = { + RefName: '', + RepositoryAuthentication: false, + RepositoryUsername: '', + RepositoryPassword: '', + }; + + this.onChange = this.onChange.bind(this); + this.onChangeRef = this.onChangeRef.bind(this); + } + + onChangeRef(value) { + this.onChange({ RefName: value }); + } + + onChange(values) { + this.formValues = { + ...this.formValues, + ...values, + }; + } + + async submit() { + return this.$async(async () => { + try { + const confirmed = await this.ModalService.confirmAsync({ + title: 'Are you sure?', + message: 'Any changes to this stack made locally in Portainer will be overridden by the definition in git and may cause a service interruption. Do you wish to continue', + buttons: { + confirm: { + label: 'Update', + className: 'btn-warning', + }, + }, + }); + if (!confirmed) { + return; + } + + this.state.inProgress = true; + + await this.StackService.updateGit(this.stack.Id, this.stack.EndpointId, [], false, this.formValues); + + await this.$state.reload(); + } catch (err) { + this.Notifications.error('Failure', err, 'Failed redeploying stack'); + } finally { + this.state.inProgress = false; + } + }); + } + + isSubmitButtonDisabled() { + return this.state.inProgress; + } + + $onInit() { + this.formValues.RefName = this.model.ReferenceName; + } +} + +export default StackRedeployGitFormController; 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 new file mode 100644 index 000000000..5b5efc42b --- /dev/null +++ b/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.html @@ -0,0 +1,30 @@ +
+
+ Redeploy from git repository +
+
+
+

+ This stack was deployed from the git repository {{ $ctrl.model.URL }} + . +

+

+ Update {{ $ctrl.model.ConfigFilePath }} in git and pull from here to update the stack. +

+
+
+ + + + + +
diff --git a/app/portainer/components/index.js b/app/portainer/components/index.js new file mode 100644 index 000000000..8fa3030a0 --- /dev/null +++ b/app/portainer/components/index.js @@ -0,0 +1,5 @@ +import angular from 'angular'; + +import gitFormModule from './forms/git-form'; + +export default angular.module('portainer.app.components', [gitFormModule]).name; diff --git a/app/portainer/models/stack.js b/app/portainer/models/stack.js index 10fcf3e05..1b11fa432 100644 --- a/app/portainer/models/stack.js +++ b/app/portainer/models/stack.js @@ -15,11 +15,11 @@ export function StackViewModel(data) { this.CreatedBy = data.CreatedBy; this.UpdateDate = data.UpdateDate; this.UpdatedBy = data.UpdatedBy; - this.Regular = true; this.External = false; this.Orphaned = false; this.Checked = false; + this.GitConfig = data.GitConfig; } export function ExternalStackViewModel(name, type, creationDate) { diff --git a/app/portainer/rest/stack.js b/app/portainer/rest/stack.js index 16c5f46b5..6327fc647 100644 --- a/app/portainer/rest/stack.js +++ b/app/portainer/rest/stack.js @@ -18,6 +18,7 @@ angular.module('portainer.app').factory('Stack', [ migrate: { method: 'POST', params: { id: '@id', action: 'migrate', endpointId: '@endpointId' }, ignoreLoadingBar: true }, start: { method: 'POST', params: { id: '@id', action: 'start' } }, stop: { method: 'POST', params: { id: '@id', action: 'stop' } }, + updateGit: { method: 'PUT', params: { action: 'git' } }, } ); }, diff --git a/app/portainer/services/api/stackService.js b/app/portainer/services/api/stackService.js index 7c9a698cc..fd9b84871 100644 --- a/app/portainer/services/api/stackService.js +++ b/app/portainer/services/api/stackService.js @@ -13,7 +13,9 @@ angular.module('portainer.app').factory('StackService', [ 'EndpointProvider', function StackServiceFactory($q, $async, Stack, FileUploadService, StackHelper, ServiceService, ContainerService, SwarmService, EndpointProvider) { 'use strict'; - var service = {}; + var service = { + updateGit, + }; service.stack = function (id) { var deferred = $q.defer(); @@ -394,6 +396,20 @@ angular.module('portainer.app').factory('StackService', [ return Stack.stop({ id }).$promise; } + function updateGit(id, endpointId, env, prune, gitConfig) { + return Stack.updateGit( + { endpointId, id }, + { + env, + prune, + RepositoryReferenceName: gitConfig.RefName, + RepositoryAuthentication: gitConfig.RepositoryAuthentication, + RepositoryUsername: gitConfig.RepositoryUsername, + RepositoryPassword: gitConfig.RepositoryPassword, + } + ).$promise; + } + return service; }, ]); diff --git a/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateView.html b/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateView.html index cb4b96d90..c84198744 100644 --- a/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateView.html +++ b/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateView.html @@ -103,84 +103,7 @@
-
-
- Git repository -
-
- - You can use the URL of a git repository. - -
-
- -
- -
-
-
- - Specify a reference of the repository using the following syntax: branches with - refs/heads/branch_name or tags with refs/tags/tag_name. If not specified, will use the default HEAD reference normally the - master branch. - -
-
- -
- -
-
-
- - Indicate the path to the Compose file from the root of your repository. - -
-
- -
- -
-
-
-
- - -
-
-
- - If your git account has 2FA enabled, you may receive an - authentication required error when creating your template. In this case, you will need to provide a personal-access token instead of your password. - -
-
- -
- -
- -
- -
-
-
+ diff --git a/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateViewController.js b/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateViewController.js index e24b54853..f64778957 100644 --- a/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateViewController.js +++ b/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateViewController.js @@ -54,6 +54,7 @@ class CreateCustomTemplateViewController { this.createCustomTemplateFromGitRepository = this.createCustomTemplateFromGitRepository.bind(this); this.editorUpdate = this.editorUpdate.bind(this); this.onChangeMethod = this.onChangeMethod.bind(this); + this.onChangeFormValues = this.onChangeFormValues.bind(this); } createCustomTemplate() { @@ -150,6 +151,10 @@ class CreateCustomTemplateViewController { this.state.isEditorDirty = true; } + onChangeFormValues(newValues) { + this.formValues = newValues; + } + async $onInit() { const applicationState = this.StateManager.getState(); diff --git a/app/portainer/views/stacks/create/createStackController.js b/app/portainer/views/stacks/create/createStackController.js index f632101ea..07cea305b 100644 --- a/app/portainer/views/stacks/create/createStackController.js +++ b/app/portainer/views/stacks/create/createStackController.js @@ -53,6 +53,16 @@ angular } }; + $scope.onChangeFormValues = onChangeFormValues; + + $scope.addEnvironmentVariable = function () { + $scope.formValues.Env.push({ name: '', value: '' }); + }; + + $scope.removeEnvironmentVariable = function (index) { + $scope.formValues.Env.splice(index, 1); + }; + function validateForm(accessControlData, isAdmin) { $scope.state.formValidationError = ''; var error = ''; @@ -236,4 +246,8 @@ angular }; initView(); + + function onChangeFormValues(newValues) { + $scope.formValues = newValues; + } }); diff --git a/app/portainer/views/stacks/create/createstack.html b/app/portainer/views/stacks/create/createstack.html index 4edb6cf50..8e545b7e3 100644 --- a/app/portainer/views/stacks/create/createstack.html +++ b/app/portainer/views/stacks/create/createstack.html @@ -130,79 +130,7 @@ - -
-
- Git repository -
-
- - You can use the URL of a git repository. - -
-
- -
- -
-
-
- - Specify a reference of the repository using the following syntax: branches with refs/heads/branch_name or tags with refs/tags/tag_name. If - not specified, will use the default HEAD reference normally the master branch. - -
-
- -
- -
-
-
- - Indicate the path to the Compose file from the root of your repository. - -
-
- -
- -
-
-
-
- - -
-
-
- - If your git account has 2FA enabled, you may receive an authentication required error when deploying your stack. In this case, you will need to provide - a personal-access token instead of your password. - -
-
- -
- -
- -
- -
-
-
- +
diff --git a/app/portainer/views/stacks/edit/stack.html b/app/portainer/views/stacks/edit/stack.html index 1981b613d..aae58de07 100644 --- a/app/portainer/views/stacks/edit/stack.html +++ b/app/portainer/views/stacks/edit/stack.html @@ -110,6 +110,7 @@
+