From 26ead28d7bfbd6d38df79e7b4f2d153b70563180 Mon Sep 17 00:00:00 2001 From: cong meng Date: Thu, 10 Jun 2021 14:52:33 +1200 Subject: [PATCH] Feat(stacks): orphaned stacks #4397 (#4834) * feat(stack): add the ability for an administrator user to manage orphaned stacks (#4397) * feat(stack): apply small font size to the information text of associate (#4397) Co-authored-by: Simon Meng --- api/http/handler/stacks/handler.go | 2 + api/http/handler/stacks/stack_associate.go | 91 +++++++++++++++++++ api/http/handler/stacks/stack_delete.go | 38 ++++---- api/http/handler/stacks/stack_file.go | 48 +++++----- api/http/handler/stacks/stack_inspect.go | 52 ++++++----- api/http/handler/stacks/stack_list.go | 36 +++++++- app/docker/__module.js | 2 +- .../por-access-control-form.js | 1 + .../porAccessControlForm.html | 2 +- .../stacks-datatable/stacksDatatable.html | 24 ++++- .../stacksDatatableController.js | 11 ++- app/portainer/models/stack.js | 37 +++++++- app/portainer/rest/stack.js | 1 + app/portainer/services/api/stackService.js | 83 +++++++++++++---- app/portainer/views/stacks/edit/stack.html | 58 +++++++++--- .../views/stacks/edit/stackController.js | 56 ++++++++++-- .../views/stacks/stacksController.js | 3 +- 17 files changed, 428 insertions(+), 117 deletions(-) create mode 100644 api/http/handler/stacks/stack_associate.go diff --git a/api/http/handler/stacks/handler.go b/api/http/handler/stacks/handler.go index f61952515..ed5fe27a1 100644 --- a/api/http/handler/stacks/handler.go +++ b/api/http/handler/stacks/handler.go @@ -52,6 +52,8 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackInspect))).Methods(http.MethodGet) h.Handle("/stacks/{id}", bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackDelete))).Methods(http.MethodDelete) + h.Handle("/stacks/{id}/associate", + bouncer.AdminAccess(httperror.LoggerHandler(h.stackAssociate))).Methods(http.MethodPut) h.Handle("/stacks/{id}", bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackUpdate))).Methods(http.MethodPut) h.Handle("/stacks/{id}/file", diff --git a/api/http/handler/stacks/stack_associate.go b/api/http/handler/stacks/stack_associate.go new file mode 100644 index 000000000..55397a323 --- /dev/null +++ b/api/http/handler/stacks/stack_associate.go @@ -0,0 +1,91 @@ +package stacks + +import ( + "fmt" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/stackutils" + "net/http" + "time" +) + +// PUT request on /api/stacks/:id/associate?endpointId=&swarmId=&orphanedRunning= +func (handler *Handler) stackAssociate(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} + } + + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} + } + + swarmId, err := request.RetrieveQueryParameter(r, "swarmId", true) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: swarmId", err} + } + + orphanedRunning, err := request.RetrieveBooleanQueryParameter(r, "orphanedRunning", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: orphanedRunning", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + user, err := handler.DataStore.User().User(securityContext.UserID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to load user information from the database", 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} + } + + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} + } + + if resourceControl != nil { + resourceControl.ResourceID = fmt.Sprintf("%d_%s", endpointID, stack.Name) + + err = handler.DataStore.ResourceControl().UpdateResourceControl(resourceControl.ID, resourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist resource control changes inside the database", err} + } + } + + stack.EndpointID = portainer.EndpointID(endpointID) + stack.SwarmID = swarmId + + if orphanedRunning { + stack.Status = portainer.StackStatusActive + } else { + stack.Status = portainer.StackStatusInactive + } + + stack.CreationDate = time.Now().Unix() + stack.CreatedBy = user.Username + stack.UpdateDate = 0 + stack.UpdatedBy = "" + + err = handler.DataStore.Stack().UpdateStack(stack.ID, stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack changes inside the database", err} + } + + stack.ResourceControl = resourceControl + + return response.JSON(w, stack) +} diff --git a/api/http/handler/stacks/stack_delete.go b/api/http/handler/stacks/stack_delete.go index dd33d38cc..52f003cea 100644 --- a/api/http/handler/stacks/stack_delete.go +++ b/api/http/handler/stacks/stack_delete.go @@ -58,42 +58,42 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} } - // TODO: this is a work-around for stacks created with Portainer version >= 1.17.1 - // The EndpointID property is not available for these stacks, this API endpoint - // can use the optional EndpointID query parameter to set a valid endpoint identifier to be - // used in the context of this request. endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true) if err != nil { return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} } - endpointIdentifier := stack.EndpointID - if endpointID != 0 { - endpointIdentifier = portainer.EndpointID(endpointID) + + isOrphaned := portainer.EndpointID(endpointID) != stack.EndpointID + + if isOrphaned && !securityContext.IsAdmin { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to remove orphaned stack", errors.New("Permission denied to remove orphaned stack")} } - endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointIdentifier) + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) if err == bolterrors.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} } - err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) - if err != nil { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} - } - resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} } - access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} - } - if !access { - return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} + if !isOrphaned { + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + } + + access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} + } + if !access { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} + } } err = handler.deleteStack(stack, endpoint) diff --git a/api/http/handler/stacks/stack_file.go b/api/http/handler/stacks/stack_file.go index fc11cecd0..f21083bb1 100644 --- a/api/http/handler/stacks/stack_file.go +++ b/api/http/handler/stacks/stack_file.go @@ -46,34 +46,38 @@ func (handler *Handler) stackFile(w http.ResponseWriter, r *http.Request) *httpe return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} } - 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.requestBouncer.AuthorizedEndpointOperation(r, endpoint) - if err != nil { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} - } - - resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) - if err != nil { - 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} } - access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} + endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID) + if err == bolterrors.ErrObjectNotFound { + if !securityContext.IsAdmin { + 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} } - if !access { - return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", errors.ErrResourceAccessDenied} + + if endpoint != nil { + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + } + + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} + } + + access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} + } + if !access { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", errors.ErrResourceAccessDenied} + } } stackFileContent, err := handler.FileService.GetFileContent(path.Join(stack.ProjectPath, stack.EntryPoint)) diff --git a/api/http/handler/stacks/stack_inspect.go b/api/http/handler/stacks/stack_inspect.go index 2891279cc..7f445397f 100644 --- a/api/http/handler/stacks/stack_inspect.go +++ b/api/http/handler/stacks/stack_inspect.go @@ -1,6 +1,7 @@ package stacks import ( + "github.com/portainer/portainer/api/http/errors" "net/http" httperror "github.com/portainer/libhttp/error" @@ -8,7 +9,6 @@ import ( "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" - "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/stackutils" ) @@ -40,38 +40,42 @@ func (handler *Handler) stackInspect(w http.ResponseWriter, r *http.Request) *ht return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} } + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + 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} + if !securityContext.IsAdmin { + 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.requestBouncer.AuthorizedEndpointOperation(r, endpoint) - if err != nil { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} - } + if endpoint != nil { + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + } - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user info from request context", err} - } + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} + } - resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} - } + access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} + } + if !access { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", errors.ErrResourceAccessDenied} + } - access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} - } - if !access { - return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", errors.ErrResourceAccessDenied} - } - - if resourceControl != nil { - stack.ResourceControl = resourceControl + if resourceControl != nil { + stack.ResourceControl = resourceControl + } } return response.JSON(w, stack) diff --git a/api/http/handler/stacks/stack_list.go b/api/http/handler/stacks/stack_list.go index ebc4de58d..81255d032 100644 --- a/api/http/handler/stacks/stack_list.go +++ b/api/http/handler/stacks/stack_list.go @@ -1,6 +1,7 @@ package stacks import ( + httperrors "github.com/portainer/portainer/api/http/errors" "net/http" httperror "github.com/portainer/libhttp/error" @@ -12,8 +13,9 @@ import ( ) type stackListOperationFilters struct { - SwarmID string `json:"SwarmID"` - EndpointID int `json:"EndpointID"` + SwarmID string `json:"SwarmID"` + EndpointID int `json:"EndpointID"` + IncludeOrphanedStacks bool `json:"IncludeOrphanedStacks"` } // @id StackList @@ -37,11 +39,16 @@ func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httpe return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: filters", err} } + endpoints, err := handler.DataStore.Endpoint().Endpoints() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from database", err} + } + stacks, err := handler.DataStore.Stack().Stacks() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err} } - stacks = filterStacks(stacks, &filters) + stacks = filterStacks(stacks, &filters, endpoints) resourceControls, err := handler.DataStore.ResourceControl().ResourceControls() if err != nil { @@ -56,6 +63,10 @@ func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httpe stacks = authorization.DecorateStacks(stacks, resourceControls) if !securityContext.IsAdmin { + if filters.IncludeOrphanedStacks { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access orphaned stacks", httperrors.ErrUnauthorized} + } + user, err := handler.DataStore.User().User(securityContext.UserID) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user information from the database", err} @@ -72,13 +83,20 @@ func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httpe return response.JSON(w, stacks) } -func filterStacks(stacks []portainer.Stack, filters *stackListOperationFilters) []portainer.Stack { +func filterStacks(stacks []portainer.Stack, filters *stackListOperationFilters, endpoints []portainer.Endpoint) []portainer.Stack { if filters.EndpointID == 0 && filters.SwarmID == "" { return stacks } filteredStacks := make([]portainer.Stack, 0, len(stacks)) for _, stack := range stacks { + if filters.IncludeOrphanedStacks && isOrphanedStack(stack, endpoints) { + if (stack.Type == portainer.DockerComposeStack && filters.SwarmID == "") || (stack.Type == portainer.DockerSwarmStack && filters.SwarmID != "") { + filteredStacks = append(filteredStacks, stack) + } + continue + } + if stack.Type == portainer.DockerComposeStack && stack.EndpointID == portainer.EndpointID(filters.EndpointID) { filteredStacks = append(filteredStacks, stack) } @@ -89,3 +107,13 @@ func filterStacks(stacks []portainer.Stack, filters *stackListOperationFilters) return filteredStacks } + +func isOrphanedStack(stack portainer.Stack, endpoints []portainer.Endpoint) bool { + for _, endpoint := range endpoints { + if stack.EndpointID == endpoint.ID { + return false + } + } + + return true +} diff --git a/app/docker/__module.js b/app/docker/__module.js index 516134d7d..40cf82c24 100644 --- a/app/docker/__module.js +++ b/app/docker/__module.js @@ -456,7 +456,7 @@ angular.module('portainer.docker', ['portainer.app']).config([ var stack = { name: 'docker.stacks.stack', - url: '/:name?id&type&external', + url: '/:name?id&type®ular&external&orphaned&orphanedRunning', views: { 'content@': { templateUrl: '~Portainer/views/stacks/edit/stack.html', diff --git a/app/portainer/components/accessControlForm/por-access-control-form.js b/app/portainer/components/accessControlForm/por-access-control-form.js index 4eafa2afb..d75722084 100644 --- a/app/portainer/components/accessControlForm/por-access-control-form.js +++ b/app/portainer/components/accessControlForm/por-access-control-form.js @@ -8,5 +8,6 @@ angular.module('portainer.app').component('porAccessControlForm', { // Optional. An existing resource control object that will be used to set // the default values of the component. resourceControl: '<', + hideTitle: '<', }, }); diff --git a/app/portainer/components/accessControlForm/porAccessControlForm.html b/app/portainer/components/accessControlForm/porAccessControlForm.html index b1eff0859..b7cb9f3ec 100644 --- a/app/portainer/components/accessControlForm/porAccessControlForm.html +++ b/app/portainer/components/accessControlForm/porAccessControlForm.html @@ -1,5 +1,5 @@
-
+
Access control
diff --git a/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html b/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html index 3612faeaa..bac4dee66 100644 --- a/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html +++ b/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html @@ -14,6 +14,10 @@