From 9b4870d57e97ec6dedf29147726d335187efb498 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Mon, 1 Oct 2018 04:36:49 +0300 Subject: [PATCH] feat(stack-details): Add the ability to duplicate a stack (#2278) * feat(stack-details): add duplicate-stack button * feat(stack-details): add stack-duplication-form component * feat(stack-details): add duplicate stack method on controller * feat(stack-details): add duplicate stack method * feat(stack-details): remove old duplication in progress flag * feat(stack-details): combine migration and duplication forms * feat(stack-details): pass new stack name to server * feat(stack-details): add option to rename migrated stack * feat(stack-details): disable both migrate/duplicate buttons * feat(stack-details): disable migration button on same endpoint * feat(stack-details): change duplicate icon * style(stack-details): remove whitespaces and fix pattern * feat(stack-details): add name to migration payload in swagger.yml * style(stack-details): add semicolon * bug(stack-details): toggle endpoints before and after duplication --- api/http/handler/stacks/stack_migrate.go | 7 ++ api/swagger.yaml | 4 + .../stack-duplication-form-controller.js | 76 +++++++++++++++++++ .../stack-duplication-form.html | 43 +++++++++++ .../stack-duplication-form.js | 12 +++ app/portainer/services/api/stackService.js | 18 +++-- app/portainer/views/stacks/edit/stack.html | 34 +++------ .../views/stacks/edit/stackController.js | 65 ++++++++++------ 8 files changed, 205 insertions(+), 54 deletions(-) create mode 100644 app/portainer/components/stack-duplication-form/stack-duplication-form-controller.js create mode 100644 app/portainer/components/stack-duplication-form/stack-duplication-form.html create mode 100644 app/portainer/components/stack-duplication-form/stack-duplication-form.js diff --git a/api/http/handler/stacks/stack_migrate.go b/api/http/handler/stacks/stack_migrate.go index 8a0ec0c69..704394766 100644 --- a/api/http/handler/stacks/stack_migrate.go +++ b/api/http/handler/stacks/stack_migrate.go @@ -14,6 +14,7 @@ import ( type stackMigratePayload struct { EndpointID int SwarmID string + Name string } func (payload *stackMigratePayload) Validate(r *http.Request) error { @@ -89,11 +90,17 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht stack.SwarmID = payload.SwarmID } + oldName := stack.Name + if payload.Name != "" { + stack.Name = payload.Name + } + migrationError := handler.migrateStack(r, stack, targetEndpoint) if migrationError != nil { return migrationError } + stack.Name = oldName err = handler.deleteStack(stack, endpoint) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} diff --git a/api/swagger.yaml b/api/swagger.yaml index 1fda7d40f..ba5745801 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -4160,6 +4160,10 @@ definitions: type: "string" example: "jpofkc0i9uo9wtx1zesuk649w" description: "Swarm cluster identifier, must match the identifier of the cluster where the stack will be relocated" + Name: + type: "string" + example: "new-stack" + description: "If provided will rename the migrated stack" StackCreateRequest: type: "object" required: diff --git a/app/portainer/components/stack-duplication-form/stack-duplication-form-controller.js b/app/portainer/components/stack-duplication-form/stack-duplication-form-controller.js new file mode 100644 index 000000000..4e96696e2 --- /dev/null +++ b/app/portainer/components/stack-duplication-form/stack-duplication-form-controller.js @@ -0,0 +1,76 @@ +angular.module('portainer.app').controller('StackDuplicationFormController', [ + 'Notifications', + function StackDuplicationFormController(Notifications) { + var ctrl = this; + + ctrl.state = { + duplicationInProgress: false, + migrationInProgress: false + }; + + ctrl.formValues = { + endpoint: null, + newName: '' + }; + + ctrl.isFormValidForDuplication = isFormValidForDuplication; + ctrl.isFormValidForMigration = isFormValidForMigration; + ctrl.duplicateStack = duplicateStack; + ctrl.migrateStack = migrateStack; + ctrl.isMigrationButtonDisabled = isMigrationButtonDisabled; + + function isFormValidForMigration() { + return ctrl.formValues.endpoint && ctrl.formValues.endpoint.Id; + } + + function isFormValidForDuplication() { + return isFormValidForMigration() && ctrl.formValues.newName; + } + + function duplicateStack() { + if (!ctrl.formValues.newName) { + Notifications.error( + 'Failure', + null, + 'Stack name is required for duplication' + ); + return; + } + ctrl.state.duplicationInProgress = true; + ctrl.onDuplicate({ + endpointId: ctrl.formValues.endpoint.Id, + name: ctrl.formValues.newName ? ctrl.formValues.newName : undefined + }) + .finally(function() { + ctrl.state.duplicationInProgress = false; + }); + } + + function migrateStack() { + ctrl.state.migrationInProgress = true; + ctrl.onMigrate({ + endpointId: ctrl.formValues.endpoint.Id, + name: ctrl.formValues.newName ? ctrl.formValues.newName : undefined + }) + .finally(function() { + ctrl.state.migrationInProgress = false; + }); + } + + function isMigrationButtonDisabled() { + return ( + !ctrl.isFormValidForMigration() || + ctrl.state.duplicationInProgress || + ctrl.state.migrationInProgress || + isTargetEndpointAndCurrentEquals() + ); + } + + function isTargetEndpointAndCurrentEquals() { + return ( + ctrl.formValues.endpoint && + ctrl.formValues.endpoint.Id === ctrl.currentEndpointId + ); + } + } +]); diff --git a/app/portainer/components/stack-duplication-form/stack-duplication-form.html b/app/portainer/components/stack-duplication-form/stack-duplication-form.html new file mode 100644 index 000000000..6e270b7b0 --- /dev/null +++ b/app/portainer/components/stack-duplication-form/stack-duplication-form.html @@ -0,0 +1,43 @@ +
+
+ Stack duplication / migration +
+
+ +

+ This feature allows you to duplicate or migrate this stack. +

+
+
+
+ +
+ + + +
+
+
\ No newline at end of file diff --git a/app/portainer/components/stack-duplication-form/stack-duplication-form.js b/app/portainer/components/stack-duplication-form/stack-duplication-form.js new file mode 100644 index 000000000..7f6180c39 --- /dev/null +++ b/app/portainer/components/stack-duplication-form/stack-duplication-form.js @@ -0,0 +1,12 @@ +angular.module('portainer.app').component('stackDuplicationForm', { + templateUrl: + 'app/portainer/components/stack-duplication-form/stack-duplication-form.html', + controller: 'StackDuplicationFormController', + bindings: { + onDuplicate: '&', + onMigrate: '&', + endpoints: '<', + groups: '<', + currentEndpointId: '<' + } +}); diff --git a/app/portainer/services/api/stackService.js b/app/portainer/services/api/stackService.js index dafbc4379..37b7c10a9 100644 --- a/app/portainer/services/api/stackService.js +++ b/app/portainer/services/api/stackService.js @@ -4,6 +4,7 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic 'use strict'; var service = {}; + service.stack = function(id) { var deferred = $q.defer(); @@ -33,7 +34,7 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic return deferred.promise; }; - service.migrateSwarmStack = function(stack, targetEndpointId) { + service.migrateSwarmStack = function(stack, targetEndpointId, newName) { var deferred = $q.defer(); EndpointProvider.setEndpointID(targetEndpointId); @@ -45,8 +46,7 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic 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: stack.EndpointId }, { EndpointID: targetEndpointId, SwarmID: swarm.Id }).$promise; + return Stack.migrate({ id: stack.Id, endpointId: stack.EndpointId }, { EndpointID: targetEndpointId, SwarmID: swarm.Id, Name: newName }).$promise; }) .then(function success() { deferred.resolve(); @@ -61,12 +61,12 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic return deferred.promise; }; - service.migrateComposeStack = function(stack, targetEndpointId) { + service.migrateComposeStack = function(stack, targetEndpointId, newName) { var deferred = $q.defer(); EndpointProvider.setEndpointID(targetEndpointId); - Stack.migrate({ id: stack.Id, endpointId: stack.EndpointId }, { EndpointID: targetEndpointId }).$promise + Stack.migrate({ id: stack.Id, endpointId: stack.EndpointId }, { EndpointID: targetEndpointId, Name: newName }).$promise .then(function success() { deferred.resolve(); }) @@ -258,8 +258,7 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic var deferred = $q.defer(); SwarmService.swarm() - .then(function success(data) { - var swarm = data; + .then(function success(swarm) { var payload = { Name: name, SwarmID: swarm.Id, @@ -321,5 +320,10 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic return deferred.promise; }; + service.duplicateStack = function duplicateStack(name, stackFileContent, env, endpointId, type) { + var action = type === 1 ? service.createSwarmStackFromFileContent : service.createComposeStackFromFileContent; + return action(name, stackFileContent, env, endpointId); + }; + return service; }]); diff --git a/app/portainer/views/stacks/edit/stack.html b/app/portainer/views/stacks/edit/stack.html index 85defd1e2..7a0c1427e 100644 --- a/app/portainer/views/stacks/edit/stack.html +++ b/app/portainer/views/stacks/edit/stack.html @@ -46,31 +46,15 @@ - -
-
- Stack migration -
-
- -

- This feature allows you to migrate this stack to an alternate compatible endpoint. -

-
-
- - -
-
-
- + + diff --git a/app/portainer/views/stacks/edit/stackController.js b/app/portainer/views/stacks/edit/stackController.js index 50a8651b2..55eba59c4 100644 --- a/app/portainer/views/stacks/edit/stackController.js +++ b/app/portainer/views/stacks/edit/stackController.js @@ -14,24 +14,47 @@ function ($q, $scope, $state, $transition$, StackService, NodeService, ServiceSe Endpoint: null }; + $scope.duplicateStack = function duplicateStack(name, endpointId) { + var stack = $scope.stack; + var env = FormHelper.removeInvalidEnvVars(stack.Env); + EndpointProvider.setEndpointID(endpointId); + + return StackService.duplicateStack(name, $scope.stackFileContent, env, endpointId, stack.Type) + .then(onDuplicationSuccess) + .catch(notifyOnError); + + function onDuplicationSuccess() { + Notifications.success('Stack successfully duplicated'); + $state.go('portainer.stacks', {}, { reload: true }); + EndpointProvider.setEndpointID(stack.EndpointId); + + } + + function notifyOnError(err) { + Notifications.error('Failure', err, 'Unable to duplicate stack'); + } + }; + $scope.showEditor = function() { $scope.state.showEditorTab = true; }; - $scope.migrateStack = function() { - ModalService.confirm({ - title: 'Are you sure?', - message: 'This action will deploy a new instance of this stack on the target endpoint, please note that this does NOT relocate the content of any persistent volumes that may be attached to this stack.', - buttons: { - confirm: { - label: 'Migrate', - className: 'btn-danger' + $scope.migrateStack = function (name, endpointId) { + return $q(function (resolve) { + ModalService.confirm({ + title: 'Are you sure?', + message: 'This action will deploy a new instance of this stack on the target endpoint, please note that this does NOT relocate the content of any persistent volumes that may be attached to this stack.', + buttons: { + confirm: { + label: 'Migrate', + className: 'btn-danger' + } + }, + callback: function onConfirm(confirmed) { + if (!confirmed) { return resolve(); } + return resolve(migrateStack(name, endpointId)); } - }, - callback: function onConfirm(confirmed) { - if(!confirmed) { return; } - migrateStack(); - } + }); }); }; @@ -45,9 +68,9 @@ function ($q, $scope, $state, $transition$, StackService, NodeService, ServiceSe ); }; - function migrateStack() { + function migrateStack(name, endpointId) { var stack = $scope.stack; - var targetEndpointId = $scope.formValues.Endpoint.Id; + var targetEndpointId = endpointId; var migrateRequest = StackService.migrateSwarmStack; if (stack.Type === 2) { @@ -58,13 +81,13 @@ function ($q, $scope, $state, $transition$, StackService, NodeService, ServiceSe // The EndpointID property is not available for these stacks, we can pass // the current endpoint identifier as a part of the migrate request. It will be used if // the EndpointID property is not defined on the stack. - var endpointId = EndpointProvider.endpointID(); + var originalEndpointId = EndpointProvider.endpointID(); if (stack.EndpointId === 0) { - stack.EndpointId = endpointId; + stack.EndpointId = originalEndpointId; } $scope.state.migrationInProgress = true; - migrateRequest(stack, targetEndpointId) + return migrateRequest(stack, targetEndpointId, name) .then(function success() { Notifications.success('Stack successfully migrated', stack.Name); $state.go('portainer.stacks', {}, {reload: true}); @@ -134,7 +157,6 @@ function ($q, $scope, $state, $transition$, StackService, NodeService, ServiceSe function loadStack(id) { var agentProxy = $scope.applicationState.endpoint.mode.agentProxy; - var endpointId = EndpointProvider.endpointID(); $q.all({ stack: StackService.stack(id), @@ -143,9 +165,7 @@ function ($q, $scope, $state, $transition$, StackService, NodeService, ServiceSe }) .then(function success(data) { var stack = data.stack; - $scope.endpoints = data.endpoints.filter(function(endpoint) { - return endpoint.Id !== endpointId; - }); + $scope.endpoints = data.endpoints; $scope.groups = data.groups; $scope.stack = stack; @@ -256,6 +276,7 @@ function ($q, $scope, $state, $transition$, StackService, NodeService, ServiceSe var stackName = $transition$.params().name; $scope.stackName = stackName; var external = $transition$.params().external; + $scope.currentEndpointId = EndpointProvider.endpointID(); if (external === 'true') { $scope.state.externalStack = true;