Compare commits

..

10 Commits

Author SHA1 Message Date
ArrisLee
5b311f6b5a modify StackAutoUpdate struct 2021-06-21 09:50:50 +12:00
ArrisLee
08529ac0f2 rename payload 2021-06-21 09:46:38 +12:00
ArrisLee
ce7d52dda3 rename git config update handler 2021-06-21 09:37:16 +12:00
ArrisLee
93ce0bd8f6 remove git config struct from portainer.go 2021-06-20 23:40:45 +12:00
ArrisLee
6f1ef44def Merge branch 'feat/EE-829/implement-edit-git-deploy-stack-endpoint' of github.com:portainer/portainer into feat/EE-829/implement-edit-git-deploy-stack-endpoint 2021-06-20 23:39:29 +12:00
ArrisLee
90bbc18aab apply repo config from EE-161 2021-06-20 23:38:45 +12:00
Hui
3d47258069 Update api/http/handler/stacks/stack_update_git.go
Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
2021-06-17 09:30:07 +12:00
ArrisLee
b5ea45f81b refine unit test 2021-06-15 16:12:51 +12:00
ArrisLee
fff4f6819d remove the logic of stack redeployment 2021-06-15 15:49:13 +12:00
Hui
45b477aa45 add edit git stack handler 2021-06-15 15:49:08 +12:00
20 changed files with 254 additions and 380 deletions

View File

@@ -133,8 +133,3 @@ func (service *Service) DeleteStack(ID portainer.StackID) error {
identifier := internal.Itob(int(ID))
return internal.DeleteObject(service.connection, BucketName, identifier)
}
// StackByWebhookID returns a stack via searching with webhook ID
func (service *Service) StackByWebhookID(id string) (*portainer.Stack, error) {
return nil, errors.ErrObjectNotFound
}

View File

@@ -76,13 +76,12 @@ func initDataStore(dataStorePath string, fileService portainer.FileService) port
}
func initComposeStackManager(assetsPath string, dataStorePath string, reverseTunnelService portainer.ReverseTunnelService, proxyManager *proxy.Manager) portainer.ComposeStackManager {
composeWrapper, err := exec.NewComposeStackManager(assetsPath, dataStorePath, proxyManager)
if err != nil {
log.Printf("[INFO] [main,compose] [message: falling-back to libcompose] [error: %s]", err)
return libcompose.NewComposeStackManager(dataStorePath, reverseTunnelService)
composeWrapper := exec.NewComposeWrapper(assetsPath, dataStorePath, proxyManager)
if composeWrapper != nil {
return composeWrapper
}
return composeWrapper
return libcompose.NewComposeStackManager(dataStorePath, reverseTunnelService)
}
func initSwarmStackManager(assetsPath string, dataStorePath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService, reverseTunnelService portainer.ReverseTunnelService) (portainer.SwarmStackManager, error) {

View File

@@ -1,116 +0,0 @@
package exec
import (
"fmt"
"os"
"path"
"strings"
wrapper "github.com/portainer/docker-compose-wrapper"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/api/http/proxy/factory"
)
// ComposeStackManager is a wrapper for docker-compose binary
type ComposeStackManager struct {
wrapper *wrapper.ComposeWrapper
dataPath string
proxyManager *proxy.Manager
}
// NewComposeStackManager returns a docker-compose wrapper if corresponding binary present, otherwise nil
func NewComposeStackManager(binaryPath string, dataPath string, proxyManager *proxy.Manager) (*ComposeStackManager, error) {
wrap, err := wrapper.NewComposeWrapper(binaryPath)
if err != nil {
return nil, err
}
return &ComposeStackManager{
wrapper: wrap,
proxyManager: proxyManager,
dataPath: dataPath,
}, nil
}
// ComposeSyntaxMaxVersion returns the maximum supported version of the docker compose syntax
func (w *ComposeStackManager) ComposeSyntaxMaxVersion() string {
return portainer.ComposeSyntaxMaxVersion
}
// Up builds, (re)creates and starts containers in the background. Wraps `docker-compose up -d` command
func (w *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
url, proxy, err := w.fetchEndpointProxy(endpoint)
if err != nil {
return err
}
if proxy != nil {
defer proxy.Close()
}
envFilePath, err := createEnvFile(stack)
if err != nil {
return err
}
filePaths := getStackFilePaths(stack)
_, err = w.wrapper.Up(filePaths, url, stack.Name, envFilePath, w.dataPath)
return err
}
// Down stops and removes containers, networks, images, and volumes. Wraps `docker-compose down --remove-orphans` command
func (w *ComposeStackManager) Down(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
url, proxy, err := w.fetchEndpointProxy(endpoint)
if err != nil {
return err
}
if proxy != nil {
defer proxy.Close()
}
filePaths := getStackFilePaths(stack)
_, err = w.wrapper.Down(filePaths, url, stack.Name)
return err
}
// NormalizeStackName returns the passed stack name, for interface implementation only
func (w *ComposeStackManager) NormalizeStackName(name string) string {
return name
}
func (w *ComposeStackManager) fetchEndpointProxy(endpoint *portainer.Endpoint) (string, *factory.ProxyServer, error) {
if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") {
return "", nil, nil
}
proxy, err := w.proxyManager.CreateComposeProxyServer(endpoint)
if err != nil {
return "", nil, err
}
return fmt.Sprintf("http://127.0.0.1:%d", proxy.Port), proxy, nil
}
func createEnvFile(stack *portainer.Stack) (string, error) {
if stack.Env == nil || len(stack.Env) == 0 {
return "", nil
}
envFilePath := path.Join(stack.ProjectPath, "stack.env")
envfile, err := os.OpenFile(envFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return "", err
}
for _, v := range stack.Env {
envfile.WriteString(fmt.Sprintf("%s=%s\n", v.Name, v.Value))
}
envfile.Close()
return envFilePath, nil
}

View File

@@ -1,71 +0,0 @@
package exec
import (
"io/ioutil"
"os"
"path"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
)
func Test_createEnvFile(t *testing.T) {
dir := t.TempDir()
tests := []struct {
name string
stack *portainer.Stack
expected string
expectedFile bool
}{
// {
// name: "should not add env file option if stack is missing",
// stack: nil,
// expected: "",
// },
{
name: "should not add env file option if stack doesn't have env variables",
stack: &portainer.Stack{
ProjectPath: dir,
},
expected: "",
},
{
name: "should not add env file option if stack's env variables are empty",
stack: &portainer.Stack{
ProjectPath: dir,
Env: []portainer.Pair{},
},
expected: "",
},
{
name: "should add env file option if stack has env variables",
stack: &portainer.Stack{
ProjectPath: dir,
Env: []portainer.Pair{
{Name: "var1", Value: "value1"},
{Name: "var2", Value: "value2"},
},
},
expected: "var1=value1\nvar2=value2\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, _ := createEnvFile(tt.stack)
if tt.expected != "" {
assert.Equal(t, path.Join(tt.stack.ProjectPath, "stack.env"), result)
f, _ := os.Open(path.Join(dir, "stack.env"))
content, _ := ioutil.ReadAll(f)
assert.Equal(t, tt.expected, string(content))
} else {
assert.Equal(t, "", result)
}
})
}
}

View File

@@ -1,15 +0,0 @@
package exec
import (
"path"
portainer "github.com/portainer/portainer/api"
)
func getStackFilePaths(stack *portainer.Stack) []string {
var filePaths []string
for _, file := range append([]string{stack.EntryPoint}, stack.AdditionalFiles...) {
filePaths = append(filePaths, path.Join(stack.ProjectPath, file))
}
return filePaths
}

View File

@@ -1,26 +0,0 @@
package exec
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
)
func Test_getStackFilePaths(t *testing.T) {
stack := &portainer.Stack{
ProjectPath: "/tmp/stack/1",
EntryPoint: "file-one.yml",
}
t.Run("stack doesn't have additional files", func(t *testing.T) {
expected := []string{"/tmp/stack/1/file-one.yml"}
assert.ElementsMatch(t, expected, getStackFilePaths(stack))
})
t.Run("stack has additional files", func(t *testing.T) {
stack.AdditionalFiles = []string{"file-two.yml", "file-three.yml"}
expected := []string{"/tmp/stack/1/file-one.yml", "/tmp/stack/1/file-two.yml", "/tmp/stack/1/file-three.yml"}
assert.ElementsMatch(t, expected, getStackFilePaths(stack))
})
}

View File

@@ -10,7 +10,7 @@ import (
"path"
"runtime"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api"
)
// SwarmStackManager represents a service for managing stacks.
@@ -66,23 +66,22 @@ func (manager *SwarmStackManager) Logout(endpoint *portainer.Endpoint) error {
// Deploy executes the docker stack deploy command.
func (manager *SwarmStackManager) Deploy(stack *portainer.Stack, prune bool, endpoint *portainer.Endpoint) error {
filePaths := getStackFilePaths(stack)
stackFilePath := path.Join(stack.ProjectPath, stack.EntryPoint)
command, args := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint)
if prune {
args = append(args, "stack", "deploy", "--prune", "--with-registry-auth")
args = append(args, "stack", "deploy", "--prune", "--with-registry-auth", "--compose-file", stackFilePath, stack.Name)
} else {
args = append(args, "stack", "deploy", "--with-registry-auth")
args = append(args, "stack", "deploy", "--with-registry-auth", "--compose-file", stackFilePath, stack.Name)
}
args = configureFilePaths(args, filePaths)
args = append(args, stack.Name)
env := make([]string, 0)
for _, envvar := range stack.Env {
env = append(env, envvar.Name+"="+envvar.Value)
}
return runCommandAndCaptureStdErr(command, args, env, stack.ProjectPath)
stackFolder := path.Dir(stackFilePath)
return runCommandAndCaptureStdErr(command, args, env, stackFolder)
}
// Remove executes the docker stack rm command.
@@ -190,10 +189,3 @@ func (manager *SwarmStackManager) retrieveConfigurationFromDisk(path string) (ma
return config, nil
}
func configureFilePaths(args []string, filePaths []string) []string {
for _, path := range filePaths {
args = append(args, "--compose-file", path)
}
return args
}

View File

@@ -1,15 +0,0 @@
package exec
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestConfigFilePaths(t *testing.T) {
args := []string{"stack", "deploy", "--with-registry-auth"}
filePaths := []string{"dir/file", "dir/file-two", "dir/file-three"}
expected := []string{"stack", "deploy", "--with-registry-auth", "--compose-file", "dir/file", "--compose-file", "dir/file-two", "--compose-file", "dir/file-three"}
output := configureFilePaths(args, filePaths)
assert.ElementsMatch(t, expected, output, "wrong output file paths")
}

View File

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

19
api/git/types/types.go Normal file
View File

@@ -0,0 +1,19 @@
package gittypes
// RepoConfig represents a configuration for a repo
type RepoConfig struct {
// The repo url
URL string `example:"https://github.com/portainer/portainer-ee.git"`
// The reference name
ReferenceName string `example:"refs/heads/branch_name"`
// Path to where the config file is in this url/refName
ConfigFilePath string `example:"docker-compose.yml"`
// Git credentials
Authentication *GitAuthentication
ConfigHash []byte
}
type GitAuthentication struct {
Username string
Password string
}

View File

@@ -27,7 +27,6 @@ require (
github.com/mitchellh/mapstructure v1.1.2 // indirect
github.com/orcaman/concurrent-map v0.0.0-20190826125027-8c72a8bb44f6
github.com/pkg/errors v0.9.1
github.com/portainer/docker-compose-wrapper v0.0.0-20210527221011-0a1418224b92
github.com/portainer/libcompose v0.5.3
github.com/portainer/libcrypto v0.0.0-20190723020515-23ebe86ab2c2
github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33

View File

@@ -238,10 +238,6 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/portainer/docker-compose-wrapper v0.0.0-20210415235931-d457f9aba1cc h1:MvSEkOvhW3m2D3L0/Ymrjgg0t3CpHlHwpZfpgpIGNiw=
github.com/portainer/docker-compose-wrapper v0.0.0-20210415235931-d457f9aba1cc/go.mod h1:No8p8iZt9N2HOtDS9aWkh1ILxmQVoOTZZjHGlOij/ec=
github.com/portainer/docker-compose-wrapper v0.0.0-20210527221011-0a1418224b92 h1:Hh7SHCf3SJblVywU0TTn5lpTKsH5W23LAKH5sqWggig=
github.com/portainer/docker-compose-wrapper v0.0.0-20210527221011-0a1418224b92/go.mod h1:PF2O2O4UNYWdtPcp6n/mIKpKk+f1jhFTezS8txbf+XM=
github.com/portainer/libcompose v0.5.3 h1:tE4WcPuGvo+NKeDkDWpwNavNLZ5GHIJ4RvuZXsI9uI8=
github.com/portainer/libcompose v0.5.3/go.mod h1:7SKd/ho69rRKHDFSDUwkbMcol2TMKU5OslDsajr8Ro8=
github.com/portainer/libcrypto v0.0.0-20190723020515-23ebe86ab2c2 h1:0PfgGLys9yHr4rtnirg0W0Cjvv6/DzxBIZk5sV59208=

View File

@@ -13,6 +13,7 @@ import (
"github.com/portainer/libhttp/request"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security"
)
@@ -123,15 +124,17 @@ func (payload *composeStackFromGitRepositoryPayload) Validate(r *http.Request) e
if govalidator.IsNull(payload.Name) {
return errors.New("Invalid stack name")
}
if govalidator.IsNull(payload.RepositoryURL) || !govalidator.IsURL(payload.RepositoryURL) {
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
}
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")
if govalidator.IsNull(payload.RepositoryReferenceName) {
return errors.New("Invalid RepositoryReferenceName")
}
if err := validateStackAutoUpdate(payload.AutoUpdate); err != nil {
return err
if payload.RepositoryAuthentication && govalidator.IsNull(payload.RepositoryPassword) {
return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled")
}
return nil
}
@@ -149,21 +152,11 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
isUnique, err := handler.checkUniqueName(endpoint, payload.Name, 0, false)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for name collision", Err: err}
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for name collision", err}
}
if !isUnique {
return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("A stack with the name '%s' already exists", payload.Name), Err: errStackAlreadyExists}
}
//make sure the webhook ID is unique
if payload.AutoUpdate.Webhook != "" {
isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for webhook ID collision", Err: err}
}
if !isUnique {
return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), Err: errWebhookIDAlreadyExists}
}
errorMessage := fmt.Sprintf("A stack with the name '%s' already exists", payload.Name)
return &httperror.HandlerError{http.StatusConflict, errorMessage, errors.New(errorMessage)}
}
stackID := handler.DataStore.Stack().GetNextIdentifier()
@@ -176,8 +169,19 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
AdditionalFiles: payload.AdditionalFiles,
AutoUpdate: payload.AutoUpdate,
Env: payload.Env,
Status: portainer.StackStatusActive,
CreationDate: time.Now().Unix(),
GitConfig: &gittypes.RepoConfig{
URL: payload.RepositoryURL,
ReferenceName: payload.RepositoryReferenceName,
},
Status: portainer.StackStatusActive,
CreationDate: time.Now().Unix(),
}
if payload.RepositoryAuthentication {
stack.GitConfig.Authentication = &gittypes.GitAuthentication{
Username: payload.RepositoryUsername,
Password: payload.RepositoryPassword,
}
}
projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID)))
@@ -374,17 +378,15 @@ func (handler *Handler) deployComposeStack(config *composeStackDeploymentConfig)
!securitySettings.AllowContainerCapabilitiesForRegularUsers) &&
!isAdminOrEndpointAdmin {
for _, file := range append([]string{config.stack.EntryPoint}, config.stack.AdditionalFiles...) {
path := path.Join(config.stack.ProjectPath, file)
stackContent, err := handler.FileService.GetFileContent(path)
if err != nil {
return err
}
composeFilePath := path.Join(config.stack.ProjectPath, config.stack.EntryPoint)
stackContent, err := handler.FileService.GetFileContent(composeFilePath)
if err != nil {
return err
}
err = handler.isValidStackFile(stackContent, securitySettings)
if err != nil {
return err
}
err = handler.isValidStackFile(stackContent, securitySettings)
if err != nil {
return err
}
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/portainer/libhttp/request"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security"
)
@@ -134,11 +135,14 @@ func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) err
if govalidator.IsNull(payload.RepositoryURL) || !govalidator.IsURL(payload.RepositoryURL) {
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
}
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")
if govalidator.IsNull(payload.RepositoryReferenceName) {
return errors.New("Invalid RepositoryReferenceName")
}
if err := validateStackAutoUpdate(payload.AutoUpdate); err != nil {
return err
if payload.RepositoryAuthentication && govalidator.IsNull(payload.RepositoryPassword) {
return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled")
}
if govalidator.IsNull(payload.ComposeFile) {
payload.ComposeFile = filesystem.ComposeFileDefaultName
}
return nil
}
@@ -147,26 +151,16 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter,
var payload swarmStackFromGitRepositoryPayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
isUnique, err := handler.checkUniqueName(endpoint, payload.Name, 0, true)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for name collision", Err: err}
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for name collision", err}
}
if !isUnique {
return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("A stack with the name '%s' already exists", payload.Name), Err: errStackAlreadyExists}
}
//make sure the webhook ID is unique
if payload.AutoUpdate.Webhook != "" {
isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for webhook ID collision", Err: err}
}
if !isUnique {
return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), Err: errWebhookIDAlreadyExists}
}
errorMessage := fmt.Sprintf("A stack with the name '%s' is already running", payload.Name)
return &httperror.HandlerError{http.StatusConflict, errorMessage, errors.New(errorMessage)}
}
stackID := handler.DataStore.Stack().GetNextIdentifier()
@@ -179,9 +173,20 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter,
EntryPoint: payload.ComposeFile,
AdditionalFiles: payload.AdditionalFiles,
AutoUpdate: payload.AutoUpdate,
Env: payload.Env,
Status: portainer.StackStatusActive,
CreationDate: time.Now().Unix(),
GitConfig: &gittypes.RepoConfig{
URL: payload.RepositoryURL,
ReferenceName: payload.RepositoryReferenceName,
},
Env: payload.Env,
Status: portainer.StackStatusActive,
CreationDate: time.Now().Unix(),
}
if payload.RepositoryAuthentication {
stack.GitConfig.Authentication = &gittypes.GitAuthentication{
Username: payload.RepositoryUsername,
Password: payload.RepositoryPassword,
}
}
projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID)))
@@ -201,7 +206,7 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter,
err = handler.cloneGitRepository(gitCloneParams)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to clone git repository", Err: err}
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clone git repository", err}
}
config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false)
@@ -211,14 +216,14 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter,
err = handler.deploySwarmStack(config)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: err.Error(), Err: err}
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}
}
stack.CreatedBy = config.user.Username
err = handler.DataStore.Stack().CreateStack(stack)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the stack inside the database", Err: err}
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err}
}
doCleanUp = false
@@ -374,17 +379,16 @@ func (handler *Handler) deploySwarmStack(config *swarmStackDeploymentConfig) err
settings := &config.endpoint.SecuritySettings
if !settings.AllowBindMountsForRegularUsers && !isAdminOrEndpointAdmin {
for _, file := range append([]string{config.stack.EntryPoint}, config.stack.AdditionalFiles...) {
path := path.Join(config.stack.ProjectPath, file)
stackContent, err := handler.FileService.GetFileContent(path)
if err != nil {
return err
}
composeFilePath := path.Join(config.stack.ProjectPath, config.stack.EntryPoint)
err = handler.isValidStackFile(stackContent, settings)
if err != nil {
return err
}
stackContent, err := handler.FileService.GetFileContent(composeFilePath)
if err != nil {
return err
}
err = handler.isValidStackFile(stackContent, settings)
if err != nil {
return err
}
}

View File

@@ -11,16 +11,14 @@ import (
"github.com/gorilla/mux"
httperror "github.com/portainer/libhttp/error"
portainer "github.com/portainer/portainer/api"
dberrors "github.com/portainer/portainer/api/bolt/errors"
"github.com/portainer/portainer/api/docker"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
)
var (
errStackAlreadyExists = errors.New("A stack already exists with this name")
errWebhookIDAlreadyExists = errors.New("A webhook ID already exists")
errStackNotExternal = errors.New("Not an external stack")
errStackAlreadyExists = errors.New("A stack already exists with this name")
errStackNotExternal = errors.New("Not an external stack")
)
// Handler is the HTTP handler used to handle stack operations.
@@ -56,6 +54,8 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackDelete))).Methods(http.MethodDelete)
h.Handle("/stacks/{id}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackUpdate))).Methods(http.MethodPut)
h.Handle("/stacks/{id}/git/config",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackGitConfigUpdate))).Methods(http.MethodPost)
h.Handle("/stacks/{id}/file",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackFile))).Methods(http.MethodGet)
h.Handle("/stacks/{id}/migrate",
@@ -157,11 +157,3 @@ func (handler *Handler) checkUniqueName(endpoint *portainer.Endpoint, name strin
return true, nil
}
func (handler *Handler) checkUniqueWebhookID(webhookID string) (bool, error) {
_, err := handler.DataStore.Stack().StackByWebhookID(webhookID)
if err == dberrors.ErrObjectNotFound {
return true, nil
}
return false, err
}

View File

@@ -12,9 +12,11 @@ func Test_ValidateStackAutoUpdate_Valid(t *testing.T) {
Webhook: "8dce8c2f-9ca1-482b-ad20-271e86536ada",
Interval: "5h30m40s10ms",
}
if err := validateStackAutoUpdate(mock); err != nil {
t.Errorf("wrong validation, mock data: %v should be valid, but got error: %v", mock, err)
}
err := validateStackAutoUpdate(mock)
assert.NoError(t, err)
mock = nil
err = validateStackAutoUpdate(mock)
assert.NoError(t, err)
}
func Test_ValidateStackAutoUpdate_InValid(t *testing.T) {

View File

@@ -131,7 +131,7 @@ func (handler *Handler) createComposeStack(w http.ResponseWriter, r *http.Reques
return handler.createComposeStackFromFileUpload(w, r, endpoint, userID)
}
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid value for query parameter: method. Value must be one of: string, repository or file", Err: errors.New(request.ErrInvalidQueryParameter)}
return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: method. Value must be one of: string, repository or file", errors.New(request.ErrInvalidQueryParameter)}
}
func (handler *Handler) createSwarmStack(w http.ResponseWriter, r *http.Request, method string, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError {
@@ -144,7 +144,7 @@ func (handler *Handler) createSwarmStack(w http.ResponseWriter, r *http.Request,
return handler.createSwarmStackFromFileUpload(w, r, endpoint, userID)
}
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid value for query parameter: method. Value must be one of: string, repository or file", Err: errors.New(request.ErrInvalidQueryParameter)}
return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: method. Value must be one of: string, repository or file", errors.New(request.ErrInvalidQueryParameter)}
}
func (handler *Handler) isValidStackFile(stackFileContent []byte, securitySettings *portainer.EndpointSecuritySettings) error {

View File

@@ -0,0 +1,125 @@
package stacks
import (
"errors"
"net/http"
"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"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/stackutils"
)
type stackGitConfigUpdatePayload struct {
AutoUpdate *portainer.StackAutoUpdate
Env []portainer.Pair
RepositoryReferenceName string
}
func (payload *stackGitConfigUpdatePayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.RepositoryReferenceName) {
return errors.New("Invalid RepositoryReferenceName")
}
if err := validateStackAutoUpdate(payload.AutoUpdate); err != nil {
return err
}
return nil
}
// @id Stacks
// @summary Update and redeploy an existing stack (with Git config)
// @description Update and redeploy an existing stack (with Git config)
// @description **Access policy**: authenticated
// @tags stacks
// @security jwt
// @produce json
// @param id path int true "Stack identifier"
// @param endpointId query int false "Stacks created before version 1.18.0 might not have an associated endpoint identifier. Use this optional parameter to set the endpoint identifier used by the stack."
// @param body body stackGitConfigUpdatePayload true "Stack Git config"
// @success 200 {object} portainer.Stack "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
// @failure 404 "Not found"
// @failure 500 "Server error"
// @router /stacks/{id}/git [post]
func (handler *Handler) stackGitConfigUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
stackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid stack identifier route variable", Err: err}
}
var payload stackGitConfigUpdatePayload
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
}
stack, err := handler.DataStore.Stack().Stack(portainer.StackID(stackID))
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find a stack with the specified identifier inside the database", Err: err}
} else if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find a stack with the specified identifier inside the database", Err: err}
} else if stack.GitConfig == nil {
msg := "No Git config in the found stack"
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: msg, Err: errors.New(msg)}
}
// 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{StatusCode: http.StatusBadRequest, Message: "Invalid query parameter: endpointId", Err: 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{StatusCode: http.StatusNotFound, Message: "Unable to find the endpoint associated to the stack inside the database", Err: err}
} else if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find the endpoint associated to the stack inside the database", Err: err}
}
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Permission denied to access endpoint", Err: err}
}
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve a resource control associated to the stack", Err: err}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err}
}
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to verify user authorizations to validate stack access", Err: err}
}
if !access {
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Access denied to resource", Err: httperrors.ErrResourceAccessDenied}
}
//update retrieved stack data based on the payload
stack.GitConfig.ReferenceName = payload.RepositoryReferenceName
stack.AutoUpdate = payload.AutoUpdate
stack.Env = payload.Env
//save the updated stack to DB
err = handler.DataStore.Stack().UpdateStack(stack.ID, stack)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the stack changes inside the database", Err: err}
}
return response.JSON(w, stack)
}

View File

@@ -86,14 +86,12 @@ func (manager *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portain
for _, envvar := range stack.Env {
env[envvar.Name] = envvar.Value
}
var composeFiles []string
for _, file := range append([]string{stack.EntryPoint}, stack.AdditionalFiles...) {
composeFiles = append(composeFiles, path.Join(stack.ProjectPath, file))
}
composeFilePath := path.Join(stack.ProjectPath, stack.EntryPoint)
proj, err := docker.NewProject(&ctx.Context{
ConfigDir: manager.dataPath,
Context: project.Context{
ComposeFiles: composeFiles,
ComposeFiles: []string{composeFilePath},
EnvironmentLookup: &lookup.ComposableEnvLookup{
Lookups: []config.EnvironmentLookup{
&lookup.EnvfileLookup{
@@ -122,13 +120,10 @@ func (manager *ComposeStackManager) Down(stack *portainer.Stack, endpoint *porta
return err
}
var composeFiles []string
for _, file := range append([]string{stack.EntryPoint}, stack.AdditionalFiles...) {
composeFiles = append(composeFiles, path.Join(stack.ProjectPath, file))
}
composeFilePath := path.Join(stack.ProjectPath, stack.EntryPoint)
proj, err := docker.NewProject(&ctx.Context{
Context: project.Context{
ComposeFiles: composeFiles,
ComposeFiles: []string{composeFilePath},
ProjectName: stack.Name,
},
ClientFactory: clientFactory,
@@ -139,11 +134,3 @@ func (manager *ComposeStackManager) Down(stack *portainer.Stack, endpoint *porta
return proj.Down(context.Background(), options.Down{RemoveVolume: false, RemoveOrphans: true})
}
func stackFilePaths(stack *portainer.Stack) []string {
var filePaths []string
for _, file := range append([]string{stack.EntryPoint}, stack.AdditionalFiles...) {
filePaths = append(filePaths, path.Join(stack.ProjectPath, file))
}
return filePaths
}

View File

@@ -3,6 +3,8 @@ package portainer
import (
"io"
"time"
gittypes "github.com/portainer/portainer/api/git/types"
)
type (
@@ -682,9 +684,11 @@ type (
// Path to the Stack file
EntryPoint string `json:"EntryPoint" example:"docker-compose.yml"`
// Additional Stack files
AdditionalFiles []string `json:"AdditionalFiles"`
AdditionalFiles []string `json:"AdditionalFiles" example:""`
// Stack auto update settings
AutoUpdate *StackAutoUpdate `json:"AutoUpdate"`
AutoUpdate *StackAutoUpdate `json:"AutoUpdate" example:""`
// Git settings
GitConfig *gittypes.RepoConfig `json:"GitConfig"`
// A list of environment variables used during stack deployment
Env []Pair `json:"Env" example:""`
//
@@ -703,10 +707,11 @@ type (
UpdatedBy string `example:"bob"`
}
//StackAutoUpdate represents the git auto sync config for stack deployment
// StackAutoUpdate represents the git auto sync config for stack deployment
StackAutoUpdate struct {
Interval string
Webhook string //a UUID generated from client
JobID string
}
// StackID represents a stack identifier (it must be composed of Name + "_" + SwarmID to create a unique identifier)
@@ -1248,7 +1253,6 @@ type (
UpdateStack(ID StackID, stack *Stack) error
DeleteStack(ID StackID) error
GetNextIdentifier() int
StackByWebhookID(ID string) (*Stack, error)
}
// SnapshotService represents a service for managing endpoint snapshots