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 @@ -
-
- -
-
-
- - -
-
- - -
-
-
+ + + {{ $select.selected.Name }} + + + {{ endpoint.Name }} + + diff --git a/app/portainer/components/endpoint-selector/endpointSelectorController.js b/app/portainer/components/endpoint-selector/endpointSelectorController.js index fab3193cc..23770e71f 100644 --- a/app/portainer/components/endpoint-selector/endpointSelectorController.js +++ b/app/portainer/components/endpoint-selector/endpointSelectorController.js @@ -2,21 +2,22 @@ angular.module('portainer.app') .controller('EndpointSelectorController', function () { var ctrl = this; - this.state = { - show: false, - selectedGroup: null, - selectedEndpoint: null + this.sortGroups = function(groups) { + return _.sortBy(groups, ['name']); }; - this.selectGroup = function() { - this.availableEndpoints = this.endpoints.filter(function f(endpoint) { - return endpoint.GroupId === ctrl.state.selectedGroup.Id; - }); + this.groupEndpoints = function(endpoint) { + for (var i = 0; i < ctrl.availableGroups.length; i++) { + var group = ctrl.availableGroups[i]; + + if (endpoint.GroupId === group.Id) { + return group.Name; + } + } }; this.$onInit = function() { this.availableGroups = filterEmptyGroups(this.groups, this.endpoints); - this.availableEndpoints = this.endpoints; }; function filterEmptyGroups(groups, endpoints) { diff --git a/app/portainer/components/sidebar-endpoint-selector/sidebar-endpoint-selector.js b/app/portainer/components/sidebar-endpoint-selector/sidebar-endpoint-selector.js new file mode 100644 index 000000000..32f5ec116 --- /dev/null +++ b/app/portainer/components/sidebar-endpoint-selector/sidebar-endpoint-selector.js @@ -0,0 +1,9 @@ +angular.module('portainer.app').component('sidebarEndpointSelector', { + templateUrl: 'app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelector.html', + controller: 'SidebarEndpointSelectorController', + bindings: { + 'endpoints': '<', + 'groups': '<', + 'selectEndpoint': '<' + } +}); diff --git a/app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelector.html b/app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelector.html new file mode 100644 index 000000000..79332e2b0 --- /dev/null +++ b/app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelector.html @@ -0,0 +1,27 @@ +
+
+ +
+
+
+ + +
+
+ + +
+
+
diff --git a/app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelectorController.js b/app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelectorController.js new file mode 100644 index 000000000..ff8d54a57 --- /dev/null +++ b/app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelectorController.js @@ -0,0 +1,34 @@ +angular.module('portainer.app') +.controller('SidebarEndpointSelectorController', function () { + var ctrl = this; + + this.state = { + show: false, + selectedGroup: null, + selectedEndpoint: null + }; + + this.selectGroup = function() { + this.availableEndpoints = this.endpoints.filter(function f(endpoint) { + return endpoint.GroupId === ctrl.state.selectedGroup.Id; + }); + }; + + this.$onInit = function() { + this.availableGroups = filterEmptyGroups(this.groups, this.endpoints); + this.availableEndpoints = this.endpoints; + }; + + function filterEmptyGroups(groups, endpoints) { + return groups.filter(function f(group) { + for (var i = 0; i < endpoints.length; i++) { + + var endpoint = endpoints[i]; + if (endpoint.GroupId === group.Id) { + return true; + } + } + return false; + }); + } +}); diff --git a/app/portainer/models/stack.js b/app/portainer/models/stack.js index 06666b588..813027f97 100644 --- a/app/portainer/models/stack.js +++ b/app/portainer/models/stack.js @@ -4,6 +4,7 @@ function StackViewModel(data) { this.Name = data.Name; this.Checked = false; this.EndpointId = data.EndpointId; + this.SwarmId = data.SwarmId; this.Env = data.Env ? data.Env : []; if (data.ResourceControl && data.ResourceControl.Id !== 0) { this.ResourceControl = new ResourceControlViewModel(data.ResourceControl); diff --git a/app/portainer/rest/stack.js b/app/portainer/rest/stack.js index 3d1efc5af..d1de2da1e 100644 --- a/app/portainer/rest/stack.js +++ b/app/portainer/rest/stack.js @@ -8,6 +8,7 @@ angular.module('portainer.app') create: { method: 'POST', ignoreLoadingBar: true }, update: { method: 'PUT', params: { id: '@id' }, ignoreLoadingBar: true }, remove: { method: 'DELETE', params: { id: '@id', external: '@external', endpointId: '@endpointId' } }, - getStackFile: { method: 'GET', params: { id : '@id', action: 'file' } } + getStackFile: { method: 'GET', params: { id : '@id', action: 'file' } }, + migrate: { method: 'POST', params: { id : '@id', action: 'migrate' }, ignoreLoadingBar: true } }); }]); diff --git a/app/portainer/services/api/stackService.js b/app/portainer/services/api/stackService.js index 66f2fd6bb..8a70bfc0c 100644 --- a/app/portainer/services/api/stackService.js +++ b/app/portainer/services/api/stackService.js @@ -1,6 +1,6 @@ angular.module('portainer.app') -.factory('StackService', ['$q', 'Stack', 'ResourceControlService', 'FileUploadService', 'StackHelper', 'ServiceService', 'ContainerService', 'SwarmService', -function StackServiceFactory($q, Stack, ResourceControlService, FileUploadService, StackHelper, ServiceService, ContainerService, SwarmService) { +.factory('StackService', ['$q', 'Stack', 'ResourceControlService', 'FileUploadService', 'StackHelper', 'ServiceService', 'ContainerService', 'SwarmService', 'EndpointProvider', +function StackServiceFactory($q, Stack, ResourceControlService, FileUploadService, StackHelper, ServiceService, ContainerService, SwarmService, EndpointProvider) { 'use strict'; var service = {}; @@ -33,6 +33,51 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic return deferred.promise; }; + service.migrateSwarmStack = function(stack, targetEndpointId) { + var deferred = $q.defer(); + + EndpointProvider.setEndpointID(targetEndpointId); + + SwarmService.swarm() + .then(function success(data) { + var swarm = data; + if (swarm.Id === stack.SwarmId) { + deferred.reject({ msg: 'Target endpoint is located in the same Swarm cluster as the current endpoint', err: null }); + return; + } + + return Stack.migrate({ id: stack.Id }, { EndpointID: targetEndpointId, SwarmID: swarm.Id }).$promise; + }) + .then(function success(data) { + deferred.resolve(); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to migrate stack', err: err }); + }) + .finally(function final() { + EndpointProvider.setEndpointID(stack.EndpointId); + }); + + return deferred.promise; + }; + + service.migrateComposeStack = function(stack, targetEndpointId) { + var deferred = $q.defer(); + + EndpointProvider.setEndpointID(targetEndpointId); + + Stack.migrate({ id: stack.Id }, { EndpointID: targetEndpointId }).$promise + .then(function success(data) { + deferred.resolve(); + }) + .catch(function error(err) { + EndpointProvider.setEndpointID(stack.EndpointId); + deferred.reject({ msg: 'Unable to migrate stack', err: err }); + }); + + return deferred.promise; + }; + service.stacks = function(compose, swarm, endpointId) { var deferred = $q.defer(); diff --git a/app/portainer/views/sidebar/sidebar.html b/app/portainer/views/sidebar/sidebar.html index c9450da0d..32c5bed32 100644 --- a/app/portainer/views/sidebar/sidebar.html +++ b/app/portainer/views/sidebar/sidebar.html @@ -9,11 +9,11 @@