diff --git a/api/http/handler/stacks/handler.go b/api/http/handler/stacks/handler.go index d907d52d2..ba231ebfe 100644 --- a/api/http/handler/stacks/handler.go +++ b/api/http/handler/stacks/handler.go @@ -47,5 +47,7 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackUpdate))).Methods(http.MethodPut) h.Handle("/stacks/{id}/file", bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackFile))).Methods(http.MethodGet) + h.Handle("/stacks/{id}/migrate", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackMigrate))).Methods(http.MethodPost) return h } diff --git a/api/http/handler/stacks/stack_migrate.go b/api/http/handler/stacks/stack_migrate.go new file mode 100644 index 000000000..33d410b7b --- /dev/null +++ b/api/http/handler/stacks/stack_migrate.go @@ -0,0 +1,132 @@ +package stacks + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type stackMigratePayload struct { + EndpointID int + SwarmID string +} + +func (payload *stackMigratePayload) Validate(r *http.Request) error { + if payload.EndpointID == 0 { + return portainer.Error("Invalid endpoint identifier. Must be a positive number") + } + return nil +} + +// POST request on /api/stacks/:id/migrate +func (handler *Handler) stackMigrate(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} + } + + var payload stackMigratePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + stack, err := handler.StackService.Stack(portainer.StackID(stackID)) + if err == portainer.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} + } + + resourceControl, err := handler.ResourceControlService.ResourceControlByResourceID(stack.Name) + if err != nil && err != portainer.ErrObjectNotFound { + 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} + } + + if resourceControl != nil { + if !securityContext.IsAdmin && !proxy.CanAccessStack(stack, resourceControl, securityContext.UserID, securityContext.UserMemberships) { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} + } + } + + endpoint, err := handler.EndpointService.Endpoint(stack.EndpointID) + if err == portainer.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} + } + + targetEndpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(payload.EndpointID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + stack.EndpointID = portainer.EndpointID(payload.EndpointID) + if payload.SwarmID != "" { + stack.SwarmID = payload.SwarmID + } + + migrationError := handler.migrateStack(r, stack, targetEndpoint) + if migrationError != nil { + return migrationError + } + + err = handler.deleteStack(stack, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + err = handler.StackService.UpdateStack(stack.ID, stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack changes inside the database", err} + } + + return response.JSON(w, stack) +} + +func (handler *Handler) migrateStack(r *http.Request, stack *portainer.Stack, next *portainer.Endpoint) *httperror.HandlerError { + if stack.Type == portainer.DockerSwarmStack { + return handler.migrateSwarmStack(r, stack, next) + } + return handler.migrateComposeStack(r, stack, next) +} + +func (handler *Handler) migrateComposeStack(r *http.Request, stack *portainer.Stack, next *portainer.Endpoint) *httperror.HandlerError { + config, configErr := handler.createComposeDeployConfig(r, stack, next) + if configErr != nil { + return configErr + } + + err := handler.deployComposeStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + return nil +} + +func (handler *Handler) migrateSwarmStack(r *http.Request, stack *portainer.Stack, next *portainer.Endpoint) *httperror.HandlerError { + config, configErr := handler.createSwarmDeployConfig(r, stack, next, true) + if configErr != nil { + return configErr + } + + err := handler.deploySwarmStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + return nil +} diff --git a/app/__module.js b/app/__module.js index a1c438489..e1cf659fa 100644 --- a/app/__module.js +++ b/app/__module.js @@ -1,6 +1,7 @@ angular.module('portainer', [ 'ui.bootstrap', 'ui.router', + 'ui.select', 'isteven-multi-select', 'ngCookies', 'ngSanitize', diff --git a/app/docker/rest/swarm.js b/app/docker/rest/swarm.js index b8f83fd93..de4cc85a4 100644 --- a/app/docker/rest/swarm.js +++ b/app/docker/rest/swarm.js @@ -5,6 +5,6 @@ angular.module('portainer.docker') endpointId: EndpointProvider.endpointID }, { - get: {method: 'GET'} + get: { method: 'GET' } }); }]); diff --git a/app/portainer/components/endpoint-selector/endpoint-selector.js b/app/portainer/components/endpoint-selector/endpoint-selector.js index cb4ddc207..82b54e212 100644 --- a/app/portainer/components/endpoint-selector/endpoint-selector.js +++ b/app/portainer/components/endpoint-selector/endpoint-selector.js @@ -2,8 +2,8 @@ angular.module('portainer.app').component('endpointSelector', { templateUrl: 'app/portainer/components/endpoint-selector/endpointSelector.html', controller: 'EndpointSelectorController', bindings: { + 'model': '=', 'endpoints': '<', - 'groups': '<', - 'selectEndpoint': '<' + 'groups': '<' } }); diff --git a/app/portainer/components/endpoint-selector/endpointSelector.html b/app/portainer/components/endpoint-selector/endpointSelector.html index 79332e2b0..c3800195f 100644 --- a/app/portainer/components/endpoint-selector/endpointSelector.html +++ b/app/portainer/components/endpoint-selector/endpointSelector.html @@ -1,27 +1,8 @@ -