diff --git a/api/exec/compose_stack.go b/api/exec/compose_stack.go index 5daf80ece..a1d27f21f 100644 --- a/api/exec/compose_stack.go +++ b/api/exec/compose_stack.go @@ -77,6 +77,11 @@ func (w *ComposeStackManager) Down(stack *portainer.Stack, endpoint *portainer.E return err } +// NormalizeStackName returns the passed stack name, for interface implementation only +func (w *ComposeStackManager) NormalizeStackName(name string) string { + return name +} + func stackFilePath(stack *portainer.Stack) string { return path.Join(stack.ProjectPath, stack.EntryPoint) } diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go index 1ace49de2..271012a82 100644 --- a/api/http/handler/stacks/create_compose_stack.go +++ b/api/http/handler/stacks/create_compose_stack.go @@ -5,9 +5,7 @@ import ( "fmt" "net/http" "path" - "regexp" "strconv" - "strings" "time" "github.com/asaskevich/govalidator" @@ -19,13 +17,6 @@ import ( "github.com/portainer/portainer/api/http/useractivity" ) -// this is coming from libcompose -// https://github.com/portainer/libcompose/blob/master/project/context.go#L117-L120 -func normalizeStackName(name string) string { - r := regexp.MustCompile("[^a-z0-9]+") - return r.ReplaceAllString(strings.ToLower(name), "") -} - type composeStackFromFileContentPayload struct { Name string StackFileContent string @@ -36,7 +27,7 @@ func (payload *composeStackFromFileContentPayload) Validate(r *http.Request) err if govalidator.IsNull(payload.Name) { return errors.New("Invalid stack name") } - payload.Name = normalizeStackName(payload.Name) + if govalidator.IsNull(payload.StackFileContent) { return errors.New("Invalid stack file content") } @@ -47,9 +38,11 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter, var payload composeStackFromFileContentPayload 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} } + payload.Name = handler.ComposeStackManager.NormalizeStackName(payload.Name) + isUnique, err := handler.checkUniqueName(endpoint, payload.Name, 0, false) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for name collision", err} @@ -73,7 +66,7 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter, stackFolder := strconv.Itoa(int(stack.ID)) projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Compose file on disk", err} + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist Compose file on disk", Err: err} } stack.ProjectPath = projectPath @@ -87,14 +80,14 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter, err = handler.deployComposeStack(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} } useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, payload) @@ -118,16 +111,14 @@ func (payload *composeStackFromGitRepositoryPayload) Validate(r *http.Request) e if govalidator.IsNull(payload.Name) { return errors.New("Invalid stack name") } - payload.Name = normalizeStackName(payload.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.ComposeFilePathInRepository) { - payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName - } + return nil } @@ -135,7 +126,12 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite var payload composeStackFromGitRepositoryPayload 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} + } + + payload.Name = handler.ComposeStackManager.NormalizeStackName(payload.Name) + if payload.ComposeFilePathInRepository == "" { + payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName } isUnique, err := handler.checkUniqueName(endpoint, payload.Name, 0, false) @@ -175,7 +171,7 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite 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.createComposeDeployConfig(r, stack, endpoint) @@ -185,14 +181,14 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite err = handler.deployComposeStack(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} } useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, payload) @@ -207,35 +203,37 @@ type composeStackFromFileUploadPayload struct { Env []portainer.Pair } -func (payload *composeStackFromFileUploadPayload) Validate(r *http.Request) error { +func decodeRequestForm(r *http.Request) (*composeStackFromFileUploadPayload, error) { + payload := &composeStackFromFileUploadPayload{} name, err := request.RetrieveMultiPartFormValue(r, "Name", false) if err != nil { - return errors.New("Invalid stack name") + return nil, errors.New("Invalid stack name") } - payload.Name = normalizeStackName(name) + payload.Name = name composeFileContent, _, err := request.RetrieveMultiPartFormFile(r, "file") if err != nil { - return errors.New("Invalid Compose file. Ensure that the Compose file is uploaded correctly") + return nil, errors.New("Invalid Compose file. Ensure that the Compose file is uploaded correctly") } payload.StackFileContent = composeFileContent var env []portainer.Pair err = request.RetrieveMultiPartFormJSONValue(r, "Env", &env, true) if err != nil { - return errors.New("Invalid Env parameter") + return nil, errors.New("Invalid Env parameter") } payload.Env = env - return nil + return payload, nil } func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError { - payload := &composeStackFromFileUploadPayload{} - err := payload.Validate(r) + payload, err := decodeRequestForm(r) if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err} } + payload.Name = handler.ComposeStackManager.NormalizeStackName(payload.Name) + isUnique, err := handler.checkUniqueName(endpoint, payload.Name, 0, false) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for name collision", err} @@ -259,7 +257,7 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter, stackFolder := strconv.Itoa(int(stack.ID)) projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, payload.StackFileContent) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Compose file on disk", err} + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist Compose file on disk", Err: err} } stack.ProjectPath = projectPath @@ -273,14 +271,14 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter, err = handler.deployComposeStack(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} } useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, payload) @@ -301,23 +299,23 @@ type composeStackDeploymentConfig struct { func (handler *Handler) createComposeDeployConfig(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) (*composeStackDeploymentConfig, *httperror.HandlerError) { securityContext, err := security.RetrieveRestrictedRequestContext(r) if err != nil { - return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + return nil, &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err} } dockerhub, err := handler.DataStore.DockerHub().DockerHub() if err != nil { - return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub details from the database", err} + return nil, &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve DockerHub details from the database", Err: err} } registries, err := handler.DataStore.Registry().Registries() if err != nil { - return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} + return nil, &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve registries from the database", Err: err} } filteredRegistries := security.FilterRegistries(registries, securityContext) user, err := handler.DataStore.User().User(securityContext.UserID) if err != nil { - return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to load user information from the database", err} + return nil, &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to load user information from the database", Err: err} } config := &composeStackDeploymentConfig{ diff --git a/api/libcompose/compose_stack.go b/api/libcompose/compose_stack.go index 4ac6ebdb9..a1f3b2bfd 100644 --- a/api/libcompose/compose_stack.go +++ b/api/libcompose/compose_stack.go @@ -5,6 +5,8 @@ import ( "fmt" "path" "path/filepath" + "regexp" + "strings" "github.com/portainer/libcompose/config" "github.com/portainer/libcompose/docker" @@ -124,3 +126,9 @@ func (manager *ComposeStackManager) Down(stack *portainer.Stack, endpoint *porta return proj.Down(context.Background(), options.Down{RemoveVolume: false, RemoveOrphans: true}) } + +// NormalizeStackName returns a new stack name with unsupported characters replaced +func (manager *ComposeStackManager) NormalizeStackName(name string) string { + r := regexp.MustCompile("[^a-z0-9]+") + return r.ReplaceAllString(strings.ToLower(name), "") +} diff --git a/api/portainer.go b/api/portainer.go index 637f23ca0..1c6bac7ee 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -938,6 +938,7 @@ type ( // ComposeStackManager represents a service to manage Compose stacks ComposeStackManager interface { ComposeSyntaxMaxVersion() string + NormalizeStackName(name string) string Up(stack *Stack, endpoint *Endpoint) error Down(stack *Stack, endpoint *Endpoint) error }