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 + + + + |