diff --git a/api/bolt/stack/stack.go b/api/bolt/stack/stack.go index f9cfafad7..1208ee980 100644 --- a/api/bolt/stack/stack.go +++ b/api/bolt/stack/stack.go @@ -133,3 +133,8 @@ 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 +} diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 91bfce5f3..808b849f5 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -76,12 +76,13 @@ func initDataStore(dataStorePath string, fileService portainer.FileService) port } func initComposeStackManager(assetsPath string, dataStorePath string, reverseTunnelService portainer.ReverseTunnelService, proxyManager *proxy.Manager) portainer.ComposeStackManager { - composeWrapper := exec.NewComposeWrapper(assetsPath, dataStorePath, proxyManager) - if composeWrapper != nil { - return composeWrapper + 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) } - return libcompose.NewComposeStackManager(dataStorePath, reverseTunnelService) + return composeWrapper } func initSwarmStackManager(assetsPath string, dataStorePath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService, reverseTunnelService portainer.ReverseTunnelService) (portainer.SwarmStackManager, error) { diff --git a/api/exec/compose_stack.go b/api/exec/compose_stack.go new file mode 100644 index 000000000..230c5714c --- /dev/null +++ b/api/exec/compose_stack.go @@ -0,0 +1,116 @@ +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 +} diff --git a/api/exec/compose_stack_test.go b/api/exec/compose_stack_test.go new file mode 100644 index 000000000..909713cec --- /dev/null +++ b/api/exec/compose_stack_test.go @@ -0,0 +1,71 @@ +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) + } + }) + } +} diff --git a/api/exec/helper.go b/api/exec/helper.go new file mode 100644 index 000000000..22f3af9dd --- /dev/null +++ b/api/exec/helper.go @@ -0,0 +1,15 @@ +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 +} diff --git a/api/exec/helper_test.go b/api/exec/helper_test.go new file mode 100644 index 000000000..d7104dcbd --- /dev/null +++ b/api/exec/helper_test.go @@ -0,0 +1,26 @@ +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)) + }) +} diff --git a/api/exec/swarm_stack.go b/api/exec/swarm_stack.go index cf59f7607..2a6644dbb 100644 --- a/api/exec/swarm_stack.go +++ b/api/exec/swarm_stack.go @@ -10,7 +10,7 @@ import ( "path" "runtime" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" ) // SwarmStackManager represents a service for managing stacks. @@ -66,22 +66,23 @@ 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 { - stackFilePath := path.Join(stack.ProjectPath, stack.EntryPoint) + filePaths := getStackFilePaths(stack) command, args := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint) if prune { - args = append(args, "stack", "deploy", "--prune", "--with-registry-auth", "--compose-file", stackFilePath, stack.Name) + args = append(args, "stack", "deploy", "--prune", "--with-registry-auth") } else { - args = append(args, "stack", "deploy", "--with-registry-auth", "--compose-file", stackFilePath, stack.Name) + args = append(args, "stack", "deploy", "--with-registry-auth") } + 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) } - - stackFolder := path.Dir(stackFilePath) - return runCommandAndCaptureStdErr(command, args, env, stackFolder) + return runCommandAndCaptureStdErr(command, args, env, stack.ProjectPath) } // Remove executes the docker stack rm command. @@ -189,3 +190,10 @@ 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 +} diff --git a/api/exec/swarm_stack_test.go b/api/exec/swarm_stack_test.go new file mode 100644 index 000000000..47d28ce2c --- /dev/null +++ b/api/exec/swarm_stack_test.go @@ -0,0 +1,15 @@ +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") +} diff --git a/api/go.mod b/api/go.mod index 82a130f3c..ade567f36 100644 --- a/api/go.mod +++ b/api/go.mod @@ -27,6 +27,7 @@ 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 diff --git a/api/go.sum b/api/go.sum index b7e4bb5c2..3904cd953 100644 --- a/api/go.sum +++ b/api/go.sum @@ -238,6 +238,10 @@ 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= diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go index 2c78ee0f9..a4e69ba38 100644 --- a/api/http/handler/stacks/create_compose_stack.go +++ b/api/http/handler/stacks/create_compose_stack.go @@ -112,8 +112,9 @@ type composeStackFromGitRepositoryPayload struct { // Password used in basic authentication. Required when RepositoryAuthentication is true. RepositoryPassword string `example:"myGitPassword"` // Path to the Stack file inside the Git repository - ComposeFilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"` - + ComposeFile string `example:"docker-compose.yml" default:"docker-compose.yml"` + AdditionalFiles []string + AutoUpdate *portainer.StackAutoUpdate // A list of environment variables used during stack deployment Env []portainer.Pair } @@ -122,14 +123,15 @@ 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 err := validateStackAutoUpdate(payload.AutoUpdate); err != nil { + return err + } return nil } @@ -141,29 +143,41 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite } payload.Name = handler.ComposeStackManager.NormalizeStackName(payload.Name) - if payload.ComposeFilePathInRepository == "" { - payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName + if payload.ComposeFile == "" { + payload.ComposeFile = filesystem.ComposeFileDefaultName } isUnique, err := handler.checkUniqueName(endpoint, payload.Name, 0, false) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for name collision", err} + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for name collision", Err: err} } if !isUnique { - errorMessage := fmt.Sprintf("A stack with the name '%s' already exists", payload.Name) - return &httperror.HandlerError{http.StatusConflict, errorMessage, errors.New(errorMessage)} + 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} + } } stackID := handler.DataStore.Stack().GetNextIdentifier() stack := &portainer.Stack{ - ID: portainer.StackID(stackID), - Name: payload.Name, - Type: portainer.DockerComposeStack, - EndpointID: endpoint.ID, - EntryPoint: payload.ComposeFilePathInRepository, - Env: payload.Env, - Status: portainer.StackStatusActive, - CreationDate: time.Now().Unix(), + ID: portainer.StackID(stackID), + Name: payload.Name, + Type: portainer.DockerComposeStack, + EndpointID: endpoint.ID, + EntryPoint: payload.ComposeFile, + AdditionalFiles: payload.AdditionalFiles, + AutoUpdate: payload.AutoUpdate, + Env: payload.Env, + Status: portainer.StackStatusActive, + CreationDate: time.Now().Unix(), } projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID))) @@ -360,15 +374,17 @@ func (handler *Handler) deployComposeStack(config *composeStackDeploymentConfig) !securitySettings.AllowContainerCapabilitiesForRegularUsers) && !isAdminOrEndpointAdmin { - composeFilePath := path.Join(config.stack.ProjectPath, config.stack.EntryPoint) - stackContent, err := handler.FileService.GetFileContent(composeFilePath) - if err != nil { - return err - } + 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 + } - err = handler.isValidStackFile(stackContent, securitySettings) - if err != nil { - return err + err = handler.isValidStackFile(stackContent, securitySettings) + if err != nil { + return err + } } } diff --git a/api/http/handler/stacks/create_swarm_stack.go b/api/http/handler/stacks/create_swarm_stack.go index e34df227e..dd5e9ac42 100644 --- a/api/http/handler/stacks/create_swarm_stack.go +++ b/api/http/handler/stacks/create_swarm_stack.go @@ -119,7 +119,9 @@ type swarmStackFromGitRepositoryPayload struct { // Password used in basic authentication. Required when RepositoryAuthentication is true. RepositoryPassword string `example:"myGitPassword"` // Path to the Stack file inside the Git repository - ComposeFilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"` + ComposeFile string `example:"docker-compose.yml" default:"docker-compose.yml"` + AdditionalFiles []string + AutoUpdate *portainer.StackAutoUpdate } func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) error { @@ -135,8 +137,8 @@ func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) err 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.ComposeFilePathInRepository) { - payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName + if err := validateStackAutoUpdate(payload.AutoUpdate); err != nil { + return err } return nil } @@ -145,29 +147,41 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, var payload swarmStackFromGitRepositoryPayload err := request.DecodeAndValidateJSONPayload(r, &payload) if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err} } isUnique, err := handler.checkUniqueName(endpoint, payload.Name, 0, true) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for name collision", err} + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for name collision", Err: err} } if !isUnique { - errorMessage := fmt.Sprintf("A stack with the name '%s' is already running", payload.Name) - return &httperror.HandlerError{http.StatusConflict, errorMessage, errors.New(errorMessage)} + 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} + } } stackID := handler.DataStore.Stack().GetNextIdentifier() stack := &portainer.Stack{ - ID: portainer.StackID(stackID), - Name: payload.Name, - Type: portainer.DockerSwarmStack, - SwarmID: payload.SwarmID, - EndpointID: endpoint.ID, - EntryPoint: payload.ComposeFilePathInRepository, - Env: payload.Env, - Status: portainer.StackStatusActive, - CreationDate: time.Now().Unix(), + ID: portainer.StackID(stackID), + Name: payload.Name, + Type: portainer.DockerSwarmStack, + SwarmID: payload.SwarmID, + EndpointID: endpoint.ID, + EntryPoint: payload.ComposeFile, + AdditionalFiles: payload.AdditionalFiles, + AutoUpdate: payload.AutoUpdate, + Env: payload.Env, + Status: portainer.StackStatusActive, + CreationDate: time.Now().Unix(), } projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID))) @@ -187,7 +201,7 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, err = handler.cloneGitRepository(gitCloneParams) 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) @@ -197,14 +211,14 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, err = handler.deploySwarmStack(config) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: err.Error(), Err: err} } stack.CreatedBy = config.user.Username err = handler.DataStore.Stack().CreateStack(stack) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the stack inside the database", Err: err} } doCleanUp = false @@ -360,16 +374,17 @@ func (handler *Handler) deploySwarmStack(config *swarmStackDeploymentConfig) err settings := &config.endpoint.SecuritySettings if !settings.AllowBindMountsForRegularUsers && !isAdminOrEndpointAdmin { - composeFilePath := path.Join(config.stack.ProjectPath, config.stack.EntryPoint) + 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 + } - stackContent, err := handler.FileService.GetFileContent(composeFilePath) - if err != nil { - return err - } - - err = handler.isValidStackFile(stackContent, settings) - if err != nil { - return err + err = handler.isValidStackFile(stackContent, settings) + if err != nil { + return err + } } } diff --git a/api/http/handler/stacks/handler.go b/api/http/handler/stacks/handler.go index f61952515..49f7d3403 100644 --- a/api/http/handler/stacks/handler.go +++ b/api/http/handler/stacks/handler.go @@ -11,14 +11,16 @@ 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") - errStackNotExternal = errors.New("Not an external stack") + 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") ) // Handler is the HTTP handler used to handle stack operations. @@ -155,3 +157,11 @@ 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 +} diff --git a/api/http/handler/stacks/helper.go b/api/http/handler/stacks/helper.go new file mode 100644 index 000000000..f0b4b929b --- /dev/null +++ b/api/http/handler/stacks/helper.go @@ -0,0 +1,27 @@ +package stacks + +import ( + "errors" + "time" + + "github.com/asaskevich/govalidator" + portainer "github.com/portainer/portainer/api" +) + +func validateStackAutoUpdate(autoUpdate *portainer.StackAutoUpdate) error { + if autoUpdate == nil { + return nil + } + if autoUpdate.Interval == "" && autoUpdate.Webhook == "" { + return errors.New("Both Interval and Webhook fields are empty") + } + if autoUpdate.Webhook != "" && !govalidator.IsUUID(autoUpdate.Webhook) { + return errors.New("Invalid Webhook format") + } + if autoUpdate.Interval != "" { + if _, err := time.ParseDuration(autoUpdate.Interval); err != nil { + return errors.New("Invalid Interval format") + } + } + return nil +} diff --git a/api/http/handler/stacks/helper_test.go b/api/http/handler/stacks/helper_test.go new file mode 100644 index 000000000..29cb4c8f0 --- /dev/null +++ b/api/http/handler/stacks/helper_test.go @@ -0,0 +1,31 @@ +package stacks + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/stretchr/testify/assert" +) + +func Test_ValidateStackAutoUpdate_Valid(t *testing.T) { + mock := &portainer.StackAutoUpdate{ + 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) + } +} + +func Test_ValidateStackAutoUpdate_InValid(t *testing.T) { + mock := &portainer.StackAutoUpdate{} + err := validateStackAutoUpdate(mock) + assert.Error(t, err) + mock.Webhook = "fake-web-hook" + err = validateStackAutoUpdate(mock) + assert.Error(t, err) + mock.Webhook = "" + mock.Interval = "1dd2hh3mm" + err = validateStackAutoUpdate(mock) + assert.Error(t, err) +} diff --git a/api/http/handler/stacks/stack_create.go b/api/http/handler/stacks/stack_create.go index 7da0fd386..410f78367 100644 --- a/api/http/handler/stacks/stack_create.go +++ b/api/http/handler/stacks/stack_create.go @@ -131,7 +131,7 @@ func (handler *Handler) createComposeStack(w http.ResponseWriter, r *http.Reques return handler.createComposeStackFromFileUpload(w, r, endpoint, userID) } - return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: method. Value must be one of: string, repository or file", errors.New(request.ErrInvalidQueryParameter)} + 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)} } 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{http.StatusBadRequest, "Invalid value for query parameter: method. Value must be one of: string, repository or file", errors.New(request.ErrInvalidQueryParameter)} + 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)} } func (handler *Handler) isValidStackFile(stackFileContent []byte, securitySettings *portainer.EndpointSecuritySettings) error { diff --git a/api/libcompose/compose_stack.go b/api/libcompose/compose_stack.go index 41da7239d..859159c7c 100644 --- a/api/libcompose/compose_stack.go +++ b/api/libcompose/compose_stack.go @@ -86,12 +86,14 @@ func (manager *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portain for _, envvar := range stack.Env { env[envvar.Name] = envvar.Value } - - composeFilePath := path.Join(stack.ProjectPath, stack.EntryPoint) + var composeFiles []string + for _, file := range append([]string{stack.EntryPoint}, stack.AdditionalFiles...) { + composeFiles = append(composeFiles, path.Join(stack.ProjectPath, file)) + } proj, err := docker.NewProject(&ctx.Context{ ConfigDir: manager.dataPath, Context: project.Context{ - ComposeFiles: []string{composeFilePath}, + ComposeFiles: composeFiles, EnvironmentLookup: &lookup.ComposableEnvLookup{ Lookups: []config.EnvironmentLookup{ &lookup.EnvfileLookup{ @@ -120,10 +122,13 @@ func (manager *ComposeStackManager) Down(stack *portainer.Stack, endpoint *porta return err } - composeFilePath := path.Join(stack.ProjectPath, stack.EntryPoint) + var composeFiles []string + for _, file := range append([]string{stack.EntryPoint}, stack.AdditionalFiles...) { + composeFiles = append(composeFiles, path.Join(stack.ProjectPath, file)) + } proj, err := docker.NewProject(&ctx.Context{ Context: project.Context{ - ComposeFiles: []string{composeFilePath}, + ComposeFiles: composeFiles, ProjectName: stack.Name, }, ClientFactory: clientFactory, @@ -134,3 +139,11 @@ 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 +} diff --git a/api/portainer.go b/api/portainer.go index 282a8b5f4..ccaf89101 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -681,6 +681,10 @@ type ( SwarmID string `json:"SwarmId" example:"jpofkc0i9uo9wtx1zesuk649w"` // Path to the Stack file EntryPoint string `json:"EntryPoint" example:"docker-compose.yml"` + // Additional Stack files + AdditionalFiles []string `json:"AdditionalFiles"` + // Stack auto update settings + AutoUpdate *StackAutoUpdate `json:"AutoUpdate"` // A list of environment variables used during stack deployment Env []Pair `json:"Env" example:""` // @@ -699,6 +703,12 @@ type ( UpdatedBy string `example:"bob"` } + //StackAutoUpdate represents the git auto sync config for stack deployment + StackAutoUpdate struct { + Interval string + Webhook string //a UUID generated from client + } + // StackID represents a stack identifier (it must be composed of Name + "_" + SwarmID to create a unique identifier) StackID int @@ -1238,6 +1248,7 @@ 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