mirror of https://github.com/portainer/portainer
feat(stacks): redeploy git stack [EE-161] (#5139)
* feat(git): save git config when creating stack (#5048) * feat(git): save git config when creating stack * chore(fs): test fileExists * fix(git): fix tests to use CloneRepository * refactor(git): move options to new object * feat(stacks): redeploy git stack api (#5112) * feat(stacks): redeploy git stacks form [EE-666] * feat(stack): show loading after confirmation * fix(stacks): show same size description * fix(stacks): reload state when deployed * feat(stacks): set stopped stacks status to activate when updating * feat(stacks): backup stack folder before cloning * feat(stacks): don't accept prune and env on update git Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com> Co-authored-by: Chaim Lev-Ari <chiptus@gmail.com>Testing-merge-of-161-446-447
parent
296ecc5960
commit
0b93714de4
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
package gittypes
|
||||
|
||||
type RepoConfig struct {
|
||||
URL string
|
||||
ReferenceName string
|
||||
ConfigFilePath string
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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=<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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -1023,3 +1023,8 @@ json-tree .branch-preview {
|
|||
overflow-y: auto;
|
||||
}
|
||||
/* !uib-typeahead override */
|
||||
|
||||
.my-8 {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
|
|
@ -136,78 +136,7 @@
|
|||
</div>
|
||||
<!-- !upload -->
|
||||
<!-- repository -->
|
||||
<div ng-show="$ctrl.state.Method === 'repository'">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Git repository
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
You can use the URL of a git repository.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="stack_repository_url" class="col-sm-2 control-label text-left">Repository URL</label>
|
||||
<div class="col-sm-10">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
ng-model="$ctrl.formValues.RepositoryURL"
|
||||
id="stack_repository_url"
|
||||
placeholder="https://github.com/portainer/portainer-compose"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
Specify a reference of the repository using the following syntax: branches with
|
||||
<code>refs/heads/branch_name</code> or tags with <code>refs/tags/tag_name</code>. If not specified, will use the default <code>HEAD</code> reference normally the
|
||||
<code>master</code> branch.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="stack_repository_url" class="col-sm-2 control-label text-left">Repository reference</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" ng-model="$ctrl.formValues.RepositoryReferenceName" id="stack_repository_reference_name" placeholder="refs/heads/master" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
Indicate the path to the Compose file from the root of your repository.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="stack_repository_path" class="col-sm-2 control-label text-left">Compose path</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" ng-model="$ctrl.formValues.ComposeFilePathInRepository" id="stack_repository_path" placeholder="docker-compose.yml" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">
|
||||
Authentication
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="$ctrl.formValues.RepositoryAuthentication" /><i></i> </label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-if="$ctrl.formValues.RepositoryAuthentication">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
If your git account has 2FA enabled, you may receive an
|
||||
<code>authentication required</code> error when deploying your stack. In this case, you will need to provide a personal-access token instead of your password.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group" ng-if="$ctrl.formValues.RepositoryAuthentication">
|
||||
<label for="repository_username" class="col-sm-1 control-label text-left">Username</label>
|
||||
<div class="col-sm-11 col-md-5">
|
||||
<input type="text" class="form-control" ng-model="$ctrl.formValues.RepositoryUsername" name="repository_username" placeholder="myGitUser" />
|
||||
</div>
|
||||
<label for="repository_password" class="col-sm-1 control-label text-left">
|
||||
Password
|
||||
</label>
|
||||
<div class="col-sm-11 col-md-5">
|
||||
<input type="password" class="form-control" ng-model="$ctrl.formValues.RepositoryPassword" name="repository_password" placeholder="myPassword" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<git-form ng-show="$ctrl.state.Method === 'repository'" model="$ctrl.formValues" on-change="($ctrl.onChangeFormValues)"></git-form>
|
||||
<!-- !repository -->
|
||||
<!-- template -->
|
||||
<div ng-show="$ctrl.state.Method === 'template'">
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
|
@ -0,0 +1,39 @@
|
|||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<por-switch-field ng-model="$ctrl.model.RepositoryAuthentication" label="Authentication" on-change="($ctrl.onChangeAuth)"></por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-if="$ctrl.model.RepositoryAuthentication">
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
If your git account has 2FA enabled, you may receive an <code>authentication required</code> error when deploying your stack. In this case, you will need to provide a
|
||||
personal-access token instead of your password.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="repository_username" class="col-sm-1 control-label text-left">Username</label>
|
||||
<div class="col-sm-11 col-md-5">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
ng-model="$ctrl.model.RepositoryUsername"
|
||||
name="repository_username"
|
||||
placeholder="myGitUser"
|
||||
ng-change="$ctrl.onChangeUsername($ctrl.model.RepositoryUsername)"
|
||||
/>
|
||||
</div>
|
||||
<label for="repository_password" class="col-sm-1 control-label text-left">
|
||||
Password
|
||||
</label>
|
||||
<div class="col-sm-11 col-md-5">
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
ng-model="$ctrl.model.RepositoryPassword"
|
||||
name="repository_password"
|
||||
placeholder="myPassword"
|
||||
ng-change="$ctrl.onChangePassword($ctrl.model.RepositoryPassword)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -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: '<',
|
||||
},
|
||||
};
|
|
@ -0,0 +1,11 @@
|
|||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
Indicate the path to the Compose file from the root of your repository.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="stack_repository_path" class="col-sm-2 control-label text-left">Compose path</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" ng-model="$ctrl.value" ng-change="$ctrl.onChange($ctrl.value)" id="stack_repository_path" placeholder="docker-compose.yml" />
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,7 @@
|
|||
export const gitFormComposePathField = {
|
||||
templateUrl: './git-form-compose-path-field.html',
|
||||
bindings: {
|
||||
value: '<',
|
||||
onChange: '<',
|
||||
},
|
||||
};
|
|
@ -0,0 +1,12 @@
|
|||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
Specify a reference of the repository using the following syntax: branches with <code>refs/heads/branch_name</code> or tags with <code>refs/tags/tag_name</code>. If not
|
||||
specified, will use the default <code>HEAD</code> reference normally the <code>master</code> branch.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="stack_repository_reference_name" class="col-sm-2 control-label text-left">Repository reference</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" ng-model="$ctrl.value" id="stack_repository_reference_name" placeholder="refs/heads/master" ng-change="$ctrl.onChange($ctrl.value)" />
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,7 @@
|
|||
export const gitFormRefField = {
|
||||
templateUrl: './git-form-ref-field.html',
|
||||
bindings: {
|
||||
value: '<',
|
||||
onChange: '<',
|
||||
},
|
||||
};
|
|
@ -0,0 +1,18 @@
|
|||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
You can use the URL of a git repository.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="stack_repository_url" class="col-sm-2 control-label text-left">Repository URL</label>
|
||||
<div class="col-sm-10">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
ng-model="$ctrl.value"
|
||||
ng-change="$ctrl.onChange($ctrl.value)"
|
||||
id="stack_repository_url"
|
||||
placeholder="https://github.com/portainer/portainer-compose"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,7 @@
|
|||
export const gitFormUrlField = {
|
||||
templateUrl: './git-form-url-field.html',
|
||||
bindings: {
|
||||
value: '<',
|
||||
onChange: '<',
|
||||
},
|
||||
};
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
<div>
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Git repository
|
||||
</div>
|
||||
<git-form-url-field value="$ctrl.model.RepositoryURL" on-change="($ctrl.onChangeURL)"></git-form-url-field>
|
||||
<git-form-ref-field value="$ctrl.model.RepositoryReferenceName" on-change="($ctrl.onChangeRefName)"></git-form-ref-field>
|
||||
<git-form-compose-path-field value="$ctrl.model.ComposeFilePathInRepository" on-change="($ctrl.onChangeComposePath)"></git-form-compose-path-field>
|
||||
<git-form-auth-fieldset model="$ctrl.model" on-change="($ctrl.onChange)"></git-form-auth-fieldset>
|
||||
</div>
|
|
@ -0,0 +1,10 @@
|
|||
import controller from './git-form.controller.js';
|
||||
|
||||
export const gitForm = {
|
||||
templateUrl: './git-form.html',
|
||||
controller,
|
||||
bindings: {
|
||||
model: '<',
|
||||
onChange: '<',
|
||||
},
|
||||
};
|
|
@ -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;
|
|
@ -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);
|
|
@ -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;
|
|
@ -0,0 +1,30 @@
|
|||
<form name="$ctrl.redeployGitForm" class="form-horizontal my-8">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Redeploy from git repository
|
||||
</div>
|
||||
<div class="text-muted small form-group">
|
||||
<div class="col-sm-12">
|
||||
<p>
|
||||
This stack was deployed from the git repository <code>{{ $ctrl.model.URL }}</code>
|
||||
.
|
||||
</p>
|
||||
<p>
|
||||
Update <code>{{ $ctrl.model.ConfigFilePath }}</code> in git and pull from here to update the stack.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<git-form-ref-field value="$ctrl.formValues.RefName" on-change="($ctrl.onChangeRef)"></git-form-ref-field>
|
||||
<git-form-auth-fieldset model="$ctrl.formValues" on-change="($ctrl.onChange)"></git-form-auth-fieldset>
|
||||
|
||||
<button
|
||||
class="btn btn-sm btn-primary"
|
||||
ng-click="$ctrl.submit()"
|
||||
ng-disabled="$ctrl.isSubmitButtonDisabled()"
|
||||
style="margin-top: 7px; margin-left: 0;"
|
||||
button-spinner="$ctrl.state.inProgress"
|
||||
>
|
||||
<span ng-hide="$ctrl.state.inProgress"> <i class="fa fa-sync space-right" aria-hidden="true"></i> Pull and redeploy </span>
|
||||
<span ng-show="$ctrl.state.inProgress">In progress...</span>
|
||||
</button>
|
||||
</form>
|
|
@ -0,0 +1,5 @@
|
|||
import angular from 'angular';
|
||||
|
||||
import gitFormModule from './forms/git-form';
|
||||
|
||||
export default angular.module('portainer.app.components', [gitFormModule]).name;
|
|
@ -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) {
|
||||
|
|
|
@ -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' } },
|
||||
}
|
||||
);
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
]);
|
||||
|
|
|
@ -103,84 +103,7 @@
|
|||
</div>
|
||||
<!-- !upload -->
|
||||
<!-- repository -->
|
||||
<div ng-show="$ctrl.state.Method === 'repository'">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Git repository
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
You can use the URL of a git repository.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="template_repository_url" class="col-sm-2 control-label text-left">Repository URL</label>
|
||||
<div class="col-sm-10">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
ng-model="$ctrl.formValues.RepositoryURL"
|
||||
id="template_repository_url"
|
||||
placeholder="https://github.com/portainer/portainer-compose"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
Specify a reference of the repository using the following syntax: branches with
|
||||
<code>refs/heads/branch_name</code> or tags with <code>refs/tags/tag_name</code>. If not specified, will use the default <code>HEAD</code> reference normally the
|
||||
<code>master</code> branch.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="template_repository_url" class="col-sm-2 control-label text-left">Repository reference</label>
|
||||
<div class="col-sm-10">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
ng-model="$ctrl.formValues.RepositoryReferenceName"
|
||||
id="template_repository_reference_name"
|
||||
placeholder="refs/heads/master"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
Indicate the path to the Compose file from the root of your repository.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="template_repository_path" class="col-sm-2 control-label text-left">Compose path</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" ng-model="$ctrl.formValues.ComposeFilePathInRepository" id="template_repository_path" placeholder="docker-compose.yml" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">
|
||||
Authentication
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="$ctrl.formValues.RepositoryAuthentication" /><i></i> </label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-if="$ctrl.formValues.RepositoryAuthentication">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
If your git account has 2FA enabled, you may receive an
|
||||
<code>authentication required</code> error when creating your template. In this case, you will need to provide a personal-access token instead of your password.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group" ng-if="$ctrl.formValues.RepositoryAuthentication">
|
||||
<label for="repository_username" class="col-sm-1 control-label text-left">Username</label>
|
||||
<div class="col-sm-11 col-md-5">
|
||||
<input type="text" class="form-control" ng-model="$ctrl.formValues.RepositoryUsername" name="repository_username" placeholder="myGitUser" />
|
||||
</div>
|
||||
<label for="repository_password" class="col-sm-1 control-label text-left">
|
||||
Password
|
||||
</label>
|
||||
<div class="col-sm-11 col-md-5">
|
||||
<input type="password" class="form-control" ng-model="$ctrl.formValues.RepositoryPassword" name="repository_password" placeholder="myPassword" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<git-form ng-if="state.Method === 'repository'" model="$ctrl.formValues" on-change="($ctrl.onChangeFormValues)"></git-form>
|
||||
<!-- !repository -->
|
||||
<por-access-control-form form-data="$ctrl.formValues.AccessControlData"></por-access-control-form>
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
|
@ -130,79 +130,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<!-- !upload -->
|
||||
<!-- repository -->
|
||||
<div ng-show="state.Method === 'repository'">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Git repository
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
You can use the URL of a git repository.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="stack_repository_url" class="col-sm-2 control-label text-left">Repository URL</label>
|
||||
<div class="col-sm-10">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
ng-model="formValues.RepositoryURL"
|
||||
id="stack_repository_url"
|
||||
placeholder="https://github.com/portainer/portainer-compose"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
Specify a reference of the repository using the following syntax: branches with <code>refs/heads/branch_name</code> or tags with <code>refs/tags/tag_name</code>. If
|
||||
not specified, will use the default <code>HEAD</code> reference normally the <code>master</code> branch.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="stack_repository_url" class="col-sm-2 control-label text-left">Repository reference</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" ng-model="formValues.RepositoryReferenceName" id="stack_repository_reference_name" placeholder="refs/heads/master" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
Indicate the path to the Compose file from the root of your repository.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="stack_repository_path" class="col-sm-2 control-label text-left">Compose path</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" ng-model="formValues.ComposeFilePathInRepository" id="stack_repository_path" placeholder="docker-compose.yml" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">
|
||||
Authentication
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="formValues.RepositoryAuthentication" /><i></i> </label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-if="formValues.RepositoryAuthentication">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
If your git account has 2FA enabled, you may receive an <code>authentication required</code> error when deploying your stack. In this case, you will need to provide
|
||||
a personal-access token instead of your password.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group" ng-if="formValues.RepositoryAuthentication">
|
||||
<label for="repository_username" class="col-sm-1 control-label text-left">Username</label>
|
||||
<div class="col-sm-11 col-md-5">
|
||||
<input type="text" class="form-control" ng-model="formValues.RepositoryUsername" name="repository_username" placeholder="myGitUser" />
|
||||
</div>
|
||||
<label for="repository_password" class="col-sm-1 control-label text-left">
|
||||
Password
|
||||
</label>
|
||||
<div class="col-sm-11 col-md-5">
|
||||
<input type="password" class="form-control" ng-model="formValues.RepositoryPassword" name="repository_password" placeholder="myPassword" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !repository -->
|
||||
<git-form ng-if="state.Method === 'repository'" model="formValues" on-change="(onChangeFormValues)"></git-form>
|
||||
<!-- custom-template -->
|
||||
<div ng-show="state.Method === 'template'">
|
||||
<div class="form-group">
|
||||
|
|
|
@ -110,6 +110,7 @@
|
|||
</div>
|
||||
<!-- !associate -->
|
||||
|
||||
<stack-redeploy-git-form ng-if="stack.GitConfig" model="stack.GitConfig" stack="stack" authorization="PortainerStackUpdate"> </stack-redeploy-git-form>
|
||||
<stack-duplication-form
|
||||
ng-if="regular && endpoints.length > 0"
|
||||
endpoints="endpoints"
|
||||
|
|
Loading…
Reference in New Issue