From 4d5836138bb75383d013543b207aebcccf86bfba Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Tue, 4 Aug 2020 01:18:53 +0300 Subject: [PATCH] feat(stacks): add the ability to stop a stack (#4042) * feat(stacks): add stack status * feat(stacks): add empty start/stop handlers * feat(stacks): show start/stop button * feat(stacks): implement stack stop * feat(stacks): implement start stack * feat(stacks): filter by active/inactive stacks * fix(stacks): update authorizations for stack start/stop * feat(stacks): assign default status on create * fix(bolt): fix import * fix(stacks): show external stacks * fix(stacks): reload on stop/start * feat(stacks): confirm before stop --- api/bolt/migrator/migrate_dbversion23.go | 20 ++++++ api/bolt/migrator/migrator.go | 5 ++ .../handler/stacks/create_compose_stack.go | 3 + api/http/handler/stacks/create_swarm_stack.go | 3 + api/http/handler/stacks/handler.go | 4 ++ api/http/handler/stacks/stack_start.go | 61 +++++++++++++++++++ api/http/handler/stacks/stack_stop.go | 61 +++++++++++++++++++ api/portainer.go | 11 ++++ .../stacks-datatable/stacksDatatable.html | 31 +++++++++- .../stacksDatatableController.js | 22 +++++++ app/portainer/models/stack.js | 1 + app/portainer/rest/stack.js | 2 + app/portainer/services/api/stackService.js | 10 +++ app/portainer/services/modalService.js | 7 +++ app/portainer/views/stacks/edit/stack.html | 30 ++++++++- .../views/stacks/edit/stackController.js | 59 ++++++++++++++++-- 16 files changed, 322 insertions(+), 8 deletions(-) create mode 100644 api/http/handler/stacks/stack_start.go create mode 100644 api/http/handler/stacks/stack_stop.go diff --git a/api/bolt/migrator/migrate_dbversion23.go b/api/bolt/migrator/migrate_dbversion23.go index 856e77856..5d1c56904 100644 --- a/api/bolt/migrator/migrate_dbversion23.go +++ b/api/bolt/migrator/migrate_dbversion23.go @@ -1,5 +1,7 @@ package migrator +import portainer "github.com/portainer/portainer/api" + func (m *Migrator) updateSettingsToDB24() error { legacySettings, err := m.settingsService.Settings() if err != nil { @@ -12,3 +14,21 @@ func (m *Migrator) updateSettingsToDB24() error { return m.settingsService.UpdateSettings(legacySettings) } + +func (m *Migrator) updateStacksToDB24() error { + stacks, err := m.stackService.Stacks() + if err != nil { + return err + } + + for idx := range stacks { + stack := &stacks[idx] + stack.Status = portainer.StackStatusActive + err := m.stackService.UpdateStack(stack.ID, stack) + if err != nil { + return err + } + } + + return nil +} diff --git a/api/bolt/migrator/migrator.go b/api/bolt/migrator/migrator.go index 598681f28..84f1e27e3 100644 --- a/api/bolt/migrator/migrator.go +++ b/api/bolt/migrator/migrator.go @@ -335,6 +335,11 @@ func (m *Migrator) Migrate() error { if err != nil { return err } + + err = m.updateStacksToDB24() + if err != nil { + return err + } } return m.versionService.StoreDBVersion(portainer.DBVersion) diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go index aa47eacd7..6ceab991c 100644 --- a/api/http/handler/stacks/create_compose_stack.go +++ b/api/http/handler/stacks/create_compose_stack.go @@ -66,6 +66,7 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter, EndpointID: endpoint.ID, EntryPoint: filesystem.ComposeFileDefaultName, Env: payload.Env, + Status: portainer.StackStatusActive, } stackFolder := strconv.Itoa(int(stack.ID)) @@ -151,6 +152,7 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite EndpointID: endpoint.ID, EntryPoint: payload.ComposeFilePathInRepository, Env: payload.Env, + Status: portainer.StackStatusActive, } projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID))) @@ -246,6 +248,7 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter, EndpointID: endpoint.ID, EntryPoint: filesystem.ComposeFileDefaultName, Env: payload.Env, + Status: portainer.StackStatusActive, } stackFolder := strconv.Itoa(int(stack.ID)) diff --git a/api/http/handler/stacks/create_swarm_stack.go b/api/http/handler/stacks/create_swarm_stack.go index f9aac664a..0113e8a41 100644 --- a/api/http/handler/stacks/create_swarm_stack.go +++ b/api/http/handler/stacks/create_swarm_stack.go @@ -62,6 +62,7 @@ func (handler *Handler) createSwarmStackFromFileContent(w http.ResponseWriter, r EndpointID: endpoint.ID, EntryPoint: filesystem.ComposeFileDefaultName, Env: payload.Env, + Status: portainer.StackStatusActive, } stackFolder := strconv.Itoa(int(stack.ID)) @@ -151,6 +152,7 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, EndpointID: endpoint.ID, EntryPoint: payload.ComposeFilePathInRepository, Env: payload.Env, + Status: portainer.StackStatusActive, } projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID))) @@ -254,6 +256,7 @@ func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r EndpointID: endpoint.ID, EntryPoint: filesystem.ComposeFileDefaultName, Env: payload.Env, + Status: portainer.StackStatusActive, } stackFolder := strconv.Itoa(int(stack.ID)) diff --git a/api/http/handler/stacks/handler.go b/api/http/handler/stacks/handler.go index cf80b4ecd..b6210358f 100644 --- a/api/http/handler/stacks/handler.go +++ b/api/http/handler/stacks/handler.go @@ -54,6 +54,10 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackFile))).Methods(http.MethodGet) h.Handle("/stacks/{id}/migrate", bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackMigrate))).Methods(http.MethodPost) + h.Handle("/stacks/{id}/start", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackStart))).Methods(http.MethodPost) + h.Handle("/stacks/{id}/stop", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackStop))).Methods(http.MethodPost) return h } diff --git a/api/http/handler/stacks/stack_start.go b/api/http/handler/stacks/stack_start.go new file mode 100644 index 000000000..5f473e93a --- /dev/null +++ b/api/http/handler/stacks/stack_start.go @@ -0,0 +1,61 @@ +package stacks + +import ( + "errors" + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" +) + +// POST request on /api/stacks/:id/start +func (handler *Handler) stackStart(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} + } + + stack, err := handler.DataStore.Stack().Stack(portainer.StackID(stackID)) + if err == bolterrors.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} + } + + if stack.Status == portainer.StackStatusActive { + return &httperror.HandlerError{http.StatusBadRequest, "Stack is already active", errors.New("Stack is already active")} + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID) + if err == bolterrors.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} + } + + err = handler.startStack(stack, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to stop stack", err} + } + + stack.Status = portainer.StackStatusActive + err = handler.DataStore.Stack().UpdateStack(stack.ID, stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update stack status", err} + } + + return response.JSON(w, stack) +} + +func (handler *Handler) startStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error { + switch stack.Type { + case portainer.DockerComposeStack: + return handler.ComposeStackManager.Up(stack, endpoint) + case portainer.DockerSwarmStack: + return handler.SwarmStackManager.Deploy(stack, true, endpoint) + } + return nil +} diff --git a/api/http/handler/stacks/stack_stop.go b/api/http/handler/stacks/stack_stop.go new file mode 100644 index 000000000..e79b7c8dc --- /dev/null +++ b/api/http/handler/stacks/stack_stop.go @@ -0,0 +1,61 @@ +package stacks + +import ( + "errors" + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" +) + +// POST request on /api/stacks/:id/stop +func (handler *Handler) stackStop(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} + } + + stack, err := handler.DataStore.Stack().Stack(portainer.StackID(stackID)) + if err == bolterrors.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} + } + + if stack.Status == portainer.StackStatusInactive { + return &httperror.HandlerError{http.StatusBadRequest, "Stack is already inactive", errors.New("Stack is already inactive")} + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID) + if err == bolterrors.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} + } + + err = handler.stopStack(stack, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to stop stack", err} + } + + stack.Status = portainer.StackStatusInactive + err = handler.DataStore.Stack().UpdateStack(stack.ID, stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update stack status", err} + } + + return response.JSON(w, stack) +} + +func (handler *Handler) stopStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error { + switch stack.Type { + case portainer.DockerComposeStack: + return handler.ComposeStackManager.Down(stack, endpoint) + case portainer.DockerSwarmStack: + return handler.SwarmStackManager.Remove(stack, endpoint) + } + return nil +} diff --git a/api/portainer.go b/api/portainer.go index c5a0db217..dcbf25af3 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -547,12 +547,16 @@ type ( EntryPoint string `json:"EntryPoint"` Env []Pair `json:"Env"` ResourceControl *ResourceControl `json:"ResourceControl"` + Status StackStatus `json:"Status"` ProjectPath string } // StackID represents a stack identifier (it must be composed of Name + "_" + SwarmID to create a unique identifier) StackID int + // StackStatus represent a status for a stack + StackStatus int + // StackType represents the type of the stack (compose v2, stack deploy v3) StackType int @@ -1302,6 +1306,13 @@ const ( KubernetesStack ) +// StackStatus represents a status for a stack +const ( + _ StackStatus = iota + StackStatusActive + StackStatusInactive +) + const ( _ TemplateType = iota // ContainerTemplate represents a container template diff --git a/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html b/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html index 7b642041b..1bd2c313d 100644 --- a/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html +++ b/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html @@ -72,7 +72,7 @@ -
+ @@ -82,6 +82,32 @@ +
+ + Filter + + +
+
@@ -102,7 +128,7 @@
@@ -112,6 +138,7 @@ {{ item.Name }} {{ item.Name }} + Inactive {{ item.Type === 1 ? 'Swarm' : 'Compose' }} diff --git a/app/portainer/components/datatables/stacks-datatable/stacksDatatableController.js b/app/portainer/components/datatables/stacks-datatable/stacksDatatableController.js index 75e8e2400..e5f8f1252 100644 --- a/app/portainer/components/datatables/stacks-datatable/stacksDatatableController.js +++ b/app/portainer/components/datatables/stacks-datatable/stacksDatatableController.js @@ -6,6 +6,15 @@ angular.module('portainer.app').controller('StacksDatatableController', [ function ($scope, $controller, DatatableService, Authentication) { angular.extend(this, $controller('GenericDatatableController', { $scope: $scope })); + this.filters = { + state: { + open: false, + enabled: false, + showActiveStacks: true, + showUnactiveStacks: true, + }, + }; + /** * Do not allow external items */ @@ -17,6 +26,19 @@ angular.module('portainer.app').controller('StacksDatatableController', [ return !(item.External && !this.isAdmin && !this.isEndpointAdmin); }; + this.applyFilters = applyFilters.bind(this); + function applyFilters(stack) { + const { showActiveStacks, showUnactiveStacks } = this.filters.state; + return (stack.Status === 1 && showActiveStacks) || (stack.Status === 2 && showUnactiveStacks) || stack.External; + } + + this.onFilterChange = onFilterChange.bind(this); + function onFilterChange() { + const { showActiveStacks, showUnactiveStacks } = this.filters.state; + this.filters.state.enabled = !showActiveStacks || !showUnactiveStacks; + DatatableService.setDataTableFilters(this.tableKey, this.filters); + } + this.$onInit = function () { this.isAdmin = Authentication.isAdmin(); this.isEndpointAdmin = Authentication.hasAuthorizations(['EndpointResourcesAccess']); diff --git a/app/portainer/models/stack.js b/app/portainer/models/stack.js index b21dc47b9..ef28f97e3 100644 --- a/app/portainer/models/stack.js +++ b/app/portainer/models/stack.js @@ -12,6 +12,7 @@ export function StackViewModel(data) { this.ResourceControl = new ResourceControlViewModel(data.ResourceControl); } this.External = false; + this.Status = data.Status; } export function ExternalStackViewModel(name, type) { diff --git a/app/portainer/rest/stack.js b/app/portainer/rest/stack.js index 7c4753025..d521c876c 100644 --- a/app/portainer/rest/stack.js +++ b/app/portainer/rest/stack.js @@ -15,6 +15,8 @@ angular.module('portainer.app').factory('Stack', [ remove: { method: 'DELETE', params: { id: '@id', external: '@external', endpointId: '@endpointId' } }, getStackFile: { method: 'GET', params: { id: '@id', action: 'file' } }, migrate: { method: 'POST', params: { id: '@id', action: 'migrate', endpointId: '@endpointId' }, ignoreLoadingBar: true }, + start: { method: 'POST', params: { id: '@id', action: 'start' } }, + stop: { method: 'POST', params: { id: '@id', action: 'stop' } }, } ); }, diff --git a/app/portainer/services/api/stackService.js b/app/portainer/services/api/stackService.js index 47a9b6751..68bebbd56 100644 --- a/app/portainer/services/api/stackService.js +++ b/app/portainer/services/api/stackService.js @@ -349,6 +349,16 @@ angular.module('portainer.app').factory('StackService', [ return $async(kubernetesDeployAsync, endpointId, namespace, content, compose); }; + service.start = start; + function start(id) { + return Stack.start({ id }).$promise; + } + + service.stop = stop; + function stop(id) { + return Stack.stop({ id }).$promise; + } + return service; }, ]); diff --git a/app/portainer/services/modalService.js b/app/portainer/services/modalService.js index 14cd30e0c..196483ba4 100644 --- a/app/portainer/services/modalService.js +++ b/app/portainer/services/modalService.js @@ -37,6 +37,13 @@ angular.module('portainer.app').factory('ModalService', [ }); }; + service.confirmAsync = confirmAsync; + function confirmAsync(options) { + return new Promise((resolve) => { + service.confirm({ ...options, callback: (confirmed) => resolve(confirmed) }); + }); + } + service.confirm = function (options) { var box = bootbox.confirm({ title: options.title, diff --git a/app/portainer/views/stacks/edit/stack.html b/app/portainer/views/stacks/edit/stack.html index 7bc2c041c..114a1712f 100644 --- a/app/portainer/views/stacks/edit/stack.html +++ b/app/portainer/views/stacks/edit/stack.html @@ -47,6 +47,28 @@ Create template from stack + + + + diff --git a/app/portainer/views/stacks/edit/stackController.js b/app/portainer/views/stacks/edit/stackController.js index 4187777a3..1ea4a8cb0 100644 --- a/app/portainer/views/stacks/edit/stackController.js +++ b/app/portainer/views/stacks/edit/stackController.js @@ -1,4 +1,5 @@ angular.module('portainer.app').controller('StackController', [ + '$async', '$q', '$scope', '$state', @@ -17,6 +18,7 @@ angular.module('portainer.app').controller('StackController', [ 'GroupService', 'ModalService', function ( + $async, $q, $scope, $state, @@ -187,6 +189,46 @@ angular.module('portainer.app').controller('StackController', [ $scope.stackFileContent = cm.getValue(); }; + $scope.stopStack = stopStack; + function stopStack() { + return $async(stopStackAsync); + } + async function stopStackAsync() { + const confirmed = await ModalService.confirmAsync({ + title: 'Are you sure?', + message: 'Are you sure you want to stop this stack?', + buttons: { confirm: { label: 'Stop', className: 'btn-danger' } }, + }); + if (!confirmed) { + return; + } + + $scope.state.actionInProgress = true; + try { + await StackService.stop($scope.stack.Id); + $state.reload(); + } catch (err) { + Notifications.error('Failure', err, 'Unable to stop stack'); + } + $scope.state.actionInProgress = false; + } + + $scope.startStack = startStack; + function startStack() { + return $async(startStackAsync); + } + async function startStackAsync() { + $scope.state.actionInProgress = true; + const id = $scope.stack.Id; + try { + await StackService.start(id); + $state.reload(); + } catch (err) { + Notifications.error('Failure', err, 'Unable to start stack'); + } + $scope.state.actionInProgress = false; + } + function loadStack(id) { var agentProxy = $scope.applicationState.endpoint.mode.agentProxy; @@ -207,17 +249,24 @@ angular.module('portainer.app').controller('StackController', [ $scope.groups = data.groups; $scope.stack = stack; + let resourcesPromise = Promise.resolve({}); + if (stack.Status === 1) { + resourcesPromise = stack.Type === 1 ? retrieveSwarmStackResources(stack.Name, agentProxy) : retrieveComposeStackResources(stack.Name); + } + return $q.all({ stackFile: StackService.getStackFile(id), - resources: stack.Type === 1 ? retrieveSwarmStackResources(stack.Name, agentProxy) : retrieveComposeStackResources(stack.Name), + resources: resourcesPromise, }); }) .then(function success(data) { $scope.stackFileContent = data.stackFile; - if ($scope.stack.Type === 1) { - assignSwarmStackResources(data.resources, agentProxy); - } else { - assignComposeStackResources(data.resources); + if ($scope.stack.Status === 1) { + if ($scope.stack.Type === 1) { + assignSwarmStackResources(data.resources, agentProxy); + } else { + assignComposeStackResources(data.resources); + } } }) .catch(function error(err) {