diff --git a/api/http/handler/stacks/create_kubernetes_stack.go b/api/http/handler/stacks/create_kubernetes_stack.go index 201ae3e68..3cd62c3de 100644 --- a/api/http/handler/stacks/create_kubernetes_stack.go +++ b/api/http/handler/stacks/create_kubernetes_stack.go @@ -153,13 +153,6 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit if err != nil { return httperror.InternalServerError("Unable to load user information from the database", err) } - isUnique, err := handler.checkUniqueStackNameInKubernetes(endpoint, payload.StackName, 0, payload.Namespace) - if err != nil { - return httperror.InternalServerError("Unable to check for name collision", err) - } - if !isUnique { - return httperror.Conflict(fmt.Sprintf("A stack with the name '%s' already exists", payload.StackName), stackutils.ErrStackAlreadyExists) - } stackPayload := createStackPayloadFromK8sFileContentPayload(payload.StackName, payload.Namespace, payload.StackFileContent, payload.ComposeFormat, payload.FromAppTemplate) @@ -218,13 +211,6 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr if err != nil { return httperror.InternalServerError("Unable to load user information from the database", err) } - isUnique, err := handler.checkUniqueStackNameInKubernetes(endpoint, payload.StackName, 0, payload.Namespace) - if err != nil { - return httperror.InternalServerError("Unable to check for name collision", err) - } - if !isUnique { - return httperror.Conflict(fmt.Sprintf("A stack with the name '%s' already exists", payload.StackName), stackutils.ErrStackAlreadyExists) - } //make sure the webhook ID is unique if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" { @@ -296,13 +282,6 @@ func (handler *Handler) createKubernetesStackFromManifestURL(w http.ResponseWrit if err != nil { return httperror.InternalServerError("Unable to load user information from the database", err) } - isUnique, err := handler.checkUniqueStackNameInKubernetes(endpoint, payload.StackName, 0, payload.Namespace) - if err != nil { - return httperror.InternalServerError("Unable to check for name collision", err) - } - if !isUnique { - return httperror.Conflict(fmt.Sprintf("A stack with the name '%s' already exists", payload.StackName), stackutils.ErrStackAlreadyExists) - } stackPayload := createStackPayloadFromK8sUrlPayload(payload.StackName, payload.Namespace, diff --git a/api/http/handler/stacks/handler.go b/api/http/handler/stacks/handler.go index ef47fb039..29a4a6855 100644 --- a/api/http/handler/stacks/handler.go +++ b/api/http/handler/stacks/handler.go @@ -70,6 +70,8 @@ func NewHandler(bouncer security.BouncerService) *Handler { 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/name/{name}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackDeleteKubernetesByName))).Methods(http.MethodDelete) h.Handle("/stacks/{id}", bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackUpdate))).Methods(http.MethodPut) h.Handle("/stacks/{id}/git", @@ -163,31 +165,6 @@ func (handler *Handler) checkUniqueStackName(endpoint *portainer.Endpoint, name return true, nil } -func (handler *Handler) checkUniqueStackNameInKubernetes(endpoint *portainer.Endpoint, name string, stackID portainer.StackID, namespace string) (bool, error) { - isUniqueStackName, err := handler.checkUniqueStackName(endpoint, name, stackID) - if err != nil { - return false, err - } - - if !isUniqueStackName { - // Check if this stack name is really used in the kubernetes. - // Because the stack with this name could be removed via kubectl cli outside and the datastore does not be informed of this action. - if namespace == "" { - namespace = "default" - } - - kubeCli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint) - if err != nil { - return false, err - } - isUniqueStackName, err = kubeCli.HasStackName(namespace, name) - if err != nil { - return false, err - } - } - return isUniqueStackName, nil -} - func (handler *Handler) checkUniqueStackNameInDocker(endpoint *portainer.Endpoint, name string, stackID portainer.StackID, swarmMode bool) (bool, error) { isUniqueStackName, err := handler.checkUniqueStackName(endpoint, name, stackID) if err != nil { diff --git a/api/http/handler/stacks/stack_delete.go b/api/http/handler/stacks/stack_delete.go index 6148da14f..ee63f4de7 100644 --- a/api/http/handler/stacks/stack_delete.go +++ b/api/http/handler/stacks/stack_delete.go @@ -251,3 +251,137 @@ func (handler *Handler) deleteStack(userID portainer.UserID, stack *portainer.St return fmt.Errorf("unsupported stack type: %v", stack.Type) } + +// @id StackDeleteKubernetesByName +// @summary Remove Kubernetes stacks by name +// @description Remove a stack. +// @description **Access policy**: restricted +// @tags stacks +// @security ApiKeyAuth +// @security jwt +// @param name path string true "Stack name" +// @param external query boolean false "Set to true to delete an external stack. Only external Swarm stacks are supported" +// @param endpointId query int true "Environment identifier" +// @success 204 "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "Not found" +// @failure 500 "Server error" +// @router /stacks/name/{name} [delete] +func (handler *Handler) stackDeleteKubernetesByName(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackName, err := request.RetrieveRouteVariableValue(r, "name") + if err != nil { + return httperror.BadRequest("Invalid stack identifier route variable", err) + } + + log.Debug().Msgf("Trying to delete Kubernetes stack %q", stackName) + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true) + if err != nil { + return httperror.BadRequest("Invalid query parameter: endpointId", err) + } + + namespace, err := request.RetrieveQueryParameter(r, "namespace", false) + if err != nil { + return httperror.BadRequest("Invalid query parameter: namespace", err) + } + + stacks, err := handler.DataStore.Stack().StacksByName(stackName) + if err != nil && !handler.DataStore.IsErrObjectNotFound(err) { + return httperror.InternalServerError("Unable to check for stack existence inside the database", err) + } + if stacks == nil { + return httperror.InternalServerError("Unable to find a stacks with the specified identifier name the database", err) + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find the endpoint associated to the stack inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find the endpoint associated to the stack inside the database", err) + } + + log.Debug().Msgf("Trying to delete Kubernetes stack %q for endpoint `%d`", stackName, endpointID) + + // check authorizations on all the stacks one by one + stacksToDelete := make([]portainer.Stack, 0) + for _, stack := range stacks { + // only delete stacks for the specified namespace + if stack.Namespace != namespace { + continue + } + + isOrphaned := portainer.EndpointID(endpointID) != stack.EndpointID + if stack.Type != portainer.KubernetesStack { + return httperror.BadRequest("Only Kubernetes stacks can be deleted by name", errors.New("Only Kubernetes stacks can be deleted by name")) + } + + if isOrphaned && !securityContext.IsAdmin { + return httperror.Forbidden("Permission denied to remove orphaned stack", errors.New("Permission denied to remove orphaned stack")) + } + + if !isOrphaned { + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + if err != nil { + return httperror.Forbidden("Permission denied to access endpoint", err) + } + } + + canManage, err := handler.userCanManageStacks(securityContext, endpoint) + if err != nil { + return httperror.InternalServerError("Unable to verify user authorizations to validate stack deletion", err) + } + if !canManage { + errMsg := "stack deletion is disabled for non-admin users" + return httperror.Forbidden(errMsg, fmt.Errorf(errMsg)) + } + + stacksToDelete = append(stacksToDelete, stack) + } + + log.Debug().Msgf("Trying to delete Kubernetes stacks `%v` for endpoint `%d`", stacksToDelete, endpointID) + + errors := make([]error, 0) + // Delete all the stacks one by one + for _, stack := range stacksToDelete { + log.Debug().Msgf("Trying to delete Kubernetes stack id `%d`", stack.ID) + + // stop scheduler updates of the stack before removal + if stack.AutoUpdate != nil { + deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler) + } + + err = handler.deleteStack(securityContext.UserID, &stack, endpoint) + if err != nil { + log.Err(err).Msgf("Unable to delete Kubernetes stack `%d`", stack.ID) + errors = append(errors, err) + continue + } + + err = handler.DataStore.Stack().Delete(stack.ID) + if err != nil { + errors = append(errors, err) + log.Err(err).Msgf("Unable to remove the stack `%d` from the database", stack.ID) + continue + } + + err = handler.FileService.RemoveDirectory(stack.ProjectPath) + if err != nil { + errors = append(errors, err) + log.Warn().Err(err).Msg("Unable to remove stack files from disk") + } + + log.Debug().Msgf("Kubernetes stack `%d` deleted", stack.ID) + } + + if len(errors) > 0 { + return httperror.InternalServerError("Unable to delete some Kubernetes stack(s). Check Portainer logs for more details", nil) + } + + return response.Empty(w) +} diff --git a/app/kubernetes/views/applications/applicationsController.js b/app/kubernetes/views/applications/applicationsController.js index 1d7dd1253..9965373c9 100644 --- a/app/kubernetes/views/applications/applicationsController.js +++ b/app/kubernetes/views/applications/applicationsController.js @@ -61,13 +61,10 @@ class KubernetesApplicationsController { if (isAppFormCreated) { const promises = _.map(stack.Applications, (app) => this.KubernetesApplicationService.delete(app)); await Promise.all(promises); - } else { - const application = stack.Applications.find((x) => x.StackId !== null); - if (application && application.StackId) { - await this.StackService.remove({ Id: application.StackId }, false, this.endpoint.Id); - } } + await this.StackService.removeKubernetesStacksByName(stack.Name, stack.ResourcePool, false, this.endpoint.Id); + this.Notifications.success('Stack successfully removed', stack.Name); _.remove(this.state.stacks, { Name: stack.Name }); } catch (err) { diff --git a/app/portainer/rest/stack.js b/app/portainer/rest/stack.js index 1aab47b3f..4bc761552 100644 --- a/app/portainer/rest/stack.js +++ b/app/portainer/rest/stack.js @@ -1,6 +1,7 @@ import angular from 'angular'; angular.module('portainer.app').factory('Stack', StackFactory); +angular.module('portainer.app').factory('StackByName', StackByNameFactory); /* @ngInject */ function StackFactory($resource, API_ENDPOINT_STACKS) { @@ -23,3 +24,13 @@ function StackFactory($resource, API_ENDPOINT_STACKS) { } ); } + +function StackByNameFactory($resource, API_ENDPOINT_STACKS) { + return $resource( + API_ENDPOINT_STACKS + '/name/:name', + {}, + { + remove: { method: 'DELETE', params: { name: '@name', external: '@external', endpointId: '@endpointId', namespace: '@namespace' } }, + } + ); +} diff --git a/app/portainer/services/api/stackService.js b/app/portainer/services/api/stackService.js index d1aecea87..993c35c10 100644 --- a/app/portainer/services/api/stackService.js +++ b/app/portainer/services/api/stackService.js @@ -6,12 +6,13 @@ angular.module('portainer.app').factory('StackService', [ '$q', '$async', 'Stack', + 'StackByName', 'FileUploadService', 'StackHelper', 'ServiceService', 'ContainerService', 'SwarmService', - function StackServiceFactory($q, $async, Stack, FileUploadService, StackHelper, ServiceService, ContainerService, SwarmService) { + function StackServiceFactory($q, $async, Stack, StackByName, FileUploadService, StackHelper, ServiceService, ContainerService, SwarmService) { 'use strict'; var service = { updateGit, @@ -221,6 +222,19 @@ angular.module('portainer.app').factory('StackService', [ return deferred.promise; }; + service.removeKubernetesStacksByName = function (name, namespace, external, endpointId) { + var deferred = $q.defer(); + StackByName.remove({ name: name, external: external, endpointId: endpointId, namespace: namespace }) + .$promise.then(function success() { + deferred.resolve(); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to remove the stack', err: err }); + }); + + return deferred.promise; + }; + service.associate = function (stack, endpointId, orphanedRunning) { var deferred = $q.defer();