diff --git a/.golangci.yaml b/.golangci.yaml index 3be4f7e11..9df0a1811 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -9,8 +9,6 @@ linters: - gosimple - govet - errorlint - - copyloopvar - - intrange linters-settings: depguard: diff --git a/api/datastore/postinit/migrate_post_init.go b/api/datastore/postinit/migrate_post_init.go index 2e831aabd..c7dc11cae 100644 --- a/api/datastore/postinit/migrate_post_init.go +++ b/api/datastore/postinit/migrate_post_init.go @@ -90,7 +90,7 @@ func (migrator *PostInitMigrator) MigrateEnvironment(environment *portainer.Endp switch { case endpointutils.IsKubernetesEndpoint(environment): // get the kubeclient for the environment, and skip all kube migrations if there's an error - kubeclient, err := migrator.kubeFactory.GetKubeClient(environment) + kubeclient, err := migrator.kubeFactory.GetPrivilegedKubeClient(environment) if err != nil { log.Error().Err(err).Msgf("Error creating kubeclient for environment: %d", environment.ID) return err diff --git a/api/exec/kubernetes_deploy.go b/api/exec/kubernetes_deploy.go index 6f449fca4..50ccfbb07 100644 --- a/api/exec/kubernetes_deploy.go +++ b/api/exec/kubernetes_deploy.go @@ -44,7 +44,7 @@ func NewKubernetesDeployer(kubernetesTokenCacheManager *kubernetes.TokenCacheMan } func (deployer *KubernetesDeployer) getToken(userID portainer.UserID, endpoint *portainer.Endpoint, setLocalAdminToken bool) (string, error) { - kubeCLI, err := deployer.kubernetesClientFactory.GetKubeClient(endpoint) + kubeCLI, err := deployer.kubernetesClientFactory.GetPrivilegedKubeClient(endpoint) if err != nil { return "", err } diff --git a/api/http/handler/endpoints/endpoint_registries_list.go b/api/http/handler/endpoints/endpoint_registries_list.go index 812d3da38..e81bc34a9 100644 --- a/api/http/handler/endpoints/endpoint_registries_list.go +++ b/api/http/handler/endpoints/endpoint_registries_list.go @@ -128,7 +128,7 @@ func (handler *Handler) isNamespaceAuthorized(endpoint *portainer.Endpoint, name return true, nil } - kcl, err := handler.K8sClientFactory.GetKubeClient(endpoint) + kcl, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint) if err != nil { return false, errors.Wrap(err, "unable to retrieve kubernetes client") } @@ -187,7 +187,7 @@ func (handler *Handler) filterKubernetesRegistriesByUserRole(r *http.Request, re } func (handler *Handler) userNamespaces(endpoint *portainer.Endpoint, user *portainer.User) ([]string, error) { - kcl, err := handler.K8sClientFactory.GetKubeClient(endpoint) + kcl, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint) if err != nil { return nil, err } diff --git a/api/http/handler/endpoints/endpoint_registry_access.go b/api/http/handler/endpoints/endpoint_registry_access.go index 23a24fb69..b931ffa62 100644 --- a/api/http/handler/endpoints/endpoint_registry_access.go +++ b/api/http/handler/endpoints/endpoint_registry_access.go @@ -134,7 +134,7 @@ func (handler *Handler) updateKubeAccess(endpoint *portainer.Endpoint, registry namespacesToRemove := setDifference(oldNamespacesSet, newNamespacesSet) namespacesToAdd := setDifference(newNamespacesSet, oldNamespacesSet) - cli, err := handler.K8sClientFactory.GetKubeClient(endpoint) + cli, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint) if err != nil { return err } diff --git a/api/http/handler/kubernetes/application.go b/api/http/handler/kubernetes/application.go new file mode 100644 index 000000000..5434522ca --- /dev/null +++ b/api/http/handler/kubernetes/application.go @@ -0,0 +1,150 @@ +package kubernetes + +import ( + "net/http" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" + k8serrors "k8s.io/apimachinery/pkg/api/errors" +) + +// @id GetApplicationsResources +// @summary Get the total resource requests and limits of all applications +// @description Get the total CPU (cores) and memory requests (MB) and limits of all applications across all namespaces. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment(Endpoint) identifier" +// @param node query string true "Node name" +// @success 200 {object} models.K8sApplicationResource "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the total resource requests and limits for all applications from the cluster." +// @router /kubernetes/{id}/metrics/applications_resources [get] +func (handler *Handler) getApplicationsResources(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + node, err := request.RetrieveQueryParameter(r, "node", true) + if err != nil { + log.Error().Err(err).Str("context", "getApplicationsResources").Msg("Unable to parse the namespace query parameter") + return httperror.BadRequest("Unable to parse the node query parameter", err) + } + + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "getApplicationsResources").Msg("Unable to prepare kube client") + return httperror.InternalServerError("Unable to prepare kube client", httpErr) + } + + applicationsResources, err := cli.GetApplicationsResource("", node) + if err != nil { + if k8serrors.IsUnauthorized(err) { + log.Error().Err(err).Str("context", "getApplicationsResources").Msg("Unable to get the total resource requests and limits for all applications in the namespace") + return httperror.Unauthorized("Unable to get the total resource requests and limits for all applications in the namespace", err) + } + + if k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "getApplicationsResources").Msg("Unable to get the total resource requests and limits for all applications in the namespace") + return httperror.Forbidden("Unable to get the total resource requests and limits for all applications in the namespace", err) + } + + log.Error().Err(err).Str("context", "getApplicationsResources").Msg("Unable to calculate the total resource requests and limits for all applications in the namespace") + return httperror.InternalServerError("Unable to calculate the total resource requests and limits for all applications in the namespace", err) + } + + return response.JSON(w, applicationsResources) +} + +// @id GetAllKubernetesApplications +// @summary Get a list of applications across all namespaces in the cluster. If the nodeName is provided, it will return the applications running on that node. +// @description Get a list of applications across all namespaces in the cluster. If the nodeName is provided, it will return the applications running on that node. +// @description **Access policy**: authenticated +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment(Endpoint) identifier" +// @param namespace query string true "Namespace name" +// @param nodeName query string true "Node name" +// @param withDependencies query boolean false "Include dependencies in the response" +// @success 200 {array} models.K8sApplication "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the list of applications from the cluster." +// @router /kubernetes/{id}/applications [get] +func (handler *Handler) GetAllKubernetesApplications(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + applications, err := handler.getAllKubernetesApplications(r) + if err != nil { + return err + } + + return response.JSON(w, applications) +} + +// @id GetAllKubernetesApplicationsCount +// @summary Get Applications count +// @description Get the count of Applications across all namespaces in the cluster. If the nodeName is provided, it will return the count of applications running on that node. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @success 200 {integer} integer "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the count of all applications from the cluster." +// @router /kubernetes/{id}/applications/count [get] +func (handler *Handler) getAllKubernetesApplicationsCount(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + applications, err := handler.getAllKubernetesApplications(r) + if err != nil { + return err + } + + return response.JSON(w, len(applications)) +} + +func (handler *Handler) getAllKubernetesApplications(r *http.Request) ([]models.K8sApplication, *httperror.HandlerError) { + namespace, err := request.RetrieveQueryParameter(r, "namespace", true) + if err != nil { + log.Error().Err(err).Str("context", "getAllKubernetesApplications").Msg("Unable to parse the namespace query parameter") + return nil, httperror.BadRequest("Unable to parse the namespace query parameter", err) + } + + withDependencies, err := request.RetrieveBooleanQueryParameter(r, "withDependencies", true) + if err != nil { + log.Error().Err(err).Str("context", "getAllKubernetesApplications").Msg("Unable to parse the withDependencies query parameter") + return nil, httperror.BadRequest("Unable to parse the withDependencies query parameter", err) + } + + nodeName, err := request.RetrieveQueryParameter(r, "nodeName", true) + if err != nil { + log.Error().Err(err).Str("context", "getAllKubernetesApplications").Msg("Unable to parse the nodeName query parameter") + return nil, httperror.BadRequest("Unable to parse the nodeName query parameter", err) + } + + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "getAllKubernetesApplications").Str("namespace", namespace).Str("nodeName", nodeName).Msg("Unable to get a Kubernetes client for the user") + return nil, httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr) + } + + applications, err := cli.GetApplications(namespace, nodeName, withDependencies) + if err != nil { + if k8serrors.IsUnauthorized(err) { + log.Error().Err(err).Str("context", "getAllKubernetesApplications").Str("namespace", namespace).Str("nodeName", nodeName).Msg("Unable to get the list of applications") + return nil, httperror.Unauthorized("Unable to get the list of applications", err) + } + + log.Error().Err(err).Str("context", "getAllKubernetesApplications").Str("namespace", namespace).Str("nodeName", nodeName).Msg("Unable to get the list of applications") + return nil, httperror.InternalServerError("Unable to get the list of applications", err) + } + + return applications, nil +} diff --git a/api/http/handler/kubernetes/client.go b/api/http/handler/kubernetes/client.go new file mode 100644 index 000000000..a85f2cff9 --- /dev/null +++ b/api/http/handler/kubernetes/client.go @@ -0,0 +1,37 @@ +package kubernetes + +import ( + "net/http" + + "github.com/portainer/portainer/api/http/middlewares" + "github.com/portainer/portainer/api/kubernetes/cli" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/rs/zerolog/log" +) + +// prepareKubeClient is a helper function to prepare a Kubernetes client for the user +// it first fetches getProxyKubeClient to grab the user's admin status and non admin namespaces +// then these two values are parsed to create a privileged client +func (handler *Handler) prepareKubeClient(r *http.Request) (*cli.KubeClient, *httperror.HandlerError) { + cli, httpErr := handler.getProxyKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr.Err).Str("context", "prepareKubeClient").Msg("Unable to get a Kubernetes client for the user.") + return nil, httperror.InternalServerError("Unable to get a Kubernetes client for the user.", httpErr) + } + + endpoint, err := middlewares.FetchEndpoint(r) + if err != nil { + log.Error().Err(err).Str("context", "prepareKubeClient").Msg("Unable to find the Kubernetes endpoint associated to the request.") + return nil, httperror.NotFound("Unable to find the Kubernetes endpoint associated to the request.", err) + } + + pcli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint) + if err != nil { + log.Error().Err(err).Str("context", "prepareKubeClient").Msg("Unable to get a privileged Kubernetes client for the user.") + return nil, httperror.InternalServerError("Unable to get a privileged Kubernetes client for the user.", err) + } + pcli.IsKubeAdmin = cli.IsKubeAdmin + pcli.NonAdminNamespaces = cli.NonAdminNamespaces + + return pcli, nil +} diff --git a/api/http/handler/kubernetes/cluster_role_bindings.go b/api/http/handler/kubernetes/cluster_role_bindings.go new file mode 100644 index 000000000..918c4950d --- /dev/null +++ b/api/http/handler/kubernetes/cluster_role_bindings.go @@ -0,0 +1,45 @@ +package kubernetes + +import ( + "net/http" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" +) + +// @id GetAllKubernetesClusterRoleBindings +// @summary Get a list of kubernetes cluster role bindings +// @description Get a list of kubernetes cluster role bindings within the given environment at the cluster level. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @success 200 {array} kubernetes.K8sClusterRoleBinding "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the list of cluster role bindings." +// @router /kubernetes/{id}/clusterrolebindings [get] +func (handler *Handler) getAllKubernetesClusterRoleBindings(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli, httpErr := handler.getProxyKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr.Err).Str("context", "getAllKubernetesClusterRoleBindings").Msg("user is not authorized to fetch cluster role bindings from the Kubernetes cluster.") + return httperror.Forbidden("User is not authorized to fetch cluster role bindings from the Kubernetes cluster.", httpErr) + } + + if !cli.IsKubeAdmin { + log.Error().Str("context", "getAllKubernetesClusterRoleBindings").Msg("user is not authorized to fetch cluster role bindings from the Kubernetes cluster.") + return httperror.Forbidden("User is not authorized to fetch cluster role bindings from the Kubernetes cluster.", nil) + } + + clusterrolebindings, err := cli.GetClusterRoleBindings() + if err != nil { + log.Error().Err(err).Str("context", "getAllKubernetesClusterRoleBindings").Msg("Unable to fetch cluster role bindings.") + return httperror.InternalServerError("Unable to fetch cluster role bindings.", err) + } + + return response.JSON(w, clusterrolebindings) +} diff --git a/api/http/handler/kubernetes/cluster_roles.go b/api/http/handler/kubernetes/cluster_roles.go new file mode 100644 index 000000000..0cf226bd4 --- /dev/null +++ b/api/http/handler/kubernetes/cluster_roles.go @@ -0,0 +1,45 @@ +package kubernetes + +import ( + "net/http" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" +) + +// @id GetAllKubernetesClusterRoles +// @summary Get a list of kubernetes cluster roles +// @description Get a list of kubernetes cluster roles within the given environment at the cluster level. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @success 200 {array} kubernetes.K8sClusterRole "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the list of cluster roles." +// @router /kubernetes/{id}/clusterroles [get] +func (handler *Handler) getAllKubernetesClusterRoles(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli, httpErr := handler.getProxyKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr.Err).Str("context", "getAllKubernetesClusterRoles").Msg("user is not authorized to fetch cluster roles from the Kubernetes cluster.") + return httperror.Forbidden("User is not authorized to fetch cluster roles from the Kubernetes cluster.", httpErr) + } + + if !cli.IsKubeAdmin { + log.Error().Str("context", "getAllKubernetesClusterRoles").Msg("user is not authorized to fetch cluster roles from the Kubernetes cluster.") + return httperror.Forbidden("User is not authorized to fetch cluster roles from the Kubernetes cluster.", nil) + } + + clusterroles, err := cli.GetClusterRoles() + if err != nil { + log.Error().Err(err).Str("context", "getAllKubernetesClusterRoles").Msg("Unable to fetch clusterroles.") + return httperror.InternalServerError("Unable to fetch clusterroles.", err) + } + + return response.JSON(w, clusterroles) +} diff --git a/api/http/handler/kubernetes/config.go b/api/http/handler/kubernetes/config.go index ef5a6c097..e5bae6c4e 100644 --- a/api/http/handler/kubernetes/config.go +++ b/api/http/handler/kubernetes/config.go @@ -12,45 +12,48 @@ import ( httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" clientV1 "k8s.io/client-go/tools/clientcmd/api/v1" ) // @id GetKubernetesConfig -// @summary Generate a kubeconfig file enabling client communication with k8s api server -// @description Generate a kubeconfig file enabling client communication with k8s api server -// @description **Access policy**: authenticated +// @summary Generate a kubeconfig file +// @description Generate a kubeconfig file that allows a client to communicate with the Kubernetes API server +// @description **Access policy**: Authenticated user. // @tags kubernetes -// @security ApiKeyAuth -// @security jwt -// @accept json -// @produce json +// @security ApiKeyAuth || jwt +// @produce application/json, application/yaml // @param ids query []int false "will include only these environments(endpoints)" // @param excludeIds query []int false "will exclude these environments(endpoints)" -// @success 200 "Success" -// @failure 400 "Invalid request" -// @failure 401 "Unauthorized" -// @failure 403 "Permission denied" -// @failure 404 "Environment(Endpoint) or ServiceAccount not found" -// @failure 500 "Server error" +// @success 200 {object} interface{} "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to generate the kubeconfig file." // @router /kubernetes/config [get] func (handler *Handler) getKubernetesConfig(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { tokenData, err := security.RetrieveTokenData(r) if err != nil { + log.Error().Err(err).Str("context", "getKubernetesConfig").Msg("Permission denied to access environment") return httperror.Forbidden("Permission denied to access environment", err) } bearerToken, err := handler.JwtService.GenerateTokenForKubeconfig(tokenData) if err != nil { + log.Error().Err(err).Str("context", "getKubernetesConfig").Msg("Unable to generate JWT token") return httperror.InternalServerError("Unable to generate JWT token", err) } endpoints, handlerErr := handler.filterUserKubeEndpoints(r) if handlerErr != nil { + log.Error().Err(handlerErr).Str("context", "getKubernetesConfig").Msg("Unable to filter user kube endpoints") return handlerErr } if len(endpoints) == 0 { + log.Error().Str("context", "getKubernetesConfig").Msg("Empty endpoints list") return httperror.BadRequest("empty endpoints list", errors.New("empty endpoints list")) } @@ -67,16 +70,19 @@ func (handler *Handler) filterUserKubeEndpoints(r *http.Request) ([]portainer.En _ = request.RetrieveJSONQueryParameter(r, "excludeIds", &excludeEndpointIDs, true) if len(endpointIDs) > 0 && len(excludeEndpointIDs) > 0 { + log.Error().Str("context", "filterUserKubeEndpoints").Msg("Can't provide both 'ids' and 'excludeIds' parameters") return nil, httperror.BadRequest("Can't provide both 'ids' and 'excludeIds' parameters", errors.New("invalid parameters")) } securityContext, err := security.RetrieveRestrictedRequestContext(r) if err != nil { + log.Error().Err(err).Str("context", "filterUserKubeEndpoints").Msg("Unable to retrieve info from request context") return nil, httperror.InternalServerError("Unable to retrieve info from request context", err) } endpointGroups, err := handler.DataStore.EndpointGroup().ReadAll() if err != nil { + log.Error().Err(err).Str("context", "filterUserKubeEndpoints").Msg("Unable to retrieve environment groups from the database") return nil, httperror.InternalServerError("Unable to retrieve environment groups from the database", err) } @@ -85,6 +91,7 @@ func (handler *Handler) filterUserKubeEndpoints(r *http.Request) ([]portainer.En for _, endpointID := range endpointIDs { endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID) if err != nil { + log.Error().Err(err).Str("context", "filterUserKubeEndpoints").Msg("Unable to retrieve environment from the database") return nil, httperror.InternalServerError("Unable to retrieve environment from the database", err) } if !endpointutils.IsKubernetesEndpoint(endpoint) { @@ -101,6 +108,7 @@ func (handler *Handler) filterUserKubeEndpoints(r *http.Request) ([]portainer.En var kubeEndpoints []portainer.Endpoint endpoints, err := handler.DataStore.Endpoint().Endpoints() if err != nil { + log.Error().Err(err).Str("context", "filterUserKubeEndpoints").Msg("Unable to retrieve environments from the database") return nil, httperror.InternalServerError("Unable to retrieve environments from the database", err) } @@ -197,6 +205,7 @@ func writeFileContent(w http.ResponseWriter, r *http.Request, endpoints []portai if r.Header.Get("Accept") == "text/yaml" { yaml, err := kcli.GenerateYAML(config) if err != nil { + log.Error().Err(err).Str("context", "writeFileContent").Msg("Failed to generate Kubeconfig") return httperror.InternalServerError("Failed to generate Kubeconfig", err) } diff --git a/api/http/handler/kubernetes/configmaps.go b/api/http/handler/kubernetes/configmaps.go new file mode 100644 index 000000000..633afe92d --- /dev/null +++ b/api/http/handler/kubernetes/configmaps.go @@ -0,0 +1,159 @@ +package kubernetes + +import ( + "net/http" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" + k8serrors "k8s.io/apimachinery/pkg/api/errors" +) + +// @id GetKubernetesConfigMap +// @summary Get a ConfigMap +// @description Get a ConfigMap by name for a given namespace. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param namespace path string true "The namespace name where the configmap is located" +// @param configmap path string true "The configmap name to get details for" +// @success 200 {object} models.K8sConfigMap "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or a configmap with the specified name in the given namespace." +// @failure 500 "Server error occurred while attempting to retrieve a configmap by name within the specified namespace." +// @router /kubernetes/{id}/namespaces/{namespace}/configmaps/{configmap} [get] +func (handler *Handler) getKubernetesConfigMap(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesConfigMap").Str("namespace", namespace).Msg("Unable to retrieve namespace identifier route variable") + return httperror.BadRequest("Unable to retrieve namespace identifier route variable", err) + } + + configMapName, err := request.RetrieveRouteVariableValue(r, "configmap") + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesConfigMap").Str("namespace", namespace).Msg("Unable to retrieve configMap identifier route variable") + return httperror.BadRequest("Unable to retrieve configMap identifier route variable", err) + } + + cli, httpErr := handler.getProxyKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "getKubernetesConfigMap").Str("namespace", namespace).Str("configMap", configMapName).Msg("Unable to get a Kubernetes client for the user") + return httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr) + } + + configMap, err := cli.GetConfigMap(namespace, configMapName) + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "getKubernetesConfigMap").Str("namespace", namespace).Str("configMap", configMapName).Msg("Unauthorized access to the Kubernetes API") + return httperror.Forbidden("Unauthorized access to the Kubernetes API", err) + } + + if k8serrors.IsNotFound(err) { + log.Error().Err(err).Str("context", "getKubernetesConfigMap").Str("namespace", namespace).Str("configMap", configMapName).Msg("Unable to retrieve configMap") + return httperror.NotFound("Unable to retrieve configMap", err) + } + + log.Error().Err(err).Str("context", "getKubernetesConfigMap").Str("namespace", namespace).Str("configMap", configMapName).Msg("Unable to retrieve configMap") + return httperror.InternalServerError("Unable to retrieve configMap", err) + } + + configMapWithApplications, err := cli.CombineConfigMapWithApplications(configMap) + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesConfigMap").Str("namespace", namespace).Str("configMap", configMapName).Msg("Unable to combine configMap with applications") + return httperror.InternalServerError("Unable to combine configMap with applications", err) + } + + return response.JSON(w, configMapWithApplications) +} + +// @id GetAllKubernetesConfigMaps +// @summary Get a list of ConfigMaps +// @description Get a list of ConfigMaps across all namespaces in the cluster. For non-admin users, it will only return ConfigMaps based on the namespaces that they have access to. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param isUsed query bool true "Set to true to include information about applications that use the ConfigMaps in the response" +// @success 200 {array} models.K8sConfigMap "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve all configmaps from the cluster." +// @router /kubernetes/{id}/configmaps [get] +func (handler *Handler) GetAllKubernetesConfigMaps(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + configMaps, err := handler.getAllKubernetesConfigMaps(r) + if err != nil { + return err + } + + return response.JSON(w, configMaps) +} + +// @id GetAllKubernetesConfigMapsCount +// @summary Get ConfigMaps count +// @description Get the count of ConfigMaps across all namespaces in the cluster. For non-admin users, it will only return the count of ConfigMaps based on the namespaces that they have access to. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @success 200 {integer} integer "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the count of all configmaps from the cluster." +// @router /kubernetes/{id}/configmaps/count [get] +func (handler *Handler) getAllKubernetesConfigMapsCount(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + configMaps, err := handler.getAllKubernetesConfigMaps(r) + if err != nil { + return err + } + + return response.JSON(w, len(configMaps)) +} + +func (handler *Handler) getAllKubernetesConfigMaps(r *http.Request) ([]models.K8sConfigMap, *httperror.HandlerError) { + isUsed, err := request.RetrieveBooleanQueryParameter(r, "isUsed", true) + if err != nil { + log.Error().Err(err).Str("context", "getAllKubernetesConfigMaps").Msg("Unable to retrieve isUsed query parameter") + return nil, httperror.BadRequest("Unable to retrieve isUsed query parameter", err) + } + + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "getAllKubernetesConfigMaps").Msg("Unable to prepare kube client") + return nil, httperror.InternalServerError("Unable to prepare kube client", httpErr) + } + + configMaps, err := cli.GetConfigMaps("") + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "getAllKubernetesConfigMaps").Msg("Unauthorized access to the Kubernetes API") + return nil, httperror.Forbidden("Unauthorized access to the Kubernetes API", err) + } + + log.Error().Err(err).Str("context", "getAllKubernetesConfigMaps").Msg("Unable to get configMaps") + return nil, httperror.InternalServerError("Unable to get configMaps", err) + } + + if isUsed { + configMapsWithApplications, err := cli.CombineConfigMapsWithApplications(configMaps) + if err != nil { + log.Error().Err(err).Str("context", "getAllKubernetesConfigMaps").Msg("Unable to combine configMaps with associated applications") + return nil, httperror.InternalServerError("Unable to combine configMaps with associated applications", err) + } + + return configMapsWithApplications, nil + } + + return configMaps, nil +} diff --git a/api/http/handler/kubernetes/configmaps_and_secrets.go b/api/http/handler/kubernetes/configmaps_and_secrets.go deleted file mode 100644 index 0b6341b8b..000000000 --- a/api/http/handler/kubernetes/configmaps_and_secrets.go +++ /dev/null @@ -1,44 +0,0 @@ -package kubernetes - -import ( - "net/http" - - httperror "github.com/portainer/portainer/pkg/libhttp/error" - "github.com/portainer/portainer/pkg/libhttp/request" - "github.com/portainer/portainer/pkg/libhttp/response" -) - -// @id getKubernetesConfigMapsAndSecrets -// @summary Get ConfigMaps and Secrets -// @description Get all ConfigMaps and Secrets for a given namespace -// @description **Access policy**: authenticated -// @tags kubernetes -// @security ApiKeyAuth -// @security jwt -// @accept json -// @produce json -// @param id path int true "Environment (Endpoint) identifier" -// @param namespace path string true "Namespace name" -// @success 200 {array} []kubernetes.K8sConfigMapOrSecret "Success" -// @failure 400 "Invalid request" -// @failure 500 "Server error" -// @deprecated -// @router /kubernetes/{id}/namespaces/{namespace}/configuration [get] -func (handler *Handler) getKubernetesConfigMapsAndSecrets(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - namespace, err := request.RetrieveRouteVariableValue(r, "namespace") - if err != nil { - return httperror.BadRequest("Invalid namespace identifier route variable", err) - } - - cli, handlerErr := handler.getProxyKubeClient(r) - if handlerErr != nil { - return handlerErr - } - - configmaps, err := cli.GetConfigMapsAndSecrets(namespace) - if err != nil { - return httperror.InternalServerError("Unable to retrieve configmaps and secrets", err) - } - - return response.JSON(w, configmaps) -} diff --git a/api/http/handler/kubernetes/dashboard.go b/api/http/handler/kubernetes/dashboard.go index bb4787bd3..b27790f20 100644 --- a/api/http/handler/kubernetes/dashboard.go +++ b/api/http/handler/kubernetes/dashboard.go @@ -9,11 +9,10 @@ import ( // @id GetKubernetesDashboard // @summary Get the dashboard summary data -// @description Get the dashboard summary data which is simply a count of a range of different commonly used kubernetes resources -// @description **Access policy**: authenticated +// @description Get the dashboard summary data which is simply a count of a range of different commonly used kubernetes resources. +// @description **Access policy**: Authenticated user. // @tags kubernetes -// @security ApiKeyAuth -// @security jwt +// @security ApiKeyAuth || jwt // @accept json // @produce json // @param id path int true "Environment (Endpoint) identifier" diff --git a/api/http/handler/kubernetes/handler.go b/api/http/handler/kubernetes/handler.go index 9951cbc52..7b99a61fb 100644 --- a/api/http/handler/kubernetes/handler.go +++ b/api/http/handler/kubernetes/handler.go @@ -1,7 +1,7 @@ package kubernetes import ( - "errors" + "fmt" "net/http" "net/url" "strconv" @@ -11,11 +11,11 @@ import ( "github.com/portainer/portainer/api/http/middlewares" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" - "github.com/portainer/portainer/api/internal/endpointutils" "github.com/portainer/portainer/api/kubernetes" "github.com/portainer/portainer/api/kubernetes/cli" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/rs/zerolog/log" "github.com/gorilla/mux" ) @@ -49,94 +49,98 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza // endpoints endpointRouter := kubeRouter.PathPrefix("/{id}").Subrouter() endpointRouter.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id")) - endpointRouter.Use(kubeOnlyMiddleware) endpointRouter.Use(h.kubeClientMiddleware) + endpointRouter.Handle("/applications", httperror.LoggerHandler(h.GetAllKubernetesApplications)).Methods(http.MethodGet) + endpointRouter.Handle("/applications/count", httperror.LoggerHandler(h.getAllKubernetesApplicationsCount)).Methods(http.MethodGet) + endpointRouter.Handle("/configmaps", httperror.LoggerHandler(h.GetAllKubernetesConfigMaps)).Methods(http.MethodGet) + endpointRouter.Handle("/configmaps/count", httperror.LoggerHandler(h.getAllKubernetesConfigMapsCount)).Methods(http.MethodGet) + endpointRouter.Handle("/cluster_roles", httperror.LoggerHandler(h.getAllKubernetesClusterRoles)).Methods(http.MethodGet) + endpointRouter.Handle("/cluster_role_bindings", httperror.LoggerHandler(h.getAllKubernetesClusterRoleBindings)).Methods(http.MethodGet) + endpointRouter.Handle("/configmaps", httperror.LoggerHandler(h.GetAllKubernetesConfigMaps)).Methods(http.MethodGet) + endpointRouter.Handle("/configmaps/count", httperror.LoggerHandler(h.getAllKubernetesConfigMapsCount)).Methods(http.MethodGet) endpointRouter.Handle("/dashboard", httperror.LoggerHandler(h.getKubernetesDashboard)).Methods(http.MethodGet) endpointRouter.Handle("/nodes_limits", httperror.LoggerHandler(h.getKubernetesNodesLimits)).Methods(http.MethodGet) endpointRouter.Handle("/max_resource_limits", httperror.LoggerHandler(h.getKubernetesMaxResourceLimits)).Methods(http.MethodGet) + endpointRouter.Handle("/metrics/applications_resources", httperror.LoggerHandler(h.getApplicationsResources)).Methods(http.MethodGet) endpointRouter.Handle("/metrics/nodes", httperror.LoggerHandler(h.getKubernetesMetricsForAllNodes)).Methods(http.MethodGet) endpointRouter.Handle("/metrics/nodes/{name}", httperror.LoggerHandler(h.getKubernetesMetricsForNode)).Methods(http.MethodGet) endpointRouter.Handle("/metrics/pods/namespace/{namespace}", httperror.LoggerHandler(h.getKubernetesMetricsForAllPods)).Methods(http.MethodGet) endpointRouter.Handle("/metrics/pods/namespace/{namespace}/{name}", httperror.LoggerHandler(h.getKubernetesMetricsForPod)).Methods(http.MethodGet) - endpointRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.getKubernetesIngressControllers)).Methods(http.MethodGet) + endpointRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.getAllKubernetesIngressControllers)).Methods(http.MethodGet) endpointRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.updateKubernetesIngressControllers)).Methods(http.MethodPut) endpointRouter.Handle("/ingresses/delete", httperror.LoggerHandler(h.deleteKubernetesIngresses)).Methods(http.MethodPost) + endpointRouter.Handle("/ingresses", httperror.LoggerHandler(h.GetAllKubernetesClusterIngresses)).Methods(http.MethodGet) + endpointRouter.Handle("/ingresses/count", httperror.LoggerHandler(h.getAllKubernetesClusterIngressesCount)).Methods(http.MethodGet) + endpointRouter.Handle("/service_accounts", httperror.LoggerHandler(h.getAllKubernetesServiceAccounts)).Methods(http.MethodGet) + endpointRouter.Handle("/services", httperror.LoggerHandler(h.GetAllKubernetesServices)).Methods(http.MethodGet) + endpointRouter.Handle("/services/count", httperror.LoggerHandler(h.getAllKubernetesServicesCount)).Methods(http.MethodGet) + endpointRouter.Handle("/secrets", httperror.LoggerHandler(h.GetAllKubernetesSecrets)).Methods(http.MethodGet) + endpointRouter.Handle("/secrets/count", httperror.LoggerHandler(h.getAllKubernetesSecretsCount)).Methods(http.MethodGet) endpointRouter.Handle("/services/delete", httperror.LoggerHandler(h.deleteKubernetesServices)).Methods(http.MethodPost) - endpointRouter.Handle("/rbac_enabled", httperror.LoggerHandler(h.isRBACEnabled)).Methods(http.MethodGet) + endpointRouter.Handle("/rbac_enabled", httperror.LoggerHandler(h.getKubernetesRBACStatus)).Methods(http.MethodGet) + endpointRouter.Handle("/roles", httperror.LoggerHandler(h.getAllKubernetesRoles)).Methods(http.MethodGet) + endpointRouter.Handle("/role_bindings", httperror.LoggerHandler(h.getAllKubernetesRoleBindings)).Methods(http.MethodGet) endpointRouter.Handle("/namespaces", httperror.LoggerHandler(h.createKubernetesNamespace)).Methods(http.MethodPost) endpointRouter.Handle("/namespaces", httperror.LoggerHandler(h.updateKubernetesNamespace)).Methods(http.MethodPut) + endpointRouter.Handle("/namespaces", httperror.LoggerHandler(h.deleteKubernetesNamespace)).Methods(http.MethodDelete) endpointRouter.Handle("/namespaces", httperror.LoggerHandler(h.getKubernetesNamespaces)).Methods(http.MethodGet) - endpointRouter.Handle("/namespace/{namespace}", httperror.LoggerHandler(h.deleteKubernetesNamespace)).Methods(http.MethodDelete) + endpointRouter.Handle("/namespaces/count", httperror.LoggerHandler(h.getKubernetesNamespacesCount)).Methods(http.MethodGet) endpointRouter.Handle("/namespaces/{namespace}", httperror.LoggerHandler(h.getKubernetesNamespace)).Methods(http.MethodGet) + endpointRouter.Handle("/volumes", httperror.LoggerHandler(h.GetAllKubernetesVolumes)).Methods(http.MethodGet) + endpointRouter.Handle("/volumes/count", httperror.LoggerHandler(h.getAllKubernetesVolumesCount)).Methods(http.MethodGet) // namespaces // in the future this piece of code might be in another package (or a few different packages - namespaces/namespace?) // to keep it simple, we've decided to leave it like this. namespaceRouter := endpointRouter.PathPrefix("/namespaces/{namespace}").Subrouter() + namespaceRouter.Handle("/configmaps/{configmap}", httperror.LoggerHandler(h.getKubernetesConfigMap)).Methods(http.MethodGet) namespaceRouter.Handle("/system", bouncer.RestrictedAccess(httperror.LoggerHandler(h.namespacesToggleSystem))).Methods(http.MethodPut) namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.getKubernetesIngressControllersByNamespace)).Methods(http.MethodGet) namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.updateKubernetesIngressControllersByNamespace)).Methods(http.MethodPut) - namespaceRouter.Handle("/configuration", httperror.LoggerHandler(h.getKubernetesConfigMapsAndSecrets)).Methods(http.MethodGet) + namespaceRouter.Handle("/ingresses/{ingress}", httperror.LoggerHandler(h.getKubernetesIngress)).Methods(http.MethodGet) namespaceRouter.Handle("/ingresses", httperror.LoggerHandler(h.createKubernetesIngress)).Methods(http.MethodPost) namespaceRouter.Handle("/ingresses", httperror.LoggerHandler(h.updateKubernetesIngress)).Methods(http.MethodPut) namespaceRouter.Handle("/ingresses", httperror.LoggerHandler(h.getKubernetesIngresses)).Methods(http.MethodGet) + namespaceRouter.Handle("/secrets/{secret}", httperror.LoggerHandler(h.getKubernetesSecret)).Methods(http.MethodGet) namespaceRouter.Handle("/services", httperror.LoggerHandler(h.createKubernetesService)).Methods(http.MethodPost) namespaceRouter.Handle("/services", httperror.LoggerHandler(h.updateKubernetesService)).Methods(http.MethodPut) - namespaceRouter.Handle("/services", httperror.LoggerHandler(h.getKubernetesServices)).Methods(http.MethodGet) + namespaceRouter.Handle("/services", httperror.LoggerHandler(h.getKubernetesServicesByNamespace)).Methods(http.MethodGet) + namespaceRouter.Handle("/volumes/{volume}", httperror.LoggerHandler(h.getKubernetesVolume)).Methods(http.MethodGet) return h } -func kubeOnlyMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(rw http.ResponseWriter, request *http.Request) { - endpoint, err := middlewares.FetchEndpoint(request) - if err != nil { - httperror.InternalServerError( - "Unable to find an environment on request context", - err, - ) - return - } - - if !endpointutils.IsKubernetesEndpoint(endpoint) { - errMessage := "environment is not a Kubernetes environment" - httperror.BadRequest( - errMessage, - errors.New(errMessage), - ) - return - } - - rw.Header().Set(portainer.PortainerCacheHeader, "true") - next.ServeHTTP(rw, request) - }) -} - // getProxyKubeClient gets a kubeclient for the user. It's generally what you want as it retrieves the kubeclient // from the Authorization token of the currently logged in user. The kubeclient that is not from the proxy is actually using // admin permissions. If you're unsure which one to use, use this. func (h *Handler) getProxyKubeClient(r *http.Request) (*cli.KubeClient, *httperror.HandlerError) { endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { - return nil, httperror.BadRequest("Invalid environment identifier route variable", err) + return nil, httperror.BadRequest(fmt.Sprintf("an error occurred during the getProxyKubeClient operation, the environment identifier route variable is invalid for /api/kubernetes/%d. Error: ", endpointID), err) } tokenData, err := security.RetrieveTokenData(r) if err != nil { - return nil, httperror.Forbidden("Permission denied to access environment", err) + return nil, httperror.Forbidden(fmt.Sprintf("an error occurred during the getProxyKubeClient operation, permission denied to access the environment /api/kubernetes/%d. Error: ", endpointID), err) } cli, ok := h.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), tokenData.Token) if !ok { - return nil, httperror.InternalServerError("Failed to lookup KubeClient", nil) + return nil, httperror.InternalServerError("an error occurred during the getProxyKubeClient operation,failed to get proxy KubeClient", nil) } return cli, nil } +// kubeClientMiddleware is a middleware that will create a kubeclient for the user if it doesn't exist +// and store it in the factory for future use. +// if there is a kubeclient against this auth token already, the existing one will be reused. +// otherwise, generate a new one func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set(portainer.PortainerCacheHeader, "true") + if handler.KubernetesClientFactory == nil { next.ServeHTTP(w, r) return @@ -144,13 +148,13 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler { endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { - httperror.WriteError(w, http.StatusBadRequest, "Invalid environment identifier route variable", err) + httperror.WriteError(w, http.StatusBadRequest, fmt.Sprintf("an error occurred during the KubeClientMiddleware operation, the environment identifier route variable is invalid for /api/kubernetes/%d. Error: ", endpointID), err) return } tokenData, err := security.RetrieveTokenData(r) if err != nil { - httperror.WriteError(w, http.StatusForbidden, "Permission denied to access environment", err) + httperror.WriteError(w, http.StatusForbidden, "an error occurred during the KubeClientMiddleware operation, permission denied to access the environment. Error: ", err) } // Check if we have a kubeclient against this auth token already, otherwise generate a new one @@ -163,35 +167,60 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler { endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) if err != nil { if handler.DataStore.IsErrObjectNotFound(err) { - httperror.WriteError( - w, - http.StatusNotFound, - "Unable to find an environment with the specified identifier inside the database", - err, - ) + httperror.WriteError(w, http.StatusNotFound, + "an error occurred during the KubeClientMiddleware operation, unable to find an environment with the specified environment identifier inside the database. Error: ", err) return } - httperror.WriteError(w, http.StatusInternalServerError, "Unable to read the environment from the database", err) + httperror.WriteError(w, http.StatusInternalServerError, "an error occurred during the KubeClientMiddleware operation, error reading from the Portainer database. Error: ", err) return } + user, err := security.RetrieveUserFromRequest(r, handler.DataStore) + if err != nil { + httperror.InternalServerError("an error occurred during the KubeClientMiddleware operation, unable to retrieve the user from request. Error: ", err) + return + } + log. + Debug(). + Str("context", "KubeClientMiddleware"). + Str("endpoint", endpoint.Name). + Str("user", user.Username). + Msg("Creating a Kubernetes client") + + isKubeAdmin := true + nonAdminNamespaces := []string{} + if user.Role != portainer.AdministratorRole { + pcli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint) + if err != nil { + httperror.WriteError(w, http.StatusInternalServerError, "an error occurred during the KubeClientMiddleware operation, unable to get privileged kube client to grab all namespaces. Error: ", err) + return + } + + nonAdminNamespaces, err = pcli.GetNonAdminNamespaces(int(user.ID)) + if err != nil { + httperror.WriteError(w, http.StatusInternalServerError, "an error occurred during the KubeClientMiddleware operation, unable to retrieve non-admin namespaces. Error: ", err) + return + } + isKubeAdmin = false + } + bearerToken, err := handler.JwtService.GenerateTokenForKubeconfig(tokenData) if err != nil { - httperror.WriteError(w, http.StatusInternalServerError, "Unable to create JWT token", err) + httperror.WriteError(w, http.StatusInternalServerError, "an error occurred during the KubeClientMiddleware operation, unable to generate token for kubeconfig. Error: ", err) return } config := handler.buildConfig(r, tokenData, bearerToken, []portainer.Endpoint{*endpoint}, true) if len(config.Clusters) == 0 { - httperror.WriteError(w, http.StatusInternalServerError, "Unable build cluster kubeconfig", nil) + httperror.WriteError(w, http.StatusInternalServerError, "an error occurred during the KubeClientMiddleware operation, unable to build kubeconfig. Error: ", nil) return } // Manually setting serverURL to localhost to route the request to proxy server serverURL, err := url.Parse(config.Clusters[0].Cluster.Server) if err != nil { - httperror.WriteError(w, http.StatusInternalServerError, "Unable parse cluster's kubeconfig server URL", nil) + httperror.WriteError(w, http.StatusInternalServerError, "an error occurred during the KubeClientMiddleware operation, unable to parse server URL for building kubeconfig. Error: ", err) return } serverURL.Scheme = "https" @@ -200,17 +229,12 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler { yaml, err := cli.GenerateYAML(config) if err != nil { - httperror.WriteError( - w, - http.StatusInternalServerError, - "Unable to generate yaml from endpoint kubeconfig", - err, - ) + httperror.WriteError(w, http.StatusInternalServerError, "an error occurred during the KubeClientMiddleware operation, unable to generate kubeconfig YAML. Error: ", err) return } - kubeCli, err := handler.KubernetesClientFactory.CreateKubeClientFromKubeConfig(endpoint.Name, []byte(yaml)) + kubeCli, err := handler.KubernetesClientFactory.CreateKubeClientFromKubeConfig(endpoint.Name, []byte(yaml), isKubeAdmin, nonAdminNamespaces) if err != nil { - httperror.WriteError(w, http.StatusInternalServerError, "Failed to create client from kubeconfig", err) + httperror.WriteError(w, http.StatusInternalServerError, "an error occurred during the KubeClientMiddleware operation, unable to create kubernetes client from kubeconfig. Error: ", err) return } diff --git a/api/http/handler/kubernetes/ingresses.go b/api/http/handler/kubernetes/ingresses.go index 6ac3fceaa..b17d11f4c 100644 --- a/api/http/handler/kubernetes/ingresses.go +++ b/api/http/handler/kubernetes/ingresses.go @@ -10,67 +10,65 @@ import ( httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" + k8serrors "k8s.io/apimachinery/pkg/api/errors" ) -// @id getKubernetesIngressControllers +// @id GetAllKubernetesIngressControllers // @summary Get a list of ingress controllers -// @description Get a list of ingress controllers for the given environment -// @description **Access policy**: authenticated +// @description Get a list of ingress controllers for the given environment. If the allowedOnly query parameter is set, only ingress controllers that are allowed by the environment's ingress configuration will be returned. +// @description **Access policy**: Authenticated user. // @tags kubernetes -// @security ApiKeyAuth -// @security jwt -// @accept json +// @security ApiKeyAuth || jwt // @produce json -// @param id path int true "Environment (Endpoint) identifier" +// @param id path int true "Environment identifier" // @param allowedOnly query boolean false "Only return allowed ingress controllers" // @success 200 {object} models.K8sIngressControllers "Success" -// @failure 400 "Invalid request" -// @failure 500 "Server error" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve ingress controllers" // @router /kubernetes/{id}/ingresscontrollers [get] -func (handler *Handler) getKubernetesIngressControllers(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { +func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { - return httperror.BadRequest( - "Invalid environment identifier route variable", - err, - ) + log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Invalid environment identifier route variable") + return httperror.BadRequest("Invalid environment identifier route variable", err) } endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) - if handler.DataStore.IsErrObjectNotFound(err) { - return httperror.NotFound( - "Unable to find an environment with the specified identifier inside the database", - err, - ) - } else if err != nil { - return httperror.InternalServerError( - "Unable to find an environment with the specified identifier inside the database", - err, - ) + if err != nil { + if handler.DataStore.IsErrObjectNotFound(err) { + log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database") + return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) + } + + log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database") + return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err) } allowedOnly, err := request.RetrieveBooleanQueryParameter(r, "allowedOnly", true) if err != nil { - return httperror.BadRequest( - "Invalid allowedOnly boolean query parameter", - err, - ) + log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to retrieve allowedOnly query parameter") + return httperror.BadRequest("Unable to retrieve allowedOnly query parameter", err) } - cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint) + cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint) if err != nil { - return httperror.InternalServerError( - "Unable to create Kubernetes client", - err, - ) + log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to get privileged kube client") + return httperror.InternalServerError("Unable to get privileged kube client", err) } controllers, err := cli.GetIngressControllers() if err != nil { - return httperror.InternalServerError( - "Failed to fetch ingressclasses", - err, - ) + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unauthorized access to the Kubernetes API") + return httperror.Forbidden("Unauthorized access to the Kubernetes API", err) + } + + log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to retrieve ingress controllers from the Kubernetes") + return httperror.InternalServerError("Unable to retrieve ingress controllers from the Kubernetes", err) } // Add none controller if "AllowNone" is set for endpoint. @@ -82,16 +80,17 @@ func (handler *Handler) getKubernetesIngressControllers(w http.ResponseWriter, r }) } existingClasses := endpoint.Kubernetes.Configuration.IngressClasses - var updatedClasses []portainer.KubernetesIngressClassConfig + updatedClasses := []portainer.KubernetesIngressClassConfig{} for i := range controllers { controllers[i].Availability = true if controllers[i].ClassName != "none" { controllers[i].New = true } - var updatedClass portainer.KubernetesIngressClassConfig - updatedClass.Name = controllers[i].ClassName - updatedClass.Type = controllers[i].Type + updatedClass := portainer.KubernetesIngressClassConfig{ + Name: controllers[i].ClassName, + Type: controllers[i].Type, + } // Check if the controller is already known. for _, existingClass := range existingClasses { @@ -112,16 +111,14 @@ func (handler *Handler) getKubernetesIngressControllers(w http.ResponseWriter, r endpoint, ) if err != nil { - return httperror.InternalServerError( - "Unable to store found IngressClasses inside the database", - err, - ) + log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to store found IngressClasses inside the database") + return httperror.InternalServerError("Unable to store found IngressClasses inside the database", err) } // If the allowedOnly query parameter was set. We need to prune out // disallowed controllers from the response. if allowedOnly { - var allowedControllers models.K8sIngressControllers + allowedControllers := models.K8sIngressControllers{} for _, controller := range controllers { if controller.Availability { allowedControllers = append(allowedControllers, controller) @@ -132,62 +129,61 @@ func (handler *Handler) getKubernetesIngressControllers(w http.ResponseWriter, r return response.JSON(w, controllers) } -// @id getKubernetesIngressControllersByNamespace +// @id GetKubernetesIngressControllersByNamespace // @summary Get a list ingress controllers by namespace -// @description Get a list of ingress controllers for the given environment in the provided namespace -// @description **Access policy**: authenticated +// @description Get a list of ingress controllers for the given environment in the provided namespace. +// @description **Access policy**: Authenticated user. // @tags kubernetes -// @security ApiKeyAuth -// @security jwt -// @accept json +// @security ApiKeyAuth || jwt // @produce json -// @param id path int true "Environment (Endpoint) identifier" +// @param id path int true "Environment identifier" // @param namespace path string true "Namespace" // @success 200 {object} models.K8sIngressControllers "Success" -// @failure 400 "Invalid request" -// @failure 500 "Server error" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or a namespace with the specified name." +// @failure 500 "Server error occurred while attempting to retrieve ingress controllers by a namespace" // @router /kubernetes/{id}/namespaces/{namespace}/ingresscontrollers [get] func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { - return httperror.BadRequest( - "Invalid environment identifier route variable", - err, - ) + log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to retrieve environment identifier from request") + return httperror.BadRequest("Unable to retrieve environment identifier from request", err) } endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) - if handler.DataStore.IsErrObjectNotFound(err) { - return httperror.NotFound( - "Unable to find an environment with the specified identifier inside the database", - err, - ) - } else if err != nil { - return httperror.InternalServerError( - "Unable to find an environment with the specified identifier inside the database", - err, - ) + if err != nil { + if handler.DataStore.IsErrObjectNotFound(err) { + log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to find an environment with the specified identifier inside the database") + return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) + } + + log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to find an environment with the specified identifier inside the database") + return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err) + } + + cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint) + if err != nil { + log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to create Kubernetes client") + return httperror.InternalServerError("Unable to create Kubernetes client", err) } namespace, err := request.RetrieveRouteVariableValue(r, "namespace") if err != nil { - return httperror.BadRequest( - "Invalid namespace identifier route variable", - err, - ) - } - - cli, handlerErr := handler.getProxyKubeClient(r) - if handlerErr != nil { - return handlerErr + log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to retrieve namespace from request") + return httperror.BadRequest("Unable to retrieve namespace from request", err) } currentControllers, err := cli.GetIngressControllers() if err != nil { - return httperror.InternalServerError( - "Failed to fetch ingressclasses", - err, - ) + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Str("namespace", namespace).Msg("Unauthorized access to the Kubernetes API") + return httperror.Forbidden("Unauthorized access to the Kubernetes API", err) + } + + log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Str("namespace", namespace).Msg("Unable to retrieve ingress controllers from the Kubernetes") + return httperror.InternalServerError("Unable to retrieve ingress controllers from the Kubernetes", err) } // Add none controller if "AllowNone" is set for endpoint. if endpoint.Kubernetes.Configuration.AllowNoneIngressClass { @@ -197,21 +193,24 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon Type: "custom", }) } + kubernetesConfig := endpoint.Kubernetes.Configuration existingClasses := kubernetesConfig.IngressClasses ingressAvailabilityPerNamespace := kubernetesConfig.IngressAvailabilityPerNamespace - var updatedClasses []portainer.KubernetesIngressClassConfig - var controllers models.K8sIngressControllers + updatedClasses := []portainer.KubernetesIngressClassConfig{} + controllers := models.K8sIngressControllers{} + for i := range currentControllers { - var globallyblocked bool + globallyblocked := false currentControllers[i].Availability = true if currentControllers[i].ClassName != "none" { currentControllers[i].New = true } - var updatedClass portainer.KubernetesIngressClassConfig - updatedClass.Name = currentControllers[i].ClassName - updatedClass.Type = currentControllers[i].Type + updatedClass := portainer.KubernetesIngressClassConfig{ + Name: currentControllers[i].ClassName, + Type: currentControllers[i].Type, + } // Check if the controller is blocked globally or in the current // namespace. @@ -243,81 +242,77 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon // Update the database to match the list of found controllers. // This includes pruning out controllers which no longer exist. endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses - err = handler.DataStore.Endpoint().UpdateEndpoint( - portainer.EndpointID(endpointID), - endpoint, - ) + err = handler.DataStore.Endpoint().UpdateEndpoint(portainer.EndpointID(endpointID), endpoint) if err != nil { - return httperror.InternalServerError( - "Unable to store found IngressClasses inside the database", - err, - ) + log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to store found IngressClasses inside the database") + return httperror.InternalServerError("Unable to store found IngressClasses inside the database", err) } return response.JSON(w, controllers) } -// @id updateKubernetesIngressControllers +// @id UpdateKubernetesIngressControllers // @summary Update (block/unblock) ingress controllers -// @description Update (block/unblock) ingress controllers -// @description **Access policy**: authenticated +// @description Update (block/unblock) ingress controllers for the provided environment. +// @description **Access policy**: Authenticated user. // @tags kubernetes -// @security ApiKeyAuth -// @security jwt +// @security ApiKeyAuth || jwt // @accept json // @produce json -// @param id path int true "Environment (Endpoint) identifier" -// @param body body []models.K8sIngressControllers true "Ingress controllers" -// @success 200 {string} string "Success" -// @failure 400 "Invalid request" -// @failure 500 "Server error" +// @param id path int true "Environment identifier" +// @param body body models.K8sIngressControllers true "Ingress controllers" +// @success 204 "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or unable to find the ingress controllers to update." +// @failure 500 "Server error occurred while attempting to update ingress controllers." // @router /kubernetes/{id}/ingresscontrollers [put] func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { - return httperror.BadRequest( - "Invalid environment identifier route variable", - err, - ) + log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to retrieve environment identifier from request") + return httperror.BadRequest("Unable to retrieve environment identifier from request", err) } endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) - if handler.DataStore.IsErrObjectNotFound(err) { - return httperror.NotFound( - "Unable to find an environment with the specified identifier inside the database", - err, - ) - } else if err != nil { - return httperror.InternalServerError( - "Unable to find an environment with the specified identifier inside the database", - err, - ) + if err != nil { + if handler.DataStore.IsErrObjectNotFound(err) { + log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database") + return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) + } + + log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database") + return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err) } - var payload models.K8sIngressControllers + payload := models.K8sIngressControllers{} err = request.DecodeAndValidateJSONPayload(r, &payload) if err != nil { - return httperror.BadRequest( - "Invalid request payload", - err, - ) + log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to decode and validate the request payload") + return httperror.BadRequest("Unable to decode and validate the request payload", err) } - cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint) + cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint) if err != nil { - return httperror.InternalServerError( - "Unable to create Kubernetes client", - err, - ) + log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to get privileged kube client") + return httperror.InternalServerError("Unable to get privileged kube client", err) } existingClasses := endpoint.Kubernetes.Configuration.IngressClasses controllers, err := cli.GetIngressControllers() if err != nil { - return httperror.InternalServerError( - "Unable to get ingress controllers", - err, - ) + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unauthorized access to the Kubernetes API") + return httperror.Forbidden("Unauthorized access to the Kubernetes API", err) + } + + if k8serrors.IsNotFound(err) { + log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to retrieve ingress controllers from the Kubernetes") + return httperror.NotFound("Unable to retrieve ingress controllers from the Kubernetes", err) + } + + log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to retrieve ingress controllers from the Kubernetes") + return httperror.InternalServerError("Unable to retrieve ingress controllers from the Kubernetes", err) } // Add none controller if "AllowNone" is set for endpoint. @@ -329,14 +324,15 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter }) } - var updatedClasses []portainer.KubernetesIngressClassConfig + updatedClasses := []portainer.KubernetesIngressClassConfig{} for i := range controllers { controllers[i].Availability = true controllers[i].New = true - var updatedClass portainer.KubernetesIngressClassConfig - updatedClass.Name = controllers[i].ClassName - updatedClass.Type = controllers[i].Type + updatedClass := portainer.KubernetesIngressClassConfig{ + Name: controllers[i].ClassName, + Type: controllers[i].Type, + } // Check if the controller is already known. for _, existingClass := range existingClasses { @@ -366,59 +362,64 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter endpoint, ) if err != nil { - return httperror.InternalServerError( - "Unable to update the BlockedIngressClasses inside the database", - err, - ) + log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to store found IngressClasses inside the database") + return httperror.InternalServerError("Unable to store found IngressClasses inside the database", err) } + return response.Empty(w) } -// @id updateKubernetesIngressControllersByNamespace +// @id UpdateKubernetesIngressControllersByNamespace // @summary Update (block/unblock) ingress controllers by namespace -// @description Update (block/unblock) ingress controllers by namespace for the provided environment -// @description **Access policy**: authenticated +// @description Update (block/unblock) ingress controllers by namespace for the provided environment. +// @description **Access policy**: Authenticated user. // @tags kubernetes -// @security ApiKeyAuth -// @security jwt +// @security ApiKeyAuth || jwt // @accept json // @produce json -// @param id path int true "Environment (Endpoint) identifier" +// @param id path int true "Environment identifier" // @param namespace path string true "Namespace name" -// @param body body []models.K8sIngressControllers true "Ingress controllers" -// @success 200 {string} string "Success" -// @failure 400 "Invalid request" -// @failure 500 "Server error" +// @param body body models.K8sIngressControllers true "Ingress controllers" +// @success 204 "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to update ingress controllers by namespace." // @router /kubernetes/{id}/namespaces/{namespace}/ingresscontrollers [put] func (handler *Handler) updateKubernetesIngressControllersByNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { endpoint, err := middlewares.FetchEndpoint(r) if err != nil { - return httperror.NotFound("Unable to find an environment on request context", err) + log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Msg("Unable to fetch endpoint") + return httperror.NotFound("Unable to fetch endpoint", err) } namespace, err := request.RetrieveRouteVariableValue(r, "namespace") if err != nil { - return httperror.BadRequest("Invalid namespace identifier route variable", err) + log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Msg("Unable to retrieve namespace from request") + return httperror.BadRequest("Unable to retrieve namespace from request", err) } - var payload models.K8sIngressControllers + payload := models.K8sIngressControllers{} err = request.DecodeAndValidateJSONPayload(r, &payload) if err != nil { - return httperror.BadRequest("Invalid request payload", err) + log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Str("namespace", namespace).Msg("Unable to decode and validate the request payload") + return httperror.BadRequest("Unable to decode and validate the request payload", err) } existingClasses := endpoint.Kubernetes.Configuration.IngressClasses - var updatedClasses []portainer.KubernetesIngressClassConfig + updatedClasses := []portainer.KubernetesIngressClassConfig{} PayloadLoop: for _, p := range payload { for _, existingClass := range existingClasses { if p.ClassName != existingClass.Name { continue } - var updatedClass portainer.KubernetesIngressClassConfig - updatedClass.Name = existingClass.Name - updatedClass.Type = existingClass.Type - updatedClass.GloballyBlocked = existingClass.GloballyBlocked + updatedClass := portainer.KubernetesIngressClassConfig{ + Name: existingClass.Name, + Type: existingClass.Type, + GloballyBlocked: existingClass.GloballyBlocked, + } // Handle "allow" if p.Availability { @@ -445,10 +446,7 @@ PayloadLoop: continue PayloadLoop } } - updatedClass.BlockedNamespaces = append( - updatedClass.BlockedNamespaces, - namespace, - ) + updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, namespace) updatedClasses = append(updatedClasses, updatedClass) } } @@ -458,7 +456,7 @@ PayloadLoop: // part of updatedClasses, but we MUST include it or we would remove the // global block. for _, existingClass := range existingClasses { - var found bool + found := false for _, updatedClass := range updatedClasses { if existingClass.Name == updatedClass.Name { @@ -474,32 +472,125 @@ PayloadLoop: err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint) if err != nil { - return httperror.InternalServerError("Unable to update the BlockedIngressClasses inside the database", err) + log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Str("namespace", namespace).Msg("Unable to store BlockedIngressClasses inside the database") + return httperror.InternalServerError("Unable to store BlockedIngressClasses inside the database", err) } return response.Empty(w) } -// @id getKubernetesIngresses -// @summary Get kubernetes ingresses by namespace -// @description Get kubernetes ingresses by namespace for the provided environment -// @description **Access policy**: authenticated +// @id GetAllKubernetesClusterIngresses +// @summary Get kubernetes ingresses at the cluster level +// @description Get kubernetes ingresses at the cluster level for the provided environment. +// @description **Access policy**: Authenticated user. // @tags kubernetes -// @security ApiKeyAuth -// @security jwt -// @accept json +// @security ApiKeyAuth || jwt // @produce json -// @param id path int true "Environment (Endpoint) identifier" +// @param id path int true "Environment identifier" +// @param withServices query boolean false "Lookup services associated with each ingress" +// @success 200 {array} models.K8sIngressInfo "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve ingresses." +// @router /kubernetes/{id}/ingresses [get] +func (handler *Handler) GetAllKubernetesClusterIngresses(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + ingresses, err := handler.getKubernetesClusterIngresses(r) + if err != nil { + return err + } + + return response.JSON(w, ingresses) +} + +// @id GetAllKubernetesClusterIngressesCount +// @summary Get Ingresses count +// @description Get the number of kubernetes ingresses within the given environment. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @success 200 {integer} integer "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve ingresses count." +// @router /kubernetes/{id}/ingresses/count [get] +func (handler *Handler) getAllKubernetesClusterIngressesCount(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + ingresses, err := handler.getKubernetesClusterIngresses(r) + if err != nil { + return err + } + + return response.JSON(w, len(ingresses)) +} + +func (handler *Handler) getKubernetesClusterIngresses(r *http.Request) ([]models.K8sIngressInfo, *httperror.HandlerError) { + withServices, err := request.RetrieveBooleanQueryParameter(r, "withServices", true) + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesClusterIngresses").Msg("Unable to retrieve withApplications query parameter") + return nil, httperror.BadRequest("Unable to retrieve withApplications query parameter", err) + } + + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "getKubernetesClusterIngresses").Msg("Unable to get a Kubernetes client for the user") + return nil, httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr) + } + + ingresses, err := cli.GetIngresses("") + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "getKubernetesClusterIngresses").Msg("Unauthorized access to the Kubernetes API") + return nil, httperror.Forbidden("Unauthorized access to the Kubernetes API", err) + } + + if k8serrors.IsNotFound(err) { + log.Error().Err(err).Str("context", "getKubernetesClusterIngresses").Msg("Unable to retrieve ingresses from the Kubernetes for a cluster level user") + return nil, httperror.NotFound("Unable to retrieve ingresses from the Kubernetes for a cluster level user", err) + } + + log.Error().Err(err).Str("context", "getKubernetesClusterIngresses").Msg("Unable to retrieve ingresses from the Kubernetes for a cluster level user") + return nil, httperror.InternalServerError("Unable to retrieve ingresses from the Kubernetes for a cluster level user", err) + } + + if withServices { + ingressesWithServices, err := cli.CombineIngressesWithServices(ingresses) + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesClusterIngresses").Msg("Unable to combine ingresses with services") + return nil, httperror.InternalServerError("Unable to combine ingresses with services", err) + } + + return ingressesWithServices, nil + } + + return ingresses, nil +} + +// @id GetAllKubernetesIngresses +// @summary Get a list of Ingresses +// @description Get a list of Ingresses. If namespace is provided, it will return the list of Ingresses in that namespace. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" // @param namespace path string true "Namespace name" -// @param body body []models.K8sIngressInfo true "Ingress details" -// @success 200 {string} string "Success" -// @failure 400 "Invalid request" -// @failure 500 "Server error" +// @success 200 {array} models.K8sIngressInfo "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve ingresses" // @router /kubernetes/{id}/namespaces/{namespace}/ingresses [get] func (handler *Handler) getKubernetesIngresses(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { namespace, err := request.RetrieveRouteVariableValue(r, "namespace") if err != nil { - return httperror.BadRequest("Invalid namespace identifier route variable", err) + log.Error().Err(err).Str("context", "getKubernetesIngresses").Msg("Unable to retrieve namespace from request") + return httperror.BadRequest("Unable to retrieve namespace from request", err) } cli, handlerErr := handler.getProxyKubeClient(r) @@ -509,38 +600,103 @@ func (handler *Handler) getKubernetesIngresses(w http.ResponseWriter, r *http.Re ingresses, err := cli.GetIngresses(namespace) if err != nil { - return httperror.InternalServerError("Unable to retrieve ingresses", err) + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "getKubernetesIngresses").Str("namespace", namespace).Msg("Unauthorized access to the Kubernetes API") + return httperror.Forbidden("Unauthorized access to the Kubernetes API", err) + } + + log.Error().Err(err).Str("context", "getKubernetesIngresses").Str("namespace", namespace).Msg("Unable to retrieve ingresses from the Kubernetes for a namespace level user") + return httperror.InternalServerError("Unable to retrieve ingresses from the Kubernetes for a namespace level user", err) } return response.JSON(w, ingresses) } -// @id createKubernetesIngress -// @summary Create a kubernetes ingress by namespace -// @description Create a kubernetes ingress by namespace for the provided environment -// @description **Access policy**: authenticated +// @id GetKubernetesIngress +// @summary Get an Ingress by name +// @description Get an Ingress by name for the provided environment. +// @description **Access policy**: Authenticated user. // @tags kubernetes -// @security ApiKeyAuth -// @security jwt +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param namespace path string true "Namespace name" +// @param ingress path string true "Ingress name" +// @success 200 {object} models.K8sIngressInfo "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or unable to find an ingress with the specified name." +// @failure 500 "Server error occurred while attempting to retrieve an ingress." +// @router /kubernetes/{id}/namespaces/{namespace}/ingresses/{ingress} [get] +func (handler *Handler) getKubernetesIngress(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesIngress").Msg("Unable to retrieve namespace from request") + return httperror.BadRequest("Unable to retrieve namespace from request", err) + } + + ingressName, err := request.RetrieveRouteVariableValue(r, "ingress") + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesIngress").Msg("Unable to retrieve ingress from request") + return httperror.BadRequest("Unable to retrieve ingress from request", err) + } + + cli, handlerErr := handler.getProxyKubeClient(r) + if handlerErr != nil { + return handlerErr + } + + ingress, err := cli.GetIngress(namespace, ingressName) + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "getKubernetesIngress").Str("namespace", namespace).Str("ingress", ingressName).Msg("Unauthorized access to the Kubernetes API") + return httperror.Forbidden("Unauthorized access to the Kubernetes API", err) + } + + if k8serrors.IsNotFound(err) { + log.Error().Err(err).Str("context", "getKubernetesIngress").Str("namespace", namespace).Str("ingress", ingressName).Msg("Unable to retrieve ingress from the Kubernetes for a namespace level user") + return httperror.NotFound("Unable to retrieve ingress from the Kubernetes for a namespace level user", err) + } + + log.Error().Err(err).Str("context", "getKubernetesIngress").Str("namespace", namespace).Str("ingress", ingressName).Msg("Unable to retrieve ingress from the Kubernetes for a namespace level user") + return httperror.InternalServerError("Unable to retrieve ingress from the Kubernetes for a namespace level user", err) + } + + return response.JSON(w, ingress) +} + +// @id CreateKubernetesIngress +// @summary Create an Ingress +// @description Create an Ingress for the provided environment. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt // @accept json // @produce json -// @param id path int true "Environment (Endpoint) identifier" +// @param id path int true "Environment identifier" // @param namespace path string true "Namespace name" // @param body body models.K8sIngressInfo true "Ingress details" -// @success 200 {string} string "Success" -// @failure 400 "Invalid request" -// @failure 500 "Server error" +// @success 204 "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 409 "Conflict - an ingress with the same name already exists in the specified namespace." +// @failure 500 "Server error occurred while attempting to create an ingress." // @router /kubernetes/{id}/namespaces/{namespace}/ingresses [post] func (handler *Handler) createKubernetesIngress(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { namespace, err := request.RetrieveRouteVariableValue(r, "namespace") if err != nil { - return httperror.BadRequest("Invalid namespace identifier route variable", err) + log.Error().Err(err).Str("context", "createKubernetesIngress").Msg("Unable to retrieve namespace from request") + return httperror.BadRequest("Unable to retrieve namespace from request", err) } - var payload models.K8sIngressInfo + payload := models.K8sIngressInfo{} err = request.DecodeAndValidateJSONPayload(r, &payload) if err != nil { - return httperror.BadRequest("Invalid request payload", err) + log.Error().Err(err).Str("context", "createKubernetesIngress").Msg("Unable to decode and validate the request payload") + return httperror.BadRequest("Unable to decode and validate the request payload", err) } owner := "admin" @@ -556,26 +712,39 @@ func (handler *Handler) createKubernetesIngress(w http.ResponseWriter, r *http.R err = cli.CreateIngress(namespace, payload, owner) if err != nil { - return httperror.InternalServerError("Unable to retrieve the ingress", err) + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "createKubernetesIngress").Str("namespace", namespace).Msg("Unauthorized access to the Kubernetes API") + return httperror.Forbidden("Unauthorized access to the Kubernetes API", err) + } + + if k8serrors.IsAlreadyExists(err) { + log.Error().Err(err).Str("context", "createKubernetesIngress").Str("namespace", namespace).Msg("Ingress already exists") + return httperror.Conflict("Ingress already exists", err) + } + + log.Error().Err(err).Str("context", "createKubernetesIngress").Str("namespace", namespace).Msg("Unable to create an ingress") + return httperror.InternalServerError("Unable to create an ingress", err) } return response.Empty(w) } -// @id deleteKubernetesIngresses -// @summary Delete kubernetes ingresses -// @description Delete kubernetes ingresses for the provided environment -// @description **Access policy**: authenticated +// @id DeleteKubernetesIngresses +// @summary Delete one or more Ingresses +// @description Delete one or more Ingresses in the provided environment. +// @description **Access policy**: Authenticated user. // @tags kubernetes -// @security ApiKeyAuth -// @security jwt +// @security ApiKeyAuth || jwt // @accept json // @produce json -// @param id path int true "Environment (Endpoint) identifier" +// @param id path int true "Environment identifier" // @param body body models.K8sIngressDeleteRequests true "Ingress details" -// @success 200 {string} string "Success" -// @failure 400 "Invalid request" -// @failure 500 "Server error" +// @success 204 "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or unable to find a specific ingress." +// @failure 500 "Server error occurred while attempting to delete specified ingresses." // @router /kubernetes/{id}/ingresses/delete [post] func (handler *Handler) deleteKubernetesIngresses(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { cli, handlerErr := handler.getProxyKubeClient(r) @@ -583,46 +752,62 @@ func (handler *Handler) deleteKubernetesIngresses(w http.ResponseWriter, r *http return handlerErr } - var payload models.K8sIngressDeleteRequests + payload := models.K8sIngressDeleteRequests{} err := request.DecodeAndValidateJSONPayload(r, &payload) if err != nil { - return httperror.BadRequest("Invalid request payload", err) + log.Error().Err(err).Str("context", "deleteKubernetesIngresses").Msg("Unable to decode and validate the request payload") + return httperror.BadRequest("Unable to decode and validate the request payload", err) } err = cli.DeleteIngresses(payload) if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "deleteKubernetesIngresses").Msg("Unauthorized access to the Kubernetes API") + return httperror.Forbidden("Unauthorized access to the Kubernetes API", err) + } + + if k8serrors.IsNotFound(err) { + log.Error().Err(err).Str("context", "deleteKubernetesIngresses").Msg("Unable to retrieve ingresses from the Kubernetes for a namespace level user") + return httperror.NotFound("Unable to retrieve ingresses from the Kubernetes for a namespace level user", err) + } + + log.Error().Err(err).Str("context", "deleteKubernetesIngresses").Msg("Unable to delete ingresses") return httperror.InternalServerError("Unable to delete ingresses", err) } return response.Empty(w) } -// @id updateKubernetesIngress -// @summary Update kubernetes ingress rule -// @description Update kubernetes ingress rule for the provided environment -// @description **Access policy**: authenticated +// @id UpdateKubernetesIngress +// @summary Update an Ingress +// @description Update an Ingress for the provided environment. +// @description **Access policy**: Authenticated user. // @tags kubernetes -// @security ApiKeyAuth -// @security jwt +// @security ApiKeyAuth || jwt // @accept json // @produce json -// @param id path int true "Environment (Endpoint) identifier" +// @param id path int true "Environment identifier" // @param namespace path string true "Namespace name" // @param body body models.K8sIngressInfo true "Ingress details" -// @success 200 {string} string "Success" -// @failure 400 "Invalid request" -// @failure 500 "Server error" +// @success 204 "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or unable to find the specified ingress." +// @failure 500 "Server error occurred while attempting to update the specified ingress." // @router /kubernetes/{id}/namespaces/{namespace}/ingresses [put] func (handler *Handler) updateKubernetesIngress(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { namespace, err := request.RetrieveRouteVariableValue(r, "namespace") if err != nil { - return httperror.BadRequest("Invalid namespace identifier route variable", err) + log.Error().Err(err).Str("context", "updateKubernetesIngress").Msg("Unable to retrieve namespace from request") + return httperror.BadRequest("Unable to retrieve namespace from request", err) } - var payload models.K8sIngressInfo + payload := models.K8sIngressInfo{} err = request.DecodeAndValidateJSONPayload(r, &payload) if err != nil { - return httperror.BadRequest("Invalid request payload", err) + log.Error().Err(err).Str("context", "updateKubernetesIngress").Msg("Unable to decode and validate the request payload") + return httperror.BadRequest("Unable to decode and validate the request payload", err) } cli, handlerErr := handler.getProxyKubeClient(r) @@ -632,7 +817,18 @@ func (handler *Handler) updateKubernetesIngress(w http.ResponseWriter, r *http.R err = cli.UpdateIngress(namespace, payload) if err != nil { - return httperror.InternalServerError("Unable to update the ingress", err) + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "updateKubernetesIngress").Str("namespace", namespace).Msg("Unauthorized access to the Kubernetes API") + return httperror.Forbidden("Unauthorized access to the Kubernetes API", err) + } + + if k8serrors.IsNotFound(err) { + log.Error().Err(err).Str("context", "updateKubernetesIngress").Str("namespace", namespace).Msg("Unable to retrieve ingresses from the K ubernetes for a namespace level user") + return httperror.NotFound("Unable to retrieve ingresses from the Kubernetes for a namespace level user", err) + } + + log.Error().Err(err).Str("context", "updateKubernetesIngress").Str("namespace", namespace).Msg("Unable to update ingress in a namespace") + return httperror.InternalServerError("Unable to update ingress in a namespace", err) } return response.Empty(w) diff --git a/api/http/handler/kubernetes/metrics.go b/api/http/handler/kubernetes/metrics.go index bacc02d4d..aeee919a7 100644 --- a/api/http/handler/kubernetes/metrics.go +++ b/api/http/handler/kubernetes/metrics.go @@ -7,23 +7,22 @@ import ( httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// @id getKubernetesMetricsForAllNodes +// @id GetKubernetesMetricsForAllNodes // @summary Get a list of nodes with their live metrics -// @description Get a list of nodes with their live metrics -// @description **Access policy**: authenticated +// @description Get a list of metrics associated with all nodes of a cluster. +// @description **Access policy**: Authenticated user. // @tags kubernetes -// @security ApiKeyAuth -// @security jwt -// @accept json +// @security ApiKeyAuth || jwt // @produce json -// @param id path int true "Environment (Endpoint) identifier" +// @param id path int true "Environment identifier" // @success 200 {object} v1beta1.NodeMetricsList "Success" -// @failure 400 "Invalid request" -// @failure 500 "Server error" +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 500 "Server error occurred while attempting to retrieve the list of nodes with their live metrics." // @router /kubernetes/{id}/metrics/nodes [get] func (handler *Handler) getKubernetesMetricsForAllNodes(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { endpoint, err := middlewares.FetchEndpoint(r) @@ -33,45 +32,49 @@ func (handler *Handler) getKubernetesMetricsForAllNodes(w http.ResponseWriter, r cli, err := handler.KubernetesClientFactory.CreateRemoteMetricsClient(endpoint) if err != nil { + log.Error().Err(err).Str("context", "getKubernetesMetricsForAllNodes").Msg("Failed to create metrics KubeClient") return httperror.InternalServerError("failed to create metrics KubeClient", nil) } metrics, err := cli.MetricsV1beta1().NodeMetricses().List(r.Context(), v1.ListOptions{}) if err != nil { + log.Error().Err(err).Str("context", "getKubernetesMetricsForAllNodes").Msg("Failed to fetch metrics") return httperror.InternalServerError("Failed to fetch metrics", err) } return response.JSON(w, metrics) } -// @id getKubernetesMetricsForNode +// @id GetKubernetesMetricsForNode // @summary Get live metrics for a node -// @description Get live metrics for a node -// @description **Access policy**: authenticated +// @description Get live metrics for the specified node. +// @description **Access policy**: Authenticated user. // @tags kubernetes -// @security ApiKeyAuth -// @security jwt -// @accept json +// @security ApiKeyAuth || jwt // @produce json -// @param id path int true "Environment (Endpoint) identifier" +// @param id path int true "Environment identifier" // @param name path string true "Node identifier" // @success 200 {object} v1beta1.NodeMetrics "Success" -// @failure 400 "Invalid request" -// @failure 500 "Server error" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 500 "Server error occurred while attempting to retrieve the live metrics for the specified node." // @router /kubernetes/{id}/metrics/nodes/{name} [get] func (handler *Handler) getKubernetesMetricsForNode(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { endpoint, err := middlewares.FetchEndpoint(r) if err != nil { + log.Error().Err(err).Str("context", "getKubernetesMetricsForNode").Msg("Failed to fetch endpoint") return httperror.InternalServerError(err.Error(), err) } cli, err := handler.KubernetesClientFactory.CreateRemoteMetricsClient(endpoint) if err != nil { + log.Error().Err(err).Str("context", "getKubernetesMetricsForNode").Msg("Failed to create metrics KubeClient") return httperror.InternalServerError("failed to create metrics KubeClient", nil) } nodeName, err := request.RetrieveRouteVariableValue(r, "name") if err != nil { + log.Error().Err(err).Str("context", "getKubernetesMetricsForNode").Msg("Invalid node identifier route variable") return httperror.BadRequest("Invalid node identifier route variable", err) } @@ -81,90 +84,98 @@ func (handler *Handler) getKubernetesMetricsForNode(w http.ResponseWriter, r *ht v1.GetOptions{}, ) if err != nil { + log.Error().Err(err).Str("context", "getKubernetesMetricsForNode").Msg("Failed to fetch metrics") return httperror.InternalServerError("Failed to fetch metrics", err) } return response.JSON(w, metrics) } -// @id getKubernetesMetricsForAllPods +// @id GetKubernetesMetricsForAllPods // @summary Get a list of pods with their live metrics -// @description Get a list of pods with their live metrics -// @description **Access policy**: authenticated +// @description Get a list of pods with their live metrics for the specified namespace. +// @description **Access policy**: Authenticated user. // @tags kubernetes -// @security ApiKeyAuth -// @security jwt -// @accept json +// @security ApiKeyAuth || jwt // @produce json -// @param id path int true "Environment (Endpoint) identifier" +// @param id path int true "Environment identifier" // @param namespace path string true "Namespace" // @success 200 {object} v1beta1.PodMetricsList "Success" -// @failure 400 "Invalid request" -// @failure 500 "Server error" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 500 "Server error occurred while attempting to retrieve the list of pods with their live metrics." // @router /kubernetes/{id}/metrics/pods/{namespace} [get] func (handler *Handler) getKubernetesMetricsForAllPods(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { endpoint, err := middlewares.FetchEndpoint(r) if err != nil { + log.Error().Err(err).Str("context", "getKubernetesMetricsForAllPods").Msg("Failed to fetch endpoint") return httperror.InternalServerError(err.Error(), err) } cli, err := handler.KubernetesClientFactory.CreateRemoteMetricsClient(endpoint) if err != nil { + log.Error().Err(err).Str("context", "getKubernetesMetricsForAllPods").Msg("Failed to create metrics KubeClient") return httperror.InternalServerError("failed to create metrics KubeClient", nil) } namespace, err := request.RetrieveRouteVariableValue(r, "namespace") if err != nil { + log.Error().Err(err).Str("context", "getKubernetesMetricsForAllPods").Msg("Invalid namespace identifier route variable") return httperror.BadRequest("Invalid namespace identifier route variable", err) } metrics, err := cli.MetricsV1beta1().PodMetricses(namespace).List(r.Context(), v1.ListOptions{}) if err != nil { + log.Error().Err(err).Str("context", "getKubernetesMetricsForAllPods").Msg("Failed to fetch metrics") return httperror.InternalServerError("Failed to fetch metrics", err) } return response.JSON(w, metrics) } -// @id getKubernetesMetricsForPod +// @id GetKubernetesMetricsForPod // @summary Get live metrics for a pod -// @description Get live metrics for a pod -// @description **Access policy**: authenticated +// @description Get live metrics for the specified pod. +// @description **Access policy**: Authenticated user. // @tags kubernetes -// @security ApiKeyAuth -// @security jwt -// @accept json +// @security ApiKeyAuth || jwt // @produce json -// @param id path int true "Environment (Endpoint) identifier" +// @param id path int true "Environment identifier" // @param namespace path string true "Namespace" // @param name path string true "Pod identifier" // @success 200 {object} v1beta1.PodMetrics "Success" -// @failure 400 "Invalid request" -// @failure 500 "Server error" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 500 "Server error occurred while attempting to retrieve the live metrics for the specified pod." // @router /kubernetes/{id}/metrics/pods/{namespace}/{name} [get] func (handler *Handler) getKubernetesMetricsForPod(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { endpoint, err := middlewares.FetchEndpoint(r) if err != nil { + log.Error().Err(err).Str("context", "getKubernetesMetricsForPod").Msg("Failed to fetch endpoint") return httperror.InternalServerError(err.Error(), err) } cli, err := handler.KubernetesClientFactory.CreateRemoteMetricsClient(endpoint) if err != nil { + log.Error().Err(err).Str("context", "getKubernetesMetricsForPod").Msg("Failed to create metrics KubeClient") return httperror.InternalServerError("failed to create metrics KubeClient", nil) } namespace, err := request.RetrieveRouteVariableValue(r, "namespace") if err != nil { + log.Error().Err(err).Str("context", "getKubernetesMetricsForPod").Msg("Invalid namespace identifier route variable") return httperror.BadRequest("Invalid namespace identifier route variable", err) } podName, err := request.RetrieveRouteVariableValue(r, "name") if err != nil { + log.Error().Err(err).Str("context", "getKubernetesMetricsForPod").Msg("Invalid pod identifier route variable") return httperror.BadRequest("Invalid pod identifier route variable", err) } metrics, err := cli.MetricsV1beta1().PodMetricses(namespace).Get(r.Context(), podName, v1.GetOptions{}) if err != nil { + log.Error().Err(err).Str("context", "getKubernetesMetricsForPod").Str("namespace", namespace).Str("pod", podName).Msg("Failed to fetch metrics") return httperror.InternalServerError("Failed to fetch metrics", err) } diff --git a/api/http/handler/kubernetes/namespaces.go b/api/http/handler/kubernetes/namespaces.go index c7a99e4ce..6a02d8fb8 100644 --- a/api/http/handler/kubernetes/namespaces.go +++ b/api/http/handler/kubernetes/namespaces.go @@ -1,185 +1,282 @@ package kubernetes import ( + "errors" + "fmt" "net/http" models "github.com/portainer/portainer/api/http/models/kubernetes" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" + k8serrors "k8s.io/apimachinery/pkg/api/errors" ) -// @id getKubernetesNamespaces -// @summary Get a list of kubernetes namespaces -// @description Get a list of all kubernetes namespaces in the cluster -// @description **Access policy**: authenticated +// @id GetKubernetesNamespaces +// @summary Get a list of namespaces +// @description Get a list of all namespaces within the given environment based on the user role and permissions. If the user is an admin, they can access all namespaces. If the user is not an admin, they can only access namespaces that they have access to. +// @description **Access policy**: Authenticated user. // @tags kubernetes -// @security ApiKeyAuth -// @security jwt -// @accept json +// @security ApiKeyAuth || jwt // @produce json -// @param id path int true "Environment (Endpoint) identifier" -// @success 200 {object} map[string]portainer.K8sNamespaceInfo "Success" -// @failure 400 "Invalid request" -// @failure 500 "Server error" +// @param id path int true "Environment identifier" +// @param withResourceQuota query boolean true "When set to true, include the resource quota information as part of the Namespace information. Default is false" +// @success 200 {array} portainer.K8sNamespaceInfo "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the list of namespaces." // @router /kubernetes/{id}/namespaces [get] func (handler *Handler) getKubernetesNamespaces(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - cli, handlerErr := handler.getProxyKubeClient(r) - if handlerErr != nil { - return handlerErr + withResourceQuota, err := request.RetrieveBooleanQueryParameter(r, "withResourceQuota", true) + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesNamespaces").Msg("Invalid query parameter withResourceQuota") + return httperror.BadRequest("an error occurred during the GetKubernetesNamespaces operation, invalid query parameter withResourceQuota. Error: ", err) + } + + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetKubernetesNamespaces").Msg("Unable to get a Kubernetes client for the user") + return httperror.InternalServerError("an error occurred during the GetKubernetesNamespaces operation, unable to get a Kubernetes client for the user. Error: ", httpErr) } namespaces, err := cli.GetNamespaces() if err != nil { - return httperror.InternalServerError("Unable to retrieve namespaces", err) + log.Error().Err(err).Str("context", "GetKubernetesNamespaces").Msg("Unable to retrieve namespaces from the Kubernetes cluster") + return httperror.InternalServerError("an error occurred during the GetKubernetesNamespaces operation, unable to retrieve namespaces from the Kubernetes cluster. Error: ", err) } - return response.JSON(w, namespaces) + if withResourceQuota { + return cli.CombineNamespacesWithResourceQuotas(namespaces, w) + } + + return response.JSON(w, cli.ConvertNamespaceMapToSlice(namespaces)) } -// @id getKubernetesNamespace -// @summary Get kubernetes namespace details -// @description Get kubernetes namespace details for the provided namespace within the given environment -// @description **Access policy**: authenticated +// @id GetKubernetesNamespacesCount +// @summary Get the total number of kubernetes namespaces within the given Portainer environment. +// @description Get the total number of kubernetes namespaces within the given environment, including the system namespaces. The total count depends on the user's role and permissions. +// @description **Access policy**: Authenticated user. // @tags kubernetes -// @security ApiKeyAuth -// @security jwt -// @accept json +// @security ApiKeyAuth || jwt // @produce json -// @param id path int true "Environment (Endpoint) identifier" -// @param namespace path string true "Namespace" +// @param id path int true "Environment identifier" +// @success 200 {integer} integer "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to compute the namespace count." +// @router /kubernetes/{id}/namespaces/count [get] +func (handler *Handler) getKubernetesNamespacesCount(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetKubernetesNamespacesCount").Msg("Unable to get a Kubernetes client for the user") + return httperror.InternalServerError("an error occurred during the GetKubernetesNamespacesCount operation, unable to get a Kubernetes client for the user. Error: ", httpErr) + } + + namespaces, err := cli.GetNamespaces() + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesNamespacesCount").Msg("Unable to retrieve namespaces from the Kubernetes cluster to count the total") + return httperror.InternalServerError("an error occurred during the GetKubernetesNamespacesCount operation, unable to retrieve namespaces from the Kubernetes cluster to count the total. Error: ", err) + } + + return response.JSON(w, len(namespaces)) +} + +// @id GetKubernetesNamespace +// @summary Get namespace details +// @description Get namespace details for the provided namespace within the given environment. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param namespace path string true "The namespace name to get details for" +// @param withResourceQuota query boolean true "When set to true, include the resource quota information as part of the Namespace information. Default is false" // @success 200 {object} portainer.K8sNamespaceInfo "Success" -// @failure 400 "Invalid request" -// @failure 500 "Server error" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or unable to find a specific namespace." +// @failure 500 "Server error occurred while attempting to retrieve specified namespace information." // @router /kubernetes/{id}/namespaces/{namespace} [get] func (handler *Handler) getKubernetesNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - ns, err := request.RetrieveRouteVariableValue(r, "namespace") + namespaceName, err := request.RetrieveRouteVariableValue(r, "namespace") if err != nil { - return httperror.BadRequest( - "Invalid namespace identifier route variable", - err, - ) + log.Error().Err(err).Str("context", "GetKubernetesNamespace").Msg("Invalid namespace parameter namespace") + return httperror.BadRequest("an error occurred during the GetKubernetesNamespace operation, invalid namespace parameter namespace. Error: ", err) } - cli, handlerErr := handler.getProxyKubeClient(r) - if handlerErr != nil { - return handlerErr + withResourceQuota, err := request.RetrieveBooleanQueryParameter(r, "withResourceQuota", true) + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesNamespace").Msg("Invalid query parameter withResourceQuota") + return httperror.BadRequest("an error occurred during the GetKubernetesNamespace operation for the namespace %s, invalid query parameter withResourceQuota. Error: ", err) } - namespace, err := cli.GetNamespace(ns) + cli, httpErr := handler.getProxyKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetKubernetesNamespace").Msg("Unable to get a Kubernetes client for the user") + return httperror.InternalServerError("an error occurred during the GetKubernetesNamespace operation for the namespace %s, unable to get a Kubernetes client for the user. Error: ", httpErr) + } + + namespaceInfo, err := cli.GetNamespace(namespaceName) if err != nil { - return httperror.InternalServerError("Unable to retrieve namespace", err) + if k8serrors.IsNotFound(err) { + log.Error().Err(err).Str("context", "GetKubernetesNamespace").Msg("Unable to find the namespace") + return httperror.NotFound(fmt.Sprintf("an error occurred during the GetKubernetesNamespace operation for the namespace %s, unable to find the namespace. Error: ", namespaceName), err) + } + + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "GetKubernetesNamespace").Msg("Unauthorized to access the namespace") + return httperror.Forbidden(fmt.Sprintf("an error occurred during the GetKubernetesNamespace operation, unauthorized to access the namespace: %s. Error: ", namespaceName), err) + } + + log.Error().Err(err).Str("context", "GetKubernetesNamespace").Msg("Unable to get the namespace") + return httperror.InternalServerError(fmt.Sprintf("an error occurred during the GetKubernetesNamespace operation, unable to get the namespace: %s. Error: ", namespaceName), err) + } + + if withResourceQuota { + return cli.CombineNamespaceWithResourceQuota(namespaceInfo, w) + } + + return response.JSON(w, namespaceInfo) +} + +// @id CreateKubernetesNamespace +// @summary Create a namespace +// @description Create a namespace within the given environment. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @accept json +// @produce json +// @param id path int true "Environment identifier" +// @param body body models.K8sNamespaceDetails true "Namespace configuration details" +// @success 200 {object} portainer.K8sNamespaceInfo "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 409 "Conflict - the namespace already exists." +// @failure 500 "Server error occurred while attempting to create the namespace." +// @router /kubernetes/{id}/namespaces [post] +func (handler *Handler) createKubernetesNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + payload := models.K8sNamespaceDetails{} + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + log.Error().Err(err).Str("context", "CreateKubernetesNamespace").Msg("Invalid request payload") + return httperror.BadRequest("an error occurred during the CreateKubernetesNamespace operation, invalid request payload. Error: ", err) + } + + namespaceName := payload.Name + cli, httpErr := handler.getProxyKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "CreateKubernetesNamespace").Str("namespace", namespaceName).Msg("Unable to get a Kubernetes client for the user") + return httperror.InternalServerError("an error occurred during the CreateKubernetesNamespace operation for the namespace %s, unable to get a Kubernetes client for the user. Error: ", httpErr) + } + + namespace, err := cli.CreateNamespace(payload) + if err != nil { + if k8serrors.IsAlreadyExists(err) { + log.Error().Err(err).Str("context", "CreateKubernetesNamespace").Str("namespace", namespaceName).Msg("The namespace already exists") + return httperror.Conflict(fmt.Sprintf("an error occurred during the CreateKubernetesNamespace operation, the namespace %s already exists. Error: ", namespaceName), err) + } + + log.Error().Err(err).Str("context", "CreateKubernetesNamespace").Str("namespace", namespaceName).Msg("Unable to create the namespace") + return httperror.InternalServerError(fmt.Sprintf("an error occurred during the CreateKubernetesNamespace operation, unable to create the namespace: %s", namespaceName), err) } return response.JSON(w, namespace) } -// @id createKubernetesNamespace -// @summary Create a kubernetes namespace -// @description Create a kubernetes namespace within the given environment -// @description **Access policy**: authenticated +// @id DeleteKubernetesNamespace +// @summary Delete a kubernetes namespace +// @description Delete a kubernetes namespace within the given environment. +// @description **Access policy**: Authenticated user. // @tags kubernetes -// @security ApiKeyAuth -// @security jwt -// @accept json -// @produce json -// @param id path int true "Environment (Endpoint) identifier" -// @param body body models.K8sNamespaceDetails true "Namespace configuration details" +// @security ApiKeyAuth || jwt +// @param id path int true "Environment identifier" // @success 200 {string} string "Success" -// @failure 400 "Invalid request" -// @failure 500 "Server error" -// @router /kubernetes/{id}/namespaces [post] -func (handler *Handler) createKubernetesNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - var payload models.K8sNamespaceDetails - err := request.DecodeAndValidateJSONPayload(r, &payload) - if err != nil { - return httperror.BadRequest("Invalid request payload", err) - } - - cli, handlerErr := handler.getProxyKubeClient(r) - if handlerErr != nil { - return handlerErr - } - - err = cli.CreateNamespace(payload) - if err != nil { - return httperror.InternalServerError("Unable to create namespace", err) - } - - return nil -} - -// @id deleteKubernetesNamespace -// @summary Delete kubernetes namespace -// @description Delete a kubernetes namespace within the given environment -// @description **Access policy**: authenticated -// @tags kubernetes -// @security ApiKeyAuth -// @security jwt -// @accept json -// @produce json -// @param id path int true "Environment (Endpoint) identifier" -// @param namespace path string true "Namespace" -// @success 200 {string} string "Success" -// @failure 400 "Invalid request" -// @failure 500 "Server error" -// @router /kubernetes/{id}/namespaces/{namespace} [delete] +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 403 "Unauthorized access or operation not allowed." +// @failure 500 "Server error occurred while attempting to delete the namespace." +// @router /kubernetes/{id}/namespaces [delete] func (handler *Handler) deleteKubernetesNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - var payload models.K8sNamespaceDetails - err := request.DecodeAndValidateJSONPayload(r, &payload) + namespaceNames, err := request.GetPayload[deleteKubernetesNamespacePayload](r) if err != nil { - return httperror.BadRequest("Invalid request payload", err) + log.Error().Err(err).Str("context", "DeleteKubernetesNamespace").Msg("Invalid namespace identifier route variable") + return httperror.BadRequest("an error occurred during the DeleteKubernetesNamespace operation, invalid namespace identifier route variable. Error: ", err) } - namespace, err := request.RetrieveRouteVariableValue(r, "namespace") - if err != nil { - return httperror.BadRequest("Invalid namespace identifier route variable", err) + cli, httpErr := handler.getProxyKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "DeleteKubernetesNamespace").Msg("Unable to get a Kubernetes client for the user") + return httperror.InternalServerError("an error occurred during the DeleteKubernetesNamespace operation for the namespace %s, unable to get a Kubernetes client for the user. Error: ", httpErr) } - cli, handlerErr := handler.getProxyKubeClient(r) - if handlerErr != nil { - return handlerErr + for _, namespaceName := range *namespaceNames { + _, err := cli.DeleteNamespace(namespaceName) + if err != nil { + if k8serrors.IsNotFound(err) { + log.Error().Err(err).Str("context", "DeleteKubernetesNamespace").Str("namespace", namespaceName).Msg("Unable to find the namespace") + return httperror.NotFound(fmt.Sprintf("an error occurred during the DeleteKubernetesNamespace operation for the namespace %s, unable to find the namespace. Error: ", namespaceName), err) + } + + log.Error().Err(err).Str("context", "DeleteKubernetesNamespace").Str("namespace", namespaceName).Msg("Unable to delete the namespace") + return httperror.InternalServerError(fmt.Sprintf("an error occurred during the DeleteKubernetesNamespace operation for the namespace %s, unable to delete the Kubernetes namespace. Error: ", namespaceName), err) + } } - err = cli.DeleteNamespace(namespace) - if err != nil { - return httperror.InternalServerError("Unable to delete namespace", err) + return response.JSON(w, namespaceNames) +} + +type deleteKubernetesNamespacePayload []string + +func (payload deleteKubernetesNamespacePayload) Validate(r *http.Request) error { + if len(payload) == 0 { + return errors.New("namespace names are required") } return nil } -// @id updateKubernetesNamespace -// @summary Updates a kubernetes namespace -// @description Update a kubernetes namespace within the given environment -// @description **Access policy**: authenticated +// @id UpdateKubernetesNamespace +// @summary Update a namespace +// @description Update a namespace within the given environment. +// @description **Access policy**: Authenticated user. // @tags kubernetes -// @security ApiKeyAuth -// @security jwt +// @security ApiKeyAuth || jwt // @accept json // @produce json -// @param id path int true "Environment (Endpoint) identifier" +// @param id path int true "Environment identifier" // @param namespace path string true "Namespace" // @param body body models.K8sNamespaceDetails true "Namespace details" -// @success 200 {string} string "Success" -// @failure 400 "Invalid request" -// @failure 500 "Server error" +// @success 200 {object} portainer.K8sNamespaceInfo "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or unable to find a specific namespace." +// @failure 500 "Server error occurred while attempting to update the namespace." // @router /kubernetes/{id}/namespaces/{namespace} [put] func (handler *Handler) updateKubernetesNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - var payload models.K8sNamespaceDetails + payload := models.K8sNamespaceDetails{} err := request.DecodeAndValidateJSONPayload(r, &payload) if err != nil { - return httperror.BadRequest("Invalid request payload", err) + return httperror.BadRequest("an error occurred during the UpdateKubernetesNamespace operation, invalid request payload. Error: ", err) } - cli, handlerErr := handler.getProxyKubeClient(r) - if handlerErr != nil { - return handlerErr + namespaceName := payload.Name + cli, httpErr := handler.getProxyKubeClient(r) + if httpErr != nil { + return httperror.InternalServerError(fmt.Sprintf("an error occurred during the UpdateKubernetesNamespace operation for the namespace %s, unable to get a Kubernetes client for the user. Error: ", namespaceName), httpErr) } - err = cli.UpdateNamespace(payload) + namespace, err := cli.UpdateNamespace(payload) if err != nil { - return httperror.InternalServerError("Unable to update namespace", err) + return httperror.InternalServerError(fmt.Sprintf("an error occurred during the UpdateKubernetesNamespace operation for the namespace %s, unable to update the Kubernetes namespace. Error: ", namespaceName), err) } - return nil + + return response.JSON(w, namespace) } diff --git a/api/http/handler/kubernetes/nodes_limits.go b/api/http/handler/kubernetes/nodes_limits.go index ee2834593..c25b8bdbc 100644 --- a/api/http/handler/kubernetes/nodes_limits.go +++ b/api/http/handler/kubernetes/nodes_limits.go @@ -6,53 +6,72 @@ import ( "github.com/portainer/portainer/api/http/middlewares" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" ) // @id GetKubernetesNodesLimits // @summary Get CPU and memory limits of all nodes within k8s cluster -// @description Get CPU and memory limits of all nodes within k8s cluster -// @description **Access policy**: authenticated +// @description Get CPU and memory limits of all nodes within k8s cluster. +// @description **Access policy**: Authenticated user. // @tags kubernetes -// @security ApiKeyAuth -// @security jwt -// @accept json +// @security ApiKeyAuth || jwt // @produce json // @param id path int true "Environment(Endpoint) identifier" // @success 200 {object} portainer.K8sNodesLimits "Success" // @failure 400 "Invalid request" -// @failure 401 "Unauthorized" -// @failure 403 "Permission denied" -// @failure 404 "Environment(Endpoint) not found" -// @failure 500 "Server error" +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve nodes limits." // @router /kubernetes/{id}/nodes_limits [get] func (handler *Handler) getKubernetesNodesLimits(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { endpoint, err := middlewares.FetchEndpoint(r) if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesNodesLimits").Msg("Unable to find an environment on request context") return httperror.NotFound("Unable to find an environment on request context", err) } - cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint) + cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint) if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesNodesLimits").Msg("Unable to create Kubernetes client") return httperror.InternalServerError("Unable to create Kubernetes client", err) } nodesLimits, err := cli.GetNodesLimits() if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesNodesLimits").Msg("Unable to retrieve nodes limits") return httperror.InternalServerError("Unable to retrieve nodes limits", err) } return response.JSON(w, nodesLimits) } +// @id GetKubernetesMaxResourceLimits +// @summary Get max CPU and memory limits of all nodes within k8s cluster +// @description Get max CPU and memory limits (unused resources) of all nodes within k8s cluster. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment(Endpoint) identifier" +// @success 200 {object} portainer.K8sNodesLimits "Success" +// @failure 400 "Invalid request" +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve nodes limits." +// @router /kubernetes/{id}/max_resource_limits [get] func (handler *Handler) getKubernetesMaxResourceLimits(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { endpoint, err := middlewares.FetchEndpoint(r) if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesMaxResourceLimits").Msg("Unable to find an environment on request context") return httperror.NotFound("Unable to find an environment on request context", err) } - cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint) + cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint) if err != nil { - return httperror.InternalServerError("Failed to lookup KubeClient", err) + log.Error().Err(err).Str("context", "GetKubernetesMaxResourceLimits").Msg("Unable to create Kubernetes client") + return httperror.InternalServerError("Unable to create Kubernetes client", err) } overCommit := endpoint.Kubernetes.Configuration.EnableResourceOverCommit @@ -61,6 +80,7 @@ func (handler *Handler) getKubernetesMaxResourceLimits(w http.ResponseWriter, r // name is set to "" so all namespaces resources are considered when calculating max resource limits resourceLimit, err := cli.GetMaxResourceLimits("", overCommit, overCommitPercent) if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesMaxResourceLimits").Msg("Unable to retrieve max resource limit") return httperror.InternalServerError("Unable to retrieve max resource limit", err) } diff --git a/api/http/handler/kubernetes/rbac.go b/api/http/handler/kubernetes/rbac.go index 7079f6a88..43f5c51bc 100644 --- a/api/http/handler/kubernetes/rbac.go +++ b/api/http/handler/kubernetes/rbac.go @@ -5,28 +5,33 @@ import ( httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" ) -// @id IsRBACEnabled +// @id GetKubernetesRBACStatus // @summary Check if RBAC is enabled -// @description Check if RBAC is enabled in the current Kubernetes cluster. -// @description **Access policy**: administrator -// @tags rbac_enabled -// @security ApiKeyAuth -// @security jwt -// @produce text/plain +// @description Check if RBAC is enabled in the specified Kubernetes cluster. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json // @param id path int true "Environment(Endpoint) identifier" -// @success 200 "Success" -// @failure 500 "Server error" +// @success 200 {boolean} bool "RBAC status" +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the RBAC status." // @router /kubernetes/{id}/rbac_enabled [get] -func (handler *Handler) isRBACEnabled(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { +func (handler *Handler) getKubernetesRBACStatus(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { cli, handlerErr := handler.getProxyKubeClient(r) if handlerErr != nil { - return handlerErr + log.Error().Err(handlerErr).Str("context", "GetKubernetesRBACStatus").Msg("Unable to get a Kubernetes client for the user") + return httperror.InternalServerError("Unable to get a Kubernetes client for the user. Error: ", handlerErr) } isRBACEnabled, err := cli.IsRBACEnabled() if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesRBACStatus").Msg("Failed to check RBAC status") return httperror.InternalServerError("Failed to check RBAC status", err) } diff --git a/api/http/handler/kubernetes/role_bindings.go b/api/http/handler/kubernetes/role_bindings.go new file mode 100644 index 000000000..6c58f4b50 --- /dev/null +++ b/api/http/handler/kubernetes/role_bindings.go @@ -0,0 +1,40 @@ +package kubernetes + +import ( + "net/http" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" +) + +// @id GetKubernetesRoleBindings +// @summary Get a list of kubernetes role bindings +// @description Get a list of kubernetes role bindings that the user has access to. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @success 200 {array} kubernetes.K8sRoleBinding "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the list of role bindings." +// @router /kubernetes/{id}/rolebindings [get] +func (handler *Handler) getAllKubernetesRoleBindings(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetAllKubernetesRoleBindings").Msg("Unable to prepare kube client") + return httperror.InternalServerError("unable to prepare kube client. Error: ", httpErr) + } + + rolebindings, err := cli.GetRoleBindings("") + if err != nil { + log.Error().Err(err).Str("context", "GetAllKubernetesRoleBindings").Msg("Unable to fetch rolebindings") + return httperror.InternalServerError("unable to fetch rolebindings. Error: ", err) + } + + return response.JSON(w, rolebindings) +} diff --git a/api/http/handler/kubernetes/roles.go b/api/http/handler/kubernetes/roles.go new file mode 100644 index 000000000..f44309ff2 --- /dev/null +++ b/api/http/handler/kubernetes/roles.go @@ -0,0 +1,40 @@ +package kubernetes + +import ( + "net/http" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" +) + +// @id GetKubernetesRoles +// @summary Get a list of kubernetes roles +// @description Get a list of kubernetes roles that the user has access to. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @success 200 {array} kubernetes.K8sRole "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the list of roles." +// @router /kubernetes/{id}/roles [get] +func (handler *Handler) getAllKubernetesRoles(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetAllKubernetesRoles").Msg("Unable to prepare kube client") + return httperror.InternalServerError("unable to prepare kube client. Error: ", httpErr) + } + + roles, err := cli.GetRoles("") + if err != nil { + log.Error().Err(err).Str("context", "GetAllKubernetesRoles").Msg("Unable to fetch roles across all namespaces") + return httperror.InternalServerError("unable to fetch roles across all namespaces. Error: ", err) + } + + return response.JSON(w, roles) +} diff --git a/api/http/handler/kubernetes/secrets.go b/api/http/handler/kubernetes/secrets.go new file mode 100644 index 000000000..1375e9e6b --- /dev/null +++ b/api/http/handler/kubernetes/secrets.go @@ -0,0 +1,143 @@ +package kubernetes + +import ( + "net/http" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" +) + +// @id GetKubernetesSecret +// @summary Get a Secret +// @description Get a Secret by name for a given namespace. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param namespace path string true "The namespace name where the secret is located" +// @param secret path string true "The secret name to get details for" +// @success 200 {object} models.K8sSecret "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve a secret by name belong in a namespace." +// @router /kubernetes/{id}/namespaces/{namespace}/secrets/{secret} [get] +func (handler *Handler) getKubernetesSecret(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesSecret").Str("namespace", namespace).Msg("Unable to retrieve namespace identifier route variable") + return httperror.BadRequest("unable to retrieve namespace identifier route variable. Error: ", err) + } + + secretName, err := request.RetrieveRouteVariableValue(r, "secret") + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesSecret").Str("namespace", namespace).Msg("Unable to retrieve secret identifier route variable") + return httperror.BadRequest("unable to retrieve secret identifier route variable. Error: ", err) + } + + cli, httpErr := handler.getProxyKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetKubernetesSecret").Str("namespace", namespace).Msg("Unable to get a Kubernetes client for the user") + return httperror.InternalServerError("unable to get a Kubernetes client for the user. Error: ", httpErr) + } + + secret, err := cli.GetSecret(namespace, secretName) + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesSecret").Str("namespace", namespace).Str("secret", secretName).Msg("Unable to get secret") + return httperror.InternalServerError("unable to get secret. Error: ", err) + } + + secretWithApplication, err := cli.CombineSecretWithApplications(secret) + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesSecret").Str("namespace", namespace).Str("secret", secretName).Msg("Unable to combine secret with associated applications") + return httperror.InternalServerError("unable to combine secret with associated applications. Error: ", err) + } + + return response.JSON(w, secretWithApplication) +} + +// @id GetKubernetesSecrets +// @summary Get a list of Secrets +// @description Get a list of Secrets for a given namespace. If isUsed is set to true, information about the applications that use the secrets is also returned. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param isUsed query bool true "When set to true, associate the Secrets with the applications that use them" +// @success 200 {array} models.K8sSecret "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve all secrets from the cluster." +// @router /kubernetes/{id}/secrets [get] +func (handler *Handler) GetAllKubernetesSecrets(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + secrets, err := handler.getAllKubernetesSecrets(r) + if err != nil { + return err + } + + return response.JSON(w, secrets) +} + +// @id GetKubernetesSecretsCount +// @summary Get Secrets count +// @description Get the count of Secrets across all namespaces that the user has access to. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @success 200 {integer} integer "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the count of all secrets from the cluster." +// @router /kubernetes/{id}/secrets/count [get] +func (handler *Handler) getAllKubernetesSecretsCount(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + secrets, err := handler.getAllKubernetesSecrets(r) + if err != nil { + return err + } + + return response.JSON(w, len(secrets)) +} + +func (handler *Handler) getAllKubernetesSecrets(r *http.Request) ([]models.K8sSecret, *httperror.HandlerError) { + isUsed, err := request.RetrieveBooleanQueryParameter(r, "isUsed", true) + if err != nil { + log.Error().Err(err).Str("context", "GetAllKubernetesSecrets").Msg("Unable to retrieve isUsed query parameter") + return nil, httperror.BadRequest("unable to retrieve isUsed query parameter. Error: ", err) + } + + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetAllKubernetesSecrets").Msg("Unable to prepare kube client") + return nil, httperror.InternalServerError("unable to prepare kube client. Error: ", httpErr) + } + + secrets, err := cli.GetSecrets("") + if err != nil { + log.Error().Err(err).Str("context", "GetAllKubernetesSecrets").Msg("Unable to get secrets") + return nil, httperror.InternalServerError("unable to get secrets. Error: ", err) + } + + if isUsed { + secretsWithApplications, err := cli.CombineSecretsWithApplications(secrets) + if err != nil { + log.Error().Err(err).Str("context", "GetAllKubernetesSecrets").Msg("Unable to combine secrets with associated applications") + return nil, httperror.InternalServerError("unable to combine secrets with associated applications. Error: ", err) + } + + return secretsWithApplications, nil + } + + return secrets, nil +} diff --git a/api/http/handler/kubernetes/service_accounts.go b/api/http/handler/kubernetes/service_accounts.go new file mode 100644 index 000000000..424634804 --- /dev/null +++ b/api/http/handler/kubernetes/service_accounts.go @@ -0,0 +1,40 @@ +package kubernetes + +import ( + "net/http" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" +) + +// @id GetKubernetesServiceAccounts +// @summary Get a list of kubernetes service accounts +// @description Get a list of kubernetes service accounts that the user has access to. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @success 200 {array} kubernetes.K8sServiceAccount "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the list of service accounts." +// @router /kubernetes/{id}/serviceaccounts [get] +func (handler *Handler) getAllKubernetesServiceAccounts(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetAllKubernetesServiceAccounts").Msg("Unable to prepare kube client") + return httperror.InternalServerError("unable to prepare kube client. Error: ", httpErr) + } + + serviceAccounts, err := cli.GetServiceAccounts("") + if err != nil { + log.Error().Err(err).Str("context", "GetAllKubernetesServiceAccounts").Msg("Unable to fetch service accounts across all namespaces") + return httperror.InternalServerError("unable to fetch service accounts. Error: ", err) + } + + return response.JSON(w, serviceAccounts) +} diff --git a/api/http/handler/kubernetes/services.go b/api/http/handler/kubernetes/services.go index dae3ccd94..02744762d 100644 --- a/api/http/handler/kubernetes/services.go +++ b/api/http/handler/kubernetes/services.go @@ -7,165 +7,298 @@ import ( httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" + k8serrors "k8s.io/apimachinery/pkg/api/errors" ) -// @id getKubernetesServices -// @summary Get a list of kubernetes services for a given namespace -// @description Get a list of kubernetes services for a given namespace -// @description **Access policy**: authenticated +// @id GetKubernetesServices +// @summary Get a list of services +// @description Get a list of services that the user has access to. +// @description **Access policy**: Authenticated user. // @tags kubernetes -// @security ApiKeyAuth -// @security jwt -// @accept json +// @security ApiKeyAuth || jwt // @produce json -// @param id path int true "Environment (Endpoint) identifier" -// @param namespace path string true "Namespace name" -// @param lookupapplications query boolean false "Lookup applications associated with each service" +// @param id path int true "Environment identifier" +// @param withApplications query boolean false "Lookup applications associated with each service" // @success 200 {array} models.K8sServiceInfo "Success" -// @failure 400 "Invalid request" -// @failure 500 "Server error" -// @router /kubernetes/{id}/namespaces/{namespace}/services [get] -func (handler *Handler) getKubernetesServices(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - namespace, err := request.RetrieveRouteVariableValue(r, "namespace") +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve all services." +// @router /kubernetes/{id}/services [get] +func (handler *Handler) GetAllKubernetesServices(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + services, err := handler.getAllKubernetesServices(r) if err != nil { - return httperror.BadRequest("Invalid namespace identifier route variable", err) - } - - cli, handlerErr := handler.getProxyKubeClient(r) - if handlerErr != nil { - return handlerErr - } - - lookup, err := request.RetrieveBooleanQueryParameter(r, "lookupapplications", true) - if err != nil { - return httperror.BadRequest("Invalid lookupapplications query parameter", err) - } - - services, err := cli.GetServices(namespace, lookup) - if err != nil { - return httperror.InternalServerError("Unable to retrieve services", err) + return err } return response.JSON(w, services) } -// @id createKubernetesService -// @summary Create a kubernetes service -// @description Create a kubernetes service within a given namespace -// @description **Access policy**: authenticated +// @id GetAllKubernetesServicesCount +// @summary Get services count +// @description Get the count of services that the user has access to. +// @description **Access policy**: Authenticated user. // @tags kubernetes -// @security ApiKeyAuth -// @security jwt +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @success 200 {integer} integer "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the total count of all services." +// @router /kubernetes/{id}/services/count [get] +func (handler *Handler) getAllKubernetesServicesCount(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + services, err := handler.getAllKubernetesServices(r) + if err != nil { + return err + } + + return response.JSON(w, len(services)) +} + +func (handler *Handler) getAllKubernetesServices(r *http.Request) ([]models.K8sServiceInfo, *httperror.HandlerError) { + withApplications, err := request.RetrieveBooleanQueryParameter(r, "withApplications", true) + if err != nil { + log.Error().Err(err).Str("context", "GetAllKubernetesServices").Msg("Unable to retrieve withApplications identifier") + return nil, httperror.BadRequest("unable to retrieve withApplications query parameter. Error: ", err) + } + + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetAllKubernetesServices").Msg("Unable to get a Kubernetes client for the user") + return nil, httperror.InternalServerError("unable to get a Kubernetes client for the user. Error: ", httpErr) + } + + services, err := cli.GetServices("") + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "GetAllKubernetesServices").Msg("Unauthorized access to the Kubernetes API") + return nil, httperror.Forbidden("unauthorized access to the Kubernetes API. Error: ", err) + } + + log.Error().Err(err).Str("context", "GetAllKubernetesServices").Msg("Unable to retrieve services from the Kubernetes for a cluster level user") + return nil, httperror.InternalServerError("unable to retrieve services from the Kubernetes for a cluster level user. Error: ", err) + } + + if withApplications && len(services) > 0 { + servicesWithApplications, err := cli.CombineServicesWithApplications(services) + if err != nil { + log.Error().Err(err).Str("context", "GetAllKubernetesServices").Msg("Unable to combine services with applications") + return nil, httperror.InternalServerError("unable to combine services with applications. Error: ", err) + } + + return servicesWithApplications, nil + } + + return services, nil +} + +// @id GetKubernetesServicesByNamespace +// @summary Get a list of services for a given namespace +// @description Get a list of services for a given namespace. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param namespace path string true "Namespace name" +// @success 200 {array} models.K8sServiceInfo "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve all services for a namespace." +// @router /kubernetes/{id}/namespaces/{namespace}/services [get] +func (handler *Handler) getKubernetesServicesByNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesServicesByNamespace").Str("namespace", namespace).Msg("Unable to retrieve namespace identifier route variable") + return httperror.BadRequest("unable to retrieve namespace identifier route variable. Error: ", err) + } + + cli, httpError := handler.getProxyKubeClient(r) + if httpError != nil { + return httpError + } + + services, err := cli.GetServices(namespace) + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "GetKubernetesServicesByNamespace").Str("namespace", namespace).Msg("Unauthorized access to the Kubernetes API") + return httperror.Forbidden("unauthorized access to the Kubernetes API. Error: ", err) + } + + log.Error().Err(err).Str("context", "GetKubernetesServicesByNamespace").Str("namespace", namespace).Msg("Unable to retrieve services from the Kubernetes for a namespace level user") + return httperror.InternalServerError("unable to retrieve services from the Kubernetes for a namespace level user. Error: ", err) + } + + return response.JSON(w, services) +} + +// @id CreateKubernetesService +// @summary Create a service +// @description Create a service within a given namespace +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt // @accept json // @produce json -// @param id path int true "Environment (Endpoint) identifier" +// @param id path int true "Environment identifier" // @param namespace path string true "Namespace name" // @param body body models.K8sServiceInfo true "Service definition" -// @success 200 "Success" -// @failure 400 "Invalid request" -// @failure 500 "Server error" +// @success 204 "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to create a service." // @router /kubernetes/{id}/namespaces/{namespace}/services [post] func (handler *Handler) createKubernetesService(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { namespace, err := request.RetrieveRouteVariableValue(r, "namespace") if err != nil { - return httperror.BadRequest("Invalid namespace identifier route variable", err) + log.Error().Err(err).Str("context", "CreateKubernetesService").Str("namespace", namespace).Msg("Unable to retrieve namespace identifier route variable") + return httperror.BadRequest("unable to retrieve namespace identifier route variable. Error: ", err) } var payload models.K8sServiceInfo err = request.DecodeAndValidateJSONPayload(r, &payload) if err != nil { - return httperror.BadRequest("Invalid request payload", err) + log.Error().Err(err).Str("context", "CreateKubernetesService").Str("namespace", namespace).Msg("Unable to decode and validate the request payload") + return httperror.BadRequest("unable to decode and validate the request payload. Error: ", err) } - cli, handlerErr := handler.getProxyKubeClient(r) - if handlerErr != nil { - return handlerErr + serviceName := payload.Name + cli, httpError := handler.getProxyKubeClient(r) + if httpError != nil { + log.Error().Err(httpError).Str("context", "CreateKubernetesService").Str("namespace", namespace).Str("service", serviceName).Msg("Unable to get a Kubernetes client for the user") + return httperror.InternalServerError("unable to get a Kubernetes client for the user. Error: ", httpError) } err = cli.CreateService(namespace, payload) if err != nil { - return httperror.InternalServerError("Unable to create sercice", err) + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "CreateKubernetesService").Str("namespace", namespace).Str("service", serviceName).Msg("Unauthorized access to the Kubernetes API") + return httperror.Forbidden("unauthorized access to the Kubernetes API. Error: ", err) + } + + if k8serrors.IsAlreadyExists(err) { + log.Error().Err(err).Str("context", "CreateKubernetesService").Str("namespace", namespace).Str("service", serviceName).Msg("A service with the same name already exists in the namespace") + return httperror.Conflict("a service with the same name already exists in the namespace. Error: ", err) + } + + log.Error().Err(err).Str("context", "CreateKubernetesService").Str("namespace", namespace).Str("service", serviceName).Msg("Unable to create a service") + return httperror.InternalServerError("unable to create a service. Error: ", err) } - return nil + return response.Empty(w) } -// @id deleteKubernetesServices -// @summary Delete kubernetes services -// @description Delete the provided list of kubernetes services -// @description **Access policy**: authenticated +// @id DeleteKubernetesServices +// @summary Delete services +// @description Delete the provided list of services. +// @description **Access policy**: Authenticated user. // @tags kubernetes -// @security ApiKeyAuth -// @security jwt +// @security ApiKeyAuth || jwt // @accept json // @produce json -// @param id path int true "Environment (Endpoint) identifier" +// @param id path int true "Environment identifier" // @param body body models.K8sServiceDeleteRequests true "A map where the key is the namespace and the value is an array of services to delete" -// @success 200 "Success" -// @failure 400 "Invalid request" -// @failure 500 "Server error" +// @success 204 "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or unable to find a specific service." +// @failure 500 "Server error occurred while attempting to delete services." // @router /kubernetes/{id}/services/delete [post] func (handler *Handler) deleteKubernetesServices(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - var payload models.K8sServiceDeleteRequests + payload := models.K8sServiceDeleteRequests{} err := request.DecodeAndValidateJSONPayload(r, &payload) if err != nil { - return httperror.BadRequest( - "Invalid request payload", - err, - ) + log.Error().Err(err).Str("context", "DeleteKubernetesServices").Msg("Unable to decode and validate the request payload") + return httperror.BadRequest("unable to decode and validate the request payload. Error: ", err) } - cli, handlerErr := handler.getProxyKubeClient(r) - if handlerErr != nil { - return handlerErr + cli, httpError := handler.getProxyKubeClient(r) + if httpError != nil { + return httpError } err = cli.DeleteServices(payload) if err != nil { - return httperror.InternalServerError( - "Unable to delete service", - err, - ) + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "DeleteKubernetesServices").Msg("Unauthorized access to the Kubernetes API") + return httperror.Forbidden("unauthorized access to the Kubernetes API. Error: ", err) + } + + if k8serrors.IsNotFound(err) { + log.Error().Err(err).Str("context", "DeleteKubernetesServices").Msg("Unable to find the services to delete") + return httperror.NotFound("unable to find the services to delete. Error: ", err) + } + + log.Error().Err(err).Str("context", "DeleteKubernetesServices").Msg("Unable to delete services") + return httperror.InternalServerError("unable to delete services. Error: ", err) } - return nil + + return response.Empty(w) } -// @id updateKubernetesService -// @summary Update a kubernetes service -// @description Update a kubernetes service within a given namespace -// @description **Access policy**: authenticated +// @id UpdateKubernetesService +// @summary Update a service +// @description Update a service within a given namespace. +// @description **Access policy**: Authenticated user. // @tags kubernetes -// @security ApiKeyAuth -// @security jwt +// @security ApiKeyAuth || jwt // @accept json // @produce json -// @param id path int true "Environment (Endpoint) identifier" +// @param id path int true "Environment identifier" // @param namespace path string true "Namespace name" // @param body body models.K8sServiceInfo true "Service definition" -// @success 200 "Success" -// @failure 400 "Invalid request" -// @failure 500 "Server error" +// @success 204 "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or unable to find the service to update." +// @failure 500 "Server error occurred while attempting to update a service." // @router /kubernetes/{id}/namespaces/{namespace}/services [put] func (handler *Handler) updateKubernetesService(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { namespace, err := request.RetrieveRouteVariableValue(r, "namespace") if err != nil { - return httperror.BadRequest("Invalid namespace identifier route variable", err) + log.Error().Err(err).Str("context", "UpdateKubernetesService").Str("namespace", namespace).Msg("Unable to retrieve namespace identifier route variable") + return httperror.BadRequest("unable to retrieve namespace identifier route variable. Error: ", err) } var payload models.K8sServiceInfo err = request.DecodeAndValidateJSONPayload(r, &payload) if err != nil { - return httperror.BadRequest("Invalid request payload", err) + log.Error().Err(err).Str("context", "UpdateKubernetesService").Str("namespace", namespace).Msg("Unable to decode and validate the request payload") + return httperror.BadRequest("unable to decode and validate the request payload. Error: ", err) } - cli, handlerErr := handler.getProxyKubeClient(r) - if handlerErr != nil { - return handlerErr + serviceName := payload.Name + cli, httpError := handler.getProxyKubeClient(r) + if httpError != nil { + log.Error().Err(httpError).Str("context", "UpdateKubernetesService").Str("namespace", namespace).Str("service", serviceName).Msg("Unable to get a Kubernetes client for the user") + return httperror.InternalServerError("unable to get a Kubernetes client for the user. Error: ", httpError) } err = cli.UpdateService(namespace, payload) if err != nil { - return httperror.InternalServerError("Unable to update service", err) + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "UpdateKubernetesService").Str("namespace", namespace).Str("service", serviceName).Msg("Unauthorized access to the Kubernetes API") + return httperror.Forbidden("unauthorized access to the Kubernetes API. Error: ", err) + } + + if k8serrors.IsNotFound(err) { + log.Error().Err(err).Str("context", "UpdateKubernetesService").Str("namespace", namespace).Str("service", serviceName).Msg("Unable to find the service to update") + return httperror.NotFound("unable to find the service to update. Error: ", err) + } + + log.Error().Err(err).Str("context", "UpdateKubernetesService").Str("namespace", namespace).Str("service", serviceName).Msg("Unable to update a service") + return httperror.InternalServerError("unable to update a service. Error: ", err) } return nil diff --git a/api/http/handler/kubernetes/toggle_system.go b/api/http/handler/kubernetes/toggle_system.go index f08b636bb..ad7d19d63 100644 --- a/api/http/handler/kubernetes/toggle_system.go +++ b/api/http/handler/kubernetes/toggle_system.go @@ -20,19 +20,20 @@ func (payload *namespacesToggleSystemPayload) Validate(r *http.Request) error { // @id KubernetesNamespacesToggleSystem // @summary Toggle the system state for a namespace -// @description Toggle the system state for a namespace -// @description **Access policy**: administrator or environment(endpoint) admin -// @security ApiKeyAuth -// @security jwt +// @description Toggle the system state for a namespace +// @description **Access policy**: Administrator or environment administrator. +// @security ApiKeyAuth || jwt // @tags kubernetes // @accept json -// @param id path int true "Environment(Endpoint) identifier" +// @param id path int true "Environment identifier" // @param namespace path string true "Namespace name" // @param body body namespacesToggleSystemPayload true "Update details" -// @success 200 "Success" -// @failure 400 "Invalid request" -// @failure 404 "Environment(Endpoint) not found" -// @failure 500 "Server error" +// @success 204 "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or unable to find the namespace to update." +// @failure 500 "Server error occurred while attempting to update the system state of the namespace." // @router /kubernetes/{id}/namespaces/{namespace}/system [put] func (handler *Handler) namespacesToggleSystem(rw http.ResponseWriter, r *http.Request) *httperror.HandlerError { endpoint, err := middlewares.FetchEndpoint(r) @@ -51,7 +52,7 @@ func (handler *Handler) namespacesToggleSystem(rw http.ResponseWriter, r *http.R return httperror.BadRequest("Invalid request payload", err) } - kubeClient, err := handler.KubernetesClientFactory.GetKubeClient(endpoint) + kubeClient, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint) if err != nil { return httperror.InternalServerError("Unable to create kubernetes client", err) } diff --git a/api/http/handler/kubernetes/volumes.go b/api/http/handler/kubernetes/volumes.go new file mode 100644 index 000000000..6702f6b7a --- /dev/null +++ b/api/http/handler/kubernetes/volumes.go @@ -0,0 +1,147 @@ +package kubernetes + +import ( + "net/http" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/rs/zerolog/log" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id GetAllKubernetesVolumes +// @summary Get Kubernetes volumes within the given Portainer environment +// @description Get a list of all kubernetes volumes within the given environment (Endpoint). The Endpoint ID must be a valid Portainer environment identifier. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param withApplications query boolean false "When set to True, include the applications that are using the volumes. It is set to false by default" +// @success 200 {object} map[string]kubernetes.K8sVolumeInfo "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 403 "Unauthorized access or operation not allowed." +// @failure 500 "Server error occurred while attempting to retrieve kubernetes volumes." +// @router /kubernetes/{id}/volumes [get] +func (handler *Handler) GetAllKubernetesVolumes(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + volumes, err := handler.getKubernetesVolumes(r) + if err != nil { + return err + } + + return response.JSON(w, volumes) +} + +// @id getAllKubernetesVolumesCount +// @summary Get the total number of kubernetes volumes within the given Portainer environment. +// @description Get the total number of kubernetes volumes within the given environment (Endpoint). The total count depends on the user's role and permissions. The Endpoint ID must be a valid Portainer environment identifier. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @success 200 {integer} integer "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 403 "Unauthorized access or operation not allowed." +// @failure 500 "Server error occurred while attempting to retrieve kubernetes volumes count." +// @router /kubernetes/{id}/volumes/count [get] +func (handler *Handler) getAllKubernetesVolumesCount(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + volumes, err := handler.getKubernetesVolumes(r) + if err != nil { + return err + } + + return response.JSON(w, len(volumes)) +} + +// @id GetKubernetesVolume +// @summary Get a Kubernetes volume within the given Portainer environment +// @description Get a Kubernetes volume within the given environment (Endpoint). The Endpoint ID must be a valid Portainer environment identifier. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param namespace path string true "Namespace identifier" +// @param volume path string true "Volume name" +// @success 200 {object} kubernetes.K8sVolumeInfo "Success" +// @failure 400 "Invalid request" +// @failure 500 "Server error" +// @router /kubernetes/{id}/volumes/{namespace}/{volume} [get] +func (handler *Handler) getKubernetesVolume(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesVolume").Msg("Unable to retrieve namespace identifier") + return httperror.BadRequest("Invalid namespace identifier", err) + } + + volumeName, err := request.RetrieveRouteVariableValue(r, "volume") + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesVolume").Msg("Unable to retrieve volume name") + return httperror.BadRequest("Invalid volume name", err) + } + + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetKubernetesVolume").Msg("Unable to get Kubernetes client") + return httperror.InternalServerError("Failed to prepare Kubernetes client", httpErr) + } + + volume, err := cli.GetVolume(namespace, volumeName) + if err != nil { + if k8serrors.IsUnauthorized(err) { + log.Error().Err(err).Str("context", "GetKubernetesVolume").Str("namespace", namespace).Str("volume", volumeName).Msg("Unauthorized access") + return httperror.Unauthorized("Unauthorized access to volume", err) + } + + if k8serrors.IsNotFound(err) { + log.Error().Err(err).Str("context", "GetKubernetesVolume").Str("namespace", namespace).Str("volume", volumeName).Msg("Volume not found") + return httperror.NotFound("Volume not found", err) + } + + log.Error().Err(err).Str("context", "GetKubernetesVolume").Str("namespace", namespace).Str("volume", volumeName).Msg("Failed to retrieve volume") + return httperror.InternalServerError("Failed to retrieve volume", err) + } + + return response.JSON(w, volume) +} + +func (handler *Handler) getKubernetesVolumes(r *http.Request) ([]models.K8sVolumeInfo, *httperror.HandlerError) { + withApplications, err := request.RetrieveBooleanQueryParameter(r, "withApplications", true) + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesVolumes").Bool("withApplications", withApplications).Msg("Unable to parse query parameter") + return nil, httperror.BadRequest("Invalid 'withApplications' parameter", err) + } + + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetKubernetesVolumes").Msg("Unable to get Kubernetes client") + return nil, httperror.InternalServerError("Failed to prepare Kubernetes client", httpErr) + } + + volumes, err := cli.GetVolumes("") + if err != nil { + if k8serrors.IsUnauthorized(err) { + log.Error().Err(err).Str("context", "GetKubernetesVolumes").Msg("Unauthorized access") + return nil, httperror.Unauthorized("Unauthorized access to volumes", err) + } + + log.Error().Err(err).Str("context", "GetKubernetesVolumes").Msg("Failed to retrieve volumes") + return nil, httperror.InternalServerError("Failed to retrieve volumes", err) + } + + if withApplications { + volumesWithApplications, err := cli.CombineVolumesWithApplications(&volumes) + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesVolumes").Msg("Failed to combine volumes with applications") + return nil, httperror.InternalServerError("Failed to combine volumes with applications", err) + } + + return *volumesWithApplications, nil + } + + return volumes, nil +} diff --git a/api/http/handler/registries/handler.go b/api/http/handler/registries/handler.go index 7e2d8243a..dee14885e 100644 --- a/api/http/handler/registries/handler.go +++ b/api/http/handler/registries/handler.go @@ -135,7 +135,7 @@ func (handler *Handler) userHasRegistryAccess(r *http.Request, registry *portain // validate access for kubernetes namespaces (leverage registry.RegistryAccesses[endpointId].Namespaces) if endpointutils.IsKubernetesEndpoint(endpoint) { - kcl, err := handler.K8sClientFactory.GetKubeClient(endpoint) + kcl, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint) if err != nil { return false, false, errors.Wrap(err, "unable to retrieve kubernetes client to validate registry access") } diff --git a/api/http/handler/registries/registry_configure.go b/api/http/handler/registries/registry_configure.go index 24bdb55ba..4b1a1ea0f 100644 --- a/api/http/handler/registries/registry_configure.go +++ b/api/http/handler/registries/registry_configure.go @@ -41,7 +41,7 @@ func (payload *registryConfigurePayload) Validate(r *http.Request) error { if useAuthentication { username, err := request.RetrieveMultiPartFormValue(r, "Username", false) if err != nil { - return errors.New("Invalid username") + return errors.New("invalid username") } payload.Username = username @@ -61,19 +61,19 @@ func (payload *registryConfigurePayload) Validate(r *http.Request) error { if useTLS && !skipTLSVerify { cert, _, err := request.RetrieveMultiPartFormFile(r, "TLSCertFile") if err != nil { - return errors.New("Invalid certificate file. Ensure that the file is uploaded correctly") + return errors.New("invalid certificate file. Ensure that the file is uploaded correctly") } payload.TLSCertFile = cert key, _, err := request.RetrieveMultiPartFormFile(r, "TLSKeyFile") if err != nil { - return errors.New("Invalid key file. Ensure that the file is uploaded correctly") + return errors.New("invalid key file. Ensure that the file is uploaded correctly") } payload.TLSKeyFile = key ca, _, err := request.RetrieveMultiPartFormFile(r, "TLSCACertFile") if err != nil { - return errors.New("Invalid CA certificate file. Ensure that the file is uploaded correctly") + return errors.New("invalid CA certificate file. Ensure that the file is uploaded correctly") } payload.TLSCACertFile = ca } diff --git a/api/http/handler/registries/registry_create.go b/api/http/handler/registries/registry_create.go index c9d73d4f9..48b5d32f5 100644 --- a/api/http/handler/registries/registry_create.go +++ b/api/http/handler/registries/registry_create.go @@ -45,15 +45,15 @@ type registryCreatePayload struct { func (payload *registryCreatePayload) Validate(_ *http.Request) error { if len(payload.Name) == 0 { - return errors.New("Invalid registry name") + return errors.New("invalid registry name") } if len(payload.URL) == 0 { - return errors.New("Invalid registry URL") + return errors.New("invalid registry URL") } if payload.Authentication { if len(payload.Username) == 0 || len(payload.Password) == 0 { - return errors.New("Invalid credentials. Username and password must be specified when authentication is enabled") + return errors.New("invalid credentials. Username and password must be specified when authentication is enabled") } if payload.Type == portainer.EcrRegistry { if len(payload.Ecr.Region) == 0 { @@ -127,10 +127,10 @@ func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) * } for _, r := range registries { if r.Name == registry.Name { - return httperror.Conflict("Another registry with the same name already exists", errors.New("A registry is already defined with this name")) + return httperror.Conflict("Another registry with the same name already exists", errors.New("a registry is already defined with this name")) } if handler.registriesHaveSameURLAndCredentials(&r, registry) { - return httperror.Conflict("Another registry with the same URL and credentials already exists", errors.New("A registry is already defined for this URL and credentials")) + return httperror.Conflict("Another registry with the same URL and credentials already exists", errors.New("a registry is already defined for this URL and credentials")) } } diff --git a/api/http/handler/registries/registry_delete.go b/api/http/handler/registries/registry_delete.go index 6b24365fa..137e6b216 100644 --- a/api/http/handler/registries/registry_delete.go +++ b/api/http/handler/registries/registry_delete.go @@ -67,7 +67,7 @@ func (handler *Handler) deleteKubernetesSecrets(registry *portainer.Registry) { continue } - cli, err := handler.K8sClientFactory.GetKubeClient(endpoint) + cli, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint) if err != nil { // Skip environments that can't get a kubeclient from log.Warn().Err(err).Msgf("Unable to get kubernetes client for environment %d", endpointId) diff --git a/api/http/handler/registries/registry_update.go b/api/http/handler/registries/registry_update.go index 001960b31..10d7f84dd 100644 --- a/api/http/handler/registries/registry_update.go +++ b/api/http/handler/registries/registry_update.go @@ -96,7 +96,7 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) * // See https://portainer.atlassian.net/browse/EE-2706 for more details for _, r := range registries { if r.ID != registry.ID && r.Name == registry.Name { - return httperror.Conflict("Another registry with the same name already exists", errors.New("A registry is already defined with this name")) + return httperror.Conflict("Another registry with the same name already exists", errors.New("a registry is already defined with this name")) } } @@ -147,7 +147,7 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) * for _, r := range registries { if r.ID != registry.ID && handler.registriesHaveSameURLAndCredentials(&r, registry) { - return httperror.Conflict("Another registry with the same URL and credentials already exists", errors.New("A registry is already defined for this URL and credentials")) + return httperror.Conflict("Another registry with the same URL and credentials already exists", errors.New("a registry is already defined for this URL and credentials")) } } } @@ -193,7 +193,7 @@ func syncConfig(registry *portainer.Registry) *portainer.RegistryManagementConfi } func (handler *Handler) updateEndpointRegistryAccess(endpoint *portainer.Endpoint, registry *portainer.Registry, endpointAccess portainer.RegistryAccessPolicies) error { - cli, err := handler.K8sClientFactory.GetKubeClient(endpoint) + cli, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint) if err != nil { return err } diff --git a/api/http/handler/stacks/create_kubernetes_stack.go b/api/http/handler/stacks/create_kubernetes_stack.go index 6be5c3ee1..397ccfec2 100644 --- a/api/http/handler/stacks/create_kubernetes_stack.go +++ b/api/http/handler/stacks/create_kubernetes_stack.go @@ -163,7 +163,7 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit // Refresh ECR registry secret if needed // RefreshEcrSecret method checks if the namespace has any ECR registry // otherwise return nil - cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint) + cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint) if err == nil { registryutils.RefreshEcrSecret(cli, endpoint, handler.DataStore, payload.Namespace) } diff --git a/api/http/handler/stacks/stack_associate.go b/api/http/handler/stacks/stack_associate.go index 7f4165bc8..309e07635 100644 --- a/api/http/handler/stacks/stack_associate.go +++ b/api/http/handler/stacks/stack_associate.go @@ -1,11 +1,11 @@ package stacks import ( + "errors" "fmt" "net/http" "time" - "github.com/pkg/errors" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/stacks/stackutils" @@ -95,7 +95,7 @@ func (handler *Handler) stackAssociate(w http.ResponseWriter, r *http.Request) * return httperror.InternalServerError("Unable to verify user authorizations to validate stack deletion", err) } if !canManage { - errMsg := "stack management is disabled for non-admin users" + errMsg := "Stack management is disabled for non-admin users" return httperror.Forbidden(errMsg, errors.New(errMsg)) } diff --git a/api/http/handler/stacks/update_kubernetes_stack.go b/api/http/handler/stacks/update_kubernetes_stack.go index 750efc21b..95195bb10 100644 --- a/api/http/handler/stacks/update_kubernetes_stack.go +++ b/api/http/handler/stacks/update_kubernetes_stack.go @@ -124,7 +124,7 @@ func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer. // Refresh ECR registry secret if needed // RefreshEcrSecret method checks if the namespace has any ECR registry // otherwise return nil - cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint) + cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint) if err == nil { registryutils.RefreshEcrSecret(cli, endpoint, handler.DataStore, stack.Namespace) } diff --git a/api/http/handler/teammemberships/handler.go b/api/http/handler/teammemberships/handler.go index 0fd915359..aa4a2192d 100644 --- a/api/http/handler/teammemberships/handler.go +++ b/api/http/handler/teammemberships/handler.go @@ -47,7 +47,7 @@ func (handler *Handler) updateUserServiceAccounts(membership *portainer.TeamMemb restrictDefaultNamespace := endpoint.Kubernetes.Configuration.RestrictDefaultNamespace // update kubernenets service accounts if the team is associated with a kubernetes environment if endpointutils.IsKubernetesEndpoint(&endpoint) { - kubecli, err := handler.K8sClientFactory.GetKubeClient(&endpoint) + kubecli, err := handler.K8sClientFactory.GetPrivilegedKubeClient(&endpoint) if err != nil { log.Error().Err(err).Msgf("failed getting kube client for environment %d", endpoint.ID) continue diff --git a/api/http/handler/websocket/pod.go b/api/http/handler/websocket/pod.go index d37371d33..0099abfe0 100644 --- a/api/http/handler/websocket/pod.go +++ b/api/http/handler/websocket/pod.go @@ -102,7 +102,7 @@ func (handler *Handler) websocketPodExec(w http.ResponseWriter, r *http.Request) return nil } - cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint) + cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint) if err != nil { return httperror.InternalServerError("Unable to create Kubernetes client", err) } @@ -165,7 +165,7 @@ func (handler *Handler) getToken(request *http.Request, endpoint *portainer.Endp return "", false, err } - kubecli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint) + kubecli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint) if err != nil { return "", false, err } diff --git a/api/http/handler/websocket/shell_pod.go b/api/http/handler/websocket/shell_pod.go index cb3d1fd2b..aedff1564 100644 --- a/api/http/handler/websocket/shell_pod.go +++ b/api/http/handler/websocket/shell_pod.go @@ -42,12 +42,12 @@ func (handler *Handler) websocketShellPodExec(w http.ResponseWriter, r *http.Req return httperror.Forbidden("Permission denied to access environment", err) } - cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint) + cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint) if err != nil { return httperror.InternalServerError("Unable to create Kubernetes client", err) } - serviceAccount, err := cli.GetServiceAccount(tokenData) + serviceAccount, err := cli.GetPortainerUserServiceAccount(tokenData) if err != nil { return httperror.InternalServerError("Unable to find serviceaccount associated with user", err) } diff --git a/api/http/models/kubernetes/application.go b/api/http/models/kubernetes/application.go index b61422042..d34edf816 100644 --- a/api/http/models/kubernetes/application.go +++ b/api/http/models/kubernetes/application.go @@ -1,11 +1,77 @@ package kubernetes -type ( - K8sApplication struct { - UID string `json:",omitempty"` - Name string `json:""` - Namespace string `json:",omitempty"` - Kind string `json:",omitempty"` - Labels map[string]string `json:",omitempty"` - } +import ( + "time" + + corev1 "k8s.io/api/core/v1" ) + +type K8sApplication struct { + ID string `json:"Id"` + Name string `json:"Name"` + Image string `json:"Image"` + Containers []interface{} `json:"Containers,omitempty"` + Services []corev1.Service `json:"Services"` + CreationDate time.Time `json:"CreationDate"` + ApplicationOwner string `json:"ApplicationOwner,omitempty"` + StackName string `json:"StackName,omitempty"` + ResourcePool string `json:"ResourcePool"` + ApplicationType string `json:"ApplicationType"` + Metadata *Metadata `json:"Metadata,omitempty"` + Status string `json:"Status"` + TotalPodsCount int `json:"TotalPodsCount"` + RunningPodsCount int `json:"RunningPodsCount"` + DeploymentType string `json:"DeploymentType"` + Pods []Pod `json:"Pods,omitempty"` + Configurations []Configuration `json:"Configurations,omitempty"` + LoadBalancerIPAddress string `json:"LoadBalancerIPAddress,omitempty"` + PublishedPorts []PublishedPort `json:"PublishedPorts,omitempty"` + Namespace string `json:"Namespace,omitempty"` + UID string `json:"Uid,omitempty"` + StackID string `json:"StackId,omitempty"` + ServiceID string `json:"ServiceId,omitempty"` + ServiceName string `json:"ServiceName,omitempty"` + ServiceType string `json:"ServiceType,omitempty"` + Kind string `json:"Kind,omitempty"` + MatchLabels map[string]string `json:"MatchLabels,omitempty"` + Labels map[string]string `json:"Labels,omitempty"` + Resource K8sApplicationResource `json:"Resource,omitempty"` +} + +type Metadata struct { + Labels map[string]string `json:"labels"` +} + +type Pod struct { + Status string `json:"Status"` +} + +type Configuration struct { + Data map[string]interface{} `json:"Data,omitempty"` + Kind string `json:"Kind"` + ConfigurationOwner string `json:"ConfigurationOwner"` +} + +type PublishedPort struct { + IngressRules []IngressRule `json:"IngressRules"` + Port int `json:"Port"` +} + +type IngressRule struct { + Host string `json:"Host"` + IP string `json:"IP"` + Path string `json:"Path"` + TLS []TLSInfo `json:"TLS"` +} + +type TLSInfo struct { + Hosts []string `json:"hosts"` +} + +// Existing types +type K8sApplicationResource struct { + CPURequest int64 `json:"CpuRequest"` + CPULimit int64 `json:"CpuLimit"` + MemoryRequest int64 `json:"MemoryRequest"` + MemoryLimit int64 `json:"MemoryLimit"` +} diff --git a/api/http/models/kubernetes/cluster_role_bindings.go b/api/http/models/kubernetes/cluster_role_bindings.go new file mode 100644 index 000000000..5a709dcac --- /dev/null +++ b/api/http/models/kubernetes/cluster_role_bindings.go @@ -0,0 +1,16 @@ +package kubernetes + +import ( + "time" + + rbacv1 "k8s.io/api/rbac/v1" +) + +type ( + K8sClusterRoleBinding struct { + Name string `json:"name"` + RoleRef rbacv1.RoleRef `json:"roleRef"` + Subjects []rbacv1.Subject `json:"subjects"` + CreationDate time.Time `json:"creationDate"` + } +) diff --git a/api/http/models/kubernetes/cluster_roles.go b/api/http/models/kubernetes/cluster_roles.go new file mode 100644 index 000000000..78fcd909e --- /dev/null +++ b/api/http/models/kubernetes/cluster_roles.go @@ -0,0 +1,8 @@ +package kubernetes + +import "time" + +type K8sClusterRole struct { + Name string `json:"name"` + CreationDate time.Time `json:"creationDate"` +} diff --git a/api/http/models/kubernetes/configmaps_and_secrets.go b/api/http/models/kubernetes/configmaps_and_secrets.go deleted file mode 100644 index eddff7c47..000000000 --- a/api/http/models/kubernetes/configmaps_and_secrets.go +++ /dev/null @@ -1,17 +0,0 @@ -package kubernetes - -type ( - K8sConfigMapOrSecret struct { - UID string `json:"UID"` - Name string `json:"Name"` - Namespace string `json:"Namespace"` - CreationDate string `json:"CreationDate"` - Annotations map[string]string `json:"Annotations"` - Data map[string]string `json:"Data"` - Applications []string `json:"Applications"` - IsSecret bool `json:"IsSecret"` - - // SecretType will be an empty string for config maps. - SecretType string `json:"SecretType"` - } -) diff --git a/api/http/models/kubernetes/configuration.go b/api/http/models/kubernetes/configuration.go new file mode 100644 index 000000000..2cdbd8fb4 --- /dev/null +++ b/api/http/models/kubernetes/configuration.go @@ -0,0 +1,32 @@ +package kubernetes + +type ( + K8sConfigMap struct { + K8sConfiguration + } + + K8sSecret struct { + K8sConfiguration + SecretType string `json:"SecretType"` + } + + K8sConfiguration struct { + UID string `json:"UID"` + Name string `json:"Name"` + Namespace string `json:"Namespace"` + CreationDate string `json:"CreationDate"` + Annotations map[string]string `json:"Annotations"` + Data map[string]string `json:"Data"` + IsUsed bool `json:"IsUsed"` + Labels map[string]string `json:"Labels"` + ConfigurationOwnerResources []K8sConfigurationOwnerResource `json:"ConfigurationOwners"` + ConfigurationOwner string `json:"ConfigurationOwner"` + ConfigurationOwnerId string `json:"ConfigurationOwnerId"` + } + + K8sConfigurationOwnerResource struct { + Id string `json:"Id"` + Name string `json:"Name"` + ResourceKind string `json:"ResourceKind"` + } +) diff --git a/api/http/models/kubernetes/ingress.go b/api/http/models/kubernetes/ingress.go index afaf2ffa6..f8e900e8d 100644 --- a/api/http/models/kubernetes/ingress.go +++ b/api/http/models/kubernetes/ingress.go @@ -44,6 +44,7 @@ type ( Port int `json:"Port"` Path string `json:"Path"` PathType string `json:"PathType"` + HasService bool `json:"HasService"` } // K8sIngressDeleteRequests is a mapping of namespace names to a slice of diff --git a/api/http/models/kubernetes/role_bindings.go b/api/http/models/kubernetes/role_bindings.go new file mode 100644 index 000000000..7f75dd191 --- /dev/null +++ b/api/http/models/kubernetes/role_bindings.go @@ -0,0 +1,17 @@ +package kubernetes + +import ( + "time" + + rbacv1 "k8s.io/api/rbac/v1" +) + +type ( + K8sRoleBinding struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + RoleRef rbacv1.RoleRef `json:"roleRef"` + Subjects []rbacv1.Subject `json:"subjects"` + CreationDate time.Time `json:"creationDate"` + } +) diff --git a/api/http/models/kubernetes/roles.go b/api/http/models/kubernetes/roles.go new file mode 100644 index 000000000..65757def3 --- /dev/null +++ b/api/http/models/kubernetes/roles.go @@ -0,0 +1,9 @@ +package kubernetes + +import "time" + +type K8sRole struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + CreationDate time.Time `json:"creationDate"` +} diff --git a/api/http/models/kubernetes/service_accounts.go b/api/http/models/kubernetes/service_accounts.go new file mode 100644 index 000000000..533d4a986 --- /dev/null +++ b/api/http/models/kubernetes/service_accounts.go @@ -0,0 +1,9 @@ +package kubernetes + +import "time" + +type K8sServiceAccount struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + CreationDate time.Time `json:"creationDate"` +} diff --git a/api/http/models/kubernetes/services.go b/api/http/models/kubernetes/services.go index 2b4e15641..04ac27bf3 100644 --- a/api/http/models/kubernetes/services.go +++ b/api/http/models/kubernetes/services.go @@ -7,16 +7,16 @@ import ( type ( K8sServiceInfo struct { - Name string - UID string - Type string - Namespace string - Annotations map[string]string - CreationTimestamp string - Labels map[string]string - AllocateLoadBalancerNodePorts *bool `json:",omitempty"` - Ports []K8sServicePort - Selector map[string]string + Name string `json:",omitempty"` + UID string `json:",omitempty"` + Type string `json:",omitempty"` + Namespace string `json:",omitempty"` + Annotations map[string]string `json:",omitempty"` + CreationDate string `json:",omitempty"` + Labels map[string]string `json:",omitempty"` + AllocateLoadBalancerNodePorts *bool `json:",omitempty"` + Ports []K8sServicePort `json:",omitempty"` + Selector map[string]string `json:",omitempty"` IngressStatus []K8sServiceIngress `json:",omitempty"` // serviceList screen diff --git a/api/http/models/kubernetes/volumes.go b/api/http/models/kubernetes/volumes.go new file mode 100644 index 000000000..31c24d06c --- /dev/null +++ b/api/http/models/kubernetes/volumes.go @@ -0,0 +1,49 @@ +package kubernetes + +import ( + "time" + + corev1 "k8s.io/api/core/v1" +) + +type ( + K8sVolumeInfo struct { + PersistentVolume K8sPersistentVolume `json:"persistentVolume"` + PersistentVolumeClaim K8sPersistentVolumeClaim `json:"persistentVolumeClaim"` + StorageClass K8sStorageClass `json:"storageClass"` + } + + K8sPersistentVolume struct { + Name string `json:"name,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` + AccessModes []corev1.PersistentVolumeAccessMode `json:"accessModes,omitempty"` + Capacity corev1.ResourceList `json:"capacity"` + ClaimRef *corev1.ObjectReference `json:"claimRef"` + StorageClassName string `json:"storageClassName,omitempty"` + PersistentVolumeReclaimPolicy corev1.PersistentVolumeReclaimPolicy `json:"persistentVolumeReclaimPolicy"` + VolumeMode *corev1.PersistentVolumeMode `json:"volumeMode"` + CSI *corev1.CSIPersistentVolumeSource `json:"csi,omitempty"` + } + + K8sPersistentVolumeClaim struct { + ID string `json:"id"` + Name string `json:"name"` + Namespace string `json:"namespace"` + Storage int64 `json:"storage"` + CreationDate time.Time `json:"creationDate"` + AccessModes []corev1.PersistentVolumeAccessMode `json:"accessModes,omitempty"` + VolumeName string `json:"volumeName"` + ResourcesRequests *corev1.ResourceList `json:"resourcesRequests"` + StorageClass *string `json:"storageClass"` + VolumeMode *corev1.PersistentVolumeMode `json:"volumeMode"` + OwningApplications []K8sApplication `json:"owningApplications,omitempty"` + Phase corev1.PersistentVolumeClaimPhase `json:"phase"` + } + + K8sStorageClass struct { + Name string `json:"name"` + Provisioner string `json:"provisioner"` + ReclaimPolicy *corev1.PersistentVolumeReclaimPolicy `json:"reclaimPolicy"` + AllowVolumeExpansion *bool `json:"allowVolumeExpansion"` + } +) diff --git a/api/http/proxy/factory/kubernetes.go b/api/http/proxy/factory/kubernetes.go index 497096e91..0a74fa3f2 100644 --- a/api/http/proxy/factory/kubernetes.go +++ b/api/http/proxy/factory/kubernetes.go @@ -27,7 +27,7 @@ func (factory *ProxyFactory) newKubernetesLocalProxy(endpoint *portainer.Endpoin return nil, err } - kubecli, err := factory.kubernetesClientFactory.GetKubeClient(endpoint) + kubecli, err := factory.kubernetesClientFactory.GetPrivilegedKubeClient(endpoint) if err != nil { return nil, err } @@ -61,7 +61,7 @@ func (factory *ProxyFactory) newKubernetesEdgeHTTPProxy(endpoint *portainer.Endp return nil, err } - kubecli, err := factory.kubernetesClientFactory.GetKubeClient(endpoint) + kubecli, err := factory.kubernetesClientFactory.GetPrivilegedKubeClient(endpoint) if err != nil { return nil, err } @@ -88,7 +88,7 @@ func (factory *ProxyFactory) newKubernetesAgentHTTPSProxy(endpoint *portainer.En remoteURL.Scheme = "https" - kubecli, err := factory.kubernetesClientFactory.GetKubeClient(endpoint) + kubecli, err := factory.kubernetesClientFactory.GetPrivilegedKubeClient(endpoint) if err != nil { return nil, err } diff --git a/api/http/proxy/factory/kubernetes/refresh_registry.go b/api/http/proxy/factory/kubernetes/refresh_registry.go index 84881cb10..f70a908d3 100644 --- a/api/http/proxy/factory/kubernetes/refresh_registry.go +++ b/api/http/proxy/factory/kubernetes/refresh_registry.go @@ -7,7 +7,7 @@ import ( ) func (transport *baseTransport) refreshRegistry(request *http.Request, namespace string) (err error) { - cli, err := transport.k8sClientFactory.GetKubeClient(transport.endpoint) + cli, err := transport.k8sClientFactory.GetPrivilegedKubeClient(transport.endpoint) if err != nil { return } diff --git a/api/http/proxy/factory/kubernetes/transport.go b/api/http/proxy/factory/kubernetes/transport.go index 0afa7e828..aa6031eae 100644 --- a/api/http/proxy/factory/kubernetes/transport.go +++ b/api/http/proxy/factory/kubernetes/transport.go @@ -142,7 +142,7 @@ func (transport *baseTransport) getRoundTripToken(request *http.Request, tokenMa } else { token, err = tokenManager.GetUserServiceAccountToken(int(tokenData.ID), transport.endpoint.ID) if err != nil { - log.Debug(). + log.Error(). Err(err). Msg("failed retrieving service account token") diff --git a/api/internal/authorization/endpoint_role_with_override.go b/api/internal/authorization/endpoint_role_with_override.go index 36ab82466..750c5f4bc 100644 --- a/api/internal/authorization/endpoint_role_with_override.go +++ b/api/internal/authorization/endpoint_role_with_override.go @@ -11,7 +11,7 @@ func (service *Service) CleanNAPWithOverridePolicies( endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup, ) error { - kubecli, err := service.K8sClientFactory.GetKubeClient(endpoint) + kubecli, err := service.K8sClientFactory.GetPrivilegedKubeClient(endpoint) if err != nil { return err } diff --git a/api/internal/endpointutils/endpointutils.go b/api/internal/endpointutils/endpointutils.go index 68120355b..b26e165cf 100644 --- a/api/internal/endpointutils/endpointutils.go +++ b/api/internal/endpointutils/endpointutils.go @@ -91,7 +91,7 @@ func InitialIngressClassDetection(endpoint *portainer.Endpoint, endpointService } }() - cli, err := factory.GetKubeClient(endpoint) + cli, err := factory.GetPrivilegedKubeClient(endpoint) if err != nil { log.Debug().Err(err).Msg("unable to create kubernetes client for ingress class detection") @@ -128,7 +128,7 @@ func InitialMetricsDetection(endpoint *portainer.Endpoint, endpointService datas } }() - cli, err := factory.GetKubeClient(endpoint) + cli, err := factory.GetPrivilegedKubeClient(endpoint) if err != nil { log.Debug().Err(err).Msg("unable to create kubernetes client for initial metrics detection") @@ -156,7 +156,7 @@ func storageDetect(endpoint *portainer.Endpoint, endpointService dataservices.En } }() - cli, err := factory.GetKubeClient(endpoint) + cli, err := factory.GetPrivilegedKubeClient(endpoint) if err != nil { log.Debug().Err(err).Msg("unable to create Kubernetes client for initial storage detection") diff --git a/api/kubernetes/cli/access.go b/api/kubernetes/cli/access.go index 3d306447c..3688357fa 100644 --- a/api/kubernetes/cli/access.go +++ b/api/kubernetes/cli/access.go @@ -2,6 +2,7 @@ package cli import ( "context" + "fmt" portainer "github.com/portainer/portainer/api" @@ -21,24 +22,27 @@ func (kcl *KubeClient) NamespaceAccessPoliciesDeleteNamespace(ns string) error { return errors.WithMessage(err, "failed to fetch access policies") } - delete(policies, ns) + if policies != nil { + delete(policies, ns) + return kcl.UpdateNamespaceAccessPolicies(policies) + } - return kcl.UpdateNamespaceAccessPolicies(policies) + return nil } // GetNamespaceAccessPolicies gets the namespace access policies // from config maps in the portainer namespace func (kcl *KubeClient) GetNamespaceAccessPolicies() (map[string]portainer.K8sNamespaceAccessPolicy, error) { configMap, err := kcl.cli.CoreV1().ConfigMaps(portainerNamespace).Get(context.TODO(), portainerConfigMapName, metav1.GetOptions{}) - if k8serrors.IsNotFound(err) { - return nil, nil - } else if err != nil { + if err != nil { + if k8serrors.IsNotFound(err) { + return nil, nil + } return nil, err } accessData := configMap.Data[portainerConfigMapAccessPoliciesKey] - - var policies map[string]portainer.K8sNamespaceAccessPolicy + policies := map[string]portainer.K8sNamespaceAccessPolicy{} err = json.Unmarshal([]byte(accessData), &policies) if err != nil { return nil, err @@ -109,10 +113,6 @@ func (kcl *KubeClient) UpdateNamespaceAccessPolicies(accessPolicies map[string]p } configMap, err := kcl.cli.CoreV1().ConfigMaps(portainerNamespace).Get(context.TODO(), portainerConfigMapName, metav1.GetOptions{}) - if k8serrors.IsNotFound(err) { - return nil - } - if err != nil { return err } @@ -122,3 +122,20 @@ func (kcl *KubeClient) UpdateNamespaceAccessPolicies(accessPolicies map[string]p return err } + +// GetNonAdminNamespaces retrieves namespaces for a non-admin user, excluding the default namespace if restricted. +func (kcl *KubeClient) GetNonAdminNamespaces(userID int) ([]string, error) { + accessPolicies, err := kcl.GetNamespaceAccessPolicies() + if err != nil { + return nil, fmt.Errorf("an error occurred during the getNonAdminNamespaces operation, unable to get namespace access policies via portainer-config. check if portainer-config configMap exists in the Kubernetes cluster: %w", err) + } + + nonAdminNamespaces := []string{defaultNamespace} + for namespace, accessPolicy := range accessPolicies { + if hasUserAccessToNamespace(userID, nil, accessPolicy) { + nonAdminNamespaces = append(nonAdminNamespaces, namespace) + } + } + + return nonAdminNamespaces, nil +} diff --git a/api/kubernetes/cli/applications.go b/api/kubernetes/cli/applications.go index 875c4c779..86a14254b 100644 --- a/api/kubernetes/cli/applications.go +++ b/api/kubernetes/cli/applications.go @@ -2,153 +2,440 @@ package cli import ( "context" - "strings" + "fmt" models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/rs/zerolog/log" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" ) -// GetApplications gets a list of kubernetes workloads (or applications) by kind. If Kind is not specified, gets the all -func (kcl *KubeClient) GetApplications(namespace, kind string) ([]models.K8sApplication, error) { - applicationList := []models.K8sApplication{} - listOpts := metav1.ListOptions{} +// GetAllKubernetesApplications gets a list of kubernetes workloads (or applications) across all namespaces in the cluster +// if the user is an admin, all namespaces in the current k8s environment(endpoint) are fetched using the fetchApplications function. +// otherwise, namespaces the non-admin user has access to will be used to filter the applications based on the allowed namespaces. +func (kcl *KubeClient) GetApplications(namespace, nodeName string, withDependencies bool) ([]models.K8sApplication, error) { + if kcl.IsKubeAdmin { + return kcl.fetchApplications(namespace, nodeName, withDependencies) + } - if kind == "" || strings.EqualFold(kind, "deployment") { - deployments, err := kcl.cli.AppsV1().Deployments(namespace).List(context.TODO(), listOpts) + return kcl.fetchApplicationsForNonAdmin(namespace, nodeName, withDependencies) +} + +// fetchApplications fetches the applications in the namespaces the user has access to. +// This function is called when the user is an admin. +func (kcl *KubeClient) fetchApplications(namespace, nodeName string, withDependencies bool) ([]models.K8sApplication, error) { + podListOptions := metav1.ListOptions{} + if nodeName != "" { + podListOptions.FieldSelector = fmt.Sprintf("spec.nodeName=%s", nodeName) + } + if !withDependencies { + // TODO: make sure not to fetch services in fetchAllApplicationsListResources from this call + pods, replicaSets, deployments, statefulSets, daemonSets, _, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions) if err != nil { return nil, err } - for _, d := range deployments.Items { - applicationList = append(applicationList, models.K8sApplication{ - UID: string(d.UID), - Name: d.Name, - Namespace: d.Namespace, - Kind: "Deployment", - Labels: d.Labels, - }) - } + return kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, nil) } - if kind == "" || strings.EqualFold(kind, "statefulset") { - statefulSets, err := kcl.cli.AppsV1().StatefulSets(namespace).List(context.TODO(), listOpts) + pods, replicaSets, deployments, statefulSets, daemonSets, services, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions) + if err != nil { + return nil, err + } + + return kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, services) +} + +// fetchApplicationsForNonAdmin fetches the applications in the namespaces the user has access to. +// This function is called when the user is not an admin. +func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string, withDependencies bool) ([]models.K8sApplication, error) { + log.Debug().Msgf("Fetching applications for non-admin user: %v", kcl.NonAdminNamespaces) + + if len(kcl.NonAdminNamespaces) == 0 { + return nil, nil + } + + podListOptions := metav1.ListOptions{} + if nodeName != "" { + podListOptions.FieldSelector = fmt.Sprintf("spec.nodeName=%s", nodeName) + } + + if !withDependencies { + pods, replicaSets, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets(namespace, podListOptions) if err != nil { return nil, err } - for _, s := range statefulSets.Items { - applicationList = append(applicationList, models.K8sApplication{ - UID: string(s.UID), - Name: s.Name, - Namespace: s.Namespace, - Kind: "StatefulSet", - Labels: s.Labels, - }) + return kcl.convertPodsToApplications(pods, replicaSets, nil, nil, nil, nil) + } + + pods, replicaSets, deployments, statefulSets, daemonSets, services, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions) + if err != nil { + return nil, err + } + + applications, err := kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, services) + if err != nil { + return nil, err + } + + nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap() + results := make([]models.K8sApplication, 0) + for _, application := range applications { + if _, ok := nonAdminNamespaceSet[application.ResourcePool]; ok { + results = append(results, application) } } - if kind == "" || strings.EqualFold(kind, "daemonset") { - daemonSets, err := kcl.cli.AppsV1().DaemonSets(namespace).List(context.TODO(), listOpts) - if err != nil { - return nil, err - } + return results, nil +} - for _, d := range daemonSets.Items { - applicationList = append(applicationList, models.K8sApplication{ - UID: string(d.UID), - Name: d.Name, - Namespace: d.Namespace, - Kind: "DaemonSet", - Labels: d.Labels, - }) - } - } +// convertPodsToApplications processes pods and converts them to applications, ensuring uniqueness by owner reference. +func (kcl *KubeClient) convertPodsToApplications(pods []corev1.Pod, replicaSets []appsv1.ReplicaSet, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service) ([]models.K8sApplication, error) { + applications := []models.K8sApplication{} + processedOwners := make(map[string]struct{}) - if kind == "" || strings.EqualFold(kind, "nakedpods") { - pods, _ := kcl.cli.CoreV1().Pods(namespace).List(context.Background(), metav1.ListOptions{}) - for _, pod := range pods.Items { - naked := false - if len(pod.OwnerReferences) == 0 { - naked = true - } else { - managed := false - loop: - for _, ownerRef := range pod.OwnerReferences { - switch ownerRef.Kind { - case "Deployment", "DaemonSet", "ReplicaSet": - managed = true - break loop - } - } - - if !managed { - naked = true - } + for _, pod := range pods { + if len(pod.OwnerReferences) > 0 { + ownerUID := string(pod.OwnerReferences[0].UID) + if _, exists := processedOwners[ownerUID]; exists { + continue } + processedOwners[ownerUID] = struct{}{} + } - if naked { - applicationList = append(applicationList, models.K8sApplication{ - UID: string(pod.UID), - Name: pod.Name, - Namespace: pod.Namespace, - Kind: "Pod", - Labels: pod.Labels, - }) + application, err := kcl.ConvertPodToApplication(pod, replicaSets, deployments, statefulSets, daemonSets, services, true) + if err != nil { + return nil, err + } + + if application != nil { + applications = append(applications, *application) + } + } + + return applications, nil +} + +// GetClusterApplicationsResource returns the total resource requests and limits for all applications in a namespace +// for a cluster level resource, set the namespace to "" +func (kcl *KubeClient) GetApplicationsResource(namespace, node string) (models.K8sApplicationResource, error) { + resource := models.K8sApplicationResource{} + podListOptions := metav1.ListOptions{} + if node != "" { + podListOptions.FieldSelector = fmt.Sprintf("spec.nodeName=%s", node) + } + + pods, err := kcl.cli.CoreV1().Pods(namespace).List(context.Background(), podListOptions) + if err != nil { + return resource, err + } + + for _, pod := range pods.Items { + for _, container := range pod.Spec.Containers { + resource.CPURequest += container.Resources.Requests.Cpu().MilliValue() + resource.CPULimit += container.Resources.Limits.Cpu().MilliValue() + resource.MemoryRequest += container.Resources.Requests.Memory().Value() + resource.MemoryLimit += container.Resources.Limits.Memory().Value() + } + } + + return resource, nil +} + +// convertApplicationResourceUnits converts the resource units from milli to core and bytes to mega bytes +func convertApplicationResourceUnits(resource models.K8sApplicationResource) models.K8sApplicationResource { + return models.K8sApplicationResource{ + CPURequest: resource.CPURequest / 1000, + CPULimit: resource.CPULimit / 1000, + MemoryRequest: resource.MemoryRequest / 1024 / 1024, + MemoryLimit: resource.MemoryLimit / 1024 / 1024, + } +} + +// GetApplicationsFromConfigMap gets a list of applications that use a specific ConfigMap +// by checking all pods in the same namespace as the ConfigMap +func (kcl *KubeClient) GetApplicationNamesFromConfigMap(configMap models.K8sConfigMap, pods []corev1.Pod, replicaSets []appsv1.ReplicaSet) ([]string, error) { + applications := []string{} + for _, pod := range pods { + if pod.Namespace == configMap.Namespace { + if isPodUsingConfigMap(&pod, configMap.Name) { + application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, false) + if err != nil { + return nil, err + } + applications = append(applications, application.Name) } } } - return applicationList, nil + return applications, nil } -// GetApplication gets a kubernetes workload (application) by kind and name. If Kind is not specified, gets the all -func (kcl *KubeClient) GetApplication(namespace, kind, name string) (models.K8sApplication, error) { - - opts := metav1.GetOptions{} - - switch strings.ToLower(kind) { - case "deployment": - d, err := kcl.cli.AppsV1().Deployments(namespace).Get(context.TODO(), name, opts) - if err != nil { - return models.K8sApplication{}, err +func (kcl *KubeClient) GetApplicationNamesFromSecret(secret models.K8sSecret, pods []corev1.Pod, replicaSets []appsv1.ReplicaSet) ([]string, error) { + applications := []string{} + for _, pod := range pods { + if pod.Namespace == secret.Namespace { + if isPodUsingSecret(&pod, secret.Name) { + application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, false) + if err != nil { + return nil, err + } + applications = append(applications, application.Name) + } } - - return models.K8sApplication{ - UID: string(d.UID), - Name: d.Name, - Namespace: d.Namespace, - Kind: "Deployment", - Labels: d.Labels, - }, nil - - case "statefulset": - s, err := kcl.cli.AppsV1().StatefulSets(namespace).Get(context.TODO(), name, opts) - if err != nil { - return models.K8sApplication{}, err - } - - return models.K8sApplication{ - UID: string(s.UID), - Name: s.Name, - Namespace: s.Namespace, - Kind: "StatefulSet", - Labels: s.Labels, - }, nil - - case "daemonset": - d, err := kcl.cli.AppsV1().DaemonSets(namespace).Get(context.TODO(), name, opts) - if err != nil { - return models.K8sApplication{}, err - } - - return models.K8sApplication{ - UID: string(d.UID), - Name: d.Name, - Namespace: d.Namespace, - Kind: "DaemonSet", - Labels: d.Labels, - }, nil } - return models.K8sApplication{}, nil + return applications, nil +} + +// ConvertPodToApplication converts a pod to an application, updating owner references if necessary +func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, replicaSets []appsv1.ReplicaSet, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, withResource bool) (*models.K8sApplication, error) { + if isReplicaSetOwner(pod) { + updateOwnerReferenceToDeployment(&pod, replicaSets) + } + + application := createApplication(&pod, deployments, statefulSets, daemonSets, services) + if application.ID == "" && application.Name == "" { + return nil, nil + } + + if withResource { + application.Resource = calculateResourceUsage(pod) + } + + return &application, nil +} + +// createApplication creates a K8sApplication object from a pod +// it sets the application name, namespace, kind, image, stack id, stack name, and labels +func createApplication(pod *corev1.Pod, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service) models.K8sApplication { + kind := "Pod" + name := pod.Name + + if len(pod.OwnerReferences) > 0 { + kind = pod.OwnerReferences[0].Kind + name = pod.OwnerReferences[0].Name + } + + application := models.K8sApplication{ + Services: []corev1.Service{}, + Metadata: &models.Metadata{}, + } + + switch kind { + case "Deployment": + for _, deployment := range deployments { + if deployment.Name == name && deployment.Namespace == pod.Namespace { + application.ApplicationType = "Deployment" + application.Kind = "Deployment" + application.ID = string(deployment.UID) + application.ResourcePool = deployment.Namespace + application.Name = name + application.Image = deployment.Spec.Template.Spec.Containers[0].Image + application.ApplicationOwner = deployment.Labels["io.portainer.kubernetes.application.owner"] + application.StackID = deployment.Labels["io.portainer.kubernetes.application.stackid"] + application.StackName = deployment.Labels["io.portainer.kubernetes.application.stack"] + application.Labels = deployment.Labels + application.MatchLabels = deployment.Spec.Selector.MatchLabels + application.CreationDate = deployment.CreationTimestamp.Time + application.TotalPodsCount = int(deployment.Status.Replicas) + application.RunningPodsCount = int(deployment.Status.ReadyReplicas) + application.DeploymentType = "Replicated" + application.Metadata = &models.Metadata{ + Labels: deployment.Labels, + } + + break + } + } + + case "StatefulSet": + for _, statefulSet := range statefulSets { + if statefulSet.Name == name && statefulSet.Namespace == pod.Namespace { + application.Kind = "StatefulSet" + application.ApplicationType = "StatefulSet" + application.ID = string(statefulSet.UID) + application.ResourcePool = statefulSet.Namespace + application.Name = name + application.Image = statefulSet.Spec.Template.Spec.Containers[0].Image + application.ApplicationOwner = statefulSet.Labels["io.portainer.kubernetes.application.owner"] + application.StackID = statefulSet.Labels["io.portainer.kubernetes.application.stackid"] + application.StackName = statefulSet.Labels["io.portainer.kubernetes.application.stack"] + application.Labels = statefulSet.Labels + application.MatchLabels = statefulSet.Spec.Selector.MatchLabels + application.CreationDate = statefulSet.CreationTimestamp.Time + application.TotalPodsCount = int(statefulSet.Status.Replicas) + application.RunningPodsCount = int(statefulSet.Status.ReadyReplicas) + application.DeploymentType = "Replicated" + application.Metadata = &models.Metadata{ + Labels: statefulSet.Labels, + } + + break + } + } + + case "DaemonSet": + for _, daemonSet := range daemonSets { + if daemonSet.Name == name && daemonSet.Namespace == pod.Namespace { + application.Kind = "DaemonSet" + application.ApplicationType = "DaemonSet" + application.ID = string(daemonSet.UID) + application.ResourcePool = daemonSet.Namespace + application.Name = name + application.Image = daemonSet.Spec.Template.Spec.Containers[0].Image + application.ApplicationOwner = daemonSet.Labels["io.portainer.kubernetes.application.owner"] + application.StackID = daemonSet.Labels["io.portainer.kubernetes.application.stackid"] + application.StackName = daemonSet.Labels["io.portainer.kubernetes.application.stack"] + application.Labels = daemonSet.Labels + application.MatchLabels = daemonSet.Spec.Selector.MatchLabels + application.CreationDate = daemonSet.CreationTimestamp.Time + application.TotalPodsCount = int(daemonSet.Status.DesiredNumberScheduled) + application.RunningPodsCount = int(daemonSet.Status.NumberReady) + application.DeploymentType = "Global" + application.Metadata = &models.Metadata{ + Labels: daemonSet.Labels, + } + + break + } + } + + case "Pod": + runningPodsCount := 1 + if pod.Status.Phase != corev1.PodRunning { + runningPodsCount = 0 + } + + application.ApplicationType = "Pod" + application.Kind = "Pod" + application.ID = string(pod.UID) + application.ResourcePool = pod.Namespace + application.Name = pod.Name + application.Image = pod.Spec.Containers[0].Image + application.ApplicationOwner = pod.Labels["io.portainer.kubernetes.application.owner"] + application.StackID = pod.Labels["io.portainer.kubernetes.application.stackid"] + application.StackName = pod.Labels["io.portainer.kubernetes.application.stack"] + application.Labels = pod.Labels + application.MatchLabels = pod.Labels + application.CreationDate = pod.CreationTimestamp.Time + application.TotalPodsCount = 1 + application.RunningPodsCount = runningPodsCount + application.DeploymentType = string(pod.Status.Phase) + application.Metadata = &models.Metadata{ + Labels: pod.Labels, + } + } + + if application.ID != "" && application.Name != "" && len(services) > 0 { + return updateApplicationWithService(application, services) + } + + return application +} + +// updateApplicationWithService updates the application with the services that match the application's selector match labels +// and are in the same namespace as the application +func updateApplicationWithService(application models.K8sApplication, services []corev1.Service) models.K8sApplication { + for _, service := range services { + serviceSelector := labels.SelectorFromSet(service.Spec.Selector) + + if service.Namespace == application.ResourcePool && serviceSelector.Matches(labels.Set(application.MatchLabels)) { + application.ServiceType = string(service.Spec.Type) + application.Services = append(application.Services, service) + } + } + + return application +} + +// calculateResourceUsage calculates the resource usage for a pod +func calculateResourceUsage(pod corev1.Pod) models.K8sApplicationResource { + resource := models.K8sApplicationResource{} + for _, container := range pod.Spec.Containers { + resource.CPURequest += container.Resources.Requests.Cpu().MilliValue() + resource.CPULimit += container.Resources.Limits.Cpu().MilliValue() + resource.MemoryRequest += container.Resources.Requests.Memory().Value() + resource.MemoryLimit += container.Resources.Limits.Memory().Value() + } + return convertApplicationResourceUnits(resource) +} + +// GetApplicationFromServiceSelector gets applications based on service selectors +// it matches the service selector with the pod labels +func (kcl *KubeClient) GetApplicationFromServiceSelector(pods []corev1.Pod, service models.K8sServiceInfo, replicaSets []appsv1.ReplicaSet) (*models.K8sApplication, error) { + servicesSelector := labels.SelectorFromSet(service.Selector) + if servicesSelector.Empty() { + return nil, nil + } + + for _, pod := range pods { + if servicesSelector.Matches(labels.Set(pod.Labels)) { + if isReplicaSetOwner(pod) { + updateOwnerReferenceToDeployment(&pod, replicaSets) + } + + return &models.K8sApplication{ + Name: pod.OwnerReferences[0].Name, + Kind: pod.OwnerReferences[0].Kind, + }, nil + } + } + + return nil, nil +} + +// GetApplicationConfigurationOwnersFromConfigMap gets a list of applications that use a specific ConfigMap +// by checking all pods in the same namespace as the ConfigMap +func (kcl *KubeClient) GetApplicationConfigurationOwnersFromConfigMap(configMap models.K8sConfigMap, pods []corev1.Pod, replicaSets []appsv1.ReplicaSet) ([]models.K8sConfigurationOwnerResource, error) { + configurationOwners := []models.K8sConfigurationOwnerResource{} + for _, pod := range pods { + if pod.Namespace == configMap.Namespace { + if isPodUsingConfigMap(&pod, configMap.Name) { + application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, false) + if err != nil { + return nil, err + } + + if application != nil { + configurationOwners = append(configurationOwners, models.K8sConfigurationOwnerResource{ + Name: application.Name, + ResourceKind: application.Kind, + Id: application.UID, + }) + } + } + } + } + + return configurationOwners, nil +} + +// GetApplicationConfigurationOwnersFromSecret gets a list of applications that use a specific Secret +// by checking all pods in the same namespace as the Secret +func (kcl *KubeClient) GetApplicationConfigurationOwnersFromSecret(secret models.K8sSecret, pods []corev1.Pod, replicaSets []appsv1.ReplicaSet) ([]models.K8sConfigurationOwnerResource, error) { + configurationOwners := []models.K8sConfigurationOwnerResource{} + for _, pod := range pods { + if pod.Namespace == secret.Namespace { + if isPodUsingSecret(&pod, secret.Name) { + application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, false) + if err != nil { + return nil, err + } + + if application != nil { + configurationOwners = append(configurationOwners, models.K8sConfigurationOwnerResource{ + Name: application.Name, + ResourceKind: application.Kind, + Id: application.UID, + }) + } + } + } + } + + return configurationOwners, nil } diff --git a/api/kubernetes/cli/client.go b/api/kubernetes/cli/client.go index 6b86c588d..5c8e32792 100644 --- a/api/kubernetes/cli/client.go +++ b/api/kubernetes/cli/client.go @@ -21,12 +21,11 @@ import ( ) const ( - DefaultKubeClientQPS = 30 - DefaultKubeClientBurst = 100 + defaultKubeClientQPS = 30 + defaultKubeClientBurst = 100 + maxConcurrency = 30 ) -const maxConcurrency = 30 - type ( // ClientFactory is used to create Kubernetes clients ClientFactory struct { @@ -34,17 +33,17 @@ type ( reverseTunnelService portainer.ReverseTunnelService signatureService portainer.DigitalSignatureService instanceID string - endpointClients map[string]*KubeClient endpointProxyClients *cache.Cache AddrHTTPS string - mu sync.Mutex } // KubeClient represent a service used to execute Kubernetes operations KubeClient struct { - cli kubernetes.Interface - instanceID string - mu sync.Mutex + cli kubernetes.Interface + instanceID string + mu sync.Mutex + IsKubeAdmin bool + NonAdminNamespaces []string } ) @@ -70,7 +69,6 @@ func NewClientFactory(signatureService portainer.DigitalSignatureService, revers signatureService: signatureService, reverseTunnelService: reverseTunnelService, instanceID: instanceID, - endpointClients: make(map[string]*KubeClient), endpointProxyClients: cache.New(timeout, timeout), AddrHTTPS: addrHTTPS, }, nil @@ -80,42 +78,33 @@ func (factory *ClientFactory) GetInstanceID() (instanceID string) { return factory.instanceID } -// Remove the cached kube client so a new one can be created -func (factory *ClientFactory) RemoveKubeClient(endpointID portainer.EndpointID) { - factory.mu.Lock() - delete(factory.endpointClients, strconv.Itoa(int(endpointID))) - factory.mu.Unlock() +// Clear removes all cached kube clients +func (factory *ClientFactory) ClearClientCache() { + log.Debug().Msgf("kubernetes namespace permissions have changed, clearing the client cache") + factory.endpointProxyClients.Flush() } -// GetKubeClient checks if an existing client is already registered for the environment(endpoint) and returns it if one is found. -// If no client is registered, it will create a new client, register it, and returns it. -func (factory *ClientFactory) GetKubeClient(endpoint *portainer.Endpoint) (*KubeClient, error) { - factory.mu.Lock() - key := strconv.Itoa(int(endpoint.ID)) - if client, ok := factory.endpointClients[key]; ok { - factory.mu.Unlock() - return client, nil - } - factory.mu.Unlock() +// Remove the cached kube client so a new one can be created +func (factory *ClientFactory) RemoveKubeClient(endpointID portainer.EndpointID) { + factory.endpointProxyClients.Delete(strconv.Itoa(int(endpointID))) +} - // EE-6901: Do not lock - client, err := factory.createCachedAdminKubeClient(endpoint) +// GetPrivilegedKubeClient checks if an existing client is already registered for the environment(endpoint) and returns it if one is found. +// If no client is registered, it will create a new client, register it, and returns it. +func (factory *ClientFactory) GetPrivilegedKubeClient(endpoint *portainer.Endpoint) (*KubeClient, error) { + key := strconv.Itoa(int(endpoint.ID)) + pcl, ok := factory.endpointProxyClients.Get(key) + if ok { + return pcl.(*KubeClient), nil + } + + kcl, err := factory.createCachedPrivilegedKubeClient(endpoint) if err != nil { return nil, err } - factory.mu.Lock() - defer factory.mu.Unlock() - - // The lock was released before the client was created, - // so we need to check again - if c, ok := factory.endpointClients[key]; ok { - return c, nil - } - - factory.endpointClients[key] = client - - return client, nil + factory.endpointProxyClients.Set(key, kcl, cache.DefaultExpiration) + return kcl, nil } // GetProxyKubeClient retrieves a KubeClient from the cache. You should be @@ -123,46 +112,47 @@ func (factory *ClientFactory) GetKubeClient(endpoint *portainer.Endpoint) (*Kube // kubernetes middleware. func (factory *ClientFactory) GetProxyKubeClient(endpointID, userID string) (*KubeClient, bool) { client, ok := factory.endpointProxyClients.Get(endpointID + "." + userID) - if !ok { - return nil, false + if ok { + return client.(*KubeClient), true } - - return client.(*KubeClient), true + return nil, false } // SetProxyKubeClient stores a kubeclient in the cache. func (factory *ClientFactory) SetProxyKubeClient(endpointID, userID string, cli *KubeClient) { - factory.endpointProxyClients.Set(endpointID+"."+userID, cli, 0) + factory.endpointProxyClients.Set(endpointID+"."+userID, cli, cache.DefaultExpiration) } // CreateKubeClientFromKubeConfig creates a KubeClient from a clusterID, and // Kubernetes config. -func (factory *ClientFactory) CreateKubeClientFromKubeConfig(clusterID string, kubeConfig []byte) (*KubeClient, error) { +func (factory *ClientFactory) CreateKubeClientFromKubeConfig(clusterID string, kubeConfig []byte, IsKubeAdmin bool, NonAdminNamespaces []string) (*KubeClient, error) { config, err := clientcmd.NewClientConfigFromBytes(kubeConfig) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to create a client config from kubeconfig: %w", err) } - cliConfig, err := config.ClientConfig() + clientConfig, err := config.ClientConfig() if err != nil { - return nil, err + return nil, fmt.Errorf("failed to get the complete client config from kubeconfig: %w", err) } - cliConfig.QPS = DefaultKubeClientQPS - cliConfig.Burst = DefaultKubeClientBurst + clientConfig.QPS = defaultKubeClientQPS + clientConfig.Burst = defaultKubeClientBurst - cli, err := kubernetes.NewForConfig(cliConfig) + cli, err := kubernetes.NewForConfig(clientConfig) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to create a new clientset for the given config: %w", err) } return &KubeClient{ - cli: cli, - instanceID: factory.instanceID, + cli: cli, + instanceID: factory.instanceID, + IsKubeAdmin: IsKubeAdmin, + NonAdminNamespaces: NonAdminNamespaces, }, nil } -func (factory *ClientFactory) createCachedAdminKubeClient(endpoint *portainer.Endpoint) (*KubeClient, error) { +func (factory *ClientFactory) createCachedPrivilegedKubeClient(endpoint *portainer.Endpoint) (*KubeClient, error) { cli, err := factory.CreateClient(endpoint) if err != nil { return nil, err @@ -235,8 +225,8 @@ func (factory *ClientFactory) buildAgentConfig(endpoint *portainer.Endpoint) (*r } config.Insecure = true - config.QPS = DefaultKubeClientQPS - config.Burst = DefaultKubeClientBurst + config.QPS = defaultKubeClientQPS + config.Burst = defaultKubeClientBurst config.Wrap(func(rt http.RoundTripper) http.RoundTripper { return &agentHeaderRoundTripper{ @@ -251,7 +241,7 @@ func (factory *ClientFactory) buildAgentConfig(endpoint *portainer.Endpoint) (*r func (factory *ClientFactory) buildEdgeConfig(endpoint *portainer.Endpoint) (*rest.Config, error) { tunnelAddr, err := factory.reverseTunnelService.TunnelAddr(endpoint) if err != nil { - return nil, errors.Wrap(err, "failed activating tunnel") + return nil, errors.Wrap(err, "failed to activate the chisel reverse tunnel. check if the tunnel port is open at the portainer instance") } endpointURL := fmt.Sprintf("http://%s/kubernetes", tunnelAddr) @@ -266,8 +256,8 @@ func (factory *ClientFactory) buildEdgeConfig(endpoint *portainer.Endpoint) (*re } config.Insecure = true - config.QPS = DefaultKubeClientQPS - config.Burst = DefaultKubeClientBurst + config.QPS = defaultKubeClientQPS + config.Burst = defaultKubeClientBurst config.Wrap(func(rt http.RoundTripper) http.RoundTripper { return &agentHeaderRoundTripper{ @@ -294,8 +284,8 @@ func buildLocalConfig() (*rest.Config, error) { return nil, err } - config.QPS = DefaultKubeClientQPS - config.Burst = DefaultKubeClientBurst + config.QPS = defaultKubeClientQPS + config.Burst = defaultKubeClientBurst return config, nil } diff --git a/api/kubernetes/cli/cluster_role.go b/api/kubernetes/cli/cluster_role.go new file mode 100644 index 000000000..d5194ccd2 --- /dev/null +++ b/api/kubernetes/cli/cluster_role.go @@ -0,0 +1,43 @@ +package cli + +import ( + "context" + "fmt" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GetClusterRoles gets all the clusterRoles for at the cluster level in a k8s endpoint. +// It returns a list of K8sClusterRole objects. +func (kcl *KubeClient) GetClusterRoles() ([]models.K8sClusterRole, error) { + if kcl.IsKubeAdmin { + return kcl.fetchClusterRoles() + } + + return []models.K8sClusterRole{}, fmt.Errorf("non-admin users are not allowed to access cluster roles") +} + +// fetchClusterRoles returns a list of all Roles in the specified namespace. +func (kcl *KubeClient) fetchClusterRoles() ([]models.K8sClusterRole, error) { + clusterRoles, err := kcl.cli.RbacV1().ClusterRoles().List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + results := make([]models.K8sClusterRole, 0) + for _, clusterRole := range clusterRoles.Items { + results = append(results, parseClusterRole(clusterRole)) + } + + return results, nil +} + +// parseClusterRole converts a rbacv1.ClusterRole object to a models.K8sClusterRole object. +func parseClusterRole(clusterRole rbacv1.ClusterRole) models.K8sClusterRole { + return models.K8sClusterRole{ + Name: clusterRole.Name, + CreationDate: clusterRole.CreationTimestamp.Time, + } +} diff --git a/api/kubernetes/cli/cluster_role_binding.go b/api/kubernetes/cli/cluster_role_binding.go new file mode 100644 index 000000000..070ef1c50 --- /dev/null +++ b/api/kubernetes/cli/cluster_role_binding.go @@ -0,0 +1,45 @@ +package cli + +import ( + "context" + "fmt" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GetClusterRoleBindings gets all the clusterRoleBindings for at the cluster level in a k8s endpoint. +// It returns a list of K8sClusterRoleBinding objects. +func (kcl *KubeClient) GetClusterRoleBindings() ([]models.K8sClusterRoleBinding, error) { + if kcl.IsKubeAdmin { + return kcl.fetchClusterRoleBindings() + } + + return []models.K8sClusterRoleBinding{}, fmt.Errorf("non-admin users are not allowed to access cluster role bindings") +} + +// fetchClusterRoleBindings returns a list of all cluster roles in the cluster. +func (kcl *KubeClient) fetchClusterRoleBindings() ([]models.K8sClusterRoleBinding, error) { + clusterRoleBindings, err := kcl.cli.RbacV1().ClusterRoleBindings().List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + results := make([]models.K8sClusterRoleBinding, 0) + for _, clusterRoleBinding := range clusterRoleBindings.Items { + results = append(results, parseClusterRoleBinding(clusterRoleBinding)) + } + + return results, nil +} + +// parseClusterRoleBinding converts a rbacv1.ClusterRoleBinding object to a models.K8sClusterRoleBinding object. +func parseClusterRoleBinding(clusterRoleBinding rbacv1.ClusterRoleBinding) models.K8sClusterRoleBinding { + return models.K8sClusterRoleBinding{ + Name: clusterRoleBinding.Name, + RoleRef: clusterRoleBinding.RoleRef, + Subjects: clusterRoleBinding.Subjects, + CreationDate: clusterRoleBinding.CreationTimestamp.Time, + } +} diff --git a/api/kubernetes/cli/configmap.go b/api/kubernetes/cli/configmap.go new file mode 100644 index 000000000..b3077c8c0 --- /dev/null +++ b/api/kubernetes/cli/configmap.go @@ -0,0 +1,161 @@ +package cli + +import ( + "context" + "fmt" + "time" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/rs/zerolog/log" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GetConfigMaps gets all the ConfigMaps for a given namespace in a k8s endpoint. +// if the user is an admin, all configMaps in the current k8s environment(endpoint) are fetched using the fetchConfigMaps function. +// otherwise, namespaces the non-admin user has access to will be used to filter the configMaps based on the allowed namespaces. +func (kcl *KubeClient) GetConfigMaps(namespace string) ([]models.K8sConfigMap, error) { + if kcl.IsKubeAdmin { + return kcl.fetchConfigMaps(namespace) + } + return kcl.fetchConfigMapsForNonAdmin(namespace) +} + +// fetchConfigMapsForNonAdmin fetches the configMaps in the namespaces the user has access to. +// This function is called when the user is not an admin. +func (kcl *KubeClient) fetchConfigMapsForNonAdmin(namespace string) ([]models.K8sConfigMap, error) { + log.Debug().Msgf("Fetching volumes for non-admin user: %v", kcl.NonAdminNamespaces) + + if len(kcl.NonAdminNamespaces) == 0 { + return nil, nil + } + + configMaps, err := kcl.fetchConfigMaps(namespace) + if err != nil { + return nil, err + } + + nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap() + results := make([]models.K8sConfigMap, 0) + for _, configMap := range configMaps { + if _, ok := nonAdminNamespaceSet[configMap.Namespace]; ok { + results = append(results, configMap) + } + } + + return results, nil +} + +// fetchConfigMaps gets all the ConfigMaps for a given namespace in a k8s endpoint. +// the result is a list of config maps parsed into a K8sConfigMap struct. +func (kcl *KubeClient) fetchConfigMaps(namespace string) ([]models.K8sConfigMap, error) { + configMaps, err := kcl.cli.CoreV1().ConfigMaps(namespace).List(context.Background(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + results := []models.K8sConfigMap{} + for _, configMap := range configMaps.Items { + results = append(results, parseConfigMap(&configMap, false)) + } + + return results, nil +} + +func (kcl *KubeClient) GetConfigMap(namespace, configMapName string) (models.K8sConfigMap, error) { + configMap, err := kcl.cli.CoreV1().ConfigMaps(namespace).Get(context.Background(), configMapName, metav1.GetOptions{}) + if err != nil { + return models.K8sConfigMap{}, err + } + + return parseConfigMap(configMap, true), nil +} + +// parseConfigMap parses a k8s ConfigMap object into a K8sConfigMap struct. +// for get operation, withData will be set to true. +// otherwise, only metadata will be parsed. +func parseConfigMap(configMap *corev1.ConfigMap, withData bool) models.K8sConfigMap { + result := models.K8sConfigMap{ + K8sConfiguration: models.K8sConfiguration{ + UID: string(configMap.UID), + Name: configMap.Name, + Namespace: configMap.Namespace, + CreationDate: configMap.CreationTimestamp.Time.UTC().Format(time.RFC3339), + Annotations: configMap.Annotations, + Labels: configMap.Labels, + ConfigurationOwner: configMap.Labels[labelPortainerKubeConfigOwner], + ConfigurationOwnerId: configMap.Labels[labelPortainerKubeConfigOwnerId], + }, + } + + if withData { + result.Data = configMap.Data + } + + return result +} + +// CombineConfigMapsWithApplications combines the config maps with the applications that use them. +// the function fetches all the pods and replica sets in the cluster and checks if the config map is used by any of the pods. +// if the config map is used by a pod, the application that uses the pod is added to the config map. +// otherwise, the config map is returned as is. +func (kcl *KubeClient) CombineConfigMapsWithApplications(configMaps []models.K8sConfigMap) ([]models.K8sConfigMap, error) { + updatedConfigMaps := make([]models.K8sConfigMap, len(configMaps)) + + pods, replicaSets, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("an error occurred during the CombineConfigMapsWithApplications operation, unable to fetch pods and replica sets. Error: %w", err) + } + + for index, configMap := range configMaps { + updatedConfigMap := configMap + + applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromConfigMap(configMap, pods, replicaSets) + if err != nil { + return nil, fmt.Errorf("an error occurred during the CombineConfigMapsWithApplications operation, unable to get applications from config map. Error: %w", err) + } + + if len(applicationConfigurationOwners) > 0 { + updatedConfigMap.ConfigurationOwnerResources = applicationConfigurationOwners + updatedConfigMap.IsUsed = true + } + + updatedConfigMaps[index] = updatedConfigMap + } + + return updatedConfigMaps, nil +} + +// CombineConfigMapWithApplications combines the config map with the applications that use it. +// the function fetches all the pods in the cluster and checks if the config map is used by any of the pods. +// it needs to check if the pods are owned by a replica set to determine if the pod is part of a deployment. +func (kcl *KubeClient) CombineConfigMapWithApplications(configMap models.K8sConfigMap) (models.K8sConfigMap, error) { + pods, err := kcl.cli.CoreV1().Pods(configMap.Namespace).List(context.Background(), metav1.ListOptions{}) + if err != nil { + return models.K8sConfigMap{}, fmt.Errorf("an error occurred during the CombineConfigMapWithApplications operation, unable to get pods. Error: %w", err) + } + + containsReplicaSetOwner := false + for _, pod := range pods.Items { + containsReplicaSetOwner = isReplicaSetOwner(pod) + break + } + + if containsReplicaSetOwner { + replicaSets, err := kcl.cli.AppsV1().ReplicaSets(configMap.Namespace).List(context.Background(), metav1.ListOptions{}) + if err != nil { + return models.K8sConfigMap{}, fmt.Errorf("an error occurred during the CombineConfigMapWithApplications operation, unable to get replica sets. Error: %w", err) + } + + applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromConfigMap(configMap, pods.Items, replicaSets.Items) + if err != nil { + return models.K8sConfigMap{}, fmt.Errorf("an error occurred during the CombineConfigMapWithApplications operation, unable to get applications from config map. Error: %w", err) + } + + if len(applicationConfigurationOwners) > 0 { + configMap.ConfigurationOwnerResources = applicationConfigurationOwners + } + } + + return configMap, nil +} diff --git a/api/kubernetes/cli/configmaps_and_secrets.go b/api/kubernetes/cli/configmaps_and_secrets.go deleted file mode 100644 index d6f8c3b5a..000000000 --- a/api/kubernetes/cli/configmaps_and_secrets.go +++ /dev/null @@ -1,64 +0,0 @@ -package cli - -import ( - "context" - "time" - - models "github.com/portainer/portainer/api/http/models/kubernetes" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// GetConfigMapsAndSecrets gets all the ConfigMaps AND all the Secrets for a -// given namespace in a k8s endpoint. The result is a list of both config maps -// and secrets. The IsSecret boolean property indicates if a given struct is a -// secret or configmap. -func (kcl *KubeClient) GetConfigMapsAndSecrets(namespace string) ([]models.K8sConfigMapOrSecret, error) { - mapsClient := kcl.cli.CoreV1().ConfigMaps(namespace) - mapsList, err := mapsClient.List(context.Background(), v1.ListOptions{}) - if err != nil { - return nil, err - } - - // TODO: Applications - var combined []models.K8sConfigMapOrSecret - for _, m := range mapsList.Items { - var cm models.K8sConfigMapOrSecret - cm.UID = string(m.UID) - cm.Name = m.Name - cm.Namespace = m.Namespace - cm.Annotations = m.Annotations - cm.Data = m.Data - cm.CreationDate = m.CreationTimestamp.Time.UTC().Format(time.RFC3339) - cm.IsSecret = false - combined = append(combined, cm) - } - - secretClient := kcl.cli.CoreV1().Secrets(namespace) - secretList, err := secretClient.List(context.Background(), v1.ListOptions{}) - if err != nil { - return nil, err - } - - for _, s := range secretList.Items { - var secret models.K8sConfigMapOrSecret - secret.UID = string(s.UID) - secret.Name = s.Name - secret.Namespace = s.Namespace - secret.Annotations = s.Annotations - secret.Data = msbToMss(s.Data) - secret.CreationDate = s.CreationTimestamp.Time.UTC().Format(time.RFC3339) - secret.IsSecret = true - secret.SecretType = string(s.Type) - combined = append(combined, secret) - } - - return combined, nil -} - -func msbToMss(msa map[string][]byte) map[string]string { - mss := make(map[string]string, len(msa)) - for k, v := range msa { - mss[k] = string(v) - } - return mss -} diff --git a/api/kubernetes/cli/dashboard.go b/api/kubernetes/cli/dashboard.go index 32cf71c9d..7a7951c71 100644 --- a/api/kubernetes/cli/dashboard.go +++ b/api/kubernetes/cli/dashboard.go @@ -151,12 +151,16 @@ func getApplicationsCount(ctx context.Context, kcl *KubeClient, namespace string } // + (naked pods) - nakedPods, err := kcl.GetApplications(namespace, "nakedpods") - if err != nil { - return 0, err - } + // TODO: Implement fetching of naked pods + // This is to be reworked as part of the dashboard refactor - return count + int64(len(nakedPods)), nil + // nakedPods, err := kcl.GetApplications(namespace, "nakedpods") + // if err != nil { + // return 0, err + // } + // For now, we're not including naked pods in the count + + return count, nil } // Get the total count of services for the given namespace diff --git a/api/kubernetes/cli/ingress.go b/api/kubernetes/cli/ingress.go index d32877906..c80ff66f8 100644 --- a/api/kubernetes/cli/ingress.go +++ b/api/kubernetes/cli/ingress.go @@ -2,183 +2,252 @@ package cli import ( "context" + "fmt" "strings" models "github.com/portainer/portainer/api/http/models/kubernetes" "github.com/portainer/portainer/api/stacks/stackutils" "github.com/rs/zerolog/log" netv1 "k8s.io/api/networking/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func (kcl *KubeClient) GetIngressControllers() (models.K8sIngressControllers, error) { - var controllers []models.K8sIngressController - - // We know that each existing class points to a controller so we can start - // by collecting these easy ones. - classClient := kcl.cli.NetworkingV1().IngressClasses() - classList, err := classClient.List(context.Background(), metav1.ListOptions{}) + classeses, err := kcl.cli.NetworkingV1().IngressClasses().List(context.Background(), metav1.ListOptions{}) if err != nil { return nil, err } - // We want to know which of these controllers is in use. - var ingresses []models.K8sIngressInfo - namespaces, err := kcl.GetNamespaces() + ingresses, err := kcl.GetIngresses("") if err != nil { return nil, err } - for namespace := range namespaces { - t, err := kcl.GetIngresses(namespace) - if err != nil { - // User might not be able to list ingresses in system/not allowed - // namespaces. - log.Debug().Err(err).Msg("failed to list ingresses for the current user, skipped sending ingress") - continue - } - ingresses = append(ingresses, t...) - } + usedClasses := make(map[string]struct{}) for _, ingress := range ingresses { usedClasses[ingress.ClassName] = struct{}{} } - for _, class := range classList.Items { - var controller models.K8sIngressController - controller.Name = class.Spec.Controller - controller.ClassName = class.Name - - // If the class is used mark it as such. + results := []models.K8sIngressController{} + for _, class := range classeses.Items { + ingressClass := parseIngressClass(class) if _, ok := usedClasses[class.Name]; ok { - controller.Used = true + ingressClass.Used = true } - switch { - case strings.Contains(controller.Name, "nginx"): - controller.Type = "nginx" - case strings.Contains(controller.Name, "traefik"): - controller.Type = "traefik" - default: - controller.Type = "other" - } - controllers = append(controllers, controller) + results = append(results, ingressClass) + } + + return results, nil +} + +// fetchIngressClasses fetches all the ingress classes in a k8s endpoint. +func (kcl *KubeClient) fetchIngressClasses() ([]models.K8sIngressController, error) { + ingressClasses, err := kcl.cli.NetworkingV1().IngressClasses().List(context.Background(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + var controllers []models.K8sIngressController + for _, ingressClass := range ingressClasses.Items { + controllers = append(controllers, parseIngressClass(ingressClass)) } return controllers, nil } +// parseIngressClass converts a k8s native ingress class object to a Portainer K8sIngressController object. +func parseIngressClass(ingressClasses netv1.IngressClass) models.K8sIngressController { + ingressContoller := models.K8sIngressController{ + Name: ingressClasses.Spec.Controller, + ClassName: ingressClasses.Name, + } + + switch { + case strings.Contains(ingressContoller.Name, "nginx"): + ingressContoller.Type = "nginx" + case strings.Contains(ingressContoller.Name, "traefik"): + ingressContoller.Type = "traefik" + default: + ingressContoller.Type = "other" + } + + return ingressContoller +} + +// GetIngress gets an ingress in a given namespace in a k8s endpoint. +func (kcl *KubeClient) GetIngress(namespace, ingressName string) (models.K8sIngressInfo, error) { + ingress, err := kcl.cli.NetworkingV1().Ingresses(namespace).Get(context.Background(), ingressName, metav1.GetOptions{}) + if err != nil { + return models.K8sIngressInfo{}, err + } + + return parseIngress(*ingress), nil +} + // GetIngresses gets all the ingresses for a given namespace in a k8s endpoint. func (kcl *KubeClient) GetIngresses(namespace string) ([]models.K8sIngressInfo, error) { - // Fetch ingress classes to build a map. We will later use the map to lookup - // each ingresses "type". - classes := make(map[string]string) - classClient := kcl.cli.NetworkingV1().IngressClasses() - classList, err := classClient.List(context.Background(), metav1.ListOptions{}) + if kcl.IsKubeAdmin { + return kcl.fetchIngresses(namespace) + } + return kcl.fetchIngressesForNonAdmin(namespace) +} + +// fetchIngressesForNonAdmin gets all the ingresses for non-admin users in a k8s endpoint. +func (kcl *KubeClient) fetchIngressesForNonAdmin(namespace string) ([]models.K8sIngressInfo, error) { + log.Debug().Msgf("Fetching ingresses for non-admin user: %v", kcl.NonAdminNamespaces) + + if len(kcl.NonAdminNamespaces) == 0 { + return nil, nil + } + + ingresses, err := kcl.fetchIngresses(namespace) if err != nil { return nil, err } - for _, class := range classList.Items { - // Write the ingress classes "type" to our map. - classes[class.Name] = class.Spec.Controller + nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap() + results := make([]models.K8sIngressInfo, 0) + for _, ingress := range ingresses { + if _, ok := nonAdminNamespaceSet[ingress.Namespace]; ok { + results = append(results, ingress) + } } - // Fetch each ingress. - ingressClient := kcl.cli.NetworkingV1().Ingresses(namespace) - ingressList, err := ingressClient.List(context.Background(), metav1.ListOptions{}) + return results, nil +} + +// fetchIngresses fetches all the ingresses for a given namespace in a k8s endpoint. +func (kcl *KubeClient) fetchIngresses(namespace string) ([]models.K8sIngressInfo, error) { + ingresses, err := kcl.cli.NetworkingV1().Ingresses(namespace).List(context.Background(), metav1.ListOptions{}) if err != nil { return nil, err } - var infos []models.K8sIngressInfo - for _, ingress := range ingressList.Items { - var info models.K8sIngressInfo - info.Name = ingress.Name - info.UID = string(ingress.UID) - info.Namespace = namespace - ingressClass := ingress.Spec.IngressClassName - info.ClassName = "" - if ingressClass != nil { - info.ClassName = *ingressClass - } - info.Type = classes[info.ClassName] - info.Annotations = ingress.Annotations - info.Labels = ingress.Labels - info.CreationDate = ingress.CreationTimestamp.Time - - // Gather TLS information. - for _, v := range ingress.Spec.TLS { - var tls models.K8sIngressTLS - tls.Hosts = v.Hosts - tls.SecretName = v.SecretName - info.TLS = append(info.TLS, tls) - } - - // Gather list of paths and hosts. - hosts := make(map[string]struct{}) - for _, r := range ingress.Spec.Rules { - // We collect all exiting hosts in a map to avoid duplicates. - // Then, later convert it to a slice for the frontend. - hosts[r.Host] = struct{}{} - - if r.HTTP == nil { - continue - } - - // There are multiple paths per rule. We want to flatten the list - // for our frontend. - for _, p := range r.HTTP.Paths { - var path models.K8sIngressPath - path.IngressName = info.Name - path.Host = r.Host - - path.Path = p.Path - if p.PathType != nil { - path.PathType = string(*p.PathType) - } - path.ServiceName = p.Backend.Service.Name - path.Port = int(p.Backend.Service.Port.Number) - info.Paths = append(info.Paths, path) - } - } - - // Store list of hosts. - for host := range hosts { - info.Hosts = append(info.Hosts, host) - } - - infos = append(infos, info) + ingressClasses, err := kcl.fetchIngressClasses() + if err != nil { + return nil, err } - return infos, nil + results := []models.K8sIngressInfo{} + if len(ingresses.Items) == 0 { + return results, nil + } + + for _, ingress := range ingresses.Items { + result := parseIngress(ingress) + if ingress.Spec.IngressClassName != nil { + result.Type = findUsedIngressFromIngressClasses(ingressClasses, *ingress.Spec.IngressClassName).Name + } + results = append(results, result) + } + + return results, nil +} + +// parseIngress converts a k8s native ingress object to a Portainer K8sIngressInfo object. +func parseIngress(ingress netv1.Ingress) models.K8sIngressInfo { + ingressClassName := "" + if ingress.Spec.IngressClassName != nil { + ingressClassName = *ingress.Spec.IngressClassName + } + + result := models.K8sIngressInfo{ + Name: ingress.Name, + Namespace: ingress.Namespace, + UID: string(ingress.UID), + Annotations: ingress.Annotations, + Labels: ingress.Labels, + CreationDate: ingress.CreationTimestamp.Time, + ClassName: ingressClassName, + } + + for _, tls := range ingress.Spec.TLS { + result.TLS = append(result.TLS, models.K8sIngressTLS{ + Hosts: tls.Hosts, + SecretName: tls.SecretName, + }) + } + + hosts := make(map[string]struct{}) + for _, r := range ingress.Spec.Rules { + hosts[r.Host] = struct{}{} + + if r.HTTP == nil { + continue + } + for _, p := range r.HTTP.Paths { + var path models.K8sIngressPath + path.IngressName = result.Name + path.Host = r.Host + + path.Path = p.Path + if p.PathType != nil { + path.PathType = string(*p.PathType) + } + path.ServiceName = p.Backend.Service.Name + path.Port = int(p.Backend.Service.Port.Number) + result.Paths = append(result.Paths, path) + } + } + + for host := range hosts { + result.Hosts = append(result.Hosts, host) + } + + return result +} + +// findUsedIngressFromIngressClasses searches for an ingress in a slice of ingress classes and returns the ingress if found. +func findUsedIngressFromIngressClasses(ingressClasses []models.K8sIngressController, className string) models.K8sIngressController { + for _, ingressClass := range ingressClasses { + if ingressClass.ClassName == className { + return ingressClass + } + } + + return models.K8sIngressController{} } // CreateIngress creates a new ingress in a given namespace in a k8s endpoint. func (kcl *KubeClient) CreateIngress(namespace string, info models.K8sIngressInfo, owner string) error { - ingressClient := kcl.cli.NetworkingV1().Ingresses(namespace) - var ingress netv1.Ingress - - ingress.Name = info.Name - ingress.Namespace = info.Namespace - if info.ClassName != "" { - ingress.Spec.IngressClassName = &info.ClassName + ingress := kcl.convertToK8sIngress(info, owner) + _, err := kcl.cli.NetworkingV1().Ingresses(namespace).Create(context.Background(), &ingress, metav1.CreateOptions{}) + if err != nil { + return err } - ingress.Annotations = info.Annotations - if ingress.Labels == nil { - ingress.Labels = make(map[string]string) - } - ingress.Labels["io.portainer.kubernetes.ingress.owner"] = stackutils.SanitizeLabel(owner) - // Store TLS information. - var tls []netv1.IngressTLS - for _, i := range info.TLS { + return nil +} + +// convertToK8sIngress converts a Portainer K8sIngressInfo object to a k8s native Ingress object. +// this is required for create and update operations. +func (kcl *KubeClient) convertToK8sIngress(info models.K8sIngressInfo, owner string) netv1.Ingress { + result := netv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: info.Name, + Namespace: info.Namespace, + Annotations: info.Annotations, + }, + + Spec: netv1.IngressSpec{ + IngressClassName: &info.ClassName, + }, + } + + labels := make(map[string]string) + labels["io.portainer.kubernetes.ingress.owner"] = stackutils.SanitizeLabel(owner) + result.Labels = labels + + tls := []netv1.IngressTLS{} + for _, t := range info.TLS { tls = append(tls, netv1.IngressTLS{ - Hosts: i.Hosts, - SecretName: i.SecretName, + Hosts: t.Hosts, + SecretName: t.SecretName, }) } - ingress.Spec.TLS = tls + result.Spec.TLS = tls - // Parse "paths" into rules with paths. rules := make(map[string][]netv1.HTTPIngressPath) for _, path := range info.Paths { pathType := netv1.PathType(path.PathType) @@ -197,7 +266,7 @@ func (kcl *KubeClient) CreateIngress(namespace string, info models.K8sIngressInf } for rule, paths := range rules { - ingress.Spec.Rules = append(ingress.Spec.Rules, netv1.IngressRule{ + result.Spec.Rules = append(result.Spec.Rules, netv1.IngressRule{ Host: rule, IngressRuleValue: netv1.IngressRuleValue{ HTTP: &netv1.HTTPIngressRuleValue{ @@ -207,102 +276,86 @@ func (kcl *KubeClient) CreateIngress(namespace string, info models.K8sIngressInf }) } - // Add rules for hosts that does not have paths. - // e.g. dafault ingress rule without path to support what we had in 2.15 for _, host := range info.Hosts { if _, ok := rules[host]; !ok { - ingress.Spec.Rules = append(ingress.Spec.Rules, netv1.IngressRule{ + result.Spec.Rules = append(result.Spec.Rules, netv1.IngressRule{ Host: host, }) } } - _, err := ingressClient.Create(context.Background(), &ingress, metav1.CreateOptions{}) - return err + return result } // DeleteIngresses processes a K8sIngressDeleteRequest by deleting each ingress // in its given namespace. func (kcl *KubeClient) DeleteIngresses(reqs models.K8sIngressDeleteRequests) error { - var err error for namespace := range reqs { for _, ingress := range reqs[namespace] { - ingressClient := kcl.cli.NetworkingV1().Ingresses(namespace) - err = ingressClient.Delete( + err := kcl.cli.NetworkingV1().Ingresses(namespace).Delete( context.Background(), ingress, metav1.DeleteOptions{}, ) + + if err != nil { + return err + } } } - return err + + return nil } // UpdateIngress updates an existing ingress in a given namespace in a k8s endpoint. func (kcl *KubeClient) UpdateIngress(namespace string, info models.K8sIngressInfo) error { - ingressClient := kcl.cli.NetworkingV1().Ingresses(namespace) - ingress, err := ingressClient.Get(context.Background(), info.Name, metav1.GetOptions{}) + ingress := kcl.convertToK8sIngress(info, "") + _, err := kcl.cli.NetworkingV1().Ingresses(namespace).Update(context.Background(), &ingress, metav1.UpdateOptions{}) if err != nil { return err } - ingress.Name = info.Name - ingress.Namespace = info.Namespace - if info.ClassName != "" { - ingress.Spec.IngressClassName = &info.ClassName - } - ingress.Annotations = info.Annotations + return nil +} - // Store TLS information. - var tls []netv1.IngressTLS - for _, i := range info.TLS { - tls = append(tls, netv1.IngressTLS{ - Hosts: i.Hosts, - SecretName: i.SecretName, - }) - } - ingress.Spec.TLS = tls - - // Parse "paths" into rules with paths. - rules := make(map[string][]netv1.HTTPIngressPath) - for _, path := range info.Paths { - pathType := netv1.PathType(path.PathType) - rules[path.Host] = append(rules[path.Host], netv1.HTTPIngressPath{ - Path: path.Path, - PathType: &pathType, - Backend: netv1.IngressBackend{ - Service: &netv1.IngressServiceBackend{ - Name: path.ServiceName, - Port: netv1.ServiceBackendPort{ - Number: int32(path.Port), - }, - }, - }, - }) +// CombineIngressWithService combines an ingress with a service that is being used by the ingress. +// this is required to display the service that is being used by the ingress in the UI edit view. +func (kcl *KubeClient) CombineIngressWithService(ingress models.K8sIngressInfo) (models.K8sIngressInfo, error) { + services, err := kcl.GetServices(ingress.Namespace) + if err != nil { + return models.K8sIngressInfo{}, fmt.Errorf("an error occurred during the CombineIngressWithService operation, unable to retrieve services from the Kubernetes for a namespace level user. Error: %w", err) } - ingress.Spec.Rules = make([]netv1.IngressRule, 0) - for rule, paths := range rules { - ingress.Spec.Rules = append(ingress.Spec.Rules, netv1.IngressRule{ - Host: rule, - IngressRuleValue: netv1.IngressRuleValue{ - HTTP: &netv1.HTTPIngressRuleValue{ - Paths: paths, - }, - }, - }) - } - - // Add rules for hosts that does not have paths. - // e.g. dafault ingress rule without path to support what we had in 2.15 - for _, host := range info.Hosts { - if _, ok := rules[host]; !ok { - ingress.Spec.Rules = append(ingress.Spec.Rules, netv1.IngressRule{ - Host: host, - }) + serviceMap := kcl.buildServicesMap(services) + for pathIndex, path := range ingress.Paths { + if _, ok := serviceMap[path.ServiceName]; ok { + ingress.Paths[pathIndex].HasService = true } } - _, err = ingressClient.Update(context.Background(), ingress, metav1.UpdateOptions{}) - return err + return ingress, nil +} + +// CombineIngressesWithServices combines a list of ingresses with a list of services that are being used by the ingresses. +// this is required to display the services that are being used by the ingresses in the UI list view. +func (kcl *KubeClient) CombineIngressesWithServices(ingresses []models.K8sIngressInfo) ([]models.K8sIngressInfo, error) { + services, err := kcl.GetServices("") + if err != nil { + if k8serrors.IsUnauthorized(err) { + return nil, fmt.Errorf("an error occurred during the CombineIngressesWithServices operation, unauthorized access to the Kubernetes API. Error: %w", err) + } + + return nil, fmt.Errorf("an error occurred during the CombineIngressesWithServices operation, unable to retrieve services from the Kubernetes for a cluster level user. Error: %w", err) + } + + serviceMap := kcl.buildServicesMap(services) + for ingressIndex, ingress := range ingresses { + for pathIndex, path := range ingress.Paths { + if _, ok := serviceMap[path.ServiceName]; ok { + (ingresses)[ingressIndex].Paths[pathIndex].HasService = true + } + } + } + + return ingresses, nil } diff --git a/api/kubernetes/cli/namespace.go b/api/kubernetes/cli/namespace.go index a3fcfe211..57cd759cd 100644 --- a/api/kubernetes/cli/namespace.go +++ b/api/kubernetes/cli/namespace.go @@ -3,20 +3,27 @@ package cli import ( "context" "fmt" + "net/http" "strconv" + "time" "github.com/pkg/errors" portainer "github.com/portainer/portainer/api" models "github.com/portainer/portainer/api/http/models/kubernetes" "github.com/portainer/portainer/api/stacks/stackutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" "github.com/rs/zerolog/log" - v1 "k8s.io/api/core/v1" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) const ( systemNamespaceLabel = "io.portainer.kubernetes.namespace.system" + namespaceOwnerLabel = "io.portainer.kubernetes.resourcepool.owner" + namespaceNameLabel = "io.portainer.kubernetes.resourcepool.name" ) func defaultSystemNamespaces() map[string]struct{} { @@ -29,24 +36,69 @@ func defaultSystemNamespaces() map[string]struct{} { } // GetNamespaces gets the namespaces in the current k8s environment(endpoint). +// if the user is an admin, all namespaces in the current k8s environment(endpoint) are fetched using the fetchNamespaces function. +// otherwise, namespaces the non-admin user has access to will be used to filter the namespaces based on the allowed namespaces. func (kcl *KubeClient) GetNamespaces() (map[string]portainer.K8sNamespaceInfo, error) { - namespaces, err := kcl.cli.CoreV1().Namespaces().List(context.TODO(), metav1.ListOptions{}) - if err != nil { - return nil, err + if kcl.IsKubeAdmin { + return kcl.fetchNamespaces() + } + return kcl.fetchNamespacesForNonAdmin() +} + +// fetchNamespacesForNonAdmin gets the namespaces in the current k8s environment(endpoint) for the non-admin user. +func (kcl *KubeClient) fetchNamespacesForNonAdmin() (map[string]portainer.K8sNamespaceInfo, error) { + log.Debug().Msgf("Fetching namespaces for non-admin user: %v", kcl.NonAdminNamespaces) + + if len(kcl.NonAdminNamespaces) == 0 { + return nil, nil } - results := make(map[string]portainer.K8sNamespaceInfo) + namespaces, err := kcl.fetchNamespaces() + if err != nil { + return nil, fmt.Errorf("an error occurred during the fetchNamespacesForNonAdmin operation, unable to list namespaces for the non-admin user: %w", err) + } - for _, ns := range namespaces.Items { - results[ns.Name] = portainer.K8sNamespaceInfo{ - IsSystem: isSystemNamespace(ns), - IsDefault: ns.Name == defaultNamespace, + nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap() + results := make(map[string]portainer.K8sNamespaceInfo) + for _, namespace := range namespaces { + if _, exists := nonAdminNamespaceSet[namespace.Name]; exists { + results[namespace.Name] = namespace } } return results, nil } +// fetchNamespaces gets the namespaces in the current k8s environment(endpoint). +// this function is used by both admin and non-admin users. +// the result gets parsed to a map of namespace name to namespace info. +func (kcl *KubeClient) fetchNamespaces() (map[string]portainer.K8sNamespaceInfo, error) { + namespaces, err := kcl.cli.CoreV1().Namespaces().List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("an error occurred during the fetchNamespacesForAdmin operation, unable to list namespaces for the admin user: %w", err) + } + + results := make(map[string]portainer.K8sNamespaceInfo) + for _, namespace := range namespaces.Items { + results[namespace.Name] = parseNamespace(&namespace) + } + + return results, nil +} + +// parseNamespace converts a k8s namespace object to a portainer namespace object. +func parseNamespace(namespace *corev1.Namespace) portainer.K8sNamespaceInfo { + return portainer.K8sNamespaceInfo{ + Id: string(namespace.UID), + Name: namespace.Name, + Status: namespace.Status, + CreationDate: namespace.CreationTimestamp.Format(time.RFC3339), + NamespaceOwner: namespace.Labels[namespaceOwnerLabel], + IsSystem: isSystemNamespace(*namespace), + IsDefault: namespace.Name == defaultNamespace, + } +} + // GetNamespace gets the namespace in the current k8s environment(endpoint). func (kcl *KubeClient) GetNamespace(name string) (portainer.K8sNamespaceInfo, error) { namespace, err := kcl.cli.CoreV1().Namespaces().Get(context.TODO(), name, metav1.GetOptions{}) @@ -54,47 +106,42 @@ func (kcl *KubeClient) GetNamespace(name string) (portainer.K8sNamespaceInfo, er return portainer.K8sNamespaceInfo{}, err } - result := portainer.K8sNamespaceInfo{ - IsSystem: isSystemNamespace(*namespace), - IsDefault: namespace.Name == defaultNamespace, - } - - return result, nil + return parseNamespace(namespace), nil } // CreateNamespace creates a new ingress in a given namespace in a k8s endpoint. -func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceDetails) error { +func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error) { portainerLabels := map[string]string{ - "io.portainer.kubernetes.resourcepool.name": stackutils.SanitizeLabel(info.Name), - "io.portainer.kubernetes.resourcepool.owner": stackutils.SanitizeLabel(info.Owner), + namespaceNameLabel: stackutils.SanitizeLabel(info.Name), + namespaceOwnerLabel: stackutils.SanitizeLabel(info.Owner), } - var ns v1.Namespace + var ns corev1.Namespace ns.Name = info.Name ns.Annotations = info.Annotations ns.Labels = portainerLabels - _, err := kcl.cli.CoreV1().Namespaces().Create(context.Background(), &ns, metav1.CreateOptions{}) + namespace, err := kcl.cli.CoreV1().Namespaces().Create(context.Background(), &ns, metav1.CreateOptions{}) if err != nil { log.Error(). Err(err). Str("Namespace", info.Name). Msg("Failed to create the namespace") - return err + return nil, err } if info.ResourceQuota != nil && info.ResourceQuota.Enabled { log.Info().Msgf("Creating resource quota for namespace %s", info.Name) log.Debug().Msgf("Creating resource quota with details: %+v", info.ResourceQuota) - resourceQuota := &v1.ResourceQuota{ + resourceQuota := &corev1.ResourceQuota{ ObjectMeta: metav1.ObjectMeta{ Name: "portainer-rq-" + info.Name, Namespace: info.Name, Labels: portainerLabels, }, - Spec: v1.ResourceQuotaSpec{ - Hard: v1.ResourceList{}, + Spec: corev1.ResourceQuotaSpec{ + Hard: corev1.ResourceList{}, }, } @@ -103,28 +150,28 @@ func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceDetails) error { cpu := resource.MustParse(info.ResourceQuota.CPU) if memory.Value() > 0 { memQuota := memory - resourceQuota.Spec.Hard[v1.ResourceLimitsMemory] = memQuota - resourceQuota.Spec.Hard[v1.ResourceRequestsMemory] = memQuota + resourceQuota.Spec.Hard[corev1.ResourceLimitsMemory] = memQuota + resourceQuota.Spec.Hard[corev1.ResourceRequestsMemory] = memQuota } if cpu.Value() > 0 { cpuQuota := cpu - resourceQuota.Spec.Hard[v1.ResourceLimitsCPU] = cpuQuota - resourceQuota.Spec.Hard[v1.ResourceRequestsCPU] = cpuQuota + resourceQuota.Spec.Hard[corev1.ResourceLimitsCPU] = cpuQuota + resourceQuota.Spec.Hard[corev1.ResourceRequestsCPU] = cpuQuota } } _, err := kcl.cli.CoreV1().ResourceQuotas(info.Name).Create(context.Background(), resourceQuota, metav1.CreateOptions{}) if err != nil { log.Error().Msgf("Failed to create resource quota for namespace %s: %s", info.Name, err) - return err + return nil, err } } - return nil + return namespace, nil } -func isSystemNamespace(namespace v1.Namespace) bool { +func isSystemNamespace(namespace corev1.Namespace) bool { systemLabelValue, hasSystemLabel := namespace.Labels[systemNamespaceLabel] if hasSystemLabel { return systemLabelValue == "true" @@ -176,32 +223,77 @@ func (kcl *KubeClient) ToggleSystemState(namespaceName string, isSystem bool) er } // UpdateIngress updates an ingress in a given namespace in a k8s endpoint. -func (kcl *KubeClient) UpdateNamespace(info models.K8sNamespaceDetails) error { - client := kcl.cli.CoreV1().Namespaces() +func (kcl *KubeClient) UpdateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error) { + namespace := corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: info.Name, + Annotations: info.Annotations, + }, + } - var ns v1.Namespace - ns.Name = info.Name - ns.Annotations = info.Annotations - - _, err := client.Update(context.Background(), &ns, metav1.UpdateOptions{}) - return err + return kcl.cli.CoreV1().Namespaces().Update(context.Background(), &namespace, metav1.UpdateOptions{}) } -func (kcl *KubeClient) DeleteNamespace(namespace string) error { - client := kcl.cli.CoreV1().Namespaces() - namespaces, err := client.List(context.Background(), metav1.ListOptions{}) +func (kcl *KubeClient) DeleteNamespace(namespaceName string) (*corev1.Namespace, error) { + namespace, err := kcl.cli.CoreV1().Namespaces().Get(context.Background(), namespaceName, metav1.GetOptions{}) if err != nil { - return err + return nil, err } - for _, ns := range namespaces.Items { - if ns.Name == namespace { - return client.Delete( - context.Background(), - namespace, - metav1.DeleteOptions{}, - ) - } + err = kcl.cli.CoreV1().Namespaces().Delete(context.Background(), namespaceName, metav1.DeleteOptions{}) + if err != nil { + return nil, err } - return fmt.Errorf("namespace %s not found", namespace) + + return namespace, nil +} + +// CombineNamespacesWithResourceQuotas combines namespaces with resource quotas where matching is based on "portainer-rq-"+namespace.Name +func (kcl *KubeClient) CombineNamespacesWithResourceQuotas(namespaces map[string]portainer.K8sNamespaceInfo, w http.ResponseWriter) *httperror.HandlerError { + resourceQuotas, err := kcl.GetResourceQuotas("") + if err != nil && !k8serrors.IsNotFound(err) { + return httperror.InternalServerError("an error occurred during the CombineNamespacesWithResourceQuotas operation, unable to retrieve resource quotas from the Kubernetes for an admin user. Error: ", err) + } + + if len(*resourceQuotas) > 0 { + return response.JSON(w, kcl.UpdateNamespacesWithResourceQuotas(namespaces, *resourceQuotas)) + } + + return response.JSON(w, kcl.ConvertNamespaceMapToSlice(namespaces)) +} + +// CombineNamespaceWithResourceQuota combines a namespace with a resource quota prefixed with "portainer-rq-"+namespace.Name +func (kcl *KubeClient) CombineNamespaceWithResourceQuota(namespace portainer.K8sNamespaceInfo, w http.ResponseWriter) *httperror.HandlerError { + resourceQuota, err := kcl.GetPortainerResourceQuota(namespace.Name) + if err != nil && !k8serrors.IsNotFound(err) { + return httperror.InternalServerError(fmt.Sprintf("an error occurred during the CombineNamespaceWithResourceQuota operation, unable to retrieve the resource quota associated with the namespace: %s for a non-admin user. Error: ", namespace.Name), err) + } + + if resourceQuota != nil { + namespace.ResourceQuota = resourceQuota + } + + return response.JSON(w, namespace) +} + +// buildNonAdminNamespacesMap builds a map of non-admin namespaces. +// the map is used to filter the namespaces based on the allowed namespaces. +func (kcl *KubeClient) buildNonAdminNamespacesMap() map[string]struct{} { + nonAdminNamespaceSet := make(map[string]struct{}, len(kcl.NonAdminNamespaces)) + for _, namespace := range kcl.NonAdminNamespaces { + nonAdminNamespaceSet[namespace] = struct{}{} + } + + return nonAdminNamespaceSet +} + +// ConvertNamespaceMapToSlice converts the namespace map to a slice of namespaces. +// this is used to for the API response. +func (kcl *KubeClient) ConvertNamespaceMapToSlice(namespaces map[string]portainer.K8sNamespaceInfo) []portainer.K8sNamespaceInfo { + namespaceSlice := make([]portainer.K8sNamespaceInfo, 0, len(namespaces)) + for _, namespace := range namespaces { + namespaceSlice = append(namespaceSlice, namespace) + } + + return namespaceSlice } diff --git a/api/kubernetes/cli/namespace_test.go b/api/kubernetes/cli/namespace_test.go index ea4821bab..bd7a6a5c7 100644 --- a/api/kubernetes/cli/namespace_test.go +++ b/api/kubernetes/cli/namespace_test.go @@ -73,8 +73,18 @@ func Test_ToggleSystemState(t *testing.T) { t.Run("for regular namespace if isSystem is true and doesn't have a label, should set the label to true", func(t *testing.T) { nsName := "namespace" + config := &core.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: portainerConfigMapName, + Namespace: portainerNamespace, + }, + Data: map[string]string{ + "NamespaceAccessPolicies": `{"ns1":{"UserAccessPolicies":{"2":{"RoleId":0}}}, "ns2":{"UserAccessPolicies":{"2":{"RoleId":0}}}}`, + }, + } + kcl := &KubeClient{ - cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: metav1.ObjectMeta{Name: nsName}}), + cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: metav1.ObjectMeta{Name: nsName}}, config), instanceID: "instance", } diff --git a/api/kubernetes/cli/pod.go b/api/kubernetes/cli/pod.go index 04806e50f..8bec24aa9 100644 --- a/api/kubernetes/cli/pod.go +++ b/api/kubernetes/cli/pod.go @@ -2,6 +2,7 @@ package cli import ( "context" + "fmt" "strconv" "time" @@ -9,10 +10,69 @@ import ( "github.com/pkg/errors" "github.com/rs/zerolog/log" - v1 "k8s.io/api/core/v1" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +func (kcl *KubeClient) GetPods(namespace string) ([]corev1.Pod, error) { + pods, err := kcl.cli.CoreV1().Pods(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + return pods.Items, nil +} + +// isReplicaSetOwner checks if the pod's owner reference is a ReplicaSet +func isReplicaSetOwner(pod corev1.Pod) bool { + return len(pod.OwnerReferences) > 0 && pod.OwnerReferences[0].Kind == "ReplicaSet" +} + +// updateOwnerReferenceToDeployment updates the pod's owner reference to the Deployment if applicable +func updateOwnerReferenceToDeployment(pod *corev1.Pod, replicaSets []appsv1.ReplicaSet) { + for _, replicaSet := range replicaSets { + if pod.OwnerReferences[0].Name == replicaSet.Name { + if len(replicaSet.OwnerReferences) > 0 && replicaSet.OwnerReferences[0].Kind == "Deployment" { + pod.OwnerReferences[0].Kind = "Deployment" + pod.OwnerReferences[0].Name = replicaSet.OwnerReferences[0].Name + } + break + } + } +} + +// containsStatefulSetOwnerReference checks if the pod list contains a pod with a StatefulSet owner reference +func containsStatefulSetOwnerReference(pods *corev1.PodList) bool { + for _, pod := range pods.Items { + if len(pod.OwnerReferences) > 0 && pod.OwnerReferences[0].Kind == "StatefulSet" { + return true + } + } + return false +} + +// containsDaemonSetOwnerReference checks if the pod list contains a pod with a DaemonSet owner reference +func containsDaemonSetOwnerReference(pods *corev1.PodList) bool { + for _, pod := range pods.Items { + if len(pod.OwnerReferences) > 0 && pod.OwnerReferences[0].Kind == "DaemonSet" { + return true + } + } + return false +} + +// containsReplicaSetOwnerReference checks if the pod list contains a pod with a ReplicaSet owner reference +func containsReplicaSetOwnerReference(pods *corev1.PodList) bool { + for _, pod := range pods.Items { + if len(pod.OwnerReferences) > 0 && pod.OwnerReferences[0].Kind == "ReplicaSet" { + return true + } + } + return false +} + // CreateUserShellPod will create a kubectl based shell for the specified user by mounting their respective service account. // The lifecycle of the pod is managed in this function; this entails management of the following pod operations: // - The shell pod will be scoped to specified service accounts access permissions @@ -24,7 +84,7 @@ func (kcl *KubeClient) CreateUserShellPod(ctx context.Context, serviceAccountNam podPrefix := userShellPodPrefix(serviceAccountName) - podSpec := &v1.Pod{ + podSpec := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ GenerateName: podPrefix, Namespace: portainerNamespace, @@ -32,20 +92,20 @@ func (kcl *KubeClient) CreateUserShellPod(ctx context.Context, serviceAccountNam "kubernetes.io/pod.type": "kubectl-shell", }, }, - Spec: v1.PodSpec{ + Spec: corev1.PodSpec{ TerminationGracePeriodSeconds: new(int64), ServiceAccountName: serviceAccountName, - Containers: []v1.Container{ + Containers: []corev1.Container{ { Name: "kubectl-shell-container", Image: shellPodImage, Command: []string{"sleep"}, // Specify sleep time to prevent zombie pods in case portainer process is terminated Args: []string{maxPodKeepAliveSecondsStr}, - ImagePullPolicy: v1.PullIfNotPresent, + ImagePullPolicy: corev1.PullIfNotPresent, }, }, - RestartPolicy: v1.RestartPolicyNever, + RestartPolicy: corev1.RestartPolicyNever, }, } @@ -58,7 +118,7 @@ func (kcl *KubeClient) CreateUserShellPod(ctx context.Context, serviceAccountNam timeoutCtx, cancelFunc := context.WithTimeout(ctx, 20*time.Second) defer cancelFunc() - if err := kcl.waitForPodStatus(timeoutCtx, v1.PodRunning, shellPod); err != nil { + if err := kcl.waitForPodStatus(timeoutCtx, corev1.PodRunning, shellPod); err != nil { kcl.cli.CoreV1().Pods(portainerNamespace).Delete(context.TODO(), shellPod.Name, metav1.DeleteOptions{}) return nil, errors.Wrap(err, "aborting pod creation; error waiting for shell pod ready status") @@ -89,7 +149,7 @@ func (kcl *KubeClient) CreateUserShellPod(ctx context.Context, serviceAccountNam // waitForPodStatus will wait until duration d (from now) for a pod to reach defined phase/status. // The pod status will be polled at specified delay until the pod reaches ready state. -func (kcl *KubeClient) waitForPodStatus(ctx context.Context, phase v1.PodPhase, pod *v1.Pod) error { +func (kcl *KubeClient) waitForPodStatus(ctx context.Context, phase corev1.PodPhase, pod *corev1.Pod) error { log.Debug().Str("pod", pod.Name).Msg("waiting for pod ready") for { @@ -110,3 +170,102 @@ func (kcl *KubeClient) waitForPodStatus(ctx context.Context, phase v1.PodPhase, } } } + +// fetchAllPodsAndReplicaSets fetches all pods and replica sets across the cluster, i.e. all namespaces +func (kcl *KubeClient) fetchAllPodsAndReplicaSets(namespace string, podListOptions metav1.ListOptions) ([]corev1.Pod, []appsv1.ReplicaSet, []appsv1.Deployment, []appsv1.StatefulSet, []appsv1.DaemonSet, []corev1.Service, error) { + return kcl.fetchResourcesWithOwnerReferences(namespace, podListOptions, false, false) +} + +// fetchAllApplicationsListResources fetches all pods, replica sets, stateful sets, and daemon sets across the cluster, i.e. all namespaces +// this is required for the applications list view +func (kcl *KubeClient) fetchAllApplicationsListResources(namespace string, podListOptions metav1.ListOptions) ([]corev1.Pod, []appsv1.ReplicaSet, []appsv1.Deployment, []appsv1.StatefulSet, []appsv1.DaemonSet, []corev1.Service, error) { + return kcl.fetchResourcesWithOwnerReferences(namespace, podListOptions, true, true) +} + +// fetchResourcesWithOwnerReferences fetches pods and other resources based on owner references +func (kcl *KubeClient) fetchResourcesWithOwnerReferences(namespace string, podListOptions metav1.ListOptions, includeStatefulSets, includeDaemonSets bool) ([]corev1.Pod, []appsv1.ReplicaSet, []appsv1.Deployment, []appsv1.StatefulSet, []appsv1.DaemonSet, []corev1.Service, error) { + pods, err := kcl.cli.CoreV1().Pods(namespace).List(context.Background(), podListOptions) + if err != nil { + if k8serrors.IsNotFound(err) { + return nil, nil, nil, nil, nil, nil, nil + } + return nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list pods across the cluster: %w", err) + } + + // if replicaSet owner reference exists, fetch the replica sets + // this also means that the deployments will be fetched because deployments own replica sets + replicaSets := &appsv1.ReplicaSetList{} + deployments := &appsv1.DeploymentList{} + if containsReplicaSetOwnerReference(pods) { + replicaSets, err = kcl.cli.AppsV1().ReplicaSets(namespace).List(context.Background(), metav1.ListOptions{}) + if err != nil && !k8serrors.IsNotFound(err) { + return nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list replica sets across the cluster: %w", err) + } + + deployments, err = kcl.cli.AppsV1().Deployments(namespace).List(context.Background(), metav1.ListOptions{}) + if err != nil && !k8serrors.IsNotFound(err) { + return nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list deployments across the cluster: %w", err) + } + } + + statefulSets := &appsv1.StatefulSetList{} + if includeStatefulSets && containsStatefulSetOwnerReference(pods) { + statefulSets, err = kcl.cli.AppsV1().StatefulSets(namespace).List(context.Background(), metav1.ListOptions{}) + if err != nil && !k8serrors.IsNotFound(err) { + return nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list stateful sets across the cluster: %w", err) + } + } + + daemonSets := &appsv1.DaemonSetList{} + if includeDaemonSets && containsDaemonSetOwnerReference(pods) { + daemonSets, err = kcl.cli.AppsV1().DaemonSets(namespace).List(context.Background(), metav1.ListOptions{}) + if err != nil && !k8serrors.IsNotFound(err) { + return nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list daemon sets across the cluster: %w", err) + } + } + + services, err := kcl.cli.CoreV1().Services(namespace).List(context.Background(), metav1.ListOptions{}) + if err != nil && !k8serrors.IsNotFound(err) { + return nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list services across the cluster: %w", err) + } + + return pods.Items, replicaSets.Items, deployments.Items, statefulSets.Items, daemonSets.Items, services.Items, nil +} + +// isPodUsingConfigMap checks if a pod is using a specific ConfigMap +func isPodUsingConfigMap(pod *corev1.Pod, configMapName string) bool { + for _, volume := range pod.Spec.Volumes { + if volume.ConfigMap != nil && volume.ConfigMap.Name == configMapName { + return true + } + } + + for _, container := range pod.Spec.Containers { + for _, env := range container.Env { + if env.ValueFrom != nil && env.ValueFrom.ConfigMapKeyRef != nil && env.ValueFrom.ConfigMapKeyRef.Name == configMapName { + return true + } + } + } + + return false +} + +// isPodUsingSecret checks if a pod is using a specific Secret +func isPodUsingSecret(pod *corev1.Pod, secretName string) bool { + for _, volume := range pod.Spec.Volumes { + if volume.Secret != nil && volume.Secret.SecretName == secretName { + return true + } + } + + for _, container := range pod.Spec.Containers { + for _, env := range container.Env { + if env.ValueFrom != nil && env.ValueFrom.SecretKeyRef != nil && env.ValueFrom.SecretKeyRef.Name == secretName { + return true + } + } + } + + return false +} diff --git a/api/kubernetes/cli/rbac.go b/api/kubernetes/cli/rbac.go index 17472820f..8b8caeba6 100644 --- a/api/kubernetes/cli/rbac.go +++ b/api/kubernetes/cli/rbac.go @@ -2,198 +2,17 @@ package cli import ( "context" - "time" - "github.com/portainer/portainer/api/internal/randomstring" - - "github.com/rs/zerolog/log" - authv1 "k8s.io/api/authorization/v1" - corev1 "k8s.io/api/core/v1" - rbacv1 "k8s.io/api/rbac/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - authv1types "k8s.io/client-go/kubernetes/typed/authorization/v1" - corev1types "k8s.io/client-go/kubernetes/typed/core/v1" - rbacv1types "k8s.io/client-go/kubernetes/typed/rbac/v1" ) -const maxRetries = 5 - -// IsRBACEnabled checks if RBAC is enabled in the cluster by creating a service account, then checking it's access to a resourcequota before and after setting a cluster role and cluster role binding +// IsRBACEnabled checks if RBAC is enabled in the current Kubernetes cluster by listing cluster roles. +// if the cluster roles can be listed, RBAC is enabled. +// otherwise, RBAC is not enabled. func (kcl *KubeClient) IsRBACEnabled() (bool, error) { - namespace := "default" - verb := "list" - resource := "resourcequotas" - - saClient := kcl.cli.CoreV1().ServiceAccounts(namespace) - uniqueString := randomstring.RandomString(4) // Append a unique string to resource names, in case they already exist - saName := "portainer-rbac-test-sa-" + uniqueString - if err := createServiceAccount(saClient, saName, namespace); err != nil { - log.Error().Err(err).Msg("Error creating service account") - - return false, err - } - defer deleteServiceAccount(saClient, saName) - - accessReviewClient := kcl.cli.AuthorizationV1().LocalSubjectAccessReviews(namespace) - allowed, err := checkServiceAccountAccess(accessReviewClient, saName, verb, resource, namespace) + _, err := kcl.cli.RbacV1().ClusterRoles().List(context.TODO(), metav1.ListOptions{}) if err != nil { - log.Error().Err(err).Msg("Error checking service account access") - - return false, err - } - - // If the service account with no authorizations is allowed, RBAC must be disabled - if allowed { return false, nil } - - // Otherwise give the service account an rbac authorisation and check again - roleClient := kcl.cli.RbacV1().Roles(namespace) - roleName := "portainer-rbac-test-role-" + uniqueString - if err := createRole(roleClient, roleName, verb, resource, namespace); err != nil { - log.Error().Err(err).Msg("Error creating role") - - return false, err - } - defer deleteRole(roleClient, roleName) - - roleBindingClient := kcl.cli.RbacV1().RoleBindings(namespace) - roleBindingName := "portainer-rbac-test-role-binding-" + uniqueString - if err := createRoleBinding(roleBindingClient, roleBindingName, roleName, saName, namespace); err != nil { - log.Error().Err(err).Msg("Error creating role binding") - - return false, err - } - defer deleteRoleBinding(roleBindingClient, roleBindingName) - - allowed, err = checkServiceAccountAccess(accessReviewClient, saName, verb, resource, namespace) - if err != nil { - log.Error().Err(err).Msg("Error checking service account access with authorizations added") - - return false, err - } - - // If the service account allowed to list resource quotas after given rbac role, then RBAC is enabled - return allowed, nil -} - -func createServiceAccount(saClient corev1types.ServiceAccountInterface, name string, namespace string) error { - serviceAccount := &corev1.ServiceAccount{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: namespace, - }, - } - - _, err := saClient.Create(context.Background(), serviceAccount, metav1.CreateOptions{}) - return err -} - -func deleteServiceAccount(saClient corev1types.ServiceAccountInterface, name string) { - if err := saClient.Delete(context.Background(), name, metav1.DeleteOptions{}); err != nil { - log.Error().Err(err).Msg("Error deleting service account: " + name) - } -} - -func createRole(roleClient rbacv1types.RoleInterface, name string, verb string, resource string, namespace string) error { - role := &rbacv1.Role{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: namespace, - }, - Rules: []rbacv1.PolicyRule{ - { - APIGroups: []string{""}, - Verbs: []string{verb}, - Resources: []string{resource}, - }, - }, - } - - _, err := roleClient.Create(context.Background(), role, metav1.CreateOptions{}) - return err -} - -func deleteRole(roleClient rbacv1types.RoleInterface, name string) { - if err := roleClient.Delete(context.Background(), name, metav1.DeleteOptions{}); err != nil { - log.Error().Err(err).Msg("Error deleting role: " + name) - } -} - -func createRoleBinding(roleBindingClient rbacv1types.RoleBindingInterface, clusterRoleBindingName string, roleName string, serviceAccountName string, namespace string) error { - clusterRoleBinding := &rbacv1.RoleBinding{ - ObjectMeta: metav1.ObjectMeta{ - Name: clusterRoleBindingName, - }, - Subjects: []rbacv1.Subject{ - { - Kind: "ServiceAccount", - Name: serviceAccountName, - Namespace: namespace, - }, - }, - RoleRef: rbacv1.RoleRef{ - Kind: "Role", - Name: roleName, - APIGroup: "rbac.authorization.k8s.io", - }, - } - - roleBinding, err := roleBindingClient.Create(context.Background(), clusterRoleBinding, metav1.CreateOptions{}) - if err != nil { - log.Error().Err(err).Msg("Error creating role binding: " + clusterRoleBindingName) - - return err - } - - // Retry checkRoleBinding a maximum of 5 times with a 100ms wait after each attempt - for range maxRetries { - err = checkRoleBinding(roleBindingClient, roleBinding.Name) - time.Sleep(100 * time.Millisecond) // Wait for 100ms, even if the check passes - - if err == nil { - break - } - } - - return err -} - -func checkRoleBinding(roleBindingClient rbacv1types.RoleBindingInterface, name string) error { - if _, err := roleBindingClient.Get(context.Background(), name, metav1.GetOptions{}); err != nil { - log.Error().Err(err).Msg("Error finding rolebinding: " + name) - - return err - } - - return nil -} - -func deleteRoleBinding(roleBindingClient rbacv1types.RoleBindingInterface, name string) { - if err := roleBindingClient.Delete(context.Background(), name, metav1.DeleteOptions{}); err != nil { - log.Error().Err(err).Msg("Error deleting role binding: " + name) - } -} - -func checkServiceAccountAccess(accessReviewClient authv1types.LocalSubjectAccessReviewInterface, serviceAccountName string, verb string, resource string, namespace string) (bool, error) { - subjectAccessReview := &authv1.LocalSubjectAccessReview{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: namespace, - }, - Spec: authv1.SubjectAccessReviewSpec{ - ResourceAttributes: &authv1.ResourceAttributes{ - Namespace: namespace, - Verb: verb, - Resource: resource, - }, - User: "system:serviceaccount:default:" + serviceAccountName, // a workaround to be able to use the service account as a user - }, - } - - result, err := accessReviewClient.Create(context.Background(), subjectAccessReview, metav1.CreateOptions{}) - if err != nil { - return false, err - } - - return result.Status.Allowed, nil + return true, nil } diff --git a/api/kubernetes/cli/resource_quota.go b/api/kubernetes/cli/resource_quota.go new file mode 100644 index 000000000..407a78f49 --- /dev/null +++ b/api/kubernetes/cli/resource_quota.go @@ -0,0 +1,95 @@ +package cli + +import ( + "context" + "fmt" + + portainer "github.com/portainer/portainer/api" + "github.com/rs/zerolog/log" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GetResourceQuotas gets all resource quotas in the current k8s environment(endpoint). +// if the user is an admin, all resource quotas in all namespaces are fetched. +// otherwise, namespaces the non-admin user has access to will be used to filter the resource quotas. +func (kcl *KubeClient) GetResourceQuotas(namespace string) (*[]corev1.ResourceQuota, error) { + if kcl.IsKubeAdmin { + return kcl.fetchResourceQuotas(namespace) + } + return kcl.fetchResourceQuotasForNonAdmin(namespace) +} + +// fetchResourceQuotasForNonAdmin gets the resource quotas in the current k8s environment(endpoint) for a non-admin user. +// the role of the user must have read access to the resource quotas in the defined namespaces. +func (kcl *KubeClient) fetchResourceQuotasForNonAdmin(namespace string) (*[]corev1.ResourceQuota, error) { + log.Debug().Msgf("Fetching resource quotas for non-admin user: %v", kcl.NonAdminNamespaces) + + if len(kcl.NonAdminNamespaces) == 0 { + return nil, nil + } + + resourceQuotas, err := kcl.fetchResourceQuotas(namespace) + if err != nil && !k8serrors.IsNotFound(err) { + return nil, err + } + + nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap() + results := []corev1.ResourceQuota{} + for _, resourceQuota := range *resourceQuotas { + if _, exists := nonAdminNamespaceSet[resourceQuota.Namespace]; exists { + results = append(results, resourceQuota) + } + } + + return &results, nil +} + +func (kcl *KubeClient) fetchResourceQuotas(namespace string) (*[]corev1.ResourceQuota, error) { + resourceQuotas, err := kcl.cli.CoreV1().ResourceQuotas(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("an error occured, failed to list resource quotas for the admin user: %w", err) + } + + return &resourceQuotas.Items, nil +} + +// GetPortainerResourceQuota gets the resource quota for the portainer namespace. +// The resource quota is prefixed with "portainer-rq-". +func (kcl *KubeClient) GetPortainerResourceQuota(namespace string) (*corev1.ResourceQuota, error) { + return kcl.cli.CoreV1().ResourceQuotas(namespace).Get(context.TODO(), "portainer-rq-"+namespace, metav1.GetOptions{}) +} + +// GetResourceQuota gets a resource quota in a specific namespace. +func (kcl *KubeClient) GetResourceQuota(namespace, resourceQuota string) (*corev1.ResourceQuota, error) { + return kcl.cli.CoreV1().ResourceQuotas(namespace).Get(context.TODO(), resourceQuota, metav1.GetOptions{}) +} + +// UpdateNamespacesWithResourceQuotas updates the namespaces with the resource quotas. +// The resource quotas are matched with the namespaces by name. +func (kcl *KubeClient) UpdateNamespacesWithResourceQuotas(namespaces map[string]portainer.K8sNamespaceInfo, resourceQuotas []corev1.ResourceQuota) []portainer.K8sNamespaceInfo { + namespacesWithQuota := map[string]portainer.K8sNamespaceInfo{} + + for _, namespace := range namespaces { + resourceQuota := kcl.GetResourceQuotaFromNamespace(namespace, resourceQuotas) + if resourceQuota != nil { + namespace.ResourceQuota = resourceQuota + } + + namespacesWithQuota[namespace.Name] = namespace + } + + return kcl.ConvertNamespaceMapToSlice(namespacesWithQuota) +} + +// GetResourceQuotaFromNamespace gets the resource quota in a specific namespace where the resource quota's name is prefixed with "portainer-rq-". +func (kcl *KubeClient) GetResourceQuotaFromNamespace(namespace portainer.K8sNamespaceInfo, resourceQuotas []corev1.ResourceQuota) *corev1.ResourceQuota { + for _, resourceQuota := range resourceQuotas { + if resourceQuota.ObjectMeta.Namespace == namespace.Name && resourceQuota.ObjectMeta.Name == "portainer-rq-"+namespace.Name { + return &resourceQuota + } + } + + return nil +} diff --git a/api/kubernetes/cli/role.go b/api/kubernetes/cli/role.go index 77bacd4a1..3460f45dc 100644 --- a/api/kubernetes/cli/role.go +++ b/api/kubernetes/cli/role.go @@ -3,11 +3,66 @@ package cli import ( "context" + models "github.com/portainer/portainer/api/http/models/kubernetes" rbacv1 "k8s.io/api/rbac/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// GetRoles gets all the roles for either at the cluster level or a given namespace in a k8s endpoint. +// It returns a list of K8sRole objects. +func (kcl *KubeClient) GetRoles(namespace string) ([]models.K8sRole, error) { + if kcl.IsKubeAdmin { + return kcl.fetchRoles(namespace) + } + + return kcl.fetchRolesForNonAdmin(namespace) +} + +// fetchRolesForNonAdmin gets all the roles for either at the cluster level or a given namespace in a k8s endpoint. +// the namespace will be coming from NonAdminNamespaces as non-admin users are restricted to certain namespaces. +// it returns a list of K8sRole objects. +func (kcl *KubeClient) fetchRolesForNonAdmin(namespace string) ([]models.K8sRole, error) { + roles, err := kcl.fetchRoles(namespace) + if err != nil { + return nil, err + } + + nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap() + results := make([]models.K8sRole, 0) + for _, role := range roles { + if _, ok := nonAdminNamespaceSet[role.Namespace]; ok { + results = append(results, role) + } + } + + return results, nil +} + +// fetchRoles returns a list of all Roles in the specified namespace. +func (kcl *KubeClient) fetchRoles(namespace string) ([]models.K8sRole, error) { + roles, err := kcl.cli.RbacV1().Roles(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + results := make([]models.K8sRole, 0) + for _, role := range roles.Items { + results = append(results, parseRole(role)) + } + + return results, nil +} + +// parseRole converts a rbacv1.Role object to a models.K8sRole object. +func parseRole(role rbacv1.Role) models.K8sRole { + return models.K8sRole{ + Name: role.Name, + Namespace: role.Namespace, + CreationDate: role.CreationTimestamp.Time, + } +} + func getPortainerUserDefaultPolicies() []rbacv1.PolicyRule { return []rbacv1.PolicyRule{ { diff --git a/api/kubernetes/cli/role_binding.go b/api/kubernetes/cli/role_binding.go new file mode 100644 index 000000000..09d4f813b --- /dev/null +++ b/api/kubernetes/cli/role_binding.go @@ -0,0 +1,65 @@ +package cli + +import ( + "context" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GetRoleBindings gets all the roleBindings for either at the cluster level or a given namespace in a k8s endpoint. +// It returns a list of K8sRoleBinding objects. +func (kcl *KubeClient) GetRoleBindings(namespace string) ([]models.K8sRoleBinding, error) { + if kcl.IsKubeAdmin { + return kcl.fetchRoleBindings(namespace) + } + + return kcl.fetchRolebindingsForNonAdmin(namespace) +} + +// fetchRolebindingsForNonAdmin gets all the roleBindings for either at the cluster level or a given namespace in a k8s endpoint. +// the namespace will be coming from NonAdminNamespaces as non-admin users are restricted to certain namespaces. +// it returns a list of K8sRoleBinding objects. +func (kcl *KubeClient) fetchRolebindingsForNonAdmin(namespace string) ([]models.K8sRoleBinding, error) { + roleBindings, err := kcl.fetchRoleBindings(namespace) + if err != nil { + return nil, err + } + + nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap() + results := make([]models.K8sRoleBinding, 0) + for _, roleBinding := range roleBindings { + if _, ok := nonAdminNamespaceSet[roleBinding.Namespace]; ok { + results = append(results, roleBinding) + } + } + + return results, nil +} + +// fetchRoleBindings returns a list of all Roles in the specified namespace. +func (kcl *KubeClient) fetchRoleBindings(namespace string) ([]models.K8sRoleBinding, error) { + roleBindings, err := kcl.cli.RbacV1().RoleBindings(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + results := make([]models.K8sRoleBinding, 0) + for _, roleBinding := range roleBindings.Items { + results = append(results, parseRoleBinding(roleBinding)) + } + + return results, nil +} + +// parseRoleBinding converts a rbacv1.RoleBinding object to a models.K8sRoleBinding object. +func parseRoleBinding(roleBinding rbacv1.RoleBinding) models.K8sRoleBinding { + return models.K8sRoleBinding{ + Name: roleBinding.Name, + Namespace: roleBinding.Namespace, + RoleRef: roleBinding.RoleRef, + Subjects: roleBinding.Subjects, + CreationDate: roleBinding.CreationTimestamp.Time, + } +} diff --git a/api/kubernetes/cli/secret.go b/api/kubernetes/cli/secret.go index f66273ab2..6a24623bd 100644 --- a/api/kubernetes/cli/secret.go +++ b/api/kubernetes/cli/secret.go @@ -3,17 +3,182 @@ package cli import ( "context" "errors" + "fmt" "time" - v1 "k8s.io/api/core/v1" + models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/rs/zerolog/log" + corev1 "k8s.io/api/core/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +const ( + labelPortainerKubeConfigOwner = "io.portainer.kubernetes.configuration.owner" + labelPortainerKubeConfigOwnerId = "io.portainer.kubernetes.configuration.owner.id" +) + +// GetSecrets gets all the Secrets for a given namespace in a k8s endpoint. +// if the user is an admin, all secrets in the current k8s environment(endpoint) are fetched using the getSecrets function. +// otherwise, namespaces the non-admin user has access to will be used to filter the secrets based on the allowed namespaces. +func (kcl *KubeClient) GetSecrets(namespace string) ([]models.K8sSecret, error) { + if kcl.IsKubeAdmin { + return kcl.getSecrets(namespace) + } + return kcl.getSecretsForNonAdmin(namespace) +} + +// getSecretsForNonAdmin fetches the secrets in the namespaces the user has access to. +// This function is called when the user is not an admin. +func (kcl *KubeClient) getSecretsForNonAdmin(namespace string) ([]models.K8sSecret, error) { + log.Debug().Msgf("Fetching volumes for non-admin user: %v", kcl.NonAdminNamespaces) + + if len(kcl.NonAdminNamespaces) == 0 { + return nil, nil + } + + secrets, err := kcl.getSecrets(namespace) + if err != nil { + return nil, err + } + + nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap() + results := make([]models.K8sSecret, 0) + for _, secret := range secrets { + if _, ok := nonAdminNamespaceSet[secret.Namespace]; ok { + results = append(results, secret) + } + } + + return results, nil +} + +// getSecrets gets all the Secrets for a given namespace in a k8s endpoint. +// the result is a list of secrets parsed into a K8sSecret struct. +func (kcl *KubeClient) getSecrets(namespace string) ([]models.K8sSecret, error) { + secrets, err := kcl.cli.CoreV1().Secrets(namespace).List(context.Background(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + results := []models.K8sSecret{} + for _, secret := range secrets.Items { + results = append(results, parseSecret(&secret, false)) + } + + return results, nil +} + +// GetSecret gets a Secret by name for a given namespace. +// the result is a secret parsed into a K8sSecret struct. +func (kcl *KubeClient) GetSecret(namespace string, secretName string) (models.K8sSecret, error) { + secret, err := kcl.cli.CoreV1().Secrets(namespace).Get(context.Background(), secretName, metav1.GetOptions{}) + if err != nil { + return models.K8sSecret{}, err + } + + return parseSecret(secret, true), nil +} + +// parseSecret parses a k8s Secret object into a K8sSecret struct. +// for get operation, withData will be set to true. +// otherwise, only metadata will be parsed. +func parseSecret(secret *corev1.Secret, withData bool) models.K8sSecret { + result := models.K8sSecret{ + K8sConfiguration: models.K8sConfiguration{ + UID: string(secret.UID), + Name: secret.Name, + Namespace: secret.Namespace, + CreationDate: secret.CreationTimestamp.Time.UTC().Format(time.RFC3339), + Annotations: secret.Annotations, + Labels: secret.Labels, + ConfigurationOwner: secret.Labels[labelPortainerKubeConfigOwner], + ConfigurationOwnerId: secret.Labels[labelPortainerKubeConfigOwnerId], + }, + SecretType: string(secret.Type), + } + + if withData { + secretData := secret.Data + secretDataMap := make(map[string]string, len(secretData)) + for key, value := range secretData { + secretDataMap[key] = string(value) + } + + result.Data = secretDataMap + } + + return result +} + +// CombineSecretsWithApplications combines the secrets with the applications that use them. +// the function fetches all the pods and replica sets in the cluster and checks if the secret is used by any of the pods. +// if the secret is used by a pod, the application that uses the pod is added to the secret. +// otherwise, the secret is returned as is. +func (kcl *KubeClient) CombineSecretsWithApplications(secrets []models.K8sSecret) ([]models.K8sSecret, error) { + updatedSecrets := make([]models.K8sSecret, len(secrets)) + + pods, replicaSets, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("an error occurred during the CombineSecretsWithApplications operation, unable to fetch pods and replica sets. Error: %w", err) + } + + for index, secret := range secrets { + updatedSecret := secret + + applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromSecret(secret, pods, replicaSets) + if err != nil { + return nil, fmt.Errorf("an error occurred during the CombineSecretsWithApplications operation, unable to get applications from secret. Error: %w", err) + } + + if len(applicationConfigurationOwners) > 0 { + updatedSecret.ConfigurationOwnerResources = applicationConfigurationOwners + } + + updatedSecrets[index] = updatedSecret + } + + return updatedSecrets, nil +} + +// CombineSecretWithApplications combines the secret with the applications that use it. +// the function fetches all the pods in the cluster and checks if the secret is used by any of the pods. +// it needs to check if the pods are owned by a replica set to determine if the pod is part of a deployment. +func (kcl *KubeClient) CombineSecretWithApplications(secret models.K8sSecret) (models.K8sSecret, error) { + pods, err := kcl.cli.CoreV1().Pods(secret.Namespace).List(context.Background(), metav1.ListOptions{}) + if err != nil { + return models.K8sSecret{}, fmt.Errorf("an error occurred during the CombineSecretWithApplications operation, unable to get pods. Error: %w", err) + } + + containsReplicaSetOwner := false + for _, pod := range pods.Items { + containsReplicaSetOwner = isReplicaSetOwner(pod) + break + } + + if containsReplicaSetOwner { + replicaSets, err := kcl.cli.AppsV1().ReplicaSets(secret.Namespace).List(context.Background(), metav1.ListOptions{}) + if err != nil { + return models.K8sSecret{}, fmt.Errorf("an error occurred during the CombineSecretWithApplications operation, unable to get replica sets. Error: %w", err) + } + + applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromSecret(secret, pods.Items, replicaSets.Items) + if err != nil { + return models.K8sSecret{}, fmt.Errorf("an error occurred during the CombineSecretWithApplications operation, unable to get applications from secret. Error: %w", err) + } + + if len(applicationConfigurationOwners) > 0 { + secret.ConfigurationOwnerResources = applicationConfigurationOwners + } + } + + return secret, nil +} + func (kcl *KubeClient) createServiceAccountToken(serviceAccountName string) error { serviceAccountSecretName := userServiceAccountTokenSecretName(serviceAccountName, kcl.instanceID) - serviceAccountSecret := &v1.Secret{ + serviceAccountSecret := &corev1.Secret{ TypeMeta: metav1.TypeMeta{}, ObjectMeta: metav1.ObjectMeta{ Name: serviceAccountSecretName, diff --git a/api/kubernetes/cli/service.go b/api/kubernetes/cli/service.go index f7c94e94a..ef655b438 100644 --- a/api/kubernetes/cli/service.go +++ b/api/kubernetes/cli/service.go @@ -2,99 +2,135 @@ package cli import ( "context" + "fmt" models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/rs/zerolog/log" - v1 "k8s.io/api/core/v1" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - labels "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/util/intstr" ) -// GetServices gets all the services for a given namespace in a k8s endpoint. -func (kcl *KubeClient) GetServices(namespace string, lookupApplications bool) ([]models.K8sServiceInfo, error) { - client := kcl.cli.CoreV1().Services(namespace) +// GetServices gets all the services for either at the cluster level or a given namespace in a k8s endpoint. +// It returns a list of K8sServiceInfo objects. +func (kcl *KubeClient) GetServices(namespace string) ([]models.K8sServiceInfo, error) { + if kcl.IsKubeAdmin { + return kcl.fetchServices(namespace) + } + return kcl.fetchServicesForNonAdmin(namespace) +} - services, err := client.List(context.Background(), metav1.ListOptions{}) +// fetchServicesForNonAdmin gets all the services for either at the cluster level or a given namespace in a k8s endpoint. +// the namespace will be coming from NonAdminNamespaces as non-admin users are restricted to certain namespaces. +// it returns a list of K8sServiceInfo objects. +func (kcl *KubeClient) fetchServicesForNonAdmin(namespace string) ([]models.K8sServiceInfo, error) { + log.Debug().Msgf("Fetching services for non-admin user: %v", kcl.NonAdminNamespaces) + + if len(kcl.NonAdminNamespaces) == 0 { + return nil, nil + } + + services, err := kcl.fetchServices(namespace) if err != nil { return nil, err } - var result []models.K8sServiceInfo + nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap() + results := make([]models.K8sServiceInfo, 0) + for _, service := range services { + if _, ok := nonAdminNamespaceSet[service.Namespace]; ok { + results = append(results, service) + } + } + return results, nil +} + +// fetchServices gets the services in a given namespace in a k8s endpoint. +// It returns a list of K8sServiceInfo objects. +func (kcl *KubeClient) fetchServices(namespace string) ([]models.K8sServiceInfo, error) { + services, err := kcl.cli.CoreV1().Services(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + results := make([]models.K8sServiceInfo, 0) for _, service := range services.Items { - servicePorts := make([]models.K8sServicePort, 0) - for _, port := range service.Spec.Ports { - servicePorts = append(servicePorts, models.K8sServicePort{ - Name: port.Name, - NodePort: int(port.NodePort), - Port: int(port.Port), - Protocol: string(port.Protocol), - TargetPort: port.TargetPort.String(), - }) - } + results = append(results, parseService(service)) + } - ingressStatus := make([]models.K8sServiceIngress, 0) - for _, status := range service.Status.LoadBalancer.Ingress { - ingressStatus = append(ingressStatus, models.K8sServiceIngress{ - IP: status.IP, - Host: status.Hostname, - }) - } + return results, nil +} - var applications []models.K8sApplication - if lookupApplications { - applications, _ = kcl.getOwningApplication(namespace, service.Spec.Selector) - } - - result = append(result, models.K8sServiceInfo{ - Name: service.Name, - UID: string(service.GetUID()), - Type: string(service.Spec.Type), - Namespace: service.Namespace, - CreationTimestamp: service.GetCreationTimestamp().String(), - AllocateLoadBalancerNodePorts: service.Spec.AllocateLoadBalancerNodePorts, - Ports: servicePorts, - IngressStatus: ingressStatus, - Labels: service.GetLabels(), - Annotations: service.GetAnnotations(), - ClusterIPs: service.Spec.ClusterIPs, - ExternalName: service.Spec.ExternalName, - ExternalIPs: service.Spec.ExternalIPs, - Applications: applications, +// parseService converts a k8s native service object to a Portainer K8sServiceInfo object. +// service ports, ingress status, labels, annotations, cluster IPs, and external IPs are parsed. +// it returns a K8sServiceInfo object. +func parseService(service corev1.Service) models.K8sServiceInfo { + servicePorts := make([]models.K8sServicePort, 0) + for _, port := range service.Spec.Ports { + servicePorts = append(servicePorts, models.K8sServicePort{ + Name: port.Name, + NodePort: int(port.NodePort), + Port: int(port.Port), + Protocol: string(port.Protocol), + TargetPort: port.TargetPort.String(), }) } - return result, nil + ingressStatus := make([]models.K8sServiceIngress, 0) + for _, status := range service.Status.LoadBalancer.Ingress { + ingressStatus = append(ingressStatus, models.K8sServiceIngress{ + IP: status.IP, + Host: status.Hostname, + }) + } + + return models.K8sServiceInfo{ + Name: service.Name, + UID: string(service.GetUID()), + Type: string(service.Spec.Type), + Namespace: service.Namespace, + CreationDate: service.GetCreationTimestamp().String(), + AllocateLoadBalancerNodePorts: service.Spec.AllocateLoadBalancerNodePorts, + Ports: servicePorts, + IngressStatus: ingressStatus, + Labels: service.GetLabels(), + Annotations: service.GetAnnotations(), + ClusterIPs: service.Spec.ClusterIPs, + ExternalName: service.Spec.ExternalName, + ExternalIPs: service.Spec.ExternalIPs, + Selector: service.Spec.Selector, + } } -func (kcl *KubeClient) fillService(info models.K8sServiceInfo) v1.Service { - var service v1.Service - +// convertToK8sService converts a K8sServiceInfo object back to a k8s native service object. +// this is required for create and update operations. +// it returns a v1.Service object. +func (kcl *KubeClient) convertToK8sService(info models.K8sServiceInfo) corev1.Service { + service := corev1.Service{} service.Name = info.Name - service.Spec.Type = v1.ServiceType(info.Type) + service.Spec.Type = corev1.ServiceType(info.Type) service.Namespace = info.Namespace service.Annotations = info.Annotations service.Labels = info.Labels service.Spec.AllocateLoadBalancerNodePorts = info.AllocateLoadBalancerNodePorts service.Spec.Selector = info.Selector - // Set ports. for _, p := range info.Ports { - var port v1.ServicePort + port := corev1.ServicePort{} port.Name = p.Name port.NodePort = int32(p.NodePort) port.Port = int32(p.Port) - port.Protocol = v1.Protocol(p.Protocol) + port.Protocol = corev1.Protocol(p.Protocol) port.TargetPort = intstr.FromString(p.TargetPort) service.Spec.Ports = append(service.Spec.Ports, port) } - // Set ingresses. for _, i := range info.IngressStatus { service.Status.LoadBalancer.Ingress = append( service.Status.LoadBalancer.Ingress, - v1.LoadBalancerIngress{IP: i.IP, Hostname: i.Host}, + corev1.LoadBalancerIngress{IP: i.IP, Hostname: i.Host}, ) } @@ -103,86 +139,84 @@ func (kcl *KubeClient) fillService(info models.K8sServiceInfo) v1.Service { // CreateService creates a new service in a given namespace in a k8s endpoint. func (kcl *KubeClient) CreateService(namespace string, info models.K8sServiceInfo) error { - serviceClient := kcl.cli.CoreV1().Services(namespace) - service := kcl.fillService(info) - - _, err := serviceClient.Create(context.Background(), &service, metav1.CreateOptions{}) + service := kcl.convertToK8sService(info) + _, err := kcl.cli.CoreV1().Services(namespace).Create(context.Background(), &service, metav1.CreateOptions{}) return err } // DeleteServices processes a K8sServiceDeleteRequest by deleting each service // in its given namespace. func (kcl *KubeClient) DeleteServices(reqs models.K8sServiceDeleteRequests) error { - var err error for namespace := range reqs { for _, service := range reqs[namespace] { - serviceClient := kcl.cli.CoreV1().Services(namespace) - err = serviceClient.Delete( - context.Background(), - service, - metav1.DeleteOptions{}, - ) + err := kcl.cli.CoreV1().Services(namespace).Delete(context.Background(), service, metav1.DeleteOptions{}) + if err != nil { + return err + } } } - return err + return nil } // UpdateService updates service in a given namespace in a k8s endpoint. func (kcl *KubeClient) UpdateService(namespace string, info models.K8sServiceInfo) error { - serviceClient := kcl.cli.CoreV1().Services(namespace) - service := kcl.fillService(info) - - _, err := serviceClient.Update(context.Background(), &service, metav1.UpdateOptions{}) + service := kcl.convertToK8sService(info) + _, err := kcl.cli.CoreV1().Services(namespace).Update(context.Background(), &service, metav1.UpdateOptions{}) return err } -// getOwningApplication gets the application that owns the given service selector. -func (kcl *KubeClient) getOwningApplication(namespace string, selector map[string]string) ([]models.K8sApplication, error) { - if len(selector) == 0 { - return nil, nil - } - - selectorLabels := labels.SelectorFromSet(selector).String() - - // look for replicasets first, limit 1 (we only support one owner) - replicasets, err := kcl.cli.AppsV1().ReplicaSets(namespace).List(context.TODO(), metav1.ListOptions{LabelSelector: selectorLabels, Limit: 1}) - if err != nil { - return nil, err - } - - var meta metav1.Object - if replicasets != nil && len(replicasets.Items) > 0 { - meta = replicasets.Items[0].GetObjectMeta() - } else { - // otherwise look for matching pods, limit 1 (we only support one owner) - pods, err := kcl.cli.CoreV1().Pods(namespace).List(context.TODO(), metav1.ListOptions{LabelSelector: selectorLabels, Limit: 1}) +// CombineServicesWithApplications retrieves applications based on service selectors in a given namespace +// for all services, it lists pods based on the service selector and converts the pod to an application +// if replicasets are found, it updates the owner reference to deployment +// it then combines the service with the application +// finally, it returns a list of K8sServiceInfo objects +func (kcl *KubeClient) CombineServicesWithApplications(services []models.K8sServiceInfo) ([]models.K8sServiceInfo, error) { + if containsServiceWithSelector(services) { + updatedServices := make([]models.K8sServiceInfo, len(services)) + pods, replicaSets, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{}) if err != nil { - return nil, err + return nil, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to fetch pods and replica sets. Error: %w", err) } - if pods == nil || len(pods.Items) == 0 { - return nil, nil + for index, service := range services { + updatedService := service + + application, err := kcl.GetApplicationFromServiceSelector(pods, service, replicaSets) + if err != nil { + return services, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to get application from service. Error: %w", err) + } + + if application != nil { + updatedService.Applications = append(updatedService.Applications, *application) + } + + updatedServices[index] = updatedService } - meta = pods.Items[0].GetObjectMeta() + return updatedServices, nil } - return makeApplication(meta), nil + return services, nil } -func makeApplication(meta metav1.Object) []models.K8sApplication { - ownerReferences := meta.GetOwnerReferences() - if len(ownerReferences) == 0 { - return nil - } - - // Currently, we only support one owner reference - ownerReference := ownerReferences[0] - return []models.K8sApplication{ - { - // Only the name is used right now, but we can add more fields in the future - Name: ownerReference.Name, - }, +// containsServiceWithSelector checks if a list of services contains a service with a selector +// it returns true if any service has a selector, otherwise false +func containsServiceWithSelector(services []models.K8sServiceInfo) bool { + for _, service := range services { + if len(service.Selector) > 0 { + return true + } } + return false +} + +// buildServicesMap builds a map of service names from a list of K8sServiceInfo objects +// it returns a map of service names for lookups +func (kcl *KubeClient) buildServicesMap(services []models.K8sServiceInfo) map[string]struct{} { + serviceMap := make(map[string]struct{}) + for _, service := range services { + serviceMap[service.Name] = struct{}{} + } + return serviceMap } diff --git a/api/kubernetes/cli/service_account.go b/api/kubernetes/cli/service_account.go index a50620b4d..f0b2679ab 100644 --- a/api/kubernetes/cli/service_account.go +++ b/api/kubernetes/cli/service_account.go @@ -4,23 +4,76 @@ import ( "context" portainer "github.com/portainer/portainer/api" - v1 "k8s.io/api/core/v1" + models "github.com/portainer/portainer/api/http/models/kubernetes" + corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// GetServiceAccount returns the portainer ServiceAccountName associated to the specified user. -func (kcl *KubeClient) GetServiceAccount(tokenData *portainer.TokenData) (*v1.ServiceAccount, error) { - var portainerServiceAccountName string +// GetServiceAccounts gets all the service accounts for either at the cluster level or a given namespace in a k8s endpoint. +// It returns a list of K8sServiceAccount objects. +func (kcl *KubeClient) GetServiceAccounts(namespace string) ([]models.K8sServiceAccount, error) { + if kcl.IsKubeAdmin { + return kcl.fetchServiceAccounts(namespace) + } + + return kcl.fetchServiceAccountsForNonAdmin(namespace) +} + +// fetchServiceAccountsForNonAdmin gets all the service accounts for either at the cluster level or a given namespace in a k8s endpoint. +// the namespace will be coming from NonAdminNamespaces as non-admin users are restricted to certain namespaces. +// it returns a list of K8sServiceAccount objects. +func (kcl *KubeClient) fetchServiceAccountsForNonAdmin(namespace string) ([]models.K8sServiceAccount, error) { + serviceAccounts, err := kcl.fetchServiceAccounts(namespace) + if err != nil { + return nil, err + } + + nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap() + results := make([]models.K8sServiceAccount, 0) + for _, serviceAccount := range serviceAccounts { + if _, ok := nonAdminNamespaceSet[serviceAccount.Namespace]; ok { + results = append(results, serviceAccount) + } + } + + return results, nil +} + +// fetchServiceAccounts returns a list of all ServiceAccounts in the specified namespace. +func (kcl *KubeClient) fetchServiceAccounts(namespace string) ([]models.K8sServiceAccount, error) { + serviceAccounts, err := kcl.cli.CoreV1().ServiceAccounts(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + results := make([]models.K8sServiceAccount, 0) + for _, serviceAccount := range serviceAccounts.Items { + results = append(results, parseServiceAccount(serviceAccount)) + } + + return results, nil +} + +// parseServiceAccount converts a corev1.ServiceAccount object to a models.K8sServiceAccount object. +func parseServiceAccount(serviceAccount corev1.ServiceAccount) models.K8sServiceAccount { + return models.K8sServiceAccount{ + Name: serviceAccount.Name, + Namespace: serviceAccount.Namespace, + CreationDate: serviceAccount.CreationTimestamp.Time, + } +} + +// GetPortainerUserServiceAccount returns the portainer ServiceAccountName associated to the specified user. +func (kcl *KubeClient) GetPortainerUserServiceAccount(tokenData *portainer.TokenData) (*corev1.ServiceAccount, error) { + portainerUserServiceAccountName := UserServiceAccountName(int(tokenData.ID), kcl.instanceID) if tokenData.Role == portainer.AdministratorRole { - portainerServiceAccountName = portainerClusterAdminServiceAccountName - } else { - portainerServiceAccountName = UserServiceAccountName(int(tokenData.ID), kcl.instanceID) + portainerUserServiceAccountName = portainerClusterAdminServiceAccountName } // verify name exists as service account resource within portainer namespace - serviceAccount, err := kcl.cli.CoreV1().ServiceAccounts(portainerNamespace).Get(context.TODO(), portainerServiceAccountName, metav1.GetOptions{}) + serviceAccount, err := kcl.cli.CoreV1().ServiceAccounts(portainerNamespace).Get(context.TODO(), portainerUserServiceAccountName, metav1.GetOptions{}) if err != nil { return nil, err } @@ -69,7 +122,7 @@ func (kcl *KubeClient) ensureRequiredResourcesExist() error { } func (kcl *KubeClient) createUserServiceAccount(namespace, serviceAccountName string) error { - serviceAccount := &v1.ServiceAccount{ + serviceAccount := &corev1.ServiceAccount{ ObjectMeta: metav1.ObjectMeta{ Name: serviceAccountName, }, diff --git a/api/kubernetes/cli/service_account_test.go b/api/kubernetes/cli/service_account_test.go index 6387d6902..69e7e88cb 100644 --- a/api/kubernetes/cli/service_account_test.go +++ b/api/kubernetes/cli/service_account_test.go @@ -18,9 +18,9 @@ func Test_GetServiceAccount(t *testing.T) { instanceID: "test", } tokenData := &portainer.TokenData{ID: 1} - _, err := k.GetServiceAccount(tokenData) + _, err := k.GetPortainerUserServiceAccount(tokenData) if err == nil { - t.Error("GetServiceAccount should fail with service account not found") + t.Error("GetPortainerUserServiceAccount should fail with service account not found") } }) @@ -46,9 +46,9 @@ func Test_GetServiceAccount(t *testing.T) { } defer k.cli.CoreV1().ServiceAccounts(portainerNamespace).Delete(context.Background(), serviceAccount.Name, metav1.DeleteOptions{}) - sa, err := k.GetServiceAccount(tokenData) + sa, err := k.GetPortainerUserServiceAccount(tokenData) if err != nil { - t.Errorf("GetServiceAccount should succeed; err=%s", err) + t.Errorf("GetPortainerUserServiceAccount should succeed; err=%s", err) } want := "portainer-sa-clusteradmin" @@ -79,14 +79,14 @@ func Test_GetServiceAccount(t *testing.T) { } defer k.cli.CoreV1().ServiceAccounts(portainerNamespace).Delete(context.Background(), serviceAccount.Name, metav1.DeleteOptions{}) - sa, err := k.GetServiceAccount(tokenData) + sa, err := k.GetPortainerUserServiceAccount(tokenData) if err != nil { - t.Errorf("GetServiceAccount should succeed; err=%s", err) + t.Errorf("GetPortainerUserServiceAccount should succeed; err=%s", err) } want := "portainer-sa-user-test-1" if sa.Name != want { - t.Errorf("GetServiceAccount should succeed and return correct sa name; got=%s want=%s", sa.Name, want) + t.Errorf("GetPortainerUserServiceAccount should succeed and return correct sa name; got=%s want=%s", sa.Name, want) } }) diff --git a/api/kubernetes/cli/volumes.go b/api/kubernetes/cli/volumes.go new file mode 100644 index 000000000..5343507c2 --- /dev/null +++ b/api/kubernetes/cli/volumes.go @@ -0,0 +1,258 @@ +package cli + +import ( + "context" + "fmt" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/rs/zerolog/log" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + storagev1 "k8s.io/api/storage/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GetVolumes gets the volumes in the current k8s environment(endpoint). +// If the user is an admin, it fetches all the volumes in the cluster. +// If the user is not an admin, it fetches the volumes in the namespaces the user has access to. +// It returns a list of K8sVolumeInfo. +func (kcl *KubeClient) GetVolumes(namespace string) ([]models.K8sVolumeInfo, error) { + if kcl.IsKubeAdmin { + return kcl.fetchVolumes(namespace) + } + return kcl.fetchVolumesForNonAdmin(namespace) +} + +// GetVolume gets the volume with the given name and namespace. +func (kcl *KubeClient) GetVolume(namespace, volumeName string) (*models.K8sVolumeInfo, error) { + persistentVolumeClaim, err := kcl.cli.CoreV1().PersistentVolumeClaims(namespace).Get(context.TODO(), volumeName, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + return nil, nil + } + + return nil, err + } + + persistentVolumesMap, storageClassesMap, err := kcl.fetchPersistentVolumesAndStorageClassesMap() + if err != nil { + return nil, err + } + + volume := parseVolume(persistentVolumeClaim, persistentVolumesMap, storageClassesMap) + return &volume, nil +} + +// fetchVolumesForNonAdmin fetches the volumes in the namespaces the user has access to. +// This function is called when the user is not an admin. +// It fetches all the persistent volume claims, persistent volumes and storage classes in the namespaces the user has access to. +func (kcl *KubeClient) fetchVolumesForNonAdmin(namespace string) ([]models.K8sVolumeInfo, error) { + log.Debug().Msgf("Fetching volumes for non-admin user: %v", kcl.NonAdminNamespaces) + + if len(kcl.NonAdminNamespaces) == 0 { + return nil, nil + } + + volumes, err := kcl.fetchVolumes(namespace) + if err != nil { + return nil, err + } + + nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap() + results := make([]models.K8sVolumeInfo, 0) + for _, volume := range volumes { + if _, ok := nonAdminNamespaceSet[volume.PersistentVolumeClaim.Namespace]; ok { + results = append(results, volume) + } + } + + return results, nil +} + +// fetchVolumes fetches all the persistent volume claims, persistent volumes and storage classes in the given namespace. +// It returns a list of K8sVolumeInfo. +// This function is called by fetchVolumesForAdmin and fetchVolumesForNonAdmin. +func (kcl *KubeClient) fetchVolumes(namespace string) ([]models.K8sVolumeInfo, error) { + volumes := make([]models.K8sVolumeInfo, 0) + persistentVolumeClaims, err := kcl.cli.CoreV1().PersistentVolumeClaims(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + if len(persistentVolumeClaims.Items) > 0 { + persistentVolumesMap, storageClassesMap, err := kcl.fetchPersistentVolumesAndStorageClassesMap() + if err != nil { + return nil, err + } + + for _, persistentVolumeClaim := range persistentVolumeClaims.Items { + volumes = append(volumes, parseVolume(&persistentVolumeClaim, persistentVolumesMap, storageClassesMap)) + } + } + + return volumes, nil +} + +// parseVolume parses the given persistent volume claim and returns a K8sVolumeInfo. +// This function is called by fetchVolumes. +// It returns a K8sVolumeInfo. +func parseVolume(persistentVolumeClaim *corev1.PersistentVolumeClaim, persistentVolumesMap map[string]models.K8sPersistentVolume, storageClassesMap map[string]models.K8sStorageClass) models.K8sVolumeInfo { + volume := models.K8sVolumeInfo{} + volumeClaim := parsePersistentVolumeClaim(persistentVolumeClaim) + + if volumeClaim.VolumeName != "" { + persistentVolume, ok := persistentVolumesMap[volumeClaim.VolumeName] + if ok { + volume.PersistentVolume = persistentVolume + } + } + + if volumeClaim.StorageClass != nil { + storageClass, ok := storageClassesMap[*volumeClaim.StorageClass] + if ok { + volume.StorageClass = storageClass + } + } + + volume.PersistentVolumeClaim = volumeClaim + return volume +} + +// parsePersistentVolumeClaim parses the given persistent volume claim and returns a K8sPersistentVolumeClaim. +func parsePersistentVolumeClaim(volume *corev1.PersistentVolumeClaim) models.K8sPersistentVolumeClaim { + storage := volume.Spec.Resources.Requests[corev1.ResourceStorage] + return models.K8sPersistentVolumeClaim{ + ID: string(volume.UID), + Name: volume.Name, + Namespace: volume.Namespace, + CreationDate: volume.CreationTimestamp.Time, + Storage: storage.Value(), + AccessModes: volume.Spec.AccessModes, + VolumeName: volume.Spec.VolumeName, + ResourcesRequests: &volume.Spec.Resources.Requests, + StorageClass: volume.Spec.StorageClassName, + VolumeMode: volume.Spec.VolumeMode, + OwningApplications: nil, + Phase: volume.Status.Phase, + } +} + +// parsePersistentVolume parses the given persistent volume and returns a K8sPersistentVolume. +func parsePersistentVolume(volume *corev1.PersistentVolume) models.K8sPersistentVolume { + return models.K8sPersistentVolume{ + Name: volume.Name, + Annotations: volume.Annotations, + AccessModes: volume.Spec.AccessModes, + Capacity: volume.Spec.Capacity, + ClaimRef: volume.Spec.ClaimRef, + StorageClassName: volume.Spec.StorageClassName, + PersistentVolumeReclaimPolicy: volume.Spec.PersistentVolumeReclaimPolicy, + VolumeMode: volume.Spec.VolumeMode, + CSI: volume.Spec.CSI, + } +} + +// buildPersistentVolumesMap builds a map of persistent volumes. +func (kcl *KubeClient) buildPersistentVolumesMap(persistentVolumes *corev1.PersistentVolumeList) map[string]models.K8sPersistentVolume { + persistentVolumesMap := make(map[string]models.K8sPersistentVolume) + for _, persistentVolume := range persistentVolumes.Items { + persistentVolumesMap[persistentVolume.Name] = parsePersistentVolume(&persistentVolume) + } + + return persistentVolumesMap +} + +// parseStorageClass parses the given storage class and returns a K8sStorageClass. +func parseStorageClass(storageClass *storagev1.StorageClass) models.K8sStorageClass { + return models.K8sStorageClass{ + Name: storageClass.Name, + Provisioner: storageClass.Provisioner, + ReclaimPolicy: storageClass.ReclaimPolicy, + AllowVolumeExpansion: storageClass.AllowVolumeExpansion, + } +} + +// buildStorageClassesMap builds a map of storage classes. +func (kcl *KubeClient) buildStorageClassesMap(storageClasses *storagev1.StorageClassList) map[string]models.K8sStorageClass { + storageClassesMap := make(map[string]models.K8sStorageClass) + for _, storageClass := range storageClasses.Items { + storageClassesMap[storageClass.Name] = parseStorageClass(&storageClass) + } + + return storageClassesMap +} + +// fetchPersistentVolumesAndStorageClassesMap fetches all the persistent volumes and storage classes in the cluster. +// It returns a map of persistent volumes and a map of storage classes. +func (kcl *KubeClient) fetchPersistentVolumesAndStorageClassesMap() (map[string]models.K8sPersistentVolume, map[string]models.K8sStorageClass, error) { + persistentVolumes, err := kcl.cli.CoreV1().PersistentVolumes().List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, nil, err + } + persistentVolumesMap := kcl.buildPersistentVolumesMap(persistentVolumes) + + storageClasses, err := kcl.cli.StorageV1().StorageClasses().List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, nil, err + } + storageClassesMap := kcl.buildStorageClassesMap(storageClasses) + + return persistentVolumesMap, storageClassesMap, nil +} + +// CombineVolumesWithApplications combines the volumes with the applications that use them. +func (kcl *KubeClient) CombineVolumesWithApplications(volumes *[]models.K8sVolumeInfo) (*[]models.K8sVolumeInfo, error) { + pods, err := kcl.cli.CoreV1().Pods("").List(context.Background(), metav1.ListOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + return volumes, nil + } + log.Error().Err(err).Msg("Failed to list pods across the cluster") + return nil, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to list pods across the cluster. Error: %w", err) + } + + hasReplicaSetOwnerReference := containsReplicaSetOwnerReference(pods) + replicaSetItems := make([]appsv1.ReplicaSet, 0) + if hasReplicaSetOwnerReference { + replicaSets, err := kcl.cli.AppsV1().ReplicaSets("").List(context.Background(), metav1.ListOptions{}) + if err != nil { + log.Error().Err(err).Msg("Failed to list replica sets across the cluster") + return nil, fmt.Errorf("an error occurred during the CombineVolumesWithApplications operation, unable to list replica sets across the cluster. Error: %w", err) + } + replicaSetItems = replicaSets.Items + } + + return kcl.updateVolumesWithOwningApplications(volumes, pods, replicaSetItems) +} + +// updateVolumesWithOwningApplications updates the volumes with the applications that use them. +func (kcl *KubeClient) updateVolumesWithOwningApplications(volumes *[]models.K8sVolumeInfo, pods *corev1.PodList, replicaSetItems []appsv1.ReplicaSet) (*[]models.K8sVolumeInfo, error) { + for i, volume := range *volumes { + for _, pod := range pods.Items { + if pod.Spec.Volumes != nil { + for _, podVolume := range pod.Spec.Volumes { + if podVolume.PersistentVolumeClaim != nil && podVolume.PersistentVolumeClaim.ClaimName == volume.PersistentVolumeClaim.Name && pod.Namespace == volume.PersistentVolumeClaim.Namespace { + application, err := kcl.ConvertPodToApplication(pod, replicaSetItems, []appsv1.Deployment{}, []appsv1.StatefulSet{}, []appsv1.DaemonSet{}, []corev1.Service{}, false) + if err != nil { + log.Error().Err(err).Msg("Failed to convert pod to application") + return nil, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to convert pod to application. Error: %w", err) + } + // Check if the application already exists in the OwningApplications slice + exists := false + for _, existingApp := range (*volumes)[i].PersistentVolumeClaim.OwningApplications { + if existingApp.Name == application.Name && existingApp.Namespace == application.Namespace { + exists = true + break + } + } + if !exists && application != nil { + (*volumes)[i].PersistentVolumeClaim.OwningApplications = append((*volumes)[i].PersistentVolumeClaim.OwningApplications, *application) + } + } + } + } + } + } + return volumes, nil +} diff --git a/api/pendingactions/handlers/delete_k8s_registry_secrets.go b/api/pendingactions/handlers/delete_k8s_registry_secrets.go index 6f38bf4a4..cccfa3a66 100644 --- a/api/pendingactions/handlers/delete_k8s_registry_secrets.go +++ b/api/pendingactions/handlers/delete_k8s_registry_secrets.go @@ -57,7 +57,7 @@ func (h *HandlerDeleteK8sRegistrySecrets) Execute(pa portainer.PendingAction, en return err } - kubeClient, err := h.kubeFactory.GetKubeClient(endpoint) + kubeClient, err := h.kubeFactory.GetPrivilegedKubeClient(endpoint) if err != nil { return err } diff --git a/api/pendingactions/pendingactions.go b/api/pendingactions/pendingactions.go index 4cea6f4c1..d470e455a 100644 --- a/api/pendingactions/pendingactions.go +++ b/api/pendingactions/pendingactions.go @@ -80,7 +80,7 @@ func (service *PendingActionsService) execute(environmentID portainer.EndpointID } else { // For Kubernetes endpoints, we need to check if the endpoint is up by // creating a kube client and performing a simple operation - client, err := service.kubeFactory.GetKubeClient(endpoint) + client, err := service.kubeFactory.GetPrivilegedKubeClient(endpoint) if err != nil { log.Debug().Msgf("failed to create Kubernetes client for environment %d: %v", environmentID, err) return diff --git a/api/portainer.go b/api/portainer.go index 5bdf7ee61..a087887fe 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -14,7 +14,7 @@ import ( "github.com/portainer/portainer/pkg/featureflags" "golang.org/x/oauth2" - v1 "k8s.io/api/core/v1" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/version" ) @@ -595,8 +595,14 @@ type ( JobType int K8sNamespaceInfo struct { - IsSystem bool `json:"IsSystem"` - IsDefault bool `json:"IsDefault"` + Id string `json:"Id"` + Name string `json:"Name"` + Status corev1.NamespaceStatus `json:"Status"` + CreationDate string `json:"CreationDate"` + NamespaceOwner string `json:"NamespaceOwner"` + IsSystem bool `json:"IsSystem"` + IsDefault bool `json:"IsDefault"` + ResourceQuota *corev1.ResourceQuota `json:"ResourceQuota"` } K8sNodeLimits struct { @@ -1491,22 +1497,22 @@ type ( SetupUserServiceAccount(userID int, teamIDs []int, restrictDefaultNamespace bool) error IsRBACEnabled() (bool, error) - GetServiceAccount(tokendata *TokenData) (*v1.ServiceAccount, error) + GetPortainerUserServiceAccount(tokendata *TokenData) (*corev1.ServiceAccount, error) GetServiceAccountBearerToken(userID int) (string, error) CreateUserShellPod(ctx context.Context, serviceAccountName, shellPodImage string) (*KubernetesShellPod, error) StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer, errChan chan error) HasStackName(namespace string, stackName string) (bool, error) NamespaceAccessPoliciesDeleteNamespace(namespace string) error - CreateNamespace(info models.K8sNamespaceDetails) error - UpdateNamespace(info models.K8sNamespaceDetails) error + CreateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error) + UpdateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error) GetNamespaces() (map[string]K8sNamespaceInfo, error) GetNamespace(string) (K8sNamespaceInfo, error) - DeleteNamespace(namespace string) error - GetConfigMapsAndSecrets(namespace string) ([]models.K8sConfigMapOrSecret, error) + DeleteNamespace(namespace string) (*corev1.Namespace, error) + GetConfigMaps(namespace string) ([]models.K8sConfigMap, error) + GetSecrets(namespace string) ([]models.K8sSecret, error) GetIngressControllers() (models.K8sIngressControllers, error) - GetApplications(namespace, kind string) ([]models.K8sApplication, error) - GetApplication(namespace, kind, name string) (models.K8sApplication, error) + GetApplications(namespace, nodename string, withDependencies bool) ([]models.K8sApplication, error) GetMetrics() (models.K8sMetrics, error) GetStorage() ([]KubernetesStorageClassConfig, error) CreateIngress(namespace string, info models.K8sIngressInfo, owner string) error @@ -1515,7 +1521,7 @@ type ( DeleteIngresses(reqs models.K8sIngressDeleteRequests) error CreateService(namespace string, service models.K8sServiceInfo) error UpdateService(namespace string, service models.K8sServiceInfo) error - GetServices(namespace string, lookupApplications bool) ([]models.K8sServiceInfo, error) + GetServices(namespace string) ([]models.K8sServiceInfo, error) DeleteServices(reqs models.K8sServiceDeleteRequests) error GetNodesLimits() (K8sNodesLimits, error) GetMaxResourceLimits(name string, overCommitEnabled bool, resourceOverCommitPercent int) (K8sNodeLimits, error) diff --git a/app/kubernetes/__module.js b/app/kubernetes/__module.js index c4b85e3f2..1eaee62ad 100644 --- a/app/kubernetes/__module.js +++ b/app/kubernetes/__module.js @@ -1,5 +1,4 @@ import { EnvironmentStatus } from '@/react/portainer/environments/types'; -import { getSelfSubjectAccessReview } from '@/react/kubernetes/namespaces/getSelfSubjectAccessReview'; import { updateAxiosAdapter } from '@/portainer/services/axios'; import { PortainerEndpointTypes } from 'Portainer/models/endpoint/models'; @@ -96,13 +95,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo } try { - const status = await checkEndpointStatus( - endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment - ? KubernetesHealthService.ping(endpoint.Id) - : // use selfsubject access review to check if we can connect to the kubernetes environment - // because it gets a fast response, and is accessible to all users - getSelfSubjectAccessReview(endpoint.Id, 'default') - ); + const status = await checkEndpointStatus(endpoint); if (endpoint.Type !== PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment) { await updateEndpointStatus(endpoint, status); @@ -131,9 +124,9 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo return false; } - async function checkEndpointStatus(promise) { + async function checkEndpointStatus(endpoint) { try { - await promise; + await KubernetesHealthService.ping(endpoint.Id); return EnvironmentStatus.Up; } catch (e) { return EnvironmentStatus.Down; @@ -459,10 +452,10 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo const resourcePools = { name: 'kubernetes.resourcePools', - url: '/pools', + url: '/namespaces', views: { 'content@': { - component: 'kubernetesResourcePoolsView', + component: 'kubernetesNamespacesView', }, }, data: { @@ -511,7 +504,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo const volumes = { name: 'kubernetes.volumes', - url: '/volumes', + url: '/volumes?tab', views: { 'content@': { component: 'kubernetesVolumesView', @@ -582,6 +575,51 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo }, }; + const moreResources = { + name: 'kubernetes.moreResources', + url: '/moreResources', + abstract: true, + }; + + const serviceAccounts = { + name: 'kubernetes.moreResources.serviceAccounts', + url: '/serviceAccounts', + views: { + 'content@': { + component: 'serviceAccountsView', + }, + }, + data: { + docs: '/user/kubernetes/more-resources/service-accounts', + }, + }; + + const clusterRoles = { + name: 'kubernetes.moreResources.clusterRoles', + url: '/clusterRoles?tab', + views: { + 'content@': { + component: 'clusterRolesView', + }, + }, + data: { + docs: '/user/kubernetes/more-resources/cluster-roles', + }, + }; + + const roles = { + name: 'kubernetes.moreResources.roles', + url: '/roles?tab', + views: { + 'content@': { + component: 'k8sRolesView', + }, + }, + data: { + docs: '/user/kubernetes/more-resources/namespace-roles', + }, + }; + $stateRegistryProvider.register(kubernetes); $stateRegistryProvider.register(helmApplication); $stateRegistryProvider.register(applications); @@ -621,5 +659,10 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo $stateRegistryProvider.register(ingresses); $stateRegistryProvider.register(ingressesCreate); $stateRegistryProvider.register(ingressesEdit); + + $stateRegistryProvider.register(moreResources); + $stateRegistryProvider.register(serviceAccounts); + $stateRegistryProvider.register(clusterRoles); + $stateRegistryProvider.register(roles); }, ]); diff --git a/app/kubernetes/converters/configMap.js b/app/kubernetes/converters/configMap.js index ec3d1087f..be0bdffcf 100644 --- a/app/kubernetes/converters/configMap.js +++ b/app/kubernetes/converters/configMap.js @@ -77,7 +77,7 @@ class KubernetesConfigMapConverter { static createPayload(data) { const res = new KubernetesConfigMapCreatePayload(); res.metadata.name = data.Name; - res.metadata.namespace = data.Namespace; + res.metadata.namespace = data.Namespace.Namespace.Name; const configurationOwner = _.truncate(data.ConfigurationOwner, { length: 63, omission: '' }); res.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] = configurationOwner; @@ -115,7 +115,7 @@ class KubernetesConfigMapConverter { const res = new KubernetesConfigMap(); res.Id = formValues.Id; res.Name = formValues.Name; - res.Namespace = formValues.ResourcePool.Namespace.Name; + res.Namespace = formValues.ResourcePool; res.ConfigurationOwner = formValues.ConfigurationOwner; res.Data = formValues.Data; return res; diff --git a/app/kubernetes/converters/secret.js b/app/kubernetes/converters/secret.js index 725398c2b..64594d9c0 100644 --- a/app/kubernetes/converters/secret.js +++ b/app/kubernetes/converters/secret.js @@ -9,7 +9,7 @@ class KubernetesSecretConverter { static createPayload(secret) { const res = new KubernetesSecretCreatePayload(); res.metadata.name = secret.Name; - res.metadata.namespace = secret.Namespace; + res.metadata.namespace = secret.Namespace.Namespace.Name; res.type = secret.Type; const configurationOwner = _.truncate(secret.ConfigurationOwner, { length: 63, omission: '' }); res.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] = configurationOwner; @@ -100,7 +100,7 @@ class KubernetesSecretConverter { static configurationFormValuesToSecret(formValues) { const res = new KubernetesApplicationSecret(); res.Name = formValues.Name; - res.Namespace = formValues.ResourcePool.Namespace.Name; + res.Namespace = formValues.ResourcePool; res.Type = formValues.Type; res.ConfigurationOwner = formValues.ConfigurationOwner; res.Data = formValues.Data; diff --git a/app/kubernetes/helpers/stackHelper.js b/app/kubernetes/helpers/stackHelper.js deleted file mode 100644 index c539caf89..000000000 --- a/app/kubernetes/helpers/stackHelper.js +++ /dev/null @@ -1,26 +0,0 @@ -import _ from 'lodash-es'; -import { KubernetesStack } from 'Kubernetes/models/stack/models'; - -class KubernetesStackHelper { - static stacksFromApplications(applications) { - const res = _.reduce( - applications, - (acc, app) => { - if (app.StackName) { - let stack = _.find(acc, { Name: app.StackName, ResourcePool: app.ResourcePool }); - if (!stack) { - stack = new KubernetesStack(); - stack.Name = app.StackName; - stack.ResourcePool = app.ResourcePool; - acc.push(stack); - } - stack.Applications.push(app); - } - return acc; - }, - [] - ); - return res; - } -} -export default KubernetesStackHelper; diff --git a/app/kubernetes/helpers/stackHelper.test.js b/app/kubernetes/helpers/stackHelper.test.js deleted file mode 100644 index 0d5056331..000000000 --- a/app/kubernetes/helpers/stackHelper.test.js +++ /dev/null @@ -1,12 +0,0 @@ -import KubernetesStackHelper from './stackHelper'; - -describe('stacksFromApplications', () => { - const { stacksFromApplications } = KubernetesStackHelper; - test('should return an empty array when passed an empty array', () => { - expect(stacksFromApplications([])).toHaveLength(0); - }); - - test('should return an empty array when passed a list of applications without stacks', () => { - expect(stacksFromApplications([{ StackName: '' }, { StackName: '' }, { StackName: '' }, { StackName: '' }])).toHaveLength(0); - }); -}); diff --git a/app/kubernetes/models/application/models/Application.ts b/app/kubernetes/models/application/models/Application.ts index 44caf9e91..c8e8aa28c 100644 --- a/app/kubernetes/models/application/models/Application.ts +++ b/app/kubernetes/models/application/models/Application.ts @@ -41,9 +41,11 @@ export class Application { annotations?: Record; }; - Limits: { - Cpu?: number; - Memory?: number; + Resource?: { + cpuLimit?: number; + cpuRequest?: number; + memoryLimit?: number; + memoryRequest?: number; }; ServiceType?: ServiceType; @@ -106,7 +108,7 @@ export class Application { this.Pods = []; this.Containers = []; this.Metadata = {}; - this.Limits = {}; + this.Resource = {}; this.ServiceId = ''; this.ServiceName = ''; this.HeadlessServiceName = undefined; diff --git a/app/kubernetes/models/storage-class/StorageClass.ts b/app/kubernetes/models/storage-class/StorageClass.ts index 77fda852d..49d65bbc6 100644 --- a/app/kubernetes/models/storage-class/StorageClass.ts +++ b/app/kubernetes/models/storage-class/StorageClass.ts @@ -3,5 +3,7 @@ export class StorageClass { Provisioner: string = ''; + ReclaimPolicy: string = ''; + AllowVolumeExpansion: boolean = false; } diff --git a/app/kubernetes/models/volume/Volume.ts b/app/kubernetes/models/volume/Volume.ts index 02c7f2366..ae5e53415 100644 --- a/app/kubernetes/models/volume/Volume.ts +++ b/app/kubernetes/models/volume/Volume.ts @@ -6,7 +6,7 @@ import { PersistentVolumeClaim } from './PersistentVolumeClaim'; type VolumeResourcePool = ReturnType; export class Volume { - ResourcePool: VolumeResourcePool = {} as VolumeResourcePool; + ResourcePool?: VolumeResourcePool = {} as VolumeResourcePool; PersistentVolumeClaim: PersistentVolumeClaim = {} as PersistentVolumeClaim; diff --git a/app/kubernetes/react/components/applications.ts b/app/kubernetes/react/components/applications.ts index cf303a35e..aa5092c4c 100644 --- a/app/kubernetes/react/components/applications.ts +++ b/app/kubernetes/react/components/applications.ts @@ -11,8 +11,6 @@ export const applicationsModule = angular .component( 'kubernetesApplicationsDatatable', r2a(withUIRouter(withCurrentUser(ApplicationsDatatable)), [ - 'dataset', - 'isLoading', 'namespace', 'namespaces', 'onNamespaceChange', diff --git a/app/kubernetes/react/components/clusterManagement.ts b/app/kubernetes/react/components/clusterManagement.ts index 8132ea80e..61219beff 100644 --- a/app/kubernetes/react/components/clusterManagement.ts +++ b/app/kubernetes/react/components/clusterManagement.ts @@ -11,11 +11,7 @@ export const clusterManagementModule = angular .module('portainer.kubernetes.react.components.clusterManagement', []) .component( 'kubernetesNodeApplicationsDatatable', - r2a(withUIRouter(withCurrentUser(NodeApplicationsDatatable)), [ - 'dataset', - 'isLoading', - 'onRefresh', - ]) + r2a(withUIRouter(withCurrentUser(NodeApplicationsDatatable)), []) ) .component( 'resourceEventsDatatable', diff --git a/app/kubernetes/react/components/index.ts b/app/kubernetes/react/components/index.ts index 7280f5af0..38bb2a23a 100644 --- a/app/kubernetes/react/components/index.ts +++ b/app/kubernetes/react/components/index.ts @@ -62,7 +62,6 @@ import { kubeEnvVarValidationSchema } from '@/react/kubernetes/applications/comp import { IntegratedAppsDatatable } from '@/react/kubernetes/components/IntegratedAppsDatatable/IntegratedAppsDatatable'; import { applicationsModule } from './applications'; -import { volumesModule } from './volumes'; import { namespacesModule } from './namespaces'; import { clusterManagementModule } from './clusterManagement'; import { registriesModule } from './registries'; @@ -70,7 +69,6 @@ import { registriesModule } from './registries'; export const ngModule = angular .module('portainer.kubernetes.react.components', [ applicationsModule, - volumesModule, namespacesModule, clusterManagementModule, registriesModule, @@ -213,13 +211,10 @@ export const ngModule = angular .component( 'kubernetesApplicationsStacksDatatable', r2a(withUIRouter(withCurrentUser(ApplicationsStacksDatatable)), [ - 'dataset', - 'onRefresh', 'onRemove', 'namespace', 'namespaces', 'onNamespaceChange', - 'isLoading', 'showSystem', 'setSystemResources', ]) diff --git a/app/kubernetes/react/components/namespaces.ts b/app/kubernetes/react/components/namespaces.ts index 32edfc0b5..58f36a3fa 100644 --- a/app/kubernetes/react/components/namespaces.ts +++ b/app/kubernetes/react/components/namespaces.ts @@ -12,11 +12,7 @@ export const namespacesModule = angular .module('portainer.kubernetes.react.components.namespaces', []) .component( 'kubernetesNamespacesDatatable', - r2a(withUIRouter(withCurrentUser(NamespacesDatatable)), [ - 'dataset', - 'onRemove', - 'onRefresh', - ]) + r2a(withUIRouter(withCurrentUser(NamespacesDatatable)), []) ) .component( 'kubernetesNamespaceApplicationsDatatable', diff --git a/app/kubernetes/react/components/volumes.ts b/app/kubernetes/react/components/volumes.ts deleted file mode 100644 index 35a4d0c3c..000000000 --- a/app/kubernetes/react/components/volumes.ts +++ /dev/null @@ -1,25 +0,0 @@ -import angular from 'angular'; - -import { r2a } from '@/react-tools/react2angular'; -import { withUIRouter } from '@/react-tools/withUIRouter'; -import { withCurrentUser } from '@/react-tools/withCurrentUser'; -import { VolumesDatatable } from '@/react/kubernetes/volumes/ListView/VolumesDatatable'; -import { StorageDatatable } from '@/react/kubernetes/volumes/ListView/StorageDatatable'; - -export const volumesModule = angular - .module('portainer.kubernetes.react.components.volumes', []) - .component( - 'kubernetesVolumesDatatable', - r2a(withUIRouter(withCurrentUser(VolumesDatatable)), [ - 'dataset', - 'onRemove', - 'onRefresh', - ]) - ) - .component( - 'kubernetesVolumesStoragesDatatable', - r2a(withUIRouter(withCurrentUser(StorageDatatable)), [ - 'dataset', - 'onRefresh', - ]) - ).name; diff --git a/app/kubernetes/react/views/index.ts b/app/kubernetes/react/views/index.ts index 62285d246..2deae5fb5 100644 --- a/app/kubernetes/react/views/index.ts +++ b/app/kubernetes/react/views/index.ts @@ -13,6 +13,11 @@ import { ConfigmapsAndSecretsView } from '@/react/kubernetes/configs/ListView/Co import { CreateNamespaceView } from '@/react/kubernetes/namespaces/CreateView/CreateNamespaceView'; import { ApplicationDetailsView } from '@/react/kubernetes/applications/DetailsView/ApplicationDetailsView'; import { ConfigureView } from '@/react/kubernetes/cluster/ConfigureView'; +import { NamespacesView } from '@/react/kubernetes/namespaces/ListView/NamespacesView'; +import { ServiceAccountsView } from '@/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsView'; +import { ClusterRolesView } from '@/react/kubernetes/more-resources/ClusterRolesView'; +import { RolesView } from '@/react/kubernetes/more-resources/RolesView'; +import { VolumesView } from '@/react/kubernetes/volumes/ListView/VolumesView'; export const viewsModule = angular .module('portainer.kubernetes.react.views', []) @@ -20,10 +25,18 @@ export const viewsModule = angular 'kubernetesCreateNamespaceView', r2a(withUIRouter(withReactQuery(withCurrentUser(CreateNamespaceView))), []) ) + .component( + 'kubernetesNamespacesView', + r2a(withUIRouter(withReactQuery(withCurrentUser(NamespacesView))), []) + ) .component( 'kubernetesServicesView', r2a(withUIRouter(withReactQuery(withCurrentUser(ServicesView))), []) ) + .component( + 'kubernetesVolumesView', + r2a(withUIRouter(withReactQuery(withCurrentUser(VolumesView))), []) + ) .component( 'kubernetesIngressesView', r2a( @@ -60,4 +73,16 @@ export const viewsModule = angular .component( 'kubernetesConsoleView', r2a(withUIRouter(withReactQuery(withCurrentUser(ConsoleView))), []) + ) + .component( + 'serviceAccountsView', + r2a(withUIRouter(withReactQuery(withCurrentUser(ServiceAccountsView))), []) + ) + .component( + 'clusterRolesView', + r2a(withUIRouter(withReactQuery(withCurrentUser(ClusterRolesView))), []) + ) + .component( + 'k8sRolesView', + r2a(withUIRouter(withReactQuery(withCurrentUser(RolesView))), []) ).name; diff --git a/app/kubernetes/services/applicationService.js b/app/kubernetes/services/applicationService.js index 7d43acb23..6866ec469 100644 --- a/app/kubernetes/services/applicationService.js +++ b/app/kubernetes/services/applicationService.js @@ -14,7 +14,6 @@ import { notifyError } from '@/portainer/services/notifications'; import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter'; import { generateNewIngressesFromFormPaths } from '@/react/kubernetes/applications/CreateView/application-services/utils'; import { KubernetesPod } from '../pod/models'; -import { KubernetesApplication } from '../models/application/models'; class KubernetesApplicationService { /* #region CONSTRUCTOR */ @@ -64,7 +63,7 @@ class KubernetesApplicationService { apiService = this.KubernetesDaemonSetService; } else if (app.ApplicationType === KubernetesApplicationTypes.StatefulSet) { apiService = this.KubernetesStatefulSetService; - } else if (app instanceof KubernetesPod || (app instanceof KubernetesApplication && app.ApplicationType === KubernetesApplicationTypes.Pod)) { + } else if (app instanceof KubernetesPod || KubernetesApplicationTypes.Pod) { apiService = this.KubernetesPodService; } else { throw new PortainerError('Unable to determine which association to use to retrieve API Service'); diff --git a/app/kubernetes/services/namespaceService.js b/app/kubernetes/services/namespaceService.js index c888b5b88..4ae9cc2e2 100644 --- a/app/kubernetes/services/namespaceService.js +++ b/app/kubernetes/services/namespaceService.js @@ -2,17 +2,17 @@ import angular from 'angular'; import PortainerError from 'Portainer/error'; import { KubernetesCommonParams } from 'Kubernetes/models/common/params'; import KubernetesNamespaceConverter from 'Kubernetes/converters/namespace'; +import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; import { updateNamespaces } from 'Kubernetes/store/namespace'; -import $allSettled from 'Portainer/services/allSettled'; -import { getSelfSubjectAccessReview } from '@/react/kubernetes/namespaces/getSelfSubjectAccessReview'; class KubernetesNamespaceService { /* @ngInject */ - constructor($async, KubernetesNamespaces, LocalStorage, $state) { + constructor($async, KubernetesNamespaces, Authentication, LocalStorage, $state) { this.$async = $async; this.$state = $state; this.KubernetesNamespaces = KubernetesNamespaces; this.LocalStorage = LocalStorage; + this.Authentication = Authentication; this.getAsync = this.getAsync.bind(this); this.getAllAsync = this.getAllAsync.bind(this); @@ -68,17 +68,13 @@ class KubernetesNamespaceService { try { // get the list of all namespaces (RBAC allows users to see the list of namespaces) const data = await this.KubernetesNamespaces().get().$promise; - // get the status of each namespace with accessReviews (to avoid failed forbidden responses, which aren't cached) - const accessReviews = await Promise.all(data.items.map((namespace) => getSelfSubjectAccessReview(this.$state.params.endpointId, namespace.metadata.name))); - const allowedNamespaceNames = accessReviews.filter((ar) => ar.status.allowed).map((ar) => ar.spec.resourceAttributes.namespace); - const promises = allowedNamespaceNames.map((name) => this.KubernetesNamespaces().status({ id: name }).$promise); - const namespaces = await $allSettled(promises); - // only return namespaces if the user has access to namespaces - const allNamespaces = namespaces.fulfilled.map((item) => { - return KubernetesNamespaceConverter.apiToNamespace(item); - }); - updateNamespaces(allNamespaces); - return allNamespaces; + // get the list of all namespaces with isAccessAllowed flags + const hasK8sAccessSystemNamespaces = this.Authentication.hasAuthorizations(['K8sAccessSystemNamespaces']); + const namespaces = data.items.filter((item) => !KubernetesNamespaceHelper.isSystemNamespace(item.metadata.name) || hasK8sAccessSystemNamespaces); + // parse the namespaces + const visibleNamespaces = namespaces.map((item) => KubernetesNamespaceConverter.apiToNamespace(item)); + updateNamespaces(visibleNamespaces); + return visibleNamespaces; } catch (err) { throw new PortainerError('Unable to retrieve namespaces', err); } diff --git a/app/kubernetes/views/applications/applicationsController.js b/app/kubernetes/views/applications/applicationsController.js index ed2c5ca22..c48836e0e 100644 --- a/app/kubernetes/views/applications/applicationsController.js +++ b/app/kubernetes/views/applications/applicationsController.js @@ -1,12 +1,11 @@ import angular from 'angular'; import _ from 'lodash-es'; -import KubernetesStackHelper from 'Kubernetes/helpers/stackHelper'; -import KubernetesApplicationHelper from 'Kubernetes/helpers/application'; -import KubernetesConfigurationHelper from 'Kubernetes/helpers/configurationHelper'; import { KubernetesApplicationTypes } from 'Kubernetes/models/application/models/appConstants'; import { KubernetesPortainerApplicationStackNameLabel } from 'Kubernetes/models/application/models'; import { getDeploymentOptions } from '@/react/portainer/environments/environment.service'; - +import { getStacksFromApplications } from '@/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/getStacksFromApplications'; +import { getApplications } from '@/react/kubernetes/applications/application.queries.ts'; +import { getNamespaces } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery'; class KubernetesApplicationsController { /* @ngInject */ constructor( @@ -16,6 +15,7 @@ class KubernetesApplicationsController { Authentication, Notifications, KubernetesApplicationService, + EndpointService, HelmService, KubernetesConfigurationService, LocalStorage, @@ -36,8 +36,6 @@ class KubernetesApplicationsController { this.KubernetesNamespaceService = KubernetesNamespaceService; this.onInit = this.onInit.bind(this); - this.getApplications = this.getApplications.bind(this); - this.getApplicationsAsync = this.getApplicationsAsync.bind(this); this.removeAction = this.removeAction.bind(this); this.removeActionAsync = this.removeActionAsync.bind(this); this.removeStacksAction = this.removeStacksAction.bind(this); @@ -88,23 +86,18 @@ class KubernetesApplicationsController { if (application.ApplicationType === KubernetesApplicationTypes.Helm) { await this.HelmService.uninstall(this.endpoint.Id, application); } else { - await this.KubernetesApplicationService.delete(application); - if (application.Metadata.labels && application.Metadata.labels[KubernetesPortainerApplicationStackNameLabel]) { - // Update applications in stack - const stack = this.state.stacks.find((x) => x.Name === application.StackName); - const index = stack.Applications.indexOf(application); - stack.Applications.splice(index, 1); - // remove stack if no app left in the stack + const appsInNamespace = await getApplications(this.endpoint.Id, { namespace: application.ResourcePool, withDependencies: false }); + const stacksInNamespace = getStacksFromApplications(appsInNamespace); + const stack = stacksInNamespace.find((x) => x.Name === application.StackName); if (stack.Applications.length === 0 && application.StackId) { await this.StackService.remove({ Id: application.StackId }, false, this.endpoint.Id); } } + await this.KubernetesApplicationService.delete(application); } this.Notifications.success('Application successfully removed', application.Name); - const index = this.state.applications.indexOf(application); - this.state.applications.splice(index, 1); } catch (err) { this.Notifications.error('Failure', err, 'Unable to remove application'); } finally { @@ -137,42 +130,15 @@ class KubernetesApplicationsController { this.state.namespaceName = namespaceName; // save the selected namespaceName in local storage with the key 'kubernetes_namespace_filter_${environmentId}_${userID}' this.LocalStorage.storeNamespaceFilter(this.endpoint.Id, this.user.ID, namespaceName); - return this.getApplicationsAsync(); }); } - async getApplicationsAsync() { - try { - this.state.isAppsLoading = true; - const [applications, configurations] = await Promise.all([ - this.KubernetesApplicationService.get(this.state.namespaceName), - this.KubernetesConfigurationService.get(this.state.namespaceName), - ]); - const configuredApplications = KubernetesConfigurationHelper.getApplicationConfigurations(applications, configurations); - const { helmApplications, nonHelmApplications } = KubernetesApplicationHelper.getNestedApplications(configuredApplications); - - this.state.applications = [...helmApplications, ...nonHelmApplications]; - this.state.stacks = KubernetesStackHelper.stacksFromApplications(applications); - this.state.ports = KubernetesApplicationHelper.portMappingsFromApplications(applications); - - this.$scope.$apply(); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to retrieve applications'); - } finally { - this.state.isAppsLoading = false; - } - } - setSystemResources(flag) { return this.$scope.$applyAsync(() => { this.state.isSystemResources = flag; }); } - getApplications() { - return this.$async(this.getApplicationsAsync); - } - async onInit() { this.state = { activeTab: this.LocalStorage.getActiveTab('applications'), @@ -190,12 +156,12 @@ class KubernetesApplicationsController { this.deploymentOptions = await getDeploymentOptions(); this.user = this.Authentication.getUserDetails(); - this.state.namespaces = await this.KubernetesNamespaceService.get(); + this.state.namespaces = await getNamespaces(this.endpoint.Id); const savedNamespace = this.LocalStorage.getNamespaceFilter(this.endpoint.Id, this.user.ID); // could be null if not found, and '' if all namepsaces is selected const preferredNamespace = savedNamespace === null ? 'default' : savedNamespace; - this.state.namespaces = this.state.namespaces.filter((n) => n.Status === 'Active'); + this.state.namespaces = this.state.namespaces.filter((n) => n.Status.phase === 'Active'); this.state.namespaces = _.sortBy(this.state.namespaces, 'Name'); // set all namespaces ('') if there are no namespaces, or if all namespaces is selected if (!this.state.namespaces.length || preferredNamespace === '') { @@ -205,8 +171,6 @@ class KubernetesApplicationsController { this.state.namespaceName = this.state.namespaces.find((n) => n.Name === preferredNamespace) ? preferredNamespace : this.state.namespaces[0].Name; } - await this.getApplications(); - this.state.viewReady = true; } diff --git a/app/kubernetes/views/applications/create/createApplicationController.js b/app/kubernetes/views/applications/create/createApplicationController.js index 5103aad29..5d5994dc5 100644 --- a/app/kubernetes/views/applications/create/createApplicationController.js +++ b/app/kubernetes/views/applications/create/createApplicationController.js @@ -918,7 +918,7 @@ class KubernetesCreateApplicationController { async checkIngressesToUpdate() { let ingressesToUpdate = []; let servicePortsToUpdate = []; - const fullIngresses = await getIngresses(this.endpoint.Id, this.formValues.ResourcePool.Namespace.Name); + const fullIngresses = await getIngresses(this.endpoint.Id); this.formValues.Services.forEach((updatedService) => { const oldServiceIndex = this.oldFormValues.Services.findIndex((oldService) => oldService.Name === updatedService.Name); const numberOfPortsInOldService = this.oldFormValues.Services[oldServiceIndex] && this.oldFormValues.Services[oldServiceIndex].Ports.length; diff --git a/app/kubernetes/views/applications/stats/statsController.js b/app/kubernetes/views/applications/stats/statsController.js index 5217460aa..1284c0e8a 100644 --- a/app/kubernetes/views/applications/stats/statsController.js +++ b/app/kubernetes/views/applications/stats/statsController.js @@ -4,7 +4,7 @@ import _ from 'lodash-es'; import filesizeParser from 'filesize-parser'; import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper'; import KubernetesPodConverter from 'Kubernetes/pod/converter'; -import { getMetricsForPod } from '@/react/kubernetes/services/service.ts'; +import { getMetricsForPod } from '@/react/kubernetes/metrics/metrics.ts'; class KubernetesApplicationStatsController { /* @ngInject */ diff --git a/app/kubernetes/views/cluster/clusterController.js b/app/kubernetes/views/cluster/clusterController.js index 4da41a192..b8f6df290 100644 --- a/app/kubernetes/views/cluster/clusterController.js +++ b/app/kubernetes/views/cluster/clusterController.js @@ -3,7 +3,7 @@ import _ from 'lodash-es'; import filesizeParser from 'filesize-parser'; import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper'; import { KubernetesResourceReservation } from 'Kubernetes/models/resource-reservation/models'; -import { getMetricsForAllNodes } from '@/react/kubernetes/services/service.ts'; +import { getMetricsForAllNodes, getTotalResourcesForAllApplications } from '@/react/kubernetes/metrics/metrics.ts'; class KubernetesClusterController { /* @ngInject */ @@ -68,20 +68,11 @@ class KubernetesClusterController { async getApplicationsAsync() { try { this.state.applicationsLoading = true; - this.applications = await this.KubernetesApplicationService.get(); - const nodeNames = _.map(this.nodes, (node) => node.Name); - this.resourceReservation = _.reduce( - this.applications, - (acc, app) => { - app.Pods = _.filter(app.Pods, (pod) => nodeNames.includes(pod.Node)); - const resourceReservation = KubernetesResourceReservationHelper.computeResourceReservation(app.Pods); - acc.CPU += resourceReservation.CPU; - acc.Memory += resourceReservation.Memory; - return acc; - }, - new KubernetesResourceReservation() - ); - this.resourceReservation.Memory = KubernetesResourceReservationHelper.megaBytesValue(this.resourceReservation.Memory); + + const applicationsResources = await getTotalResourcesForAllApplications(this.endpoint.Id); + this.resourceReservation = new KubernetesResourceReservation(); + this.resourceReservation.CPU = Math.round(applicationsResources.CpuRequest / 1000); + this.resourceReservation.Memory = KubernetesResourceReservationHelper.megaBytesValue(applicationsResources.MemoryRequest); if (this.hasResourceUsageAccess()) { await this.getResourceUsage(this.endpoint.Id); @@ -133,8 +124,7 @@ class KubernetesClusterController { await this.getNodes(); if (this.isAdmin) { - await this.getEndpoints(); - await this.getApplications(); + await Promise.allSettled([this.getEndpoints(), this.getApplicationsAsync()]); } this.state.viewReady = true; diff --git a/app/kubernetes/views/cluster/node/node.html b/app/kubernetes/views/cluster/node/node.html index 6610aa8ae..b2cadabac 100644 --- a/app/kubernetes/views/cluster/node/node.html +++ b/app/kubernetes/views/cluster/node/node.html @@ -76,12 +76,12 @@
@@ -267,11 +267,5 @@
- - + diff --git a/app/kubernetes/views/cluster/node/nodeController.js b/app/kubernetes/views/cluster/node/nodeController.js index db392d282..7c02fc3ca 100644 --- a/app/kubernetes/views/cluster/node/nodeController.js +++ b/app/kubernetes/views/cluster/node/nodeController.js @@ -9,7 +9,7 @@ import { KubernetesNodeTaintEffects, KubernetesNodeAvailabilities } from 'Kubern import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper'; import { KubernetesNodeHelper } from 'Kubernetes/node/helper'; import { confirmUpdateNode } from '@/react/kubernetes/cluster/NodeView/ConfirmUpdateNode'; -import { getMetricsForNode } from '@/react/kubernetes/services/service.ts'; +import { getMetricsForNode, getTotalResourcesForAllApplications } from '@/react/kubernetes/metrics/metrics.ts'; class KubernetesNodeController { /* @ngInject */ @@ -40,7 +40,6 @@ class KubernetesNodeController { this.getNodesAsync = this.getNodesAsync.bind(this); this.getEvents = this.getEvents.bind(this); this.getEventsAsync = this.getEventsAsync.bind(this); - this.getApplicationsAsync = this.getApplicationsAsync.bind(this); this.getEndpointsAsync = this.getEndpointsAsync.bind(this); this.updateNodeAsync = this.updateNodeAsync.bind(this); this.drainNodeAsync = this.drainNodeAsync.bind(this); @@ -300,6 +299,8 @@ class KubernetesNodeController { try { const nodeName = this.$transition$.params().nodeName; const node = await getMetricsForNode(this.$state.params.endpointId, nodeName); + node.CPU = node.usage.cpu; + node.Memory = KubernetesResourceReservationHelper.megaBytesValue(node.usage.memory); this.resourceUsage = new KubernetesResourceReservation(); this.resourceUsage.CPU = KubernetesResourceReservationHelper.parseCPU(node.usage.cpu); this.resourceUsage.Memory = KubernetesResourceReservationHelper.megaBytesValue(node.usage.memory); @@ -338,43 +339,6 @@ class KubernetesNodeController { this.selectTab(2); } - async getApplicationsAsync() { - try { - this.state.applicationsLoading = true; - this.applications = await this.KubernetesApplicationService.get(); - - this.resourceReservation = new KubernetesResourceReservation(); - this.applications = _.map(this.applications, (app) => { - app.Pods = _.filter(app.Pods, (pod) => pod.Node === this.node.Name); - return app; - }); - this.applications = _.filter(this.applications, (app) => app.Pods.length !== 0); - this.applications = _.map(this.applications, (app) => { - const resourceReservation = KubernetesResourceReservationHelper.computeResourceReservation(app.Pods); - app.CPU = resourceReservation.CPU; - app.Memory = resourceReservation.Memory; - this.resourceReservation.CPU += resourceReservation.CPU; - this.resourceReservation.Memory += resourceReservation.Memory; - return app; - }); - this.resourceReservation.Memory = KubernetesResourceReservationHelper.megaBytesValue(this.resourceReservation.Memory); - this.memoryLimit = KubernetesResourceReservationHelper.megaBytesValue(this.node.Memory); - this.state.isContainPortainer = _.find(this.applications, { ApplicationName: 'portainer' }); - - if (this.hasResourceUsageAccess()) { - await this.getNodeUsage(); - } - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to retrieve applications'); - } finally { - this.state.applicationsLoading = false; - } - } - - getApplications() { - return this.$async(this.getApplicationsAsync); - } - async onInit() { this.availabilities = KubernetesNodeAvailabilities; @@ -399,7 +363,6 @@ class KubernetesNodeController { await this.getNodes(); await this.getEvents(); - await this.getApplications(); await this.getEndpoints(); this.availableEffects = _.values(KubernetesNodeTaintEffects); @@ -407,6 +370,11 @@ class KubernetesNodeController { this.formValues.Labels = KubernetesNodeHelper.computeUsedLabels(this.applications, this.formValues.Labels); this.formValues.Labels = KubernetesNodeHelper.reorderLabels(this.formValues.Labels); + this.resourceReservation = await getTotalResourcesForAllApplications(this.$state.params.endpointId, this.node.Name); + this.resourceReservation.CpuRequest = Math.round(this.resourceReservation.CpuRequest / 1000); + this.resourceReservation.MemoryRequest = KubernetesResourceReservationHelper.megaBytesValue(this.resourceReservation.MemoryRequest); + this.node.Memory = KubernetesResourceReservationHelper.megaBytesValue(this.node.Memory); + this.state.viewReady = true; } diff --git a/app/kubernetes/views/cluster/node/stats/statsController.js b/app/kubernetes/views/cluster/node/stats/statsController.js index 9c6184560..a7d10a1f0 100644 --- a/app/kubernetes/views/cluster/node/stats/statsController.js +++ b/app/kubernetes/views/cluster/node/stats/statsController.js @@ -3,7 +3,7 @@ import moment from 'moment'; import filesizeParser from 'filesize-parser'; import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper'; import { PORTAINER_FADEOUT } from '@/constants'; -import { getMetricsForNode } from '@/react/kubernetes/services/service.ts'; +import { getMetricsForNode } from '@/react/kubernetes/metrics/metrics.ts'; class KubernetesNodeStatsController { /* @ngInject */ diff --git a/app/kubernetes/views/configurations/configmap/create/createConfigMapController.js b/app/kubernetes/views/configurations/configmap/create/createConfigMapController.js index 9b3dbf4a0..7d8d0ccb4 100644 --- a/app/kubernetes/views/configurations/configmap/create/createConfigMapController.js +++ b/app/kubernetes/views/configurations/configmap/create/createConfigMapController.js @@ -139,8 +139,6 @@ class KubernetesCreateConfigMapController { return; } - await this.getConfigurations(); - this.environmentId = this.EndpointProvider.endpointID(); this.availableServiceAccounts = await getServiceAccounts(this.environmentId, this.resourcePools[0].Namespace.Name); this.formValues.ServiceAccountName = this.availableServiceAccounts.length > 0 ? this.availableServiceAccounts[0].metadata.name : ''; diff --git a/app/kubernetes/views/configurations/configmap/edit/configMapController.js b/app/kubernetes/views/configurations/configmap/edit/configMapController.js index fcb558955..d32db7dc2 100644 --- a/app/kubernetes/views/configurations/configmap/edit/configMapController.js +++ b/app/kubernetes/views/configurations/configmap/edit/configMapController.js @@ -7,7 +7,6 @@ import KubernetesConfigurationHelper from 'Kubernetes/helpers/configurationHelpe import KubernetesConfigurationConverter from 'Kubernetes/converters/configuration'; import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper'; import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; - import { pluralize } from '@/portainer/helpers/strings'; import { confirmUpdate, confirmWebEditorDiscard } from '@@/modals/confirm'; @@ -91,18 +90,14 @@ class KubernetesConfigMapController { async updateConfigurationAsync() { try { this.state.actionInProgress = true; - if ( - this.formValues.Kind !== this.configuration.Kind || - this.formValues.ResourcePool.Namespace.Name !== this.configuration.Namespace || - this.formValues.Name !== this.configuration.Name - ) { + if (this.formValues.Kind !== this.configuration.Kind || this.formValues.ResourcePool !== this.configuration.Namespace || this.formValues.Name !== this.configuration.Name) { await this.KubernetesConfigurationService.create(this.formValues); await this.KubernetesConfigurationService.delete(this.configuration); this.Notifications.success('Success', `ConfigMap successfully updated`); this.$state.go( 'kubernetes.configurations.configmap', { - namespace: this.formValues.ResourcePool.Namespace.Name, + namespace: this.formValues.ResourcePool, name: this.formValues.Name, }, { reload: true } @@ -142,6 +137,7 @@ class KubernetesConfigMapController { this.state.configurationLoading = true; const name = this.$transition$.params().name; const namespace = this.$transition$.params().namespace; + try { const configMap = await this.KubernetesConfigMapService.get(namespace, name); this.configuration = KubernetesConfigurationConverter.configMapToConfiguration(configMap); @@ -153,7 +149,7 @@ class KubernetesConfigMapController { } } - this.formValues.ResourcePool = _.find(this.resourcePools, (resourcePool) => resourcePool.Namespace.Name === this.configuration.Namespace); + this.formValues.ResourcePool = this.configuration.Namespace; this.formValues.Id = this.configuration.Id; this.formValues.Name = this.configuration.Name; this.formValues.Type = this.configuration.Type; @@ -266,13 +262,10 @@ class KubernetesConfigMapController { this.formValues = new KubernetesConfigurationFormValues(); - this.resourcePools = await this.KubernetesResourcePoolService.get(); - const configuration = await this.getConfiguration(); if (configuration) { await this.getApplications(this.configuration.Namespace); await this.getEvents(this.configuration.Namespace); - await this.getConfigurations(); } this.tagUsedDataKeys(); diff --git a/app/kubernetes/views/configurations/secret/create/createSecretController.js b/app/kubernetes/views/configurations/secret/create/createSecretController.js index 30a128e41..32edf3a53 100644 --- a/app/kubernetes/views/configurations/secret/create/createSecretController.js +++ b/app/kubernetes/views/configurations/secret/create/createSecretController.js @@ -45,7 +45,7 @@ class KubernetesCreateSecretController { async onResourcePoolSelectionChangeAsync() { try { this.onChangeName(); - this.availableServiceAccounts = await getServiceAccounts(this.environmentId, this.formValues.ResourcePool.Namespace.Name); + this.availableServiceAccounts = await getServiceAccounts(this.environmentId, this.formValues.ResourcePool.Name); this.formValues.ServiceAccountName = this.availableServiceAccounts.length > 0 ? this.availableServiceAccounts[0].metadata.name : ''; } catch (err) { this.Notifications.error('Failure', err, 'Unable to load service accounts'); @@ -186,8 +186,6 @@ class KubernetesCreateSecretController { ); this.formValues.ResourcePool = this.resourcePools[0]; - await this.getConfigurations(); - this.environmentId = this.EndpointProvider.endpointID(); this.availableServiceAccounts = await getServiceAccounts(this.environmentId, this.resourcePools[0].Namespace.Name); this.formValues.ServiceAccountName = this.availableServiceAccounts.length > 0 ? this.availableServiceAccounts[0].metadata.name : ''; diff --git a/app/kubernetes/views/configurations/secret/edit/secretController.js b/app/kubernetes/views/configurations/secret/edit/secretController.js index a71fb70de..41a0bda96 100644 --- a/app/kubernetes/views/configurations/secret/edit/secretController.js +++ b/app/kubernetes/views/configurations/secret/edit/secretController.js @@ -88,18 +88,14 @@ class KubernetesSecretController { async updateConfigurationAsync() { try { this.state.actionInProgress = true; - if ( - this.formValues.Kind !== this.configuration.Kind || - this.formValues.ResourcePool.Namespace.Name !== this.configuration.Namespace || - this.formValues.Name !== this.configuration.Name - ) { + if (this.formValues.Kind !== this.configuration.Kind || this.formValues.ResourcePool !== this.configuration.Namespace || this.formValues.Name !== this.configuration.Name) { await this.KubernetesConfigurationService.create(this.formValues); await this.KubernetesConfigurationService.delete(this.configuration); this.Notifications.success('Success', `Secret successfully updated`); this.$state.go( 'kubernetes.secrets.secret', { - namespace: this.formValues.ResourcePool.Namespace.Name, + namespace: this.formValues.ResourcePool, name: this.formValues.Name, }, { reload: true } @@ -149,7 +145,7 @@ class KubernetesSecretController { throw new Error('Not authorized to edit secret'); } } - this.formValues.ResourcePool = _.find(this.resourcePools, (resourcePool) => resourcePool.Namespace.Name === this.configuration.Namespace); + this.formValues.ResourcePool = this.configuration.Namespace; this.formValues.Id = this.configuration.Id; this.formValues.Name = this.configuration.Name; this.formValues.Type = this.configuration.Type; @@ -252,7 +248,6 @@ class KubernetesSecretController { this.formValues = new KubernetesConfigurationFormValues(); - this.resourcePools = await this.KubernetesResourcePoolService.get(); const configuration = await this.getConfiguration(); if (configuration) { await this.getApplications(this.configuration.Namespace); diff --git a/app/kubernetes/views/resource-pools/edit/resourcePoolController.js b/app/kubernetes/views/resource-pools/edit/resourcePoolController.js index e153c60a7..8946d2dda 100644 --- a/app/kubernetes/views/resource-pools/edit/resourcePoolController.js +++ b/app/kubernetes/views/resource-pools/edit/resourcePoolController.js @@ -15,7 +15,7 @@ import { FeatureId } from '@/react/portainer/feature-flags/enums'; import { updateIngressControllerClassMap, getIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/useIngressControllerClassMap'; import { confirmUpdate } from '@@/modals/confirm'; import { confirmUpdateNamespace } from '@/react/kubernetes/namespaces/ItemView/ConfirmUpdateNamespace'; -import { getMetricsForAllPods } from '@/react/kubernetes/services/service.ts'; +import { getMetricsForAllPods } from '@/react/kubernetes/metrics/metrics.ts'; class KubernetesResourcePoolController { /* #region CONSTRUCTOR */ diff --git a/app/kubernetes/views/volumes/volumes.html b/app/kubernetes/views/volumes/volumes.html deleted file mode 100644 index 9fd0f27a3..000000000 --- a/app/kubernetes/views/volumes/volumes.html +++ /dev/null @@ -1,32 +0,0 @@ - - - - -
-
-
- - - - - - - Volumes - - - - - - - - Storage - - - - - - - -
-
-
diff --git a/app/kubernetes/views/volumes/volumes.js b/app/kubernetes/views/volumes/volumes.js deleted file mode 100644 index 72060c39a..000000000 --- a/app/kubernetes/views/volumes/volumes.js +++ /dev/null @@ -1,9 +0,0 @@ -angular.module('portainer.kubernetes').component('kubernetesVolumesView', { - templateUrl: './volumes.html', - controller: 'KubernetesVolumesController', - controllerAs: 'ctrl', - bindings: { - $transition$: '<', - endpoint: '<', - }, -}); diff --git a/app/kubernetes/views/volumes/volumesController.js b/app/kubernetes/views/volumes/volumesController.js deleted file mode 100644 index a93e757a2..000000000 --- a/app/kubernetes/views/volumes/volumesController.js +++ /dev/null @@ -1,113 +0,0 @@ -import _ from 'lodash-es'; -import filesizeParser from 'filesize-parser'; -import angular from 'angular'; -import KubernetesVolumeHelper from 'Kubernetes/helpers/volumeHelper'; -import KubernetesResourceQuotaHelper from 'Kubernetes/helpers/resourceQuotaHelper'; - -function buildStorages(storages, volumes) { - _.forEach(storages, (s) => { - const filteredVolumes = _.filter(volumes, ['PersistentVolumeClaim.storageClass.Name', s.Name, 'PersistentVolumeClaim.storageClass.Provisioner', s.Provisioner]); - s.Volumes = filteredVolumes; - s.size = computeSize(filteredVolumes); - }); - return storages; -} - -function computeSize(volumes) { - const size = _.sumBy(volumes, (v) => filesizeParser(v.PersistentVolumeClaim.Storage, { base: 10 })); - const format = KubernetesResourceQuotaHelper.formatBytes(size); - return `${format.size}${format.sizeUnit}`; -} - -class KubernetesVolumesController { - /* @ngInject */ - constructor($async, $state, Notifications, Authentication, LocalStorage, KubernetesStorageService, KubernetesVolumeService, KubernetesApplicationService) { - this.$async = $async; - this.$state = $state; - this.Notifications = Notifications; - this.Authentication = Authentication; - this.LocalStorage = LocalStorage; - this.KubernetesStorageService = KubernetesStorageService; - this.KubernetesVolumeService = KubernetesVolumeService; - this.KubernetesApplicationService = KubernetesApplicationService; - - this.onInit = this.onInit.bind(this); - this.getVolumes = this.getVolumes.bind(this); - this.getVolumesAsync = this.getVolumesAsync.bind(this); - this.removeAction = this.removeAction.bind(this); - } - - selectTab(index) { - this.LocalStorage.storeActiveTab('volumes', index); - } - - async removeAction(selectedItems) { - return this.$async(async () => { - let actionCount = selectedItems.length; - for (const volume of selectedItems) { - try { - await this.KubernetesVolumeService.delete(volume); - this.Notifications.success('Volume successfully removed', volume.PersistentVolumeClaim.Name); - const index = this.volumes.indexOf(volume); - this.volumes.splice(index, 1); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to remove volume'); - } finally { - --actionCount; - if (actionCount === 0) { - this.$state.reload(this.$state.current); - } - } - } - }); - } - - async getVolumesAsync() { - const storageClasses = this.endpoint.Kubernetes.Configuration.StorageClasses; - try { - const [volumes, applications, storages] = await Promise.all([ - this.KubernetesVolumeService.get(undefined, storageClasses), - this.KubernetesApplicationService.get(), - this.KubernetesStorageService.get(this.endpoint.Id), - ]); - - this.volumes = _.map(volumes, (volume) => { - volume.Applications = KubernetesVolumeHelper.getUsingApplications(volume, applications); - return volume; - }); - this.storages = buildStorages(storages, volumes); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to retreive namespaces'); - } - } - - getVolumes() { - return this.$async(this.getVolumesAsync); - } - - async onInit() { - this.state = { - viewReady: false, - currentName: this.$state.$current.name, - activeTab: this.LocalStorage.getActiveTab('volumes'), - isAdmin: this.Authentication.isAdmin(), - }; - - await this.getVolumes(); - - this.state.viewReady = true; - } - - $onInit() { - return this.$async(this.onInit); - } - - $onDestroy() { - if (this.state.currentName !== this.$state.$current.name) { - this.LocalStorage.storeActiveTab('volumes', 0); - } - } -} - -export default KubernetesVolumesController; -angular.module('portainer.kubernetes').controller('KubernetesVolumesController', KubernetesVolumesController); diff --git a/app/portainer/helpers/promise-utils.ts b/app/portainer/helpers/promise-utils.ts index 5fa7f672e..98c7731fe 100644 --- a/app/portainer/helpers/promise-utils.ts +++ b/app/portainer/helpers/promise-utils.ts @@ -10,6 +10,46 @@ export function promiseSequence(promises: (() => Promise)[]) { ); } +type AllSettledItems = { + fulfilledItems: T[]; + rejectedItems: { item: T; reason?: string }[]; +}; + +/** + * Separates a list of items into successful and rejected items based on the results of asynchronous operations. + * This function is useful for deleting in parallel, or other requests where the response doesn't have much information. + * + * @template T - The type of the items in the list. + * @param {T[]} items - The list of items to process. + * @param {(item: T) => Promise} asyncFn - An asynchronous function that takes the item and performs an operation on it. + * @returns {Promise>} - A promise that resolves to an object containing arrays of successful and rejected items. + */ +export async function getAllSettledItems( + items: T[], + asyncFn: (item: T) => Promise +): Promise> { + const results = await Promise.allSettled(items.map((item) => asyncFn(item))); + + // make an array of successful items and an array of rejected items + const separatedItems = results.reduce>( + (acc, result, index) => { + if (result.status === 'fulfilled') { + acc.fulfilledItems.push(items[index]); + } else { + const reason = + result.reason instanceof Error + ? result.reason.message + : String(result.reason); + acc.rejectedItems.push({ item: items[index], reason }); + } + return acc; + }, + { fulfilledItems: [], rejectedItems: [] } + ); + + return separatedItems; +} + export function isFulfilled( result: PromiseSettledResult ): result is PromiseFulfilledResult { diff --git a/app/react/components/Badge/UnusedBadge.tsx b/app/react/components/Badge/UnusedBadge.tsx index 9479d5014..743f8e83b 100644 --- a/app/react/components/Badge/UnusedBadge.tsx +++ b/app/react/components/Badge/UnusedBadge.tsx @@ -1,5 +1,5 @@ import { Badge } from '@@/Badge'; export function UnusedBadge() { - return unused; + return Unused; } diff --git a/app/react/components/buttons/DeleteButton.tsx b/app/react/components/buttons/DeleteButton.tsx index 7b5ff652d..3b9edd839 100644 --- a/app/react/components/buttons/DeleteButton.tsx +++ b/app/react/components/buttons/DeleteButton.tsx @@ -6,6 +6,7 @@ import { AutomationTestingProps } from '@/types'; import { confirmDelete } from '@@/modals/confirm'; import { Button } from './Button'; +import { LoadingButton } from './LoadingButton'; type ConfirmOrClick = | { @@ -24,6 +25,8 @@ export function DeleteButton({ disabled, size, children, + isLoading, + loadingText = 'Removing', 'data-cy': dataCy, ...props }: PropsWithChildren< @@ -31,10 +34,28 @@ export function DeleteButton({ ConfirmOrClick & { size?: ComponentProps['size']; disabled?: boolean; + isLoading?: boolean; + loadingText?: string; } >) { + if (isLoading === undefined) { + return ( + + ); + } + return ( - + ); async function handleClick() { diff --git a/app/react/components/datatables/Filter.tsx b/app/react/components/datatables/Filter.tsx index cb0b3f90b..126a151a7 100644 --- a/app/react/components/datatables/Filter.tsx +++ b/app/react/components/datatables/Filter.tsx @@ -1,7 +1,7 @@ import clsx from 'clsx'; import { useMemo } from 'react'; import { Menu, MenuButton, MenuPopover } from '@reach/menu-button'; -import { Column } from '@tanstack/react-table'; +import { Column, Row } from '@tanstack/react-table'; import { Check, Filter } from 'lucide-react'; import { getValueAsArrayOfStrings } from '@/portainer/helpers/array'; @@ -73,7 +73,15 @@ export function MultipleSelectionFilter({ } } -export function filterHOC(menuTitle: string) { +export type FilterOptionsTransformer = ( + rows: Row[], + id: string +) => string[]; + +export function filterHOC( + menuTitle: string, + filterOptionsTransformer: FilterOptionsTransformer = defaultFilterOptionsTransformer +) { return function Filter({ column: { getFilterValue, setFilterValue, getFacetedRowModel, id }, }: { @@ -81,15 +89,10 @@ export function filterHOC(menuTitle: string) { }) { const { flatRows } = getFacetedRowModel(); - const options = useMemo(() => { - const options = new Set(); - flatRows.forEach(({ getValue }) => { - const value = getValue(id); - - options.add(value); - }); - return Array.from(options); - }, [flatRows, id]); + const options = useMemo( + () => filterOptionsTransformer(flatRows, id), + [flatRows, id] + ); const value = getFilterValue(); @@ -106,3 +109,15 @@ export function filterHOC(menuTitle: string) { ); }; } + +function defaultFilterOptionsTransformer( + rows: Row[], + id: string +) { + const options = new Set(); + rows.forEach(({ getValue }) => { + const value = getValue(id); + options.add(value); + }); + return Array.from(options); +} diff --git a/app/react/hooks/useIsDeploymentOptionHidden.ts b/app/react/hooks/useIsDeploymentOptionHidden.ts new file mode 100644 index 000000000..a8a50fe9e --- /dev/null +++ b/app/react/hooks/useIsDeploymentOptionHidden.ts @@ -0,0 +1,25 @@ +import { useEnvironmentDeploymentOptions } from '@/react/portainer/environments/queries/useEnvironment'; + +import { useEnvironmentId } from './useEnvironmentId'; + +type HideDeploymentOption = 'form' | 'webEditor' | 'fileUpload'; + +export function useIsDeploymentOptionHidden( + hideDeploymentOption: HideDeploymentOption +) { + const environmentId = useEnvironmentId(); + const { data: deploymentOptions } = + useEnvironmentDeploymentOptions(environmentId); + + if (deploymentOptions) { + const isDeploymentOptionHidden = + (hideDeploymentOption === 'form' && deploymentOptions.hideAddWithForm) || + (hideDeploymentOption === 'webEditor' && + deploymentOptions.hideAddWithForm) || + (hideDeploymentOption === 'fileUpload' && + deploymentOptions.hideAddWithForm); + return isDeploymentOptionHidden; + } + + return false; +} diff --git a/app/react/kubernetes/applications/CreateView/application-services/KubeServicesForm.tsx b/app/react/kubernetes/applications/CreateView/application-services/KubeServicesForm.tsx index 5d1425f93..ca27aaf04 100644 --- a/app/react/kubernetes/applications/CreateView/application-services/KubeServicesForm.tsx +++ b/app/react/kubernetes/applications/CreateView/application-services/KubeServicesForm.tsx @@ -42,7 +42,7 @@ export function KubeServicesForm({ // start loading ingresses and controllers early to reduce perceived loading time const environmentId = useEnvironmentId(); - useIngresses(environmentId, namespace ? [namespace] : []); + useIngresses(environmentId, { withServices: true }); useIngressControllers(environmentId, namespace); // when the appName changes, update the names for each service diff --git a/app/react/kubernetes/applications/CreateView/application-services/ingress/AppIngressPathsForm.tsx b/app/react/kubernetes/applications/CreateView/application-services/ingress/AppIngressPathsForm.tsx index 3a0eb4f24..7d632fe64 100644 --- a/app/react/kubernetes/applications/CreateView/application-services/ingress/AppIngressPathsForm.tsx +++ b/app/react/kubernetes/applications/CreateView/application-services/ingress/AppIngressPathsForm.tsx @@ -39,10 +39,7 @@ export function AppIngressPathsForm({ isEditMode, }: Props) { const environmentId = useEnvironmentId(); - const ingressesQuery = useIngresses( - environmentId, - namespace ? [namespace] : undefined - ); + const ingressesQuery = useIngresses(environmentId); const { data: ingresses } = ingressesQuery; const { data: ingressControllers, ...ingressControllersQuery } = useIngressControllers(environmentId, namespace); diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationIngressesTable.tsx b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationIngressesTable.tsx index 9bbd2808e..92099f14e 100644 --- a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationIngressesTable.tsx +++ b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationIngressesTable.tsx @@ -19,7 +19,9 @@ export function ApplicationIngressesTable({ namespace, appServices, }: Props) { - const namespaceIngresses = useIngresses(environmentId, [namespace]); + const namespaceIngresses = useIngresses(environmentId, { + withServices: true, + }); // getIngressPathsForAppServices could be expensive, so memoize it const ingressPathsForAppServices = useMemo( () => getIngressPathsForAppServices(namespaceIngresses.data, appServices), diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx index 5ca79a6cf..bedcadc38 100644 --- a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx +++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx @@ -9,6 +9,7 @@ import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNam import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment'; import { useAuthorizations } from '@/react/hooks/useUser'; +import { isSystemNamespace } from '@/react/kubernetes/namespaces/queries/useIsSystemNamespace'; import { TableSettingsMenu } from '@@/datatables'; import { useRepeater } from '@@/datatables/useRepeater'; @@ -18,6 +19,7 @@ import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable'; import { NamespaceFilter } from '../ApplicationsStacksDatatable/NamespaceFilter'; import { Namespace } from '../ApplicationsStacksDatatable/types'; +import { useApplications } from '../../application.queries'; import { Application, ConfigKind } from './types'; import { useColumns } from './useColumns'; @@ -26,9 +28,7 @@ import { SubRow } from './SubRow'; import { HelmInsightsBox } from './HelmInsightsBox'; export function ApplicationsDatatable({ - dataset, onRefresh, - isLoading, onRemove, namespace = '', namespaces, @@ -37,9 +37,7 @@ export function ApplicationsDatatable({ onShowSystemChange, hideStacks, }: { - dataset: Array; onRefresh: () => void; - isLoading: boolean; onRemove: (selectedItems: Application[]) => void; namespace?: string; namespaces: Array; @@ -50,7 +48,7 @@ export function ApplicationsDatatable({ }) { const envId = useEnvironmentId(); const envQuery = useCurrentEnvironment(); - const namespaceMetaListQuery = useNamespacesQuery(envId); + const namespaceListQuery = useNamespacesQuery(envId); const tableState = useKubeStore('kubernetes.applications', 'Name'); useRepeater(tableState.autoRefreshRate, onRefresh); @@ -58,7 +56,7 @@ export function ApplicationsDatatable({ const hasWriteAuthQuery = useAuthorizations( 'K8sApplicationsW', undefined, - true + false ); const { setShowSystemResources } = tableState; @@ -67,27 +65,34 @@ export function ApplicationsDatatable({ setShowSystemResources(showSystem || false); }, [showSystem, setShowSystemResources]); - const columns = useColumns(hideStacks); + const applicationsQuery = useApplications(envId, { + refetchInterval: tableState.autoRefreshRate * 1000, + namespace, + withDependencies: true, + }); + const applications = applicationsQuery.data ?? []; + const filteredApplications = showSystem + ? applications + : applications.filter( + (application) => + !isSystemNamespace(application.ResourcePool, namespaceListQuery.data) + ); - const filteredDataset = !showSystem - ? dataset.filter( - (item) => !namespaceMetaListQuery.data?.[item.ResourcePool]?.IsSystem - ) - : dataset; + const columns = useColumns(hideStacks); return ( - !namespaceMetaListQuery.data?.[row.original.ResourcePool]?.IsSystem + !isSystemNamespace(row.original.ResourcePool, namespaceListQuery.data) } getRowCanExpand={(row) => isExpandable(row.original)} renderSubRow={(row) => ( diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.tsx b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.tsx index a52e1c7d1..f5473b954 100644 --- a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.tsx +++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.tsx @@ -1,5 +1,12 @@ -import { isoDate, truncate } from '@/portainer/filters/filters'; +import { CellContext } from '@tanstack/react-table'; +import { isoDate, truncate } from '@/portainer/filters/filters'; +import { useIsSystemNamespace } from '@/react/kubernetes/namespaces/queries/useIsSystemNamespace'; + +import { Link } from '@@/Link'; +import { SystemBadge } from '@@/Badge/SystemBadge'; + +import { Application } from './types'; import { helper } from './columns.helper'; export const stackName = helper.accessor('StackName', { @@ -9,9 +16,26 @@ export const stackName = helper.accessor('StackName', { export const namespace = helper.accessor('ResourcePool', { header: 'Namespace', - cell: ({ getValue }) => getValue() || '-', + cell: NamespaceCell, }); +function NamespaceCell({ row, getValue }: CellContext) { + const value = getValue(); + const isSystem = useIsSystemNamespace(value); + return ( +
+ + {value} + + {isSystem && } +
+ ); +} + export const image = helper.accessor('Image', { header: 'Image', cell: ({ row: { original: item } }) => ( diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/types.ts b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/types.ts index 00d52e009..35758466a 100644 --- a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/types.ts +++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/types.ts @@ -39,6 +39,12 @@ export interface Application { }>; Port: number; }>; + Resource?: { + CpuLimit?: number; + CpuRequest?: number; + MemoryLimit?: number; + MemoryRequest?: number; + }; } export enum ConfigKind { diff --git a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/ApplicationsStacksDatatable.tsx b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/ApplicationsStacksDatatable.tsx index a9b62551b..c4db89c5b 100644 --- a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/ApplicationsStacksDatatable.tsx +++ b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/ApplicationsStacksDatatable.tsx @@ -4,44 +4,41 @@ import { useEffect } from 'react'; import { useAuthorizations } from '@/react/hooks/useUser'; import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemResourceDescription'; import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { isSystemNamespace } from '@/react/kubernetes/namespaces/queries/useIsSystemNamespace'; +import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery'; import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable'; -import { useRepeater } from '@@/datatables/useRepeater'; import { useTableState } from '@@/datatables/useTableState'; -import { KubernetesStack } from '../../types'; +import { useApplications } from '../../application.queries'; import { columns } from './columns'; import { SubRows } from './SubRows'; -import { Namespace } from './types'; +import { Namespace, Stack } from './types'; import { StacksSettingsMenu } from './StacksSettingsMenu'; import { NamespaceFilter } from './NamespaceFilter'; import { TableActions } from './TableActions'; +import { getStacksFromApplications } from './getStacksFromApplications'; const storageKey = 'kubernetes.applications.stacks'; const settingsStore = createStore(storageKey); interface Props { - dataset: Array; - onRemove(selectedItems: Array): void; - onRefresh(): Promise; + onRemove(selectedItems: Array): void; namespace?: string; namespaces: Array; onNamespaceChange(namespace: string): void; - isLoading?: boolean; showSystem?: boolean; setSystemResources(showSystem: boolean): void; } export function ApplicationsStacksDatatable({ - dataset, onRemove, - onRefresh, namespace = '', namespaces, onNamespaceChange, - isLoading, showSystem, setSystemResources, }: Props) { @@ -53,16 +50,32 @@ export function ApplicationsStacksDatatable({ setShowSystemResources(showSystem || false); }, [showSystem, setShowSystemResources]); + const envId = useEnvironmentId(); + const applicationsQuery = useApplications(envId, { + refetchInterval: tableState.autoRefreshRate * 1000, + namespace, + withDependencies: true, + }); + const namespaceListQuery = useNamespacesQuery(envId); + const applications = applicationsQuery.data ?? []; + const filteredApplications = showSystem + ? applications + : applications.filter( + (item) => + !isSystemNamespace(item.ResourcePool, namespaceListQuery.data ?? []) + ); + const { authorized } = useAuthorizations('K8sApplicationsW'); - useRepeater(tableState.autoRefreshRate, onRefresh); + + const stacks = getStacksFromApplications(filteredApplications); return ( row.original.Applications.length > 0} title="Stacks" titleIcon={List} - dataset={dataset} - isLoading={isLoading} + dataset={stacks} + isLoading={applicationsQuery.isLoading || namespaceListQuery.isLoading} columns={columns} settingsManager={tableState} disableSelect={!authorized} diff --git a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/SubRows.tsx b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/SubRows.tsx index 038a90d14..7377454e4 100644 --- a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/SubRows.tsx +++ b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/SubRows.tsx @@ -6,15 +6,9 @@ import KubernetesNamespaceHelper from '@/kubernetes/helpers/namespaceHelper'; import { Link } from '@@/Link'; import { ExternalBadge } from '@@/Badge/ExternalBadge'; -import { KubernetesStack } from '../../types'; +import { Stack } from './types'; -export function SubRows({ - stack, - span, -}: { - stack: KubernetesStack; - span: number; -}) { +export function SubRows({ stack, span }: { stack: Stack; span: number }) { return ( <> {stack.Applications.map((app) => ( diff --git a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/TableActions.tsx b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/TableActions.tsx index fa7a3c647..732960fb2 100644 --- a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/TableActions.tsx +++ b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/TableActions.tsx @@ -2,14 +2,14 @@ import { Authorized } from '@/react/hooks/useUser'; import { DeleteButton } from '@@/buttons/DeleteButton'; -import { KubernetesStack } from '../../types'; +import { Stack } from './types'; export function TableActions({ selectedItems, onRemove, }: { - selectedItems: Array; - onRemove: (selectedItems: Array) => void; + selectedItems: Array; + onRemove: (selectedItems: Array) => void; }) { return ( diff --git a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/columns.tsx b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/columns.tsx index b9db1842f..40358f277 100644 --- a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/columns.tsx +++ b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/columns.tsx @@ -1,63 +1,70 @@ import { FileText } from 'lucide-react'; -import { createColumnHelper } from '@tanstack/react-table'; +import { CellContext, createColumnHelper } from '@tanstack/react-table'; -import KubernetesNamespaceHelper from '@/kubernetes/helpers/namespaceHelper'; +import { useIsSystemNamespace } from '@/react/kubernetes/namespaces/queries/useIsSystemNamespace'; import { buildExpandColumn } from '@@/datatables/expand-column'; import { Link } from '@@/Link'; import { Icon } from '@@/Icon'; import { SystemBadge } from '@@/Badge/SystemBadge'; -import { KubernetesStack } from '../../types'; +import { Stack } from './types'; -export const columnHelper = createColumnHelper(); +export const columnHelper = createColumnHelper(); + +const namespace = columnHelper.accessor('ResourcePool', { + id: 'namespace', + header: 'Namespace', + cell: NamespaceCell, +}); + +function NamespaceCell({ row, getValue }: CellContext) { + const value = getValue(); + const isSystem = useIsSystemNamespace(value); + return ( +
+ + {value} + + {isSystem && } +
+ ); +} + +const name = columnHelper.accessor('Name', { + id: 'name', + header: 'Stack', +}); + +const applications = columnHelper.accessor((row) => row.Applications.length, { + id: 'applications', + header: 'Applications', +}); + +const actions = columnHelper.display({ + id: 'actions', + header: 'Actions', + cell: ({ row: { original: item } }) => ( + + + Logs + + ), +}); export const columns = [ - buildExpandColumn(), - columnHelper.accessor('Name', { - id: 'name', - header: 'Stack', - }), - columnHelper.accessor('ResourcePool', { - id: 'namespace', - header: 'Namespace', - cell: ({ getValue, row }) => { - const value = getValue(); - return ( -
- - {value} - - {KubernetesNamespaceHelper.isSystemNamespace(value) && ( - - )} -
- ); - }, - }), - - columnHelper.accessor((row) => row.Applications.length, { - id: 'applications', - header: 'Applications', - }), - - columnHelper.display({ - id: 'actions', - header: 'Actions', - cell: ({ row: { original: item } }) => ( - - - Logs - - ), - }), + buildExpandColumn(), + name, + namespace, + applications, + actions, ]; diff --git a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/getStacksFromApplications.test.ts b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/getStacksFromApplications.test.ts new file mode 100644 index 000000000..df4b51d0b --- /dev/null +++ b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/getStacksFromApplications.test.ts @@ -0,0 +1,159 @@ +import { Application } from '../ApplicationsDatatable/types'; + +import { getStacksFromApplications } from './getStacksFromApplications'; +import { Stack } from './types'; + +describe('getStacksFromApplications', () => { + test('should return an empty array when passed an empty array', () => { + expect(getStacksFromApplications([])).toHaveLength(0); + }); + + test('should return an empty array when passed a list of applications without stacks', () => { + const appsWithoutStacks: Application[] = [ + { + StackName: '', + Id: '1', + Name: 'app1', + CreationDate: '2021-10-01T00:00:00Z', + ResourcePool: 'namespace1', + Image: 'image1', + ApplicationType: 'Pod', + DeploymentType: 'Replicated', + Status: 'status1', + TotalPodsCount: 1, + RunningPodsCount: 1, + }, + { + StackName: '', + Id: '1', + Name: 'app2', + CreationDate: '2021-10-01T00:00:00Z', + ResourcePool: 'namespace1', + Image: 'image1', + ApplicationType: 'Pod', + DeploymentType: 'Replicated', + Status: 'status1', + TotalPodsCount: 1, + RunningPodsCount: 1, + }, + { + StackName: '', + Id: '1', + Name: 'app3', + CreationDate: '2021-10-01T00:00:00Z', + ResourcePool: 'namespace1', + Image: 'image1', + ApplicationType: 'Pod', + DeploymentType: 'Replicated', + Status: 'status1', + TotalPodsCount: 1, + RunningPodsCount: 1, + }, + ]; + expect(getStacksFromApplications(appsWithoutStacks)).toHaveLength(0); + }); + + test('should return a list of stacks when passed a list of applications with stacks', () => { + const appsWithStacks: Application[] = [ + { + StackName: 'stack1', + Id: '1', + Name: 'app1', + CreationDate: '2021-10-01T00:00:00Z', + ResourcePool: 'namespace1', + Image: 'image1', + ApplicationType: 'Pod', + DeploymentType: 'Replicated', + Status: 'status1', + TotalPodsCount: 1, + RunningPodsCount: 1, + }, + { + StackName: 'stack1', + Id: '1', + Name: 'app2', + CreationDate: '2021-10-01T00:00:00Z', + ResourcePool: 'namespace1', + Image: 'image1', + ApplicationType: 'Pod', + DeploymentType: 'Replicated', + Status: 'status1', + TotalPodsCount: 1, + RunningPodsCount: 1, + }, + { + StackName: 'stack2', + Id: '1', + Name: 'app3', + CreationDate: '2021-10-01T00:00:00Z', + ResourcePool: 'namespace1', + Image: 'image1', + ApplicationType: 'Pod', + DeploymentType: 'Replicated', + Status: 'status1', + TotalPodsCount: 1, + RunningPodsCount: 1, + }, + ]; + + const expectedStacksWithApps: Stack[] = [ + { + Name: 'stack1', + ResourcePool: 'namespace1', + Applications: [ + { + StackName: 'stack1', + Id: '1', + Name: 'app1', + CreationDate: '2021-10-01T00:00:00Z', + ResourcePool: 'namespace1', + Image: 'image1', + ApplicationType: 'Pod', + DeploymentType: 'Replicated', + Status: 'status1', + TotalPodsCount: 1, + RunningPodsCount: 1, + }, + { + StackName: 'stack1', + Id: '1', + Name: 'app2', + CreationDate: '2021-10-01T00:00:00Z', + ResourcePool: 'namespace1', + Image: 'image1', + ApplicationType: 'Pod', + DeploymentType: 'Replicated', + Status: 'status1', + TotalPodsCount: 1, + RunningPodsCount: 1, + }, + ], + Highlighted: false, + }, + { + Name: 'stack2', + ResourcePool: 'namespace1', + Applications: [ + { + StackName: 'stack2', + Id: '1', + Name: 'app3', + CreationDate: '2021-10-01T00:00:00Z', + ResourcePool: 'namespace1', + Image: 'image1', + ApplicationType: 'Pod', + DeploymentType: 'Replicated', + Status: 'status1', + TotalPodsCount: 1, + RunningPodsCount: 1, + }, + ], + Highlighted: false, + }, + ]; + + expect(getStacksFromApplications(appsWithStacks)).toEqual( + expectedStacksWithApps + ); + }); +}); diff --git a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/getStacksFromApplications.ts b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/getStacksFromApplications.ts new file mode 100644 index 000000000..480cc8d28 --- /dev/null +++ b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/getStacksFromApplications.ts @@ -0,0 +1,36 @@ +import { Application } from '../ApplicationsDatatable/types'; + +import { Stack } from './types'; + +export function getStacksFromApplications(applications: Application[]) { + const res = applications.reduce((stacks, app) => { + const updatedStacks = stacks.map((stack) => { + if ( + stack.Name === app.StackName && + stack.ResourcePool === app.ResourcePool + ) { + return { + ...stack, + Applications: [...stack.Applications, app], + }; + } + return stack; + }); + + const stackExists = updatedStacks.some( + (stack) => + stack.Name === app.StackName && stack.ResourcePool === app.ResourcePool + ); + + if (!stackExists && app.StackName) { + updatedStacks.push({ + Name: app.StackName, + ResourcePool: app.ResourcePool, + Applications: [app], + Highlighted: false, + }); + } + return updatedStacks; + }, []); + return res; +} diff --git a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/types.ts b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/types.ts index b13734185..915c535fc 100644 --- a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/types.ts +++ b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/types.ts @@ -5,6 +5,8 @@ import { RefreshableTableSettings, } from '@@/datatables/types'; +import { Application } from '../ApplicationsDatatable/types'; + export interface TableSettings extends BasicTableSettings, RefreshableTableSettings, @@ -16,3 +18,10 @@ export interface Namespace { Yaml: string; IsSystem?: boolean; } + +export type Stack = { + Name: string; + ResourcePool: string; + Applications: Application[]; + Highlighted: boolean; +}; diff --git a/app/react/kubernetes/applications/application.queries.ts b/app/react/kubernetes/applications/application.queries.ts index 5588d533c..8c38a6928 100644 --- a/app/react/kubernetes/applications/application.queries.ts +++ b/app/react/kubernetes/applications/application.queries.ts @@ -1,26 +1,37 @@ import { UseQueryResult, useMutation, useQuery } from '@tanstack/react-query'; -import { Pod } from 'kubernetes-types/core/v1'; - -import { queryClient, withError } from '@/react-tools/react-query'; -import { EnvironmentId } from '@/react/portainer/environments/types'; - -import { getNamespaceServices } from '../services/service'; +import { Pod, PodList } from 'kubernetes-types/core/v1'; + +import { + queryClient, + withError, + withGlobalError, +} from '@/react-tools/react-query'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import { getNamespaceServices } from '../services/service'; +import { parseKubernetesAxiosError } from '../axiosError'; import { - getApplicationsForCluster, getApplication, patchApplication, getApplicationRevisionList, } from './application.service'; import type { AppKind, Application, ApplicationPatch } from './types'; +import { Application as K8sApplication } from './ListView/ApplicationsDatatable/types'; import { deletePod } from './pod.service'; import { getNamespaceHorizontalPodAutoscalers } from './autoscaling.service'; import { applicationIsKind, matchLabelsToLabelSelectorValue } from './utils'; -import { getNamespacePods } from './usePods'; const queryKeys = { - applicationsForCluster: (environmentId: EnvironmentId) => - ['environments', environmentId, 'kubernetes', 'applications'] as const, + applications: (environmentId: EnvironmentId, params?: GetAppsParams) => + [ + 'environments', + environmentId, + 'kubernetes', + 'applications', + params, + ] as const, application: ( environmentId: EnvironmentId, namespace: string, @@ -110,21 +121,6 @@ const queryKeys = { ] as const, }; -// useQuery to get a list of all applications from an array of namespaces -export function useApplicationsQuery( - environmentId: EnvironmentId, - namespaces?: string[] -) { - return useQuery( - queryKeys.applicationsForCluster(environmentId), - () => getApplicationsForCluster(environmentId, namespaces), - { - ...withError('Unable to retrieve applications'), - enabled: !!namespaces?.length, - } - ); -} - // when yaml is set to true, the expected return type is a string export function useApplication( environmentId: EnvironmentId, @@ -305,6 +301,37 @@ export function useApplicationPods( ); } +async function getNamespacePods( + environmentId: EnvironmentId, + namespace: string, + labelSelector?: string +) { + try { + const { data } = await axios.get( + `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/pods`, + { + params: { + labelSelector, + }, + } + ); + const items = (data.items || []).map( + (pod) => + { + ...pod, + kind: 'Pod', + apiVersion: data.apiVersion, + } + ); + return items; + } catch (e) { + throw parseKubernetesAxiosError( + e, + `Unable to retrieve Pods in namespace '${namespace}'` + ); + } +} + // useQuery to patch an application by environmentId, namespace, name and patch payload export function usePatchApplicationMutation( environmentId: EnvironmentId, @@ -380,3 +407,45 @@ export function useRedeployApplicationMutation( } ); } + +type GetAppsParams = { + namespace?: string; + nodeName?: string; + withDependencies?: boolean; +}; + +type GetAppsQueryOptions = { + refetchInterval?: number; +} & GetAppsParams; + +// useQuery to get a list of all applications from an array of namespaces +export function useApplications( + environmentId: EnvironmentId, + queryOptions?: GetAppsQueryOptions +) { + const { refetchInterval, ...params } = queryOptions ?? {}; + return useQuery( + queryKeys.applications(environmentId, params), + () => getApplications(environmentId, params), + { + refetchInterval, + ...withGlobalError('Unable to retrieve applications'), + } + ); +} + +// get all applications from a namespace +export async function getApplications( + environmentId: EnvironmentId, + params?: GetAppsParams +) { + try { + const { data } = await axios.get( + `/kubernetes/${environmentId}/applications`, + { params } + ); + return data; + } catch (e) { + throw parseAxiosError(e, 'Unable to retrieve applications'); + } +} diff --git a/app/react/kubernetes/applications/application.service.ts b/app/react/kubernetes/applications/application.service.ts index 8e7911d7e..ea6fed15b 100644 --- a/app/react/kubernetes/applications/application.service.ts +++ b/app/react/kubernetes/applications/application.service.ts @@ -1,7 +1,4 @@ import { - DaemonSetList, - StatefulSetList, - DeploymentList, Deployment, DaemonSet, StatefulSet, @@ -16,56 +13,9 @@ import { isFulfilled } from '@/portainer/helpers/promise-utils'; import { parseKubernetesAxiosError } from '../axiosError'; import { getPod, patchPod } from './pod.service'; -import { filterRevisionsByOwnerUid, getNakedPods } from './utils'; -import { - AppKind, - Application, - ApplicationList, - ApplicationPatch, -} from './types'; +import { filterRevisionsByOwnerUid } from './utils'; +import { AppKind, Application, ApplicationPatch } from './types'; import { appRevisionAnnotation } from './constants'; -import { getNamespacePods } from './usePods'; - -// This file contains services for Kubernetes apps/v1 resources (Deployments, DaemonSets, StatefulSets) - -export async function getApplicationsForCluster( - environmentId: EnvironmentId, - namespaceNames?: string[] -) { - if (!namespaceNames) { - return []; - } - const applications = await Promise.all( - namespaceNames.map((namespace) => - getApplicationsForNamespace(environmentId, namespace) - ) - ); - return applications.flat(); -} - -// get a list of all Deployments, DaemonSets, StatefulSets and naked pods (https://portainer.atlassian.net/browse/CE-2) in one namespace -async function getApplicationsForNamespace( - environmentId: EnvironmentId, - namespace: string -) { - const [deployments, daemonSets, statefulSets, pods] = await Promise.all([ - getApplicationsByKind( - environmentId, - namespace, - 'Deployment' - ), - getApplicationsByKind(environmentId, namespace, 'DaemonSet'), - getApplicationsByKind( - environmentId, - namespace, - 'StatefulSet' - ), - getNamespacePods(environmentId, namespace), - ]); - // find all pods which are 'naked' (not owned by a deployment, daemonset or statefulset) - const nakedPods = getNakedPods(pods, deployments, daemonSets, statefulSets); - return [...deployments, ...daemonSets, ...statefulSets, ...nakedPods]; -} // if not known, get the type of an application (Deployment, DaemonSet, StatefulSet or naked pod) by name export async function getApplication< @@ -235,29 +185,6 @@ async function getApplicationByKind< } } -async function getApplicationsByKind( - environmentId: EnvironmentId, - namespace: string, - appKind: 'Deployment' | 'DaemonSet' | 'StatefulSet' -) { - try { - const { data } = await axios.get( - buildUrl(environmentId, namespace, `${appKind}s`) - ); - const items = (data.items || []).map((app) => ({ - ...app, - kind: appKind, - apiVersion: data.apiVersion, - })); - return items as T['items']; - } catch (e) { - throw parseKubernetesAxiosError( - e, - `Unable to retrieve ${appKind}s in namespace '${namespace}'` - ); - } -} - export async function getApplicationRevisionList( environmentId: EnvironmentId, namespace: string, diff --git a/app/react/kubernetes/applications/components/NamespaceSelector/NamespaceSelector.tsx b/app/react/kubernetes/applications/components/NamespaceSelector/NamespaceSelector.tsx index 4e241e15a..dd8cffaa2 100644 --- a/app/react/kubernetes/applications/components/NamespaceSelector/NamespaceSelector.tsx +++ b/app/react/kubernetes/applications/components/NamespaceSelector/NamespaceSelector.tsx @@ -24,9 +24,9 @@ export function NamespaceSelector({ useNamespacesQuery(environmentId); const namespaceNames = Object.entries(namespaces ?? {}) .filter(([, ns]) => !ns.IsSystem) - .map(([nsName]) => ({ - label: nsName, - value: nsName, + .map(([, ns]) => ({ + label: ns.Name, + value: ns.Name, })); return ( diff --git a/app/react/kubernetes/applications/types.ts b/app/react/kubernetes/applications/types.ts index 295044bc3..22c3e6bec 100644 --- a/app/react/kubernetes/applications/types.ts +++ b/app/react/kubernetes/applications/types.ts @@ -8,8 +8,9 @@ import { ReplicaSet, ControllerRevision, } from 'kubernetes-types/apps/v1'; -import { Pod, PodList } from 'kubernetes-types/core/v1'; +import { Container, Pod, PodList, Volume } from 'kubernetes-types/core/v1'; import { RawExtension } from 'kubernetes-types/runtime'; +import { OwnerReference } from 'kubernetes-types/meta/v1'; import { EnvVarValues } from '@@/form-components/EnvironmentVariablesFieldset'; @@ -79,14 +80,29 @@ type Patch = { export type ApplicationPatch = Patch | RawExtension; -export type KubernetesStack = { - Name: string; - ResourcePool: string; - Applications: Array< - Application & { - Name: string; - ResourcePool: string; - } - >; - Highlighted: boolean; -}; +export interface ConfigmapRef { + name: string; +} + +export interface ValueFrom { + configMapRef?: ConfigmapRef; + secretRef?: ConfigmapRef; +} + +export interface Job { + name?: string; + namespace: string; + creationDate?: string; + uid?: string; + containers: Container[]; +} + +export interface K8sPod extends Job { + ownerReferences: OwnerReference[]; + volumes?: Volume[]; + nodeName?: string; +} + +export interface CronJob extends Job { + schedule: string; +} diff --git a/app/react/kubernetes/applications/usePods.ts b/app/react/kubernetes/applications/usePods.ts deleted file mode 100644 index e1de4a164..000000000 --- a/app/react/kubernetes/applications/usePods.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { Pod, PodList } from 'kubernetes-types/core/v1'; - -import { EnvironmentId } from '@/react/portainer/environments/types'; -import { withError } from '@/react-tools/react-query'; -import axios from '@/portainer/services/axios'; - -import { parseKubernetesAxiosError } from '../axiosError'; - -const queryKeys = { - podsForCluster: (environmentId: EnvironmentId) => [ - 'environments', - environmentId, - 'kubernetes', - 'pods', - ], -}; - -export function usePods(environemtId: EnvironmentId, namespaces?: string[]) { - return useQuery( - queryKeys.podsForCluster(environemtId), - () => getPodsForCluster(environemtId, namespaces), - { - ...withError('Unable to retrieve Pods'), - enabled: !!namespaces?.length, - } - ); -} - -export async function getPodsForCluster( - environmentId: EnvironmentId, - namespaceNames?: string[] -) { - if (!namespaceNames) { - return []; - } - const pods = await Promise.all( - namespaceNames.map((namespace) => - getNamespacePods(environmentId, namespace) - ) - ); - return pods.flat(); -} - -export async function getNamespacePods( - environmentId: EnvironmentId, - namespace: string, - labelSelector?: string -) { - try { - const { data } = await axios.get( - `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/pods`, - { - params: { - labelSelector, - }, - } - ); - const items = (data.items || []).map( - (pod) => - { - ...pod, - kind: 'Pod', - apiVersion: data.apiVersion, - } - ); - return items; - } catch (e) { - throw parseKubernetesAxiosError( - e, - `Unable to retrieve Pods in namespace '${namespace}'` - ); - } -} diff --git a/app/react/kubernetes/applications/utils.ts b/app/react/kubernetes/applications/utils.ts index 29b6e0e1f..aa6a32ef5 100644 --- a/app/react/kubernetes/applications/utils.ts +++ b/app/react/kubernetes/applications/utils.ts @@ -18,39 +18,6 @@ import { appRevisionAnnotation, } from './constants'; -// naked pods are pods which are not owned by a deployment, daemonset, statefulset or replicaset -// https://kubernetes.io/docs/concepts/configuration/overview/#naked-pods-vs-replicasets-deployments-and-jobs -// getNakedPods returns an array of naked pods from an array of pods, deployments, daemonsets and statefulsets -export function getNakedPods( - pods: Pod[], - deployments: Deployment[], - daemonSets: DaemonSet[], - statefulSets: StatefulSet[] -) { - const appLabels = [ - ...deployments.map((deployment) => deployment.spec?.selector.matchLabels), - ...daemonSets.map((daemonSet) => daemonSet.spec?.selector.matchLabels), - ...statefulSets.map( - (statefulSet) => statefulSet.spec?.selector.matchLabels - ), - ]; - - const nakedPods = pods.filter((pod) => { - const podLabels = pod.metadata?.labels; - // if the pod has no labels, it is naked - if (!podLabels) return true; - // if the pod has labels, but no app labels, it is naked - return !appLabels.some((appLabel) => { - if (!appLabel) return false; - return Object.entries(appLabel).every( - ([key, value]) => podLabels[key] === value - ); - }); - }); - - return nakedPods; -} - // type guard to check if an application is a deployment, daemonset, statefulset or pod export function applicationIsKind( appKind: 'Deployment' | 'DaemonSet' | 'StatefulSet' | 'Pod', diff --git a/app/react/kubernetes/cluster/.keep b/app/react/kubernetes/cluster/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/kubernetes/cluster/NodeView/.keep b/app/react/kubernetes/cluster/NodeView/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/kubernetes/cluster/NodeView/NodeApplicationsDatatable/NodeApplicationsDatatable.tsx b/app/react/kubernetes/cluster/NodeView/NodeApplicationsDatatable/NodeApplicationsDatatable.tsx index eb1443334..e4b6bfafc 100644 --- a/app/react/kubernetes/cluster/NodeView/NodeApplicationsDatatable/NodeApplicationsDatatable.tsx +++ b/app/react/kubernetes/cluster/NodeView/NodeApplicationsDatatable/NodeApplicationsDatatable.tsx @@ -1,7 +1,10 @@ +import { useCurrentStateAndParams } from '@uirouter/react'; + import LaptopCode from '@/assets/ico/laptop-code.svg?c'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { useApplications } from '@/react/kubernetes/applications/application.queries'; import { Datatable, TableSettingsMenu } from '@@/datatables'; -import { useRepeater } from '@@/datatables/useRepeater'; import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh'; import { useTableStateWithStorage } from '@@/datatables/useTableState'; import { @@ -11,19 +14,10 @@ import { } from '@@/datatables/types'; import { useColumns } from './columns'; -import { NodeApplication } from './types'; interface TableSettings extends BasicTableSettings, RefreshableTableSettings {} -export function NodeApplicationsDatatable({ - dataset, - onRefresh, - isLoading, -}: { - dataset: Array; - onRefresh: () => void; - isLoading: boolean; -}) { +export function NodeApplicationsDatatable() { const tableState = useTableStateWithStorage( 'kube-node-apps', 'Name', @@ -31,19 +25,28 @@ export function NodeApplicationsDatatable({ ...refreshableSettings(set), }) ); - useRepeater(tableState.autoRefreshRate, onRefresh); + + const envId = useEnvironmentId(); + const { + params: { nodeName }, + } = useCurrentStateAndParams(); + const applicationsQuery = useApplications(envId, { + nodeName, + refetchInterval: tableState.autoRefreshRate * 1000, + }); + const applications = applicationsQuery.data ?? []; const columns = useColumns(); return ( ( (); +export const helper = createColumnHelper(); diff --git a/app/react/kubernetes/cluster/NodeView/NodeApplicationsDatatable/columns.name.tsx b/app/react/kubernetes/cluster/NodeView/NodeApplicationsDatatable/columns.name.tsx index 34d94664b..e7c6ecacf 100644 --- a/app/react/kubernetes/cluster/NodeView/NodeApplicationsDatatable/columns.name.tsx +++ b/app/react/kubernetes/cluster/NodeView/NodeApplicationsDatatable/columns.name.tsx @@ -4,20 +4,18 @@ import { isExternalApplication } from '@/react/kubernetes/applications/utils'; import { useIsSystemNamespace } from '@/react/kubernetes/namespaces/queries/useIsSystemNamespace'; import { ExternalBadge } from '@/react/kubernetes/components/ExternalBadge'; import { SystemBadge } from '@/react/kubernetes/components/SystemBadge'; +import { Application } from '@/react/kubernetes/applications/ListView/ApplicationsDatatable/types'; import { Link } from '@@/Link'; import { helper } from './columns.helper'; -import { NodeApplication } from './types'; export const name = helper.accessor('Name', { header: 'Name', cell: Cell, }); -function Cell({ - row: { original: item }, -}: CellContext) { +function Cell({ row: { original: item } }: CellContext) { const isSystem = useIsSystemNamespace(item.ResourcePool); return (
diff --git a/app/react/kubernetes/cluster/NodeView/NodeApplicationsDatatable/columns.tsx b/app/react/kubernetes/cluster/NodeView/NodeApplicationsDatatable/columns.tsx index 2d2cb574a..94a3cafc4 100644 --- a/app/react/kubernetes/cluster/NodeView/NodeApplicationsDatatable/columns.tsx +++ b/app/react/kubernetes/cluster/NodeView/NodeApplicationsDatatable/columns.tsx @@ -41,22 +41,31 @@ export function useColumns() { }), helper.accessor('Image', { header: 'Image', - cell: ({ row: { original: item } }) => ( - <> - {truncate(item.Image, 64)} - {item.Containers?.length > 1 && ( - <>+ {item.Containers.length - 1} - )} - - ), + cell: ({ row: { original: item } }) => { + const containersLength = item.Containers?.length || 0; + return ( + <> + {truncate(item.Image, 64)} + {containersLength > 1 && <>+ {containersLength - 1}} + + ); + }, }), - helper.accessor('CPU', { + helper.accessor((row) => row.Resource?.CpuRequest, { header: 'CPU reservation', - cell: ({ getValue }) => _.round(getValue(), 2), + cell: ({ getValue }) => <>{_.round(getValue() || 0, 2)}, }), - helper.accessor('Memory', { + helper.accessor((row) => row.Resource?.CpuLimit, { + header: 'CPU Limit', + cell: ({ getValue }) => <>{_.round(getValue() || 0, 2)}, + }), + helper.accessor((row) => row.Resource?.MemoryRequest, { header: 'Memory reservation', - cell: ({ getValue }) => humanize(getValue()), + cell: ({ getValue }) => <>{humanize(getValue() || 0)}, + }), + helper.accessor((row) => row.Resource?.MemoryLimit, { + header: 'Memory Limit', + cell: ({ getValue }) => <>{humanize(getValue() || 0)}, }), ]), [hideStacksQuery.data] diff --git a/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/ConfigMapsDatatable.tsx b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/ConfigMapsDatatable.tsx index 600b0bdb4..717cd5fec 100644 --- a/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/ConfigMapsDatatable.tsx +++ b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/ConfigMapsDatatable.tsx @@ -1,58 +1,35 @@ import { useMemo } from 'react'; import { FileCode } from 'lucide-react'; -import { ConfigMap, Pod } from 'kubernetes-types/core/v1'; -import { CronJob, Job } from 'kubernetes-types/batch/v1'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { Authorized, useAuthorizations } from '@/react/hooks/useUser'; -import { - DefaultDatatableSettings, - TableSettings as KubeTableSettings, -} from '@/react/kubernetes/datatables/DefaultDatatableSettings'; -import { useKubeStore } from '@/react/kubernetes/datatables/default-kube-datatable-store'; +import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings'; +import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store'; import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemResourceDescription'; +import { useIsDeploymentOptionHidden } from '@/react/hooks/useIsDeploymentOptionHidden'; import { pluralize } from '@/portainer/helpers/strings'; import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery'; -import { Namespaces } from '@/react/kubernetes/namespaces/types'; +import { PortainerNamespace } from '@/react/kubernetes/namespaces/types'; import { CreateFromManifestButton } from '@/react/kubernetes/components/CreateFromManifestButton'; -import { usePods } from '@/react/kubernetes/applications/usePods'; -import { useJobs } from '@/react/kubernetes/applications/useJobs'; -import { useCronJobs } from '@/react/kubernetes/applications/useCronJobs'; +import { isSystemNamespace } from '@/react/kubernetes/namespaces/queries/useIsSystemNamespace'; import { Datatable, TableSettingsMenu } from '@@/datatables'; import { AddButton } from '@@/buttons'; +import { useTableState } from '@@/datatables/useTableState'; import { DeleteButton } from '@@/buttons/DeleteButton'; -import { - type FilteredColumnsTableSettings, - filteredColumnsSettings, -} from '@@/datatables/types'; -import { mergeOptions } from '@@/datatables/extend-options/mergeOptions'; -import { withColumnFilters } from '@@/datatables/extend-options/withColumnFilters'; -import { - useConfigMapsForCluster, - useMutationDeleteConfigMaps, -} from '../../configmap.service'; -import { IndexOptional } from '../../types'; +import { IndexOptional, Configuration } from '../../types'; +import { useDeleteConfigMaps } from '../../queries/useDeleteConfigMaps'; +import { useConfigMapsForCluster } from '../../queries/useConfigmapsForCluster'; -import { getIsConfigMapInUse } from './utils'; import { ConfigMapRowData } from './types'; import { columns } from './columns'; -interface TableSettings - extends KubeTableSettings, - FilteredColumnsTableSettings {} - const storageKey = 'k8sConfigMapsDatatable'; +const settingsStore = createStore(storageKey); export function ConfigMapsDatatable() { - const tableState = useKubeStore( - storageKey, - undefined, - (set) => ({ - ...filteredColumnsSettings(set), - }) - ); + const tableState = useTableState(settingsStore, storageKey); const { authorized: canWrite } = useAuthorizations(['K8sConfigMapsW']); const readOnly = !canWrite; const { authorized: canAccessSystemResources } = useAuthorizations( @@ -60,42 +37,23 @@ export function ConfigMapsDatatable() { ); const environmentId = useEnvironmentId(); - const { data: namespaces, ...namespacesQuery } = useNamespacesQuery( - environmentId, - { - autoRefreshRate: tableState.autoRefreshRate * 1000, - } - ); - const namespaceNames = Object.keys(namespaces || {}); - const { data: configMaps, ...configMapsQuery } = useConfigMapsForCluster( - environmentId, - namespaceNames, - { - autoRefreshRate: tableState.autoRefreshRate * 1000, - } - ); - const podsQuery = usePods(environmentId, namespaceNames); - const jobsQuery = useJobs(environmentId, namespaceNames); - const cronJobsQuery = useCronJobs(environmentId, namespaceNames); - const isInUseLoading = - podsQuery.isLoading || jobsQuery.isLoading || cronJobsQuery.isLoading; - - const filteredConfigMaps = useMemo( - () => - configMaps?.filter( - (configMap) => + const namespacesQuery = useNamespacesQuery(environmentId, { + autoRefreshRate: tableState.autoRefreshRate * 1000, + }); + const configMapsQuery = useConfigMapsForCluster(environmentId, { + autoRefreshRate: tableState.autoRefreshRate * 1000, + select: (configMaps) => + configMaps.filter( + (configmap) => (canAccessSystemResources && tableState.showSystemResources) || - !namespaces?.[configMap.metadata?.namespace ?? '']?.IsSystem - ) || [], - [configMaps, tableState, canAccessSystemResources, namespaces] - ); + !isSystemNamespace(configmap.Namespace, namespacesQuery.data) + ), + isUsed: true, + }); + const configMapRowData = useConfigMapRowData( - filteredConfigMaps, - podsQuery.data ?? [], - jobsQuery.data ?? [], - cronJobsQuery.data ?? [], - isInUseLoading, - namespaces + configMapsQuery.data ?? [], + namespacesQuery.data ); return ( @@ -104,11 +62,12 @@ export function ConfigMapsDatatable() { columns={columns} settingsManager={tableState} isLoading={configMapsQuery.isLoading || namespacesQuery.isLoading} + emptyContentLabel="No ConfigMaps found" title="ConfigMaps" titleIcon={FileCode} - getRowId={(row) => row.metadata?.uid ?? ''} - isRowSelectable={(row) => - !namespaces?.[row.original.metadata?.namespace ?? ''].IsSystem + getRowId={(row) => row.UID ?? ''} + isRowSelectable={({ original: configmap }) => + !isSystemNamespace(configmap.Namespace, namespacesQuery.data) } disableSelect={readOnly} renderTableActions={(selectedRows) => ( @@ -125,36 +84,26 @@ export function ConfigMapsDatatable() { /> } data-cy="k8s-configmaps-datatable" - extendTableOptions={mergeOptions( - withColumnFilters(tableState.columnFilters, tableState.setColumnFilters) - )} /> ); } -// useConfigMapRowData appends the `inUse` property to the ConfigMap data (for the unused badge in the name column) -// and wraps with useMemo to prevent unnecessary calculations function useConfigMapRowData( - configMaps: ConfigMap[], - pods: Pod[], - jobs: Job[], - cronJobs: CronJob[], - isInUseLoading: boolean, - namespaces?: Namespaces + configMaps: Configuration[], + namespaces?: PortainerNamespace[] ): ConfigMapRowData[] { return useMemo( () => - configMaps.map((configMap) => ({ + configMaps?.map((configMap) => ({ ...configMap, - inUse: - // if the apps are loading, set inUse to true to hide the 'unused' badge - isInUseLoading || - getIsConfigMapInUse(configMap, pods, jobs, cronJobs), + inUse: configMap.IsUsed, isSystem: namespaces - ? namespaces?.[configMap.metadata?.namespace ?? '']?.IsSystem + ? namespaces.find( + (namespace) => namespace.Name === configMap.Namespace + )?.IsSystem ?? false : false, - })), - [configMaps, isInUseLoading, pods, jobs, cronJobs, namespaces] + })) || [], + [configMaps, namespaces] ); } @@ -163,17 +112,9 @@ function TableActions({ }: { selectedItems: ConfigMapRowData[]; }) { + const isAddConfigMapHidden = useIsDeploymentOptionHidden('form'); const environmentId = useEnvironmentId(); - const deleteConfigMapMutation = useMutationDeleteConfigMaps(environmentId); - - async function handleRemoveClick(configMaps: ConfigMap[]) { - const configMapsToDelete = configMaps.map((configMap) => ({ - namespace: configMap.metadata?.namespace ?? '', - name: configMap.metadata?.name ?? '', - })); - - await deleteConfigMapMutation.mutateAsync(configMapsToDelete); - } + const deleteConfigMapMutation = useDeleteConfigMaps(environmentId); return ( @@ -187,13 +128,15 @@ function TableActions({ data-cy="k8sConfig-removeConfigButton" /> - - Add with form - + {!isAddConfigMapHidden && ( + + Add with form + + )} ); + + async function handleRemoveClick(configMaps: ConfigMapRowData[]) { + const configMapsToDelete = configMaps.map((configMap) => ({ + namespace: configMap.Namespace ?? '', + name: configMap.Name ?? '', + })); + + await deleteConfigMapMutation.mutateAsync(configMapsToDelete); + } } diff --git a/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/columns/created.tsx b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/columns/created.tsx index 200cc8885..c6b0757ca 100644 --- a/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/columns/created.tsx +++ b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/columns/created.tsx @@ -1,8 +1,6 @@ import { formatDate } from '@/portainer/filters/filters'; -import { appOwnerLabel } from '@/react/kubernetes/applications/constants'; import { ConfigMapRowData } from '../types'; -import { configurationOwnerUsernameLabel } from '../../../constants'; import { columnHelper } from './helper'; @@ -13,9 +11,7 @@ export const created = columnHelper.accessor((row) => getCreatedAtText(row), { }); function getCreatedAtText(row: ConfigMapRowData) { - const owner = - row.metadata?.labels?.[configurationOwnerUsernameLabel] || - row.metadata?.labels?.[appOwnerLabel]; - const date = formatDate(row.metadata?.creationTimestamp); + const owner = row.ConfigurationOwner || row.ConfigurationOwnerId; + const date = formatDate(row.CreationDate); return owner ? `${date} by ${owner}` : date; } diff --git a/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/columns/name.tsx b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/columns/name.tsx index 74857f407..526e009fa 100644 --- a/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/columns/name.tsx +++ b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/columns/name.tsx @@ -1,7 +1,6 @@ import { CellContext } from '@tanstack/react-table'; import { Authorized } from '@/react/hooks/useUser'; -import { appOwnerLabel } from '@/react/kubernetes/applications/constants'; import { ExternalBadge } from '@@/Badge/ExternalBadge'; import { SystemBadge } from '@@/Badge/SystemBadge'; @@ -9,22 +8,18 @@ import { UnusedBadge } from '@@/Badge/UnusedBadge'; import { Link } from '@@/Link'; import { ConfigMapRowData } from '../types'; -import { configurationOwnerUsernameLabel } from '../../../constants'; import { columnHelper } from './helper'; export const name = columnHelper.accessor( (row) => { - const name = row.metadata?.name; + const name = row.Name; const isSystemToken = name?.includes('default-token-'); const isSystemConfigMap = isSystemToken || row.isSystem; - const hasConfigurationOwner = !!( - row.metadata?.labels?.[configurationOwnerUsernameLabel] || - row.metadata?.labels?.[appOwnerLabel] + row.ConfigurationOwner || row.ConfigurationOwnerId ); - return `${name} ${isSystemConfigMap ? 'system' : ''} ${ !isSystemToken && !hasConfigurationOwner ? 'external' : '' } ${!row.inUse && !isSystemConfigMap ? 'unused' : ''}`; @@ -37,14 +32,12 @@ export const name = columnHelper.accessor( ); function Cell({ row }: CellContext) { - const name = row.original.metadata?.name; - + const name = row.original.Name; const isSystemToken = name?.includes('default-token-'); const isSystemConfigMap = isSystemToken || row.original.isSystem; const hasConfigurationOwner = !!( - row.original.metadata?.labels?.[configurationOwnerUsernameLabel] || - row.original.metadata?.labels?.[appOwnerLabel] + row.original.ConfigurationOwner || row.original.ConfigurationOwnerId ); return ( @@ -53,7 +46,7 @@ function Cell({ row }: CellContext) { row.metadata?.namespace, - { - header: 'Namespace', - id: 'namespace', - cell: ({ getValue }) => { - const namespace = getValue(); - - return ( - - {namespace} - - ); - }, - meta: { - filter: filterHOC('Filter by namespace'), - }, - enableColumnFilter: true, - filterFn: ( - row: Row, - _columnId: string, - filterValue: string[] - ) => - filterValue.length === 0 || - filterValue.includes(row.original.metadata?.namespace ?? ''), - } -); +export const namespace = columnHelper.accessor('Namespace', { + header: 'Namespace', + id: 'namespace', + cell: ({ getValue }) => { + const namespace = getValue(); + return ( + + {namespace} + + ); + }, + meta: { + filter: filterHOC('Filter by namespace'), + }, + enableColumnFilter: true, + filterFn: ( + row: Row, + _columnId: string, + filterValue: string[] + ) => + filterValue.length === 0 || + filterValue.includes(row.original.Namespace ?? ''), +}); diff --git a/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/types.ts b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/types.ts index 056f7dc12..8742e2e08 100644 --- a/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/types.ts +++ b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/types.ts @@ -1,6 +1,6 @@ -import { ConfigMap } from 'kubernetes-types/core/v1'; +import { Configuration } from '../../types'; -export interface ConfigMapRowData extends ConfigMap { +export interface ConfigMapRowData extends Configuration { inUse: boolean; isSystem: boolean; } diff --git a/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/utils.test.ts b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/utils.test.ts index 11283cb68..0d57781e1 100644 --- a/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/utils.test.ts +++ b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/utils.test.ts @@ -1,14 +1,21 @@ -import { ConfigMap, Pod } from 'kubernetes-types/core/v1'; -import { CronJob, Job } from 'kubernetes-types/batch/v1'; +import { CronJob, Job, K8sPod } from '../../../applications/types'; +import { Configuration } from '../../types'; import { getIsConfigMapInUse } from './utils'; describe('getIsConfigMapInUse', () => { it('should return false when no resources reference the configMap', () => { - const configMap: ConfigMap = { - metadata: { name: 'my-configmap', namespace: 'default' }, + const configMap: Configuration = { + Name: 'my-configmap', + Namespace: 'default', + UID: '', + Type: 1, + ConfigurationOwner: '', + ConfigurationOwnerId: '', + IsUsed: false, + Yaml: '', }; - const pods: Pod[] = []; + const pods: K8sPod[] = []; const jobs: Job[] = []; const cronJobs: CronJob[] = []; @@ -16,20 +23,26 @@ describe('getIsConfigMapInUse', () => { }); it('should return true when a pod references the configMap', () => { - const configMap: ConfigMap = { - metadata: { name: 'my-configmap', namespace: 'default' }, + const configMap: Configuration = { + Name: 'my-configmap', + Namespace: 'default', + UID: '', + Type: 1, + ConfigurationOwner: '', + ConfigurationOwnerId: '', + IsUsed: false, + Yaml: '', }; - const pods: Pod[] = [ + const pods: K8sPod[] = [ { - metadata: { namespace: 'default' }, - spec: { - containers: [ - { - name: 'container1', - envFrom: [{ configMapRef: { name: 'my-configmap' } }], - }, - ], - }, + namespace: 'default', + containers: [ + { + name: 'container1', + envFrom: [{ configMapRef: { name: 'my-configmap' } }], + }, + ], + ownerReferences: [], }, ]; const jobs: Job[] = []; @@ -39,25 +52,26 @@ describe('getIsConfigMapInUse', () => { }); it('should return true when a job references the configMap', () => { - const configMap: ConfigMap = { - metadata: { name: 'my-configmap', namespace: 'default' }, + const configMap: Configuration = { + Name: 'my-configmap', + Namespace: 'default', + UID: '', + Type: 1, + ConfigurationOwner: '', + ConfigurationOwnerId: '', + IsUsed: false, + Yaml: '', }; - const pods: Pod[] = []; + const pods: K8sPod[] = []; const jobs: Job[] = [ { - metadata: { namespace: 'default' }, - spec: { - template: { - spec: { - containers: [ - { - name: 'container1', - envFrom: [{ configMapRef: { name: 'my-configmap' } }], - }, - ], - }, + namespace: 'default', + containers: [ + { + name: 'container1', + envFrom: [{ configMapRef: { name: 'my-configmap' } }], }, - }, + ], }, ]; const cronJobs: CronJob[] = []; @@ -66,31 +80,28 @@ describe('getIsConfigMapInUse', () => { }); it('should return true when a cronJob references the configMap', () => { - const configMap: ConfigMap = { - metadata: { name: 'my-configmap', namespace: 'default' }, + const configMap: Configuration = { + Name: 'my-configmap', + Namespace: 'default', + UID: '', + Type: 1, + ConfigurationOwner: '', + ConfigurationOwnerId: '', + IsUsed: false, + Yaml: '', }; - const pods: Pod[] = []; + const pods: K8sPod[] = []; const jobs: Job[] = []; const cronJobs: CronJob[] = [ { - metadata: { namespace: 'default' }, - spec: { - schedule: '0 0 * * *', - jobTemplate: { - spec: { - template: { - spec: { - containers: [ - { - name: 'container1', - envFrom: [{ configMapRef: { name: 'my-configmap' } }], - }, - ], - }, - }, - }, + namespace: 'default', + schedule: '0 0 * * *', + containers: [ + { + name: 'container1', + envFrom: [{ configMapRef: { name: 'my-configmap' } }], }, - }, + ], }, ]; diff --git a/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/utils.ts b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/utils.ts index 6bac4df85..0f96b0392 100644 --- a/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/utils.ts +++ b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/utils.ts @@ -1,27 +1,27 @@ -import { ConfigMap, Pod, PodSpec } from 'kubernetes-types/core/v1'; -import { CronJob, Job } from 'kubernetes-types/batch/v1'; +import { PodSpec } from 'kubernetes-types/core/v1'; + +import { Configuration } from '../../types'; +import { Job, CronJob, K8sPod } from '../../../applications/types'; /** * getIsConfigMapInUse returns true if the configmap is referenced by any pod, job, or cronjob in the same namespace */ export function getIsConfigMapInUse( - configMap: ConfigMap, - pods: Pod[], + configMap: Configuration, + pods: K8sPod[], jobs: Job[], cronJobs: CronJob[] ) { // get all podspecs from pods, jobs and cronjobs that are in the same namespace - const podsInNamespace = pods - .filter((pod) => pod.metadata?.namespace === configMap.metadata?.namespace) - .map((pod) => pod.spec); - const jobsInNamespace = jobs - .filter((job) => job.metadata?.namespace === configMap.metadata?.namespace) - .map((job) => job.spec?.template.spec); - const cronJobsInNamespace = cronJobs - .filter( - (cronJob) => cronJob.metadata?.namespace === configMap.metadata?.namespace - ) - .map((cronJob) => cronJob.spec?.jobTemplate.spec?.template.spec); + const podsInNamespace = pods.filter( + (pod) => pod.namespace === configMap.Namespace + ); + const jobsInNamespace = jobs.filter( + (job) => job.namespace === configMap.Namespace + ); + const cronJobsInNamespace = cronJobs.filter( + (cronJob) => cronJob.namespace === configMap.Namespace + ); const allPodSpecs = [ ...podsInNamespace, ...jobsInNamespace, @@ -30,10 +30,10 @@ export function getIsConfigMapInUse( // check if the configmap is referenced by any pod, job or cronjob in the namespace const isReferenced = allPodSpecs.some((podSpec) => { - if (!podSpec || !configMap.metadata?.name) { + if (!podSpec || !configMap.Namespace) { return false; } - return doesPodSpecReferenceConfigMap(podSpec, configMap.metadata?.name); + return doesPodSpecReferenceConfigMap(podSpec, configMap.Name); }); return isReferenced; diff --git a/app/react/kubernetes/configs/ListView/SecretsDatatable/SecretsDatatable.tsx b/app/react/kubernetes/configs/ListView/SecretsDatatable/SecretsDatatable.tsx index 55192d9d7..8cbc6de59 100644 --- a/app/react/kubernetes/configs/ListView/SecretsDatatable/SecretsDatatable.tsx +++ b/app/react/kubernetes/configs/ListView/SecretsDatatable/SecretsDatatable.tsx @@ -1,118 +1,81 @@ import { useMemo } from 'react'; import { Lock } from 'lucide-react'; -import { Pod, Secret } from 'kubernetes-types/core/v1'; -import { CronJob, Job } from 'kubernetes-types/batch/v1'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { Authorized, useAuthorizations } from '@/react/hooks/useUser'; -import { - DefaultDatatableSettings, - TableSettings as KubeTableSettings, -} from '@/react/kubernetes/datatables/DefaultDatatableSettings'; -import { useKubeStore } from '@/react/kubernetes/datatables/default-kube-datatable-store'; +import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings'; +import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store'; import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemResourceDescription'; +import { useIsDeploymentOptionHidden } from '@/react/hooks/useIsDeploymentOptionHidden'; import { pluralize } from '@/portainer/helpers/strings'; import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery'; -import { Namespaces } from '@/react/kubernetes/namespaces/types'; +import { PortainerNamespace } from '@/react/kubernetes/namespaces/types'; import { CreateFromManifestButton } from '@/react/kubernetes/components/CreateFromManifestButton'; -import { usePods } from '@/react/kubernetes/applications/usePods'; -import { useJobs } from '@/react/kubernetes/applications/useJobs'; -import { useCronJobs } from '@/react/kubernetes/applications/useCronJobs'; +import { isSystemNamespace } from '@/react/kubernetes/namespaces/queries/useIsSystemNamespace'; import { Datatable, TableSettingsMenu } from '@@/datatables'; import { AddButton } from '@@/buttons'; +import { useTableState } from '@@/datatables/useTableState'; import { DeleteButton } from '@@/buttons/DeleteButton'; -import { - type FilteredColumnsTableSettings, - filteredColumnsSettings, -} from '@@/datatables/types'; -import { mergeOptions } from '@@/datatables/extend-options/mergeOptions'; -import { withColumnFilters } from '@@/datatables/extend-options/withColumnFilters'; -import { - useSecretsForCluster, - useMutationDeleteSecrets, -} from '../../secret.service'; -import { IndexOptional } from '../../types'; +import { useSecretsForCluster } from '../../queries/useSecretsForCluster'; +import { useDeleteSecrets } from '../../queries/useDeleteSecrets'; +import { IndexOptional, Configuration } from '../../types'; -import { getIsSecretInUse } from './utils'; import { SecretRowData } from './types'; import { columns } from './columns'; const storageKey = 'k8sSecretsDatatable'; - -interface TableSettings - extends KubeTableSettings, - FilteredColumnsTableSettings {} +const settingsStore = createStore(storageKey); export function SecretsDatatable() { - const tableState = useKubeStore( - storageKey, - undefined, - (set) => ({ - ...filteredColumnsSettings(set), - }) - ); - const environmentId = useEnvironmentId(); + const tableState = useTableState(settingsStore, storageKey); const { authorized: canWrite } = useAuthorizations(['K8sSecretsW']); const readOnly = !canWrite; const { authorized: canAccessSystemResources } = useAuthorizations( 'K8sAccessSystemNamespaces' ); + const isAddSecretHidden = useIsDeploymentOptionHidden('form'); - const { data: namespaces, ...namespacesQuery } = useNamespacesQuery( - environmentId, - { - autoRefreshRate: tableState.autoRefreshRate * 1000, - } - ); - const namespaceNames = Object.keys(namespaces || {}); - const { data: secrets, ...secretsQuery } = useSecretsForCluster( - environmentId, - namespaceNames, - { - autoRefreshRate: tableState.autoRefreshRate * 1000, - } - ); - const podsQuery = usePods(environmentId, namespaceNames); - const jobsQuery = useJobs(environmentId, namespaceNames); - const cronJobsQuery = useCronJobs(environmentId, namespaceNames); - const isInUseLoading = - podsQuery.isLoading || jobsQuery.isLoading || cronJobsQuery.isLoading; - - const filteredSecrets = useMemo( - () => - secrets?.filter( + const environmentId = useEnvironmentId(); + const namespacesQuery = useNamespacesQuery(environmentId, { + autoRefreshRate: tableState.autoRefreshRate * 1000, + }); + const secretsQuery = useSecretsForCluster(environmentId, { + autoRefreshRate: tableState.autoRefreshRate * 1000, + select: (secrets) => + secrets.filter( (secret) => (canAccessSystemResources && tableState.showSystemResources) || - !namespaces?.[secret.metadata?.namespace ?? '']?.IsSystem - ) || [], - [secrets, tableState, canAccessSystemResources, namespaces] - ); + !isSystemNamespace(secret.Namespace, namespacesQuery.data) + ), + isUsed: true, + }); + const secretRowData = useSecretRowData( - filteredSecrets, - podsQuery.data ?? [], - jobsQuery.data ?? [], - cronJobsQuery.data ?? [], - isInUseLoading, - namespaces + secretsQuery.data ?? [], + namespacesQuery.data ); return ( > - dataset={secretRowData} + dataset={secretRowData || []} columns={columns} settingsManager={tableState} isLoading={secretsQuery.isLoading || namespacesQuery.isLoading} + emptyContentLabel="No secrets found" title="Secrets" titleIcon={Lock} - getRowId={(row) => row.metadata?.uid ?? ''} - isRowSelectable={(row) => - !namespaces?.[row.original.metadata?.namespace ?? '']?.IsSystem + getRowId={(row) => row.UID ?? ''} + isRowSelectable={({ original: secret }) => + !isSystemNamespace(secret.Namespace, namespacesQuery.data) } disableSelect={readOnly} renderTableActions={(selectedRows) => ( - + )} renderTableSettings={() => ( @@ -125,9 +88,6 @@ export function SecretsDatatable() { /> } data-cy="k8s-secrets-datatable" - extendTableOptions={mergeOptions( - withColumnFilters(tableState.columnFilters, tableState.setColumnFilters) - )} /> ); } @@ -135,36 +95,41 @@ export function SecretsDatatable() { // useSecretRowData appends the `inUse` property to the secret data (for the unused badge in the name column) // and wraps with useMemo to prevent unnecessary calculations function useSecretRowData( - secrets: Secret[], - pods: Pod[], - jobs: Job[], - cronJobs: CronJob[], - isInUseLoading: boolean, - namespaces?: Namespaces + secrets: Configuration[], + namespaces?: PortainerNamespace[] ): SecretRowData[] { return useMemo( () => - secrets.map((secret) => ({ - ...secret, - inUse: - // if the apps are loading, set inUse to true to hide the 'unused' badge - isInUseLoading || getIsSecretInUse(secret, pods, jobs, cronJobs), - isSystem: namespaces - ? namespaces?.[secret.metadata?.namespace ?? '']?.IsSystem - : false, - })), - [secrets, isInUseLoading, pods, jobs, cronJobs, namespaces] + secrets?.map( + (secret) => + ({ + ...secret, + inUse: secret.IsUsed, + isSystem: namespaces + ? namespaces.find( + (namespace) => namespace.Name === secret.Namespace + )?.IsSystem ?? false + : false, + }) ?? [] + ), + [secrets, namespaces] ); } -function TableActions({ selectedItems }: { selectedItems: SecretRowData[] }) { +function TableActions({ + selectedItems, + isAddSecretHidden, +}: { + selectedItems: SecretRowData[]; + isAddSecretHidden: boolean; +}) { const environmentId = useEnvironmentId(); - const deleteSecretMutation = useMutationDeleteSecrets(environmentId); + const deleteSecretMutation = useDeleteSecrets(environmentId); async function handleRemoveClick(secrets: SecretRowData[]) { const secretsToDelete = secrets.map((secret) => ({ - namespace: secret.metadata?.namespace ?? '', - name: secret.metadata?.name ?? '', + namespace: secret.Namespace ?? '', + name: secret.Name ?? '', })); await deleteSecretMutation.mutateAsync(secretsToDelete); @@ -181,13 +146,17 @@ function TableActions({ selectedItems }: { selectedItems: SecretRowData[] }) { 'secret' )}?`} /> - - Add with form - + + {!isAddSecretHidden && ( + + Add with form + + )} + getCreatedAtText(row), { }); function getCreatedAtText(row: SecretRowData) { - const owner = - row.metadata?.labels?.[configurationOwnerUsernameLabel] || - row.metadata?.labels?.[appOwnerLabel]; - const date = formatDate(row.metadata?.creationTimestamp); + const owner = row.ConfigurationOwner || row.ConfigurationOwnerId; + const date = formatDate(row.CreationDate); return owner ? `${date} by ${owner}` : date; } diff --git a/app/react/kubernetes/configs/ListView/SecretsDatatable/columns/name.tsx b/app/react/kubernetes/configs/ListView/SecretsDatatable/columns/name.tsx index 3ee44743d..ce0e6033c 100644 --- a/app/react/kubernetes/configs/ListView/SecretsDatatable/columns/name.tsx +++ b/app/react/kubernetes/configs/ListView/SecretsDatatable/columns/name.tsx @@ -1,7 +1,6 @@ import { CellContext } from '@tanstack/react-table'; import { Authorized } from '@/react/hooks/useUser'; -import { appOwnerLabel } from '@/react/kubernetes/applications/constants'; import { SystemBadge } from '@@/Badge/SystemBadge'; import { ExternalBadge } from '@@/Badge/ExternalBadge'; @@ -9,26 +8,21 @@ import { UnusedBadge } from '@@/Badge/UnusedBadge'; import { Link } from '@@/Link'; import { SecretRowData } from '../types'; -import { configurationOwnerUsernameLabel } from '../../../constants'; import { columnHelper } from './helper'; export const name = columnHelper.accessor( (row) => { - const name = row.metadata?.name; + const name = row.Name; + const isSystemToken = name?.includes('default-token-'); - - const isRegistrySecret = - row.metadata?.annotations?.['portainer.io/registry.id']; - const isSystemSecret = isSystemToken || row.isSystem || isRegistrySecret; - + const isSystemConfigMap = isSystemToken || row.isSystem; const hasConfigurationOwner = !!( - row.metadata?.labels?.[configurationOwnerUsernameLabel] || - row.metadata?.labels?.[appOwnerLabel] + row.ConfigurationOwner || row.ConfigurationOwnerId ); - return `${name} ${isSystemSecret ? 'system' : ''} ${ + return `${name} ${isSystemConfigMap ? 'system' : ''} ${ !isSystemToken && !hasConfigurationOwner ? 'external' : '' - } ${!row.inUse && !isSystemSecret ? 'unused' : ''}`; + } ${!row.inUse && !isSystemConfigMap ? 'unused' : ''}`; }, { header: 'Name', @@ -38,23 +32,22 @@ export const name = columnHelper.accessor( ); function Cell({ row }: CellContext) { - const name = row.original.metadata?.name; + const name = row.original.Name; const isSystemToken = name?.includes('default-token-'); const isSystemSecret = isSystemToken || row.original.isSystem; const hasConfigurationOwner = !!( - row.original.metadata?.labels?.[configurationOwnerUsernameLabel] || - row.original.metadata?.labels?.[appOwnerLabel] + row.original.ConfigurationOwner || row.original.ConfigurationOwnerId ); return ( -
+
) { > {name} - {isSystemSecret && } {!isSystemToken && !hasConfigurationOwner && } {!row.original.inUse && !isSystemSecret && } diff --git a/app/react/kubernetes/configs/ListView/SecretsDatatable/columns/namespace.tsx b/app/react/kubernetes/configs/ListView/SecretsDatatable/columns/namespace.tsx index 015731f9a..52292fd6a 100644 --- a/app/react/kubernetes/configs/ListView/SecretsDatatable/columns/namespace.tsx +++ b/app/react/kubernetes/configs/ListView/SecretsDatatable/columns/namespace.tsx @@ -8,37 +8,34 @@ import { SecretRowData } from '../types'; import { columnHelper } from './helper'; -export const namespace = columnHelper.accessor( - (row) => row.metadata?.namespace, - { - header: 'Namespace', - id: 'namespace', - cell: ({ getValue }) => { - const namespace = getValue(); +export const namespace = columnHelper.accessor((row) => row.Namespace, { + header: 'Namespace', + id: 'namespace', + cell: ({ getValue }) => { + const namespace = getValue(); - return ( - - {namespace} - - ); - }, - meta: { - filter: filterHOC('Filter by namespace'), - }, - enableColumnFilter: true, - filterFn: ( - row: Row, - _columnId: string, - filterValue: string[] - ) => - filterValue.length === 0 || - filterValue.includes(row.original.metadata?.namespace ?? ''), - } -); + return ( + + {namespace} + + ); + }, + meta: { + filter: filterHOC('Filter by namespace'), + }, + enableColumnFilter: true, + filterFn: ( + row: Row, + _columnId: string, + filterValue: string[] + ) => + filterValue.length === 0 || + filterValue.includes(row.original.Namespace ?? ''), +}); diff --git a/app/react/kubernetes/configs/ListView/SecretsDatatable/types.ts b/app/react/kubernetes/configs/ListView/SecretsDatatable/types.ts index 94a4688b1..f775c8f5c 100644 --- a/app/react/kubernetes/configs/ListView/SecretsDatatable/types.ts +++ b/app/react/kubernetes/configs/ListView/SecretsDatatable/types.ts @@ -1,6 +1,6 @@ -import { Secret } from 'kubernetes-types/core/v1'; +import { Configuration } from '../../types'; -export interface SecretRowData extends Secret { +export interface SecretRowData extends Configuration { inUse: boolean; isSystem: boolean; } diff --git a/app/react/kubernetes/configs/ListView/SecretsDatatable/utils.test.ts b/app/react/kubernetes/configs/ListView/SecretsDatatable/utils.test.ts index e964d5537..fdb6fefc1 100644 --- a/app/react/kubernetes/configs/ListView/SecretsDatatable/utils.test.ts +++ b/app/react/kubernetes/configs/ListView/SecretsDatatable/utils.test.ts @@ -1,14 +1,21 @@ -import { CronJob, Job } from 'kubernetes-types/batch/v1'; -import { Secret, Pod } from 'kubernetes-types/core/v1'; +import { CronJob, Job, K8sPod } from '../../../applications/types'; +import { Configuration } from '../../types'; import { getIsSecretInUse } from './utils'; describe('getIsSecretInUse', () => { it('should return false when no resources reference the secret', () => { - const secret: Secret = { - metadata: { name: 'my-secret', namespace: 'default' }, + const secret: Configuration = { + Name: 'my-secret', + Namespace: 'default', + UID: '', + Type: 1, + ConfigurationOwner: '', + ConfigurationOwnerId: '', + IsUsed: false, + Yaml: '', }; - const pods: Pod[] = []; + const pods: K8sPod[] = []; const jobs: Job[] = []; const cronJobs: CronJob[] = []; @@ -16,20 +23,26 @@ describe('getIsSecretInUse', () => { }); it('should return true when a pod references the secret', () => { - const secret: Secret = { - metadata: { name: 'my-secret', namespace: 'default' }, + const secret: Configuration = { + Name: 'my-secret', + Namespace: 'default', + UID: '', + Type: 1, + ConfigurationOwner: '', + ConfigurationOwnerId: '', + IsUsed: false, + Yaml: '', }; - const pods: Pod[] = [ + const pods: K8sPod[] = [ { - metadata: { namespace: 'default' }, - spec: { - containers: [ - { - name: 'container1', - envFrom: [{ secretRef: { name: 'my-secret' } }], - }, - ], - }, + namespace: 'default', + containers: [ + { + name: 'container1', + envFrom: [{ secretRef: { name: 'my-secret' } }], + }, + ], + ownerReferences: [], }, ]; const jobs: Job[] = []; @@ -39,25 +52,26 @@ describe('getIsSecretInUse', () => { }); it('should return true when a job references the secret', () => { - const secret: Secret = { - metadata: { name: 'my-secret', namespace: 'default' }, + const secret: Configuration = { + Name: 'my-secret', + Namespace: 'default', + UID: '', + Type: 1, + ConfigurationOwner: '', + ConfigurationOwnerId: '', + IsUsed: false, + Yaml: '', }; - const pods: Pod[] = []; + const pods: K8sPod[] = []; const jobs: Job[] = [ { - metadata: { namespace: 'default' }, - spec: { - template: { - spec: { - containers: [ - { - name: 'container1', - envFrom: [{ secretRef: { name: 'my-secret' } }], - }, - ], - }, + namespace: 'default', + containers: [ + { + name: 'container1', + envFrom: [{ secretRef: { name: 'my-secret' } }], }, - }, + ], }, ]; const cronJobs: CronJob[] = []; @@ -66,31 +80,28 @@ describe('getIsSecretInUse', () => { }); it('should return true when a cronJob references the secret', () => { - const secret: Secret = { - metadata: { name: 'my-secret', namespace: 'default' }, + const secret: Configuration = { + Name: 'my-secret', + Namespace: 'default', + UID: '', + Type: 1, + ConfigurationOwner: '', + ConfigurationOwnerId: '', + IsUsed: false, + Yaml: '', }; - const pods: Pod[] = []; + const pods: K8sPod[] = []; const jobs: Job[] = []; const cronJobs: CronJob[] = [ { - metadata: { namespace: 'default' }, - spec: { - schedule: '0 0 * * *', - jobTemplate: { - spec: { - template: { - spec: { - containers: [ - { - name: 'container1', - envFrom: [{ secretRef: { name: 'my-secret' } }], - }, - ], - }, - }, - }, + namespace: 'default', + schedule: '0 0 * * *', + containers: [ + { + name: 'container1', + envFrom: [{ secretRef: { name: 'my-secret' } }], }, - }, + ], }, ]; diff --git a/app/react/kubernetes/configs/ListView/SecretsDatatable/utils.ts b/app/react/kubernetes/configs/ListView/SecretsDatatable/utils.ts index ee9e3f0cc..fe88c4710 100644 --- a/app/react/kubernetes/configs/ListView/SecretsDatatable/utils.ts +++ b/app/react/kubernetes/configs/ListView/SecretsDatatable/utils.ts @@ -1,27 +1,27 @@ -import { Secret, Pod, PodSpec } from 'kubernetes-types/core/v1'; -import { CronJob, Job } from 'kubernetes-types/batch/v1'; +import { PodSpec } from 'kubernetes-types/core/v1'; + +import { Configuration } from '../../types'; +import { Job, CronJob, K8sPod } from '../../../applications/types'; /** * getIsSecretInUse returns true if the secret is referenced by any pod, job, or cronjob in the same namespace */ export function getIsSecretInUse( - secret: Secret, - pods: Pod[], + secret: Configuration, + pods: K8sPod[], jobs: Job[], cronJobs: CronJob[] ) { // get all podspecs from pods, jobs and cronjobs that are in the same namespace - const podsInNamespace = pods - .filter((pod) => pod.metadata?.namespace === secret.metadata?.namespace) - .map((pod) => pod.spec); - const jobsInNamespace = jobs - .filter((job) => job.metadata?.namespace === secret.metadata?.namespace) - .map((job) => job.spec?.template.spec); - const cronJobsInNamespace = cronJobs - .filter( - (cronJob) => cronJob.metadata?.namespace === secret.metadata?.namespace - ) - .map((cronJob) => cronJob.spec?.jobTemplate.spec?.template.spec); + const podsInNamespace = pods.filter( + (pod) => pod.namespace === secret.Namespace + ); + const jobsInNamespace = jobs.filter( + (job) => job.namespace === secret.Namespace + ); + const cronJobsInNamespace = cronJobs.filter( + (cronJob) => cronJob.namespace === secret.Namespace + ); const allPodSpecs = [ ...podsInNamespace, ...jobsInNamespace, @@ -30,10 +30,10 @@ export function getIsSecretInUse( // check if the secret is referenced by any pod, job or cronjob in the namespace const isReferenced = allPodSpecs.some((podSpec) => { - if (!podSpec || !secret.metadata?.name) { + if (!podSpec || !secret.Name) { return false; } - return doesPodSpecReferenceSecret(podSpec, secret.metadata?.name); + return doesPodSpecReferenceSecret(podSpec, secret.Name); }); return isReferenced; diff --git a/app/react/kubernetes/configs/queries/query-keys.ts b/app/react/kubernetes/configs/queries/query-keys.ts new file mode 100644 index 000000000..5a3728d01 --- /dev/null +++ b/app/react/kubernetes/configs/queries/query-keys.ts @@ -0,0 +1,52 @@ +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { ConfigMapQueryParams, SecretQueryParams } from './types'; + +export const configMapQueryKeys = { + configMap: ( + environmentId: EnvironmentId, + namespace: string, + configMap: string + ) => [ + 'environments', + environmentId, + 'kubernetes', + 'configmaps', + 'namespaces', + namespace, + configMap, + ], + configMaps: (environmentId: EnvironmentId, namespace?: string) => [ + 'environments', + environmentId, + 'kubernetes', + 'configmaps', + 'namespaces', + namespace, + ], + configMapsForCluster: ( + environmentId: EnvironmentId, + params?: ConfigMapQueryParams + ) => + params + ? ['environments', environmentId, 'kubernetes', 'configmaps', params] + : ['environments', environmentId, 'kubernetes', 'configmaps'], +}; + +export const secretQueryKeys = { + secrets: (environmentId: EnvironmentId, namespace?: string) => [ + 'environments', + environmentId, + 'kubernetes', + 'secrets', + 'namespaces', + namespace, + ], + secretsForCluster: ( + environmentId: EnvironmentId, + params?: SecretQueryParams + ) => + params + ? ['environments', environmentId, 'kubernetes', 'secrets', params] + : ['environments', environmentId, 'kubernetes', 'secrets'], +}; diff --git a/app/react/kubernetes/configs/queries/types.ts b/app/react/kubernetes/configs/queries/types.ts new file mode 100644 index 000000000..778cfcb79 --- /dev/null +++ b/app/react/kubernetes/configs/queries/types.ts @@ -0,0 +1,2 @@ +export type ConfigMapQueryParams = { isUsed?: boolean }; +export type SecretQueryParams = { isUsed?: boolean }; diff --git a/app/react/kubernetes/configs/queries/useConfigMap.ts b/app/react/kubernetes/configs/queries/useConfigMap.ts new file mode 100644 index 000000000..2dfbed86f --- /dev/null +++ b/app/react/kubernetes/configs/queries/useConfigMap.ts @@ -0,0 +1,48 @@ +import { useQuery } from '@tanstack/react-query'; + +import { withGlobalError } from '@/react-tools/react-query'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { Configuration } from '../types'; + +import { configMapQueryKeys } from './query-keys'; +import { ConfigMapQueryParams } from './types'; + +export function useConfigMap( + environmentId: EnvironmentId, + namespace: string, + configMap: string, + options?: { autoRefreshRate?: number } & ConfigMapQueryParams +) { + return useQuery( + configMapQueryKeys.configMap(environmentId, namespace, configMap), + () => getConfigMap(environmentId, namespace, configMap, { withData: true }), + { + ...withGlobalError('Unable to retrieve ConfigMaps for cluster'), + refetchInterval() { + return options?.autoRefreshRate ?? false; + }, + } + ); +} + +// get a configmap +async function getConfigMap( + environmentId: EnvironmentId, + namespace: string, + configMap: string, + params?: { withData?: boolean } +) { + try { + const { data } = await axios.get( + `/kubernetes/${environmentId}/namespaces/${namespace}/configmaps/${configMap}`, + { params } + ); + return data; + } catch (e) { + // use parseAxiosError instead of parseKubernetesAxiosError + // because this is an internal portainer api endpoint, not through the kube proxy + throw parseAxiosError(e, 'Unable to retrieve ConfigMaps'); + } +} diff --git a/app/react/kubernetes/configs/queries/useConfigMaps.ts b/app/react/kubernetes/configs/queries/useConfigMaps.ts new file mode 100644 index 000000000..33214f027 --- /dev/null +++ b/app/react/kubernetes/configs/queries/useConfigMaps.ts @@ -0,0 +1,41 @@ +import { useQuery } from '@tanstack/react-query'; +import { ConfigMap, ConfigMapList } from 'kubernetes-types/core/v1'; + +import axios from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { withGlobalError } from '@/react-tools/react-query'; + +import { parseKubernetesAxiosError } from '../../axiosError'; + +import { configMapQueryKeys } from './query-keys'; + +// returns a usequery hook for the list of configmaps within a namespace from the kubernetes API +export function useConfigMaps(environmentId: EnvironmentId, namespace: string) { + return useQuery( + configMapQueryKeys.configMaps(environmentId, namespace), + () => (namespace ? getConfigMaps(environmentId, namespace) : []), + { + ...withGlobalError( + `Unable to get ConfigMaps in namespace '${namespace}'` + ), + enabled: !!namespace, + } + ); +} + +// get all configmaps for a namespace +async function getConfigMaps(environmentId: EnvironmentId, namespace?: string) { + try { + const { data } = await axios.get( + `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/configmaps` + ); + // when fetching a list, the kind isn't appended to the items, so we need to add it + const configmaps: ConfigMap[] = data.items.map((configmap) => ({ + ...configmap, + kind: 'ConfigMap', + })); + return configmaps; + } catch (e) { + throw parseKubernetesAxiosError(e, 'Unable to retrieve ConfigMaps'); + } +} diff --git a/app/react/kubernetes/configs/queries/useConfigmapsForCluster.ts b/app/react/kubernetes/configs/queries/useConfigmapsForCluster.ts new file mode 100644 index 000000000..136dd83a2 --- /dev/null +++ b/app/react/kubernetes/configs/queries/useConfigmapsForCluster.ts @@ -0,0 +1,53 @@ +import { useQuery } from '@tanstack/react-query'; + +import { withGlobalError } from '@/react-tools/react-query'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { Configuration } from '../types'; + +import { configMapQueryKeys } from './query-keys'; +import { ConfigMapQueryParams } from './types'; + +export function useConfigMapsForCluster( + environmentId: EnvironmentId, + options?: { + autoRefreshRate?: number; + select?: (data: Configuration[]) => TData; + } & ConfigMapQueryParams +) { + const { autoRefreshRate, select, ...params } = options ?? {}; + return useQuery( + configMapQueryKeys.configMapsForCluster(environmentId, params), + () => + getConfigMapsForCluster(environmentId, { + ...params, + isUsed: params?.isUsed, + }), + { + ...withGlobalError('Unable to retrieve ConfigMaps for cluster'), + refetchInterval() { + return options?.autoRefreshRate ?? false; + }, + select, + } + ); +} + +// get all configmaps for a cluster +async function getConfigMapsForCluster( + environmentId: EnvironmentId, + params?: { withData?: boolean; isUsed?: boolean } +) { + try { + const { data } = await axios.get( + `/kubernetes/${environmentId}/configmaps`, + { params } + ); + return data; + } catch (e) { + // use parseAxiosError instead of parseKubernetesAxiosError + // because this is an internal portainer api endpoint, not through the kube proxy + throw parseAxiosError(e, 'Unable to retrieve ConfigMaps'); + } +} diff --git a/app/react/kubernetes/configs/queries/useDeleteConfigMaps.ts b/app/react/kubernetes/configs/queries/useDeleteConfigMaps.ts new file mode 100644 index 000000000..d8a47f4eb --- /dev/null +++ b/app/react/kubernetes/configs/queries/useDeleteConfigMaps.ts @@ -0,0 +1,77 @@ +import { useMutation } from '@tanstack/react-query'; + +import { queryClient, withGlobalError } from '@/react-tools/react-query'; +import axios from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { + error as notifyError, + notifySuccess, +} from '@/portainer/services/notifications'; +import { isFulfilled, isRejected } from '@/portainer/helpers/promise-utils'; +import { pluralize } from '@/portainer/helpers/strings'; + +import { parseKubernetesAxiosError } from '../../axiosError'; + +import { configMapQueryKeys } from './query-keys'; + +export function useDeleteConfigMaps(environmentId: EnvironmentId) { + return useMutation( + async (configMaps: { namespace: string; name: string }[]) => { + const promises = await Promise.allSettled( + configMaps.map(({ namespace, name }) => + deleteConfigMap(environmentId, namespace, name) + ) + ); + const successfulConfigMaps = promises + .filter(isFulfilled) + .map((_, index) => configMaps[index].name); + const failedConfigMaps = promises + .filter(isRejected) + .map(({ reason }, index) => ({ + name: configMaps[index].name, + reason, + })); + return { failedConfigMaps, successfulConfigMaps }; + }, + { + ...withGlobalError('Unable to remove ConfigMaps'), + onSuccess: ({ failedConfigMaps, successfulConfigMaps }) => { + // Promise.allSettled can also resolve with errors, so check for errors here + // show an error message for each configmap that failed to delete + failedConfigMaps.forEach(({ name, reason }) => { + notifyError( + `Failed to remove ConfigMap '${name}'`, + new Error(reason.message) as Error + ); + }); + // show one summary message for all successful deletes + if (successfulConfigMaps.length) { + notifySuccess( + `${pluralize( + successfulConfigMaps.length, + 'ConfigMap' + )} successfully removed`, + successfulConfigMaps.join(', ') + ); + } + queryClient.invalidateQueries({ + queryKey: configMapQueryKeys.configMapsForCluster(environmentId), + }); + }, + } + ); +} + +async function deleteConfigMap( + environmentId: EnvironmentId, + namespace: string, + name: string +) { + try { + await axios.delete( + `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/configmaps/${name}` + ); + } catch (e) { + throw parseKubernetesAxiosError(e, 'Unable to remove ConfigMap'); + } +} diff --git a/app/react/kubernetes/configs/queries/useDeleteSecrets.ts b/app/react/kubernetes/configs/queries/useDeleteSecrets.ts new file mode 100644 index 000000000..5eb307ea4 --- /dev/null +++ b/app/react/kubernetes/configs/queries/useDeleteSecrets.ts @@ -0,0 +1,76 @@ +import { useMutation } from '@tanstack/react-query'; + +import { queryClient, withGlobalError } from '@/react-tools/react-query'; +import axios from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { + error as notifyError, + notifySuccess, +} from '@/portainer/services/notifications'; +import { isFulfilled, isRejected } from '@/portainer/helpers/promise-utils'; +import { pluralize } from '@/portainer/helpers/strings'; + +import { parseKubernetesAxiosError } from '../../axiosError'; + +import { secretQueryKeys } from './query-keys'; + +export function useDeleteSecrets(environmentId: EnvironmentId) { + return useMutation( + async (secrets: { namespace: string; name: string }[]) => { + const promises = await Promise.allSettled( + secrets.map(({ namespace, name }) => + deleteSecret(environmentId, namespace, name) + ) + ); + const successfulSecrets = promises + .filter(isFulfilled) + .map((_, index) => secrets[index].name); + const failedSecrets = promises + .filter(isRejected) + .map(({ reason }, index) => ({ + name: secrets[index].name, + reason, + })); + return { failedSecrets, successfulSecrets }; + }, + { + ...withGlobalError('Unable to remove secrets'), + onSuccess: ({ failedSecrets, successfulSecrets }) => { + // show an error message for each secret that failed to delete + failedSecrets.forEach(({ name, reason }) => { + notifyError( + `Failed to remove secret '${name}'`, + new Error(reason.message) as Error + ); + }); + // show one summary message for all successful deletes + if (successfulSecrets.length) { + notifySuccess( + `${pluralize( + successfulSecrets.length, + 'Secret' + )} successfully removed`, + successfulSecrets.join(', ') + ); + } + queryClient.invalidateQueries({ + queryKey: secretQueryKeys.secretsForCluster(environmentId), + }); + }, + } + ); +} + +async function deleteSecret( + environmentId: EnvironmentId, + namespace: string, + name: string +) { + try { + await axios.delete( + `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/secrets/${name}` + ); + } catch (e) { + throw parseKubernetesAxiosError(e, 'Unable to remove secret'); + } +} diff --git a/app/react/kubernetes/configs/queries/useSecrets.ts b/app/react/kubernetes/configs/queries/useSecrets.ts new file mode 100644 index 000000000..a426866fc --- /dev/null +++ b/app/react/kubernetes/configs/queries/useSecrets.ts @@ -0,0 +1,39 @@ +import { useQuery } from '@tanstack/react-query'; +import { Secret, SecretList } from 'kubernetes-types/core/v1'; + +import { withGlobalError } from '@/react-tools/react-query'; +import axios from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { parseKubernetesAxiosError } from '../../axiosError'; + +import { secretQueryKeys } from './query-keys'; + +// returns a usequery hook for the list of secrets from the kubernetes API +export function useSecrets(environmentId: EnvironmentId, namespace?: string) { + return useQuery( + secretQueryKeys.secrets(environmentId, namespace), + () => (namespace ? getSecrets(environmentId, namespace) : []), + { + ...withGlobalError(`Unable to get secrets in namespace '${namespace}'`), + enabled: !!namespace, + } + ); +} + +// get all secrets for a namespace +async function getSecrets(environmentId: EnvironmentId, namespace: string) { + try { + const { data } = await axios.get( + `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/secrets` + ); + // when fetching a list, the kind isn't appended to the items, so we need to add it + const secrets: Secret[] = data.items.map((secret) => ({ + ...secret, + kind: 'Secret', + })); + return secrets; + } catch (e) { + throw parseKubernetesAxiosError(e, 'Unable to retrieve secrets'); + } +} diff --git a/app/react/kubernetes/configs/queries/useSecretsForCluster.ts b/app/react/kubernetes/configs/queries/useSecretsForCluster.ts new file mode 100644 index 000000000..53225f01b --- /dev/null +++ b/app/react/kubernetes/configs/queries/useSecretsForCluster.ts @@ -0,0 +1,59 @@ +import { useQuery } from '@tanstack/react-query'; + +import { withGlobalError } from '@/react-tools/react-query'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { Configuration } from '../types'; + +import { SecretQueryParams } from './types'; +import { secretQueryKeys } from './query-keys'; + +export function useSecretsForCluster( + environmentId: EnvironmentId, + options?: { + autoRefreshRate?: number; + select?: (data: Configuration[]) => TData; + } & SecretQueryParams +) { + const { autoRefreshRate, select, ...params } = options ?? {}; + return useQuery( + secretQueryKeys.secretsForCluster(environmentId, params), + () => + getSecretsForCluster(environmentId, { + ...params, + isUsed: params?.isUsed, + }), + { + ...withGlobalError('Unable to retrieve secrets for cluster'), + refetchInterval() { + return options?.autoRefreshRate ?? false; + }, + select, + } + ); +} + +async function getSecretsForCluster( + environmentId: EnvironmentId, + params?: { withData?: boolean; isUsed?: boolean } +) { + const secrets = await getSecrets(environmentId, params); + return secrets; +} + +// get all secrets for a cluster +async function getSecrets( + environmentId: EnvironmentId, + params?: { withData?: boolean; isUsed?: boolean } | undefined +) { + try { + const { data } = await axios.get( + `/kubernetes/${environmentId}/secrets`, + { params } + ); + return data; + } catch (e) { + throw parseAxiosError(e, 'Unable to retrieve secrets'); + } +} diff --git a/app/react/kubernetes/configs/service.ts b/app/react/kubernetes/configs/service.ts deleted file mode 100644 index e9520c92e..000000000 --- a/app/react/kubernetes/configs/service.ts +++ /dev/null @@ -1,36 +0,0 @@ -import axios, { parseAxiosError } from '@/portainer/services/axios'; -import { EnvironmentId } from '@/react/portainer/environments/types'; - -import { Configuration } from './types'; - -// returns the formatted list of configmaps and secrets -export async function getConfigurations( - environmentId: EnvironmentId, - namespace: string -) { - try { - const { data: configmaps } = await axios.get( - `kubernetes/${environmentId}/namespaces/${namespace}/configuration` - ); - return configmaps; - } catch (e) { - throw parseAxiosError(e as Error, 'Unable to retrieve configmaps'); - } -} - -export async function getConfigMapsForCluster( - environmentId: EnvironmentId, - namespaces: string[] -) { - try { - const configmaps = await Promise.all( - namespaces.map((namespace) => getConfigurations(environmentId, namespace)) - ); - return configmaps.flat(); - } catch (e) { - throw parseAxiosError( - e as Error, - 'Unable to retrieve ConfigMaps for cluster' - ); - } -} diff --git a/app/react/kubernetes/configs/types.ts b/app/react/kubernetes/configs/types.ts index 1ab7fccac..6f729655f 100644 --- a/app/react/kubernetes/configs/types.ts +++ b/app/react/kubernetes/configs/types.ts @@ -1,18 +1,20 @@ export interface Configuration { - Id: string; + UID: string; Name: string; Type: number; Namespace: string; - CreationDate: Date; + CreationDate?: string; - ConfigurationOwner: string; + ConfigurationOwner: string; // username + ConfigurationOwnerId: string; // user id - Used: boolean; - Data: Document; + IsUsed: boolean; + Data?: Record; Yaml: string; SecretType?: string; IsRegistrySecret?: boolean; + IsSecret?: boolean; } // Workaround for the TS error `Type 'ConfigMap' does not satisfy the constraint 'Record'` for the datatable diff --git a/app/react/kubernetes/dashboard/DashboardView.tsx b/app/react/kubernetes/dashboard/DashboardView.tsx index 486b2299a..50a157839 100644 --- a/app/react/kubernetes/dashboard/DashboardView.tsx +++ b/app/react/kubernetes/dashboard/DashboardView.tsx @@ -9,14 +9,35 @@ import { DashboardItem } from '@@/DashboardItem/DashboardItem'; import { PageHeader } from '@@/PageHeader'; import { EnvironmentInfo } from './EnvironmentInfo'; -import { useGetDashboardQuery } from './queries/getDashboardQuery'; +import { useGetApplicationsCountQuery } from './queries/getApplicationsCountQuery'; +import { useGetConfigMapsCountQuery } from './queries/getConfigMapsCountQuery'; +import { useGetIngressesCountQuery } from './queries/getIngressesCountQuery'; +import { useGetSecretsCountQuery } from './queries/getSecretsCountQuery'; +import { useGetServicesCountQuery } from './queries/getServicesCountQuery'; +import { useGetVolumesCountQuery } from './queries/getVolumesCountQuery'; +import { useGetNamespacesCountQuery } from './queries/getNamespacesCountQuery'; export function DashboardView() { const queryClient = useQueryClient(); const environmentId = useEnvironmentId(); - const dashboardQuery = useGetDashboardQuery(environmentId); - const dashboard = dashboardQuery.data; + const applicationsCountQuery = useGetApplicationsCountQuery(environmentId); + const configMapsCountQuery = useGetConfigMapsCountQuery(environmentId); + const ingressesCountQuery = useGetIngressesCountQuery(environmentId); + const secretsCountQuery = useGetSecretsCountQuery(environmentId); + const servicesCountQuery = useGetServicesCountQuery(environmentId); + const volumesCountQuery = useGetVolumesCountQuery(environmentId); + const namespacesCountQuery = useGetNamespacesCountQuery(environmentId); + + const dashboard = { + applicationsCount: applicationsCountQuery.data, + configMapsCount: configMapsCountQuery.data, + ingressesCount: ingressesCountQuery.data, + secretsCount: secretsCountQuery.data, + servicesCount: servicesCountQuery.data, + volumesCount: volumesCountQuery.data, + namespacesCount: namespacesCountQuery.data, + }; return ( <> @@ -33,8 +54,8 @@ export function DashboardView() { + ['environments', environmentId, 'dashboard', 'applicationsCount'] as const, +}; + +export function useGetApplicationsCountQuery( + environmentId: EnvironmentId, + options?: { autoRefreshRate?: number } +) { + return useQuery( + queryKeys.list(environmentId), + async () => getApplicationsCount(environmentId), + { + ...withError('Unable to get applications count'), + refetchInterval() { + return options?.autoRefreshRate ?? false; + }, + } + ); +} + +async function getApplicationsCount(environmentId: EnvironmentId) { + try { + const { data: applicationsCount } = await axios.get( + `kubernetes/${environmentId}/applications/count` + ); + + return applicationsCount; + } catch (e) { + throw parseAxiosError( + e, + 'Unable to get dashboard stats. Some counts may be inaccurate.' + ); + } +} diff --git a/app/react/kubernetes/dashboard/queries/getConfigMapsCountQuery.ts b/app/react/kubernetes/dashboard/queries/getConfigMapsCountQuery.ts new file mode 100644 index 000000000..169f04bf1 --- /dev/null +++ b/app/react/kubernetes/dashboard/queries/getConfigMapsCountQuery.ts @@ -0,0 +1,41 @@ +import { useQuery } from '@tanstack/react-query'; + +import { withError } from '@/react-tools/react-query'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +const queryKeys = { + list: (environmentId: EnvironmentId) => + ['environments', environmentId, 'dashboard', 'configMapsCount'] as const, +}; + +export function useGetConfigMapsCountQuery( + environmentId: EnvironmentId, + options?: { autoRefreshRate?: number } +) { + return useQuery( + queryKeys.list(environmentId), + async () => getConfigMapsCount(environmentId), + { + ...withError('Unable to get applications count'), + refetchInterval() { + return options?.autoRefreshRate ?? false; + }, + } + ); +} + +async function getConfigMapsCount(environmentId: EnvironmentId) { + try { + const { data: configMapsCount } = await axios.get( + `kubernetes/${environmentId}/configmaps/count` + ); + + return configMapsCount; + } catch (e) { + throw parseAxiosError( + e, + 'Unable to get dashboard stats. Some counts may be inaccurate.' + ); + } +} diff --git a/app/react/kubernetes/dashboard/queries/getDashboardQuery.ts b/app/react/kubernetes/dashboard/queries/getIngressesCountQuery.ts similarity index 62% rename from app/react/kubernetes/dashboard/queries/getDashboardQuery.ts rename to app/react/kubernetes/dashboard/queries/getIngressesCountQuery.ts index 14e24e013..c0690818b 100644 --- a/app/react/kubernetes/dashboard/queries/getDashboardQuery.ts +++ b/app/react/kubernetes/dashboard/queries/getIngressesCountQuery.ts @@ -4,22 +4,20 @@ import { withError } from '@/react-tools/react-query'; import axios, { parseAxiosError } from '@/portainer/services/axios'; import { EnvironmentId } from '@/react/portainer/environments/types'; -import { K8sDashboard } from '../types'; - const queryKeys = { list: (environmentId: EnvironmentId) => - ['environments', environmentId, 'dashboard'] as const, + ['environments', environmentId, 'dashboard', 'ingressesCount'] as const, }; -export function useGetDashboardQuery( +export function useGetIngressesCountQuery( environmentId: EnvironmentId, options?: { autoRefreshRate?: number } ) { return useQuery( queryKeys.list(environmentId), - async () => getDashboard(environmentId), + async () => getIngressesCount(environmentId), { - ...withError('Unable to get dashboard stats'), + ...withError('Unable to get ingresses count'), refetchInterval() { return options?.autoRefreshRate ?? false; }, @@ -27,13 +25,13 @@ export function useGetDashboardQuery( ); } -async function getDashboard(environmentId: EnvironmentId) { +async function getIngressesCount(environmentId: EnvironmentId) { try { - const { data: dashboard } = await axios.get( - `kubernetes/${environmentId}/dashboard` + const { data: ingressesCount } = await axios.get( + `kubernetes/${environmentId}/ingresses/count` ); - return dashboard; + return ingressesCount; } catch (e) { throw parseAxiosError( e, diff --git a/app/react/kubernetes/dashboard/queries/getNamespacesCountQuery.ts b/app/react/kubernetes/dashboard/queries/getNamespacesCountQuery.ts new file mode 100644 index 000000000..bb197c1e7 --- /dev/null +++ b/app/react/kubernetes/dashboard/queries/getNamespacesCountQuery.ts @@ -0,0 +1,41 @@ +import { useQuery } from '@tanstack/react-query'; + +import { withError } from '@/react-tools/react-query'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +const queryKeys = { + list: (environmentId: EnvironmentId) => + ['environments', environmentId, 'dashboard', 'namespacesCount'] as const, +}; + +export function useGetNamespacesCountQuery( + environmentId: EnvironmentId, + options?: { autoRefreshRate?: number } +) { + return useQuery( + queryKeys.list(environmentId), + async () => getNamespacesCount(environmentId), + { + ...withError('Unable to get namespaces count'), + refetchInterval() { + return options?.autoRefreshRate ?? false; + }, + } + ); +} + +async function getNamespacesCount(environmentId: EnvironmentId) { + try { + const { data: namespacesCount } = await axios.get( + `kubernetes/${environmentId}/namespaces/count` + ); + + return namespacesCount; + } catch (e) { + throw parseAxiosError( + e, + 'Unable to get dashboard stats. Some counts may be inaccurate.' + ); + } +} diff --git a/app/react/kubernetes/dashboard/queries/getSecretsCountQuery.ts b/app/react/kubernetes/dashboard/queries/getSecretsCountQuery.ts new file mode 100644 index 000000000..eceb1bfc8 --- /dev/null +++ b/app/react/kubernetes/dashboard/queries/getSecretsCountQuery.ts @@ -0,0 +1,41 @@ +import { useQuery } from '@tanstack/react-query'; + +import { withError } from '@/react-tools/react-query'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +const queryKeys = { + list: (environmentId: EnvironmentId) => + ['environments', environmentId, 'dashboard', 'secretsCount'] as const, +}; + +export function useGetSecretsCountQuery( + environmentId: EnvironmentId, + options?: { autoRefreshRate?: number } +) { + return useQuery( + queryKeys.list(environmentId), + async () => getSecretsCount(environmentId), + { + ...withError('Unable to get secrets count'), + refetchInterval() { + return options?.autoRefreshRate ?? false; + }, + } + ); +} + +async function getSecretsCount(environmentId: EnvironmentId) { + try { + const { data: secretsCount } = await axios.get( + `kubernetes/${environmentId}/secrets/count` + ); + + return secretsCount; + } catch (e) { + throw parseAxiosError( + e, + 'Unable to get dashboard stats. Some counts may be inaccurate.' + ); + } +} diff --git a/app/react/kubernetes/dashboard/queries/getServicesCountQuery.ts b/app/react/kubernetes/dashboard/queries/getServicesCountQuery.ts new file mode 100644 index 000000000..4b46eafd2 --- /dev/null +++ b/app/react/kubernetes/dashboard/queries/getServicesCountQuery.ts @@ -0,0 +1,41 @@ +import { useQuery } from '@tanstack/react-query'; + +import { withError } from '@/react-tools/react-query'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +const queryKeys = { + list: (environmentId: EnvironmentId) => + ['environments', environmentId, 'dashboard', 'servicesCount'] as const, +}; + +export function useGetServicesCountQuery( + environmentId: EnvironmentId, + options?: { autoRefreshRate?: number } +) { + return useQuery( + queryKeys.list(environmentId), + async () => getServicesCount(environmentId), + { + ...withError('Unable to get services count'), + refetchInterval() { + return options?.autoRefreshRate ?? false; + }, + } + ); +} + +async function getServicesCount(environmentId: EnvironmentId) { + try { + const { data: servicesCount } = await axios.get( + `kubernetes/${environmentId}/services/count` + ); + + return servicesCount; + } catch (e) { + throw parseAxiosError( + e, + 'Unable to get dashboard stats. Some counts may be inaccurate.' + ); + } +} diff --git a/app/react/kubernetes/dashboard/queries/getVolumesCountQuery.ts b/app/react/kubernetes/dashboard/queries/getVolumesCountQuery.ts new file mode 100644 index 000000000..d22ba5fae --- /dev/null +++ b/app/react/kubernetes/dashboard/queries/getVolumesCountQuery.ts @@ -0,0 +1,41 @@ +import { useQuery } from '@tanstack/react-query'; + +import { withError } from '@/react-tools/react-query'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +const queryKeys = { + list: (environmentId: EnvironmentId) => + ['environments', environmentId, 'dashboard', 'volumesCount'] as const, +}; + +export function useGetVolumesCountQuery( + environmentId: EnvironmentId, + options?: { autoRefreshRate?: number } +) { + return useQuery( + queryKeys.list(environmentId), + async () => getVolumesCount(environmentId), + { + ...withError('Unable to get volumes count'), + refetchInterval() { + return options?.autoRefreshRate ?? false; + }, + } + ); +} + +async function getVolumesCount(environmentId: EnvironmentId) { + try { + const { data: volumesCount } = await axios.get( + `kubernetes/${environmentId}/volumes/count` + ); + + return volumesCount; + } catch (e) { + throw parseAxiosError( + e, + 'Unable to get dashboard stats. Some counts may be inaccurate.' + ); + } +} diff --git a/app/react/kubernetes/ingresses/CreateIngressView/CreateIngressView.tsx b/app/react/kubernetes/ingresses/CreateIngressView/CreateIngressView.tsx index 69985c2db..414c6d625 100644 --- a/app/react/kubernetes/ingresses/CreateIngressView/CreateIngressView.tsx +++ b/app/react/kubernetes/ingresses/CreateIngressView/CreateIngressView.tsx @@ -71,10 +71,7 @@ export function CreateIngressView() { const { data: allServices } = useNamespaceServices(environmentId, namespace); const secretsResults = useSecrets(environmentId, namespace); - const ingressesResults = useIngresses( - environmentId, - namespaces ? Object.keys(namespaces || {}) : [] - ); + const ingressesResults = useIngresses(environmentId); const { data: ingressControllers, ...ingressControllersQuery } = useIngressControllers(environmentId, namespace); @@ -90,7 +87,7 @@ export function CreateIngressView() { ] => { const ruleCounterByNamespace: Record = {}; const hostWithTLS: Record = {}; - ingressesResults.data?.forEach((ingress) => { + ingressesResults.data?.forEach((ingress: Ingress) => { ingress.TLS?.forEach((tls) => { tls.Hosts.forEach((host) => { hostWithTLS[host] = tls.SecretName; @@ -98,7 +95,7 @@ export function CreateIngressView() { }); }); const ingressNames: string[] = []; - ingressesResults.data?.forEach((ing) => { + ingressesResults.data?.forEach((ing: Ingress) => { ruleCounterByNamespace[ing.Namespace] = ruleCounterByNamespace[ing.Namespace] || 0; const n = ing.Name.match(/^(.*)-(\d+)$/); @@ -123,10 +120,10 @@ export function CreateIngressView() { const namespaceOptions = useMemo( () => Object.entries(namespaces || {}) - .filter(([, nsValue]) => !nsValue.IsSystem) - .map(([nsKey]) => ({ - label: nsKey, - value: nsKey, + .filter(([, ns]) => !ns.IsSystem) + .map(([, ns]) => ({ + label: ns.Name, + value: ns.Name, })), [namespaces] ); @@ -170,10 +167,10 @@ export function CreateIngressView() { ? Object.fromEntries( allServices?.map((service) => [ service.Name, - service.Ports.map((port) => ({ + service.Ports?.map((port) => ({ label: String(port.Port), value: String(port.Port), - })), + })) ?? [], ]) ) : {}, diff --git a/app/react/kubernetes/ingresses/IngressDatatable/IngressDatatable.tsx b/app/react/kubernetes/ingresses/IngressDatatable/IngressDatatable.tsx index e79da16e1..e8e564d84 100644 --- a/app/react/kubernetes/ingresses/IngressDatatable/IngressDatatable.tsx +++ b/app/react/kubernetes/ingresses/IngressDatatable/IngressDatatable.tsx @@ -4,27 +4,20 @@ import { useMemo } from 'react'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { useAuthorizations, Authorized } from '@/react/hooks/useUser'; import Route from '@/assets/ico/route.svg?c'; -import { - DefaultDatatableSettings, - TableSettings as KubeTableSettings, -} from '@/react/kubernetes/datatables/DefaultDatatableSettings'; -import { useKubeStore } from '@/react/kubernetes/datatables/default-kube-datatable-store'; +import { useIsDeploymentOptionHidden } from '@/react/hooks/useIsDeploymentOptionHidden'; +import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings'; +import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store'; import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemResourceDescription'; import { Datatable, TableSettingsMenu } from '@@/datatables'; import { AddButton } from '@@/buttons'; +import { useTableState } from '@@/datatables/useTableState'; import { DeleteButton } from '@@/buttons/DeleteButton'; -import { - type FilteredColumnsTableSettings, - filteredColumnsSettings, -} from '@@/datatables/types'; -import { mergeOptions } from '@@/datatables/extend-options/mergeOptions'; -import { withColumnFilters } from '@@/datatables/extend-options/withColumnFilters'; import { DeleteIngressesRequest, Ingress } from '../types'; import { useDeleteIngresses, useIngresses } from '../queries'; import { useNamespacesQuery } from '../../namespaces/queries/useNamespacesQuery'; -import { Namespaces } from '../../namespaces/types'; +import { Namespaces, PortainerNamespace } from '../../namespaces/types'; import { CreateFromManifestButton } from '../../components/CreateFromManifestButton'; import { columns } from './columns'; @@ -37,48 +30,48 @@ interface SelectedIngress { } const storageKey = 'ingressClassesNameSpace'; -interface TableSettings - extends KubeTableSettings, - FilteredColumnsTableSettings {} +const settingsStore = createStore(storageKey, 'name'); export function IngressDatatable() { - const tableState = useKubeStore( - storageKey, - undefined, - (set) => ({ - ...filteredColumnsSettings(set), - }) - ); + const tableState = useTableState(settingsStore, storageKey); const environmentId = useEnvironmentId(); const { authorized: canAccessSystemResources } = useAuthorizations( 'K8sAccessSystemNamespaces' ); - const { data: namespaces, ...namespacesQuery } = - useNamespacesQuery(environmentId); - const { data: ingresses, ...ingressesQuery } = useIngresses( - environmentId, - Object.keys(namespaces || {}), - { - autoRefreshRate: tableState.autoRefreshRate * 1000, - } - ); + const namespacesQuery = useNamespacesQuery(environmentId); + const { data: ingresses, ...ingressesQuery } = useIngresses(environmentId, { + autoRefreshRate: tableState.autoRefreshRate * 1000, + withServices: true, + }); + + const namespacesMap = useMemo(() => { + const namespacesMap = namespacesQuery.data?.reduce< + Record + >((acc, namespace) => { + acc[namespace.Name] = namespace; + return acc; + }, {}); + return namespacesMap ?? {}; + }, [namespacesQuery.data]); const filteredIngresses = useMemo( () => ingresses?.filter( (ingress) => (canAccessSystemResources && tableState.showSystemResources) || - !namespaces?.[ingress.Namespace].IsSystem + !namespacesMap?.[ingress.Namespace].IsSystem ) || [], - [ingresses, tableState, canAccessSystemResources, namespaces] + [ingresses, tableState, canAccessSystemResources, namespacesMap] ); const ingressesWithIsSystem = useIngressesRowData( filteredIngresses || [], - namespaces + namespacesMap ); + const isAddIngressHidden = useIsDeploymentOptionHidden('form'); + const deleteIngressesMutation = useDeleteIngresses(); const router = useRouter(); @@ -89,10 +82,13 @@ export function IngressDatatable() { dataset={ingressesWithIsSystem} columns={columns} isLoading={ingressesQuery.isLoading || namespacesQuery.isLoading} + emptyContentLabel="No supported ingresses found" title="Ingresses" titleIcon={Route} getRowId={(row) => row.Name + row.Type + row.Namespace} - isRowSelectable={(row) => !namespaces?.[row.original.Namespace].IsSystem} + isRowSelectable={(row) => + !namespacesMap?.[row.original.Namespace].IsSystem + } renderTableActions={tableActions} renderTableSettings={() => ( @@ -106,9 +102,6 @@ export function IngressDatatable() { } disableSelect={useCheckboxes()} data-cy="k8s-ingresses-datatable" - extendTableOptions={mergeOptions( - withColumnFilters(tableState.columnFilters, tableState.setColumnFilters) - )} /> ); @@ -137,9 +130,15 @@ export function IngressDatatable() { data-cy="remove-ingresses-button" /> - - Add with form - + {!isAddIngressHidden && ( + + Add with form + + )} diff --git a/app/react/kubernetes/ingresses/queries.ts b/app/react/kubernetes/ingresses/queries.ts index ea112afb4..c9f5f4448 100644 --- a/app/react/kubernetes/ingresses/queries.ts +++ b/app/react/kubernetes/ingresses/queries.ts @@ -3,11 +3,9 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { EnvironmentId } from '@/react/portainer/environments/types'; import { mutationOptions, - withError, + withGlobalError, withInvalidate, } from '@/react-tools/react-query'; -import { getServices } from '@/react/kubernetes/networks/services/service'; -import { isFulfilled } from '@/portainer/helpers/promise-utils'; import { getIngresses, @@ -19,13 +17,23 @@ import { } from './service'; import { DeleteIngressesRequest, Ingress } from './types'; -const ingressKeys = { - all: ['environments', 'kubernetes', 'namespace', 'ingress'] as const, - namespace: ( +const queryKeys = { + base: ['environments', 'kubernetes', 'ingress'] as const, + clusterIngresses: (environmentId: EnvironmentId) => + [...queryKeys.base, String(environmentId)] as const, + namespaceIngresses: ( environmentId: EnvironmentId, namespace: string, ingress: string - ) => [...ingressKeys.all, String(environmentId), namespace, ingress] as const, + ) => [...queryKeys.base, String(environmentId), namespace, ingress] as const, + ingress: (environmentId: EnvironmentId, namespace: string, name: string) => + [...queryKeys.base, String(environmentId), namespace, name] as const, + ingressControllers: (environmentId: EnvironmentId, namespace: string) => [ + ...queryKeys.base, + String(environmentId), + namespace, + 'ingresscontrollers', + ], }; export function useIngress( @@ -34,93 +42,34 @@ export function useIngress( name: string ) { return useQuery( - [ - 'environments', - environmentId, - 'kubernetes', - 'namespace', - namespace, - 'ingress', - name, - ], + queryKeys.ingress(environmentId, namespace, name), async () => { const ing = await getIngress(environmentId, namespace, name); return ing; }, { - ...withError('Unable to get ingress'), + ...withGlobalError('Unable to get ingress'), } ); } export function useIngresses( environmentId: EnvironmentId, - namespaces?: string[], - options?: { autoRefreshRate?: number } + options?: { + autoRefreshRate?: number; + enabled?: boolean; + withServices?: boolean; + } ) { + const { enabled, autoRefreshRate, ...params } = options ?? {}; + return useQuery( - [ - 'environments', - environmentId, - 'kubernetes', - 'namespace', - namespaces, - 'ingress', - ], - async () => { - if (!namespaces?.length) { - return []; - } - const settledIngressesPromise = await Promise.allSettled( - namespaces.map((namespace) => getIngresses(environmentId, namespace)) - ); - const ingresses = settledIngressesPromise - .filter(isFulfilled) - ?.map((i) => i.value); - // flatten the array and remove empty ingresses - const filteredIngresses = ingresses.flat().filter((ing) => ing); - - // get all services in only the namespaces that the ingresses are in to find missing services - const uniqueNamespacesWithIngress = [ - ...new Set(filteredIngresses.map((ing) => ing?.Namespace)), - ]; - const settledServicesPromise = await Promise.allSettled( - uniqueNamespacesWithIngress.map((ns) => getServices(environmentId, ns)) - ); - const services = settledServicesPromise - .filter(isFulfilled) - ?.map((s) => s.value) - .flat(); - - // check if each ingress path service has a service that still exists - const updatedFilteredIngresses: Ingress[] = filteredIngresses.map( - (ing) => { - const servicesInNamespace = services?.filter( - (service) => service?.Namespace === ing?.Namespace - ); - const serviceNamesInNamespace = servicesInNamespace?.map( - (service) => service.Name - ); - - const updatedPaths = - ing.Paths?.map((path) => { - const hasService = serviceNamesInNamespace?.includes( - path.ServiceName - ); - return { ...path, HasService: hasService }; - }) || null; - - return { ...ing, Paths: updatedPaths }; - } - ); - return updatedFilteredIngresses; - }, + ['environments', environmentId, 'kubernetes', 'ingress', params], + async () => getIngresses(environmentId, params), { - enabled: !!namespaces?.length, - ...withError('Unable to get ingresses'), - refetchInterval() { - return options?.autoRefreshRate ?? false; - }, + ...withGlobalError('Unable to get ingresses'), + refetchInterval: autoRefreshRate, + enabled, } ); } @@ -136,8 +85,8 @@ export function useCreateIngress() { ingress: Ingress; }) => createIngress(environmentId, ingress), mutationOptions( - withError('Unable to create ingress controller'), - withInvalidate(queryClient, [ingressKeys.all]) + withGlobalError('Unable to create ingress controller'), + withInvalidate(queryClient, [queryKeys.base]) ) ); } @@ -153,8 +102,8 @@ export function useUpdateIngress() { ingress: Ingress; }) => updateIngress(environmentId, ingress), mutationOptions( - withError('Unable to update ingress controller'), - withInvalidate(queryClient, [ingressKeys.all]) + withGlobalError('Unable to update ingress controller'), + withInvalidate(queryClient, [queryKeys.base]) ) ); } @@ -170,8 +119,8 @@ export function useDeleteIngresses() { data: DeleteIngressesRequest; }) => deleteIngresses(environmentId, data), mutationOptions( - withError('Unable to update ingress controller'), - withInvalidate(queryClient, [ingressKeys.all]) + withGlobalError('Unable to update ingress controller'), + withInvalidate(queryClient, [queryKeys.base]) ) ); } @@ -185,21 +134,14 @@ export function useIngressControllers( allowedOnly?: boolean ) { return useQuery( - [ - 'environments', - environmentId, - 'kubernetes', - 'namespace', - namespace, - 'ingresscontrollers', - ], + queryKeys.ingressControllers(environmentId, namespace ?? ''), async () => namespace ? getIngressControllers(environmentId, namespace, allowedOnly) : [], { enabled: !!namespace, - ...withError('Unable to get ingress controllers'), + ...withGlobalError('Unable to get ingress controllers'), } ); } diff --git a/app/react/kubernetes/ingresses/service.ts b/app/react/kubernetes/ingresses/service.ts index 4552d4a2e..6fa1aa0c3 100644 --- a/app/react/kubernetes/ingresses/service.ts +++ b/app/react/kubernetes/ingresses/service.ts @@ -20,11 +20,12 @@ export async function getIngress( export async function getIngresses( environmentId: EnvironmentId, - namespace: string + params?: { withServices?: boolean } ) { try { const { data: ingresses } = await axios.get( - buildUrl(environmentId, namespace) + `kubernetes/${environmentId}/ingresses`, + { params } ); return ingresses; } catch (e) { diff --git a/app/react/kubernetes/metrics/metrics.ts b/app/react/kubernetes/metrics/metrics.ts new file mode 100644 index 000000000..2025dc21e --- /dev/null +++ b/app/react/kubernetes/metrics/metrics.ts @@ -0,0 +1,84 @@ +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { + NodeMetrics, + NodeMetric, + ApplicationResource, +} from '@/react/kubernetes/metrics/types'; + +export async function getMetricsForAllNodes(environmentId: EnvironmentId) { + try { + const { data: nodes } = await axios.get( + `kubernetes/${environmentId}/metrics/nodes` + ); + return nodes; + } catch (e) { + throw parseAxiosError(e, 'Unable to retrieve metrics for all nodes'); + } +} + +export async function getMetricsForNode( + environmentId: EnvironmentId, + nodeName: string +) { + try { + const { data: node } = await axios.get( + `kubernetes/${environmentId}/metrics/nodes/${nodeName}` + ); + + return node; + } catch (e) { + throw parseAxiosError(e, 'Unable to retrieve metrics for node'); + } +} + +export async function getMetricsForAllPods( + environmentId: EnvironmentId, + namespace: string +) { + try { + const { data: pods } = await axios.get( + `kubernetes/${environmentId}/metrics/pods/namespace/${namespace}` + ); + return pods; + } catch (e) { + throw parseAxiosError(e, 'Unable to retrieve metrics for all pods'); + } +} + +export async function getMetricsForPod( + environmentId: EnvironmentId, + namespace: string, + podName: string +) { + try { + const { data: pod } = await axios.get( + `kubernetes/${environmentId}/metrics/pods/namespace/${namespace}/${podName}` + ); + return pod; + } catch (e) { + throw parseAxiosError(e, 'Unable to retrieve metrics for pod'); + } +} + +export async function getTotalResourcesForAllApplications( + environmentId: EnvironmentId, + nodeName?: string +) { + try { + const { data: resources } = await axios.get( + `kubernetes/${environmentId}/metrics/applications_resources`, + { + params: { + node: nodeName, + }, + } + ); + return resources; + } catch (e) { + throw parseAxiosError( + e, + 'Unable to retrieve total resources for all applications' + ); + } +} diff --git a/app/react/kubernetes/metrics/types.ts b/app/react/kubernetes/metrics/types.ts new file mode 100644 index 000000000..78223dda0 --- /dev/null +++ b/app/react/kubernetes/metrics/types.ts @@ -0,0 +1,27 @@ +export type NodeMetrics = { + items: NodeMetric[]; +}; + +export type NodeMetric = { + metadata: NodeMetricMetadata; + timestamp: Date; + usage: Usage; + window: string; +}; + +export type NodeMetricMetadata = { + creationTimestamp: Date; + name: string; +}; + +export type Usage = { + cpu: string; + memory: string; +}; + +export type ApplicationResource = { + cpuRequest: number; + cpuLimit: number; + memoryRequest: number; + memoryLimit: number; +}; diff --git a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/ClusterRoleBindingsDatatable.tsx b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/ClusterRoleBindingsDatatable.tsx new file mode 100644 index 000000000..1398a306b --- /dev/null +++ b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/ClusterRoleBindingsDatatable.tsx @@ -0,0 +1,173 @@ +import { Trash2, Link as LinkIcon } from 'lucide-react'; +import { useRouter } from '@uirouter/react'; +import { Row } from '@tanstack/react-table'; +import clsx from 'clsx'; +import { useMemo } from 'react'; + +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { useAuthorizations, Authorized } from '@/react/hooks/useUser'; +import { notifyError, notifySuccess } from '@/portainer/services/notifications'; +import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemResourceDescription'; +import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store'; +import { CreateFromManifestButton } from '@/react/kubernetes/components/CreateFromManifestButton'; + +import { confirmDelete } from '@@/modals/confirm'; +import { Datatable, Table, TableSettingsMenu } from '@@/datatables'; +import { LoadingButton } from '@@/buttons'; +import { useTableState } from '@@/datatables/useTableState'; + +import { DefaultDatatableSettings } from '../../../datatables/DefaultDatatableSettings'; + +import { ClusterRoleBinding } from './types'; +import { columns } from './columns'; +import { useGetClusterRoleBindingsQuery } from './queries/useGetClusterRoleBindingsQuery'; +import { useDeleteClusterRoleBindingsMutation } from './queries/useDeleteClusterRoleBindingsMutation'; + +const storageKey = 'clusterRoleBindings'; +const settingsStore = createStore(storageKey); + +export function ClusterRoleBindingsDatatable() { + const environmentId = useEnvironmentId(); + const tableState = useTableState(settingsStore, storageKey); + const clusterRoleBindingsQuery = useGetClusterRoleBindingsQuery( + environmentId, + { + autoRefreshRate: tableState.autoRefreshRate * 1000, + } + ); + + const filteredClusterRoleBindings = useMemo( + () => + clusterRoleBindingsQuery.data?.filter( + (crb) => tableState.showSystemResources || !crb.isSystem + ), + [clusterRoleBindingsQuery.data, tableState.showSystemResources] + ); + + const { authorized: isAuthorizedToAddOrEdit } = useAuthorizations([ + 'K8sClusterRoleBindingsW', + ]); + + return ( + row.uid} + isRowSelectable={(row) => !row.original.isSystem} + renderTableActions={(selectedRows) => ( + + )} + renderTableSettings={() => ( + + + + )} + description={ + + } + disableSelect={!isAuthorizedToAddOrEdit} + renderRow={renderRow} + data-cy="k8s-cluster-role-bindings-datatable" + /> + ); +} + +// needed to apply custom styling to the row and not globally required in the AC's for this ticket. +function renderRow(row: Row, highlightedItemId?: string) { + return ( + + cells={row.getVisibleCells()} + className={clsx('[&>td]:!py-4 [&>td]:!align-top', { + active: highlightedItemId === row.id, + })} + /> + ); +} + +interface SelectedRole { + name: string; +} + +type TableActionsProps = { + selectedItems: ClusterRoleBinding[]; +}; + +function TableActions({ selectedItems }: TableActionsProps) { + const environmentId = useEnvironmentId(); + const deleteClusterRoleBindingsMutation = + useDeleteClusterRoleBindingsMutation(environmentId); + const router = useRouter(); + + async function handleRemoveClick(roles: SelectedRole[]) { + const confirmed = await confirmDelete( + <> +

+ Are you sure you want to delete the selected cluster role binding(s)? +

+
    + {roles.map((s, index) => ( +
  • {s.name}
  • + ))} +
+ + ); + if (!confirmed) { + return null; + } + + const payload: string[] = []; + roles.forEach((r) => { + payload.push(r.name); + }); + + deleteClusterRoleBindingsMutation.mutate( + { environmentId, data: payload }, + { + onSuccess: () => { + notifySuccess( + 'Roles successfully removed', + roles.map((r) => `${r.name}`).join(', ') + ); + router.stateService.reload(); + }, + onError: (error) => { + notifyError( + 'Unable to delete cluster role bindings', + error as Error, + roles.map((r) => `${r.name}`).join(', ') + ); + }, + } + ); + return roles; + } + + return ( + + handleRemoveClick(selectedItems)} + icon={Trash2} + isLoading={deleteClusterRoleBindingsMutation.isLoading} + loadingText="Removing cluster role bindings..." + data-cy="k8s-cluster-role-bindings-remove-button" + > + Remove + + + + + ); +} diff --git a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/columns/created.tsx b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/columns/created.tsx new file mode 100644 index 000000000..c9c456278 --- /dev/null +++ b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/columns/created.tsx @@ -0,0 +1,12 @@ +import { formatDate } from '@/portainer/filters/filters'; + +import { columnHelper } from './helper'; + +export const created = columnHelper.accessor( + (row) => formatDate(row.creationDate), + { + header: 'Created', + id: 'created', + cell: ({ getValue }) => getValue(), + } +); diff --git a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/columns/helper.ts b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/columns/helper.ts new file mode 100644 index 000000000..49567a3d6 --- /dev/null +++ b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/columns/helper.ts @@ -0,0 +1,5 @@ +import { createColumnHelper } from '@tanstack/react-table'; + +import { ClusterRoleBinding } from '../types'; + +export const columnHelper = createColumnHelper(); diff --git a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/columns/index.tsx b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/columns/index.tsx new file mode 100644 index 000000000..5fcf659b1 --- /dev/null +++ b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/columns/index.tsx @@ -0,0 +1,17 @@ +import { name } from './name'; +import { roleName } from './roleName'; +import { kind } from './kind'; +import { created } from './created'; +import { subjectKind } from './subjectKind'; +import { subjectName } from './subjectName'; +import { subjectNamespace } from './subjectNamespace'; + +export const columns = [ + name, + roleName, + kind, + subjectKind, + subjectName, + subjectNamespace, + created, +]; diff --git a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/columns/kind.tsx b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/columns/kind.tsx new file mode 100644 index 000000000..5ad647d60 --- /dev/null +++ b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/columns/kind.tsx @@ -0,0 +1,6 @@ +import { columnHelper } from './helper'; + +export const kind = columnHelper.accessor('roleRef.kind', { + header: 'Role Kind', + id: 'roleKind', +}); diff --git a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/columns/name.tsx b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/columns/name.tsx new file mode 100644 index 000000000..45ebb750e --- /dev/null +++ b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/columns/name.tsx @@ -0,0 +1,22 @@ +import { SystemBadge } from '@@/Badge/SystemBadge'; + +import { columnHelper } from './helper'; + +export const name = columnHelper.accessor( + (row) => { + if (row.isSystem) { + return `${row.name} system`; + } + return row.name; + }, + { + header: 'Name', + id: 'name', + cell: ({ row }) => ( +
+ {row.original.name} + {row.original.isSystem && } +
+ ), + } +); diff --git a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/columns/roleName.tsx b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/columns/roleName.tsx new file mode 100644 index 000000000..69fb3a6f1 --- /dev/null +++ b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/columns/roleName.tsx @@ -0,0 +1,6 @@ +import { columnHelper } from './helper'; + +export const roleName = columnHelper.accessor('roleRef.name', { + header: 'Role Name', + id: 'roleName', +}); diff --git a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/columns/subjectKind.tsx b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/columns/subjectKind.tsx new file mode 100644 index 000000000..49c43cb34 --- /dev/null +++ b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/columns/subjectKind.tsx @@ -0,0 +1,13 @@ +import { columnHelper } from './helper'; + +export const subjectKind = columnHelper.accessor( + (row) => row.subjects?.map((sub) => sub.kind).join(', '), + { + header: 'Subject Kind', + id: 'subjectKind', + cell: ({ row }) => + row.original.subjects?.map((sub, index) => ( +
{sub.kind}
+ )) || '-', + } +); diff --git a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/columns/subjectName.tsx b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/columns/subjectName.tsx new file mode 100644 index 000000000..b760c35a0 --- /dev/null +++ b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/columns/subjectName.tsx @@ -0,0 +1,13 @@ +import { columnHelper } from './helper'; + +export const subjectName = columnHelper.accessor( + (row) => row.subjects?.map((sub) => sub.name).join(' '), + { + header: 'Subject Name', + id: 'subjectName', + cell: ({ row }) => + row.original.subjects?.map((sub, index) => ( +
{sub.name}
+ )) || '-', + } +); diff --git a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/columns/subjectNamespace.tsx b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/columns/subjectNamespace.tsx new file mode 100644 index 000000000..68f3145d4 --- /dev/null +++ b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/columns/subjectNamespace.tsx @@ -0,0 +1,42 @@ +import { Link } from '@@/Link'; +import { filterHOC } from '@@/datatables/Filter'; + +import { filterFn, filterNamespaceOptionsTransformer } from '../../utils'; + +import { columnHelper } from './helper'; + +export const subjectNamespace = columnHelper.accessor( + (row) => row.subjects?.flatMap((sub) => sub.namespace || '-') || [], + { + header: 'Subject Namespace', + id: 'subjectNamespace', + cell: ({ row }) => + row.original.subjects?.map((sub, index) => ( +
+ {sub.namespace ? ( + + {sub.namespace} + + ) : ( + '-' + )} +
+ )) || '-', + enableColumnFilter: true, + // use a custom filter, to remove empty namespace values + meta: { + filter: filterHOC( + 'Filter by subject namespace', + filterNamespaceOptionsTransformer + ), + }, + filterFn, + } +); diff --git a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/queries/query-keys.ts b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/queries/query-keys.ts new file mode 100644 index 000000000..99058104f --- /dev/null +++ b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/queries/query-keys.ts @@ -0,0 +1,11 @@ +import { EnvironmentId } from '@/react/portainer/environments/types'; + +export const queryKeys = { + list: (environmentId: EnvironmentId) => + [ + 'environments', + environmentId, + 'kubernetes', + 'cluster_role_bindings', + ] as const, +}; diff --git a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/queries/useDeleteClusterRoleBindingsMutation.ts b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/queries/useDeleteClusterRoleBindingsMutation.ts new file mode 100644 index 000000000..9767f90e3 --- /dev/null +++ b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/queries/useDeleteClusterRoleBindingsMutation.ts @@ -0,0 +1,34 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { withGlobalError, withInvalidate } from '@/react-tools/react-query'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { queryKeys } from './query-keys'; + +export function useDeleteClusterRoleBindingsMutation( + environmentId: EnvironmentId +) { + const queryClient = useQueryClient(); + return useMutation(deleteClusterRoleBindings, { + ...withInvalidate(queryClient, [queryKeys.list(environmentId)]), + ...withGlobalError('Unable to delete cluster role bindings'), + }); +} + +export async function deleteClusterRoleBindings({ + environmentId, + data, +}: { + environmentId: EnvironmentId; + data: string[]; +}) { + try { + return await axios.post( + `kubernetes/${environmentId}/cluster_role_bindings/delete`, + data + ); + } catch (e) { + throw parseAxiosError(e, `Unable to delete cluster role bindings`); + } +} diff --git a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/queries/useGetClusterRoleBindingsQuery.ts b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/queries/useGetClusterRoleBindingsQuery.ts new file mode 100644 index 000000000..3e2ce70c0 --- /dev/null +++ b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/queries/useGetClusterRoleBindingsQuery.ts @@ -0,0 +1,41 @@ +import { compact } from 'lodash'; +import { useQuery } from '@tanstack/react-query'; + +import { withGlobalError } from '@/react-tools/react-query'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { ClusterRoleBinding } from '../types'; + +import { queryKeys } from './query-keys'; + +export function useGetClusterRoleBindingsQuery( + environmentId: EnvironmentId, + options?: { autoRefreshRate?: number } +) { + return useQuery( + queryKeys.list(environmentId), + async () => { + const cluerRoleBindings = await getClusterRoleBindings(environmentId); + return compact(cluerRoleBindings); + }, + { + ...withGlobalError('Unable to get cluster role bindings'), + refetchInterval() { + return options?.autoRefreshRate ?? false; + }, + } + ); +} + +async function getClusterRoleBindings(environmentId: EnvironmentId) { + try { + const { data: roles } = await axios.get( + `kubernetes/${environmentId}/cluster_role_bindings` + ); + + return roles; + } catch (e) { + throw parseAxiosError(e, 'Unable to get cluster role bindings'); + } +} diff --git a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/types.ts b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/types.ts new file mode 100644 index 000000000..dcc4ff107 --- /dev/null +++ b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/types.ts @@ -0,0 +1,26 @@ +export type ClusterRoleRef = { + name: string; + kind: string; + apiGroup?: string; +}; + +export type ClusterRoleSubject = { + name: string; + kind: string; + apiGroup?: string; + namespace?: string; +}; + +export type ClusterRoleBinding = { + name: string; + uid: string; + namespace: string; + resourceVersion: string; + creationDate: string; + annotations: Record | null; + + roleRef: ClusterRoleRef; + subjects: ClusterRoleSubject[] | null; + + isSystem: boolean; +}; diff --git a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/ClusterRolesDatatable.tsx b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/ClusterRolesDatatable.tsx new file mode 100644 index 000000000..58bbd3783 --- /dev/null +++ b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/ClusterRolesDatatable.tsx @@ -0,0 +1,152 @@ +import { Trash2, UserCheck } from 'lucide-react'; +import { useRouter } from '@uirouter/react'; +import { useMemo } from 'react'; + +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { useAuthorizations, Authorized } from '@/react/hooks/useUser'; +import { notifyError, notifySuccess } from '@/portainer/services/notifications'; +import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemResourceDescription'; +import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store'; +import { CreateFromManifestButton } from '@/react/kubernetes/components/CreateFromManifestButton'; + +import { confirmDelete } from '@@/modals/confirm'; +import { Datatable, TableSettingsMenu } from '@@/datatables'; +import { LoadingButton } from '@@/buttons'; +import { useTableState } from '@@/datatables/useTableState'; + +import { DefaultDatatableSettings } from '../../../datatables/DefaultDatatableSettings'; + +import { ClusterRole } from './types'; +import { columns } from './columns'; +import { useGetClusterRolesQuery } from './queries/useGetClusterRolesQuery'; +import { useDeleteClusterRolesMutation } from './queries/useDeleteClusterRolesMutation'; + +const storageKey = 'clusterRoles'; +const settingsStore = createStore(storageKey); + +export function ClusterRolesDatatable() { + const environmentId = useEnvironmentId(); + const tableState = useTableState(settingsStore, storageKey); + const clusterRolesQuery = useGetClusterRolesQuery(environmentId, { + autoRefreshRate: tableState.autoRefreshRate * 1000, + }); + + const { authorized: isAuthorizedToAddEdit } = useAuthorizations([ + 'K8sClusterRolesW', + ]); + const filteredClusterRoles = useMemo( + () => + clusterRolesQuery.data?.filter( + (cr) => tableState.showSystemResources || !cr.isSystem + ), + [clusterRolesQuery.data, tableState.showSystemResources] + ); + + return ( + row.uid} + isRowSelectable={(row) => !row.original.isSystem} + renderTableActions={(selectedRows) => ( + + )} + renderTableSettings={() => ( + + + + )} + description={ + + } + disableSelect={!isAuthorizedToAddEdit} + data-cy="k8s-clusterroles-datatable" + /> + ); +} + +interface SelectedRole { + name: string; +} + +type TableActionsProps = { + selectedItems: ClusterRole[]; +}; + +function TableActions({ selectedItems }: TableActionsProps) { + const environmentId = useEnvironmentId(); + const deleteClusterRolesMutation = + useDeleteClusterRolesMutation(environmentId); + const router = useRouter(); + + async function handleRemoveClick(roles: SelectedRole[]) { + const confirmed = await confirmDelete( + <> +

Are you sure you want to delete the selected cluster role(s)?

+
    + {roles.map((s, index) => ( +
  • {s.name}
  • + ))} +
+ + ); + if (!confirmed) { + return null; + } + + const payload: string[] = []; + roles.forEach((r) => { + payload.push(r.name); + }); + + deleteClusterRolesMutation.mutate( + { environmentId, data: payload }, + { + onSuccess: () => { + notifySuccess( + 'Roles successfully removed', + roles.map((r) => `${r.name}`).join(', ') + ); + router.stateService.reload(); + }, + onError: (error) => { + notifyError( + 'Unable to delete cluster roles', + error as Error, + roles.map((r) => `${r.name}`).join(', ') + ); + }, + } + ); + return roles; + } + + return ( + + handleRemoveClick(selectedItems)} + icon={Trash2} + isLoading={deleteClusterRolesMutation.isLoading} + loadingText="Removing cluster roles..." + data-cy="k8sClusterRoles-removeRoleButton" + > + Remove + + + + + ); +} diff --git a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/columns/created.tsx b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/columns/created.tsx new file mode 100644 index 000000000..c9c456278 --- /dev/null +++ b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/columns/created.tsx @@ -0,0 +1,12 @@ +import { formatDate } from '@/portainer/filters/filters'; + +import { columnHelper } from './helper'; + +export const created = columnHelper.accessor( + (row) => formatDate(row.creationDate), + { + header: 'Created', + id: 'created', + cell: ({ getValue }) => getValue(), + } +); diff --git a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/columns/helper.ts b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/columns/helper.ts new file mode 100644 index 000000000..6dbf16107 --- /dev/null +++ b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/columns/helper.ts @@ -0,0 +1,5 @@ +import { createColumnHelper } from '@tanstack/react-table'; + +import { ClusterRole } from '../types'; + +export const columnHelper = createColumnHelper(); diff --git a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/columns/index.tsx b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/columns/index.tsx new file mode 100644 index 000000000..0c7a4f36d --- /dev/null +++ b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/columns/index.tsx @@ -0,0 +1,4 @@ +import { name } from './name'; +import { created } from './created'; + +export const columns = [name, created]; diff --git a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/columns/name.tsx b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/columns/name.tsx new file mode 100644 index 000000000..b73bc6180 --- /dev/null +++ b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/columns/name.tsx @@ -0,0 +1,28 @@ +import { SystemBadge } from '@@/Badge/SystemBadge'; +import { UnusedBadge } from '@@/Badge/UnusedBadge'; + +import { columnHelper } from './helper'; + +export const name = columnHelper.accessor( + (row) => { + let result = row.name; + if (row.isSystem) { + result += ' system'; + } + if (row.isUnused) { + result += ' unused'; + } + return result; + }, + { + header: 'Name', + id: 'name', + cell: ({ row }) => ( +
+ {row.original.name} + {row.original.isSystem && } + {row.original.isUnused && } +
+ ), + } +); diff --git a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/queries/query-keys.ts b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/queries/query-keys.ts new file mode 100644 index 000000000..ddbc663e2 --- /dev/null +++ b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/queries/query-keys.ts @@ -0,0 +1,6 @@ +import { EnvironmentId } from '@/react/portainer/environments/types'; + +export const queryKeys = { + list: (environmentId: EnvironmentId) => + ['environments', environmentId, 'kubernetes', 'cluster_roles'] as const, +}; diff --git a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/queries/useDeleteClusterRolesMutation.ts b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/queries/useDeleteClusterRolesMutation.ts new file mode 100644 index 000000000..85167e651 --- /dev/null +++ b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/queries/useDeleteClusterRolesMutation.ts @@ -0,0 +1,33 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { withGlobalError } from '@/react-tools/react-query'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { queryKeys } from './query-keys'; + +export function useDeleteClusterRolesMutation(environmentId: EnvironmentId) { + const queryClient = useQueryClient(); + return useMutation(deleteClusterRoles, { + onSuccess: () => + queryClient.invalidateQueries(queryKeys.list(environmentId)), + ...withGlobalError('Unable to delete cluster roles'), + }); +} + +export async function deleteClusterRoles({ + environmentId, + data, +}: { + environmentId: EnvironmentId; + data: string[]; +}) { + try { + return await axios.post( + `kubernetes/${environmentId}/cluster_roles/delete`, + data + ); + } catch (e) { + throw parseAxiosError(e, `Unable to delete cluster roles`); + } +} diff --git a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/queries/useGetClusterRolesQuery.ts b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/queries/useGetClusterRolesQuery.ts new file mode 100644 index 000000000..ab221ef3a --- /dev/null +++ b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/queries/useGetClusterRolesQuery.ts @@ -0,0 +1,39 @@ +import { compact } from 'lodash'; +import { useQuery } from '@tanstack/react-query'; + +import { withGlobalError } from '@/react-tools/react-query'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { ClusterRole } from '../types'; + +import { queryKeys } from './query-keys'; + +export function useGetClusterRolesQuery( + environmentId: EnvironmentId, + options?: { autoRefreshRate?: number } +) { + return useQuery( + queryKeys.list(environmentId), + async () => { + const clusterRoles = await getClusterRoles(environmentId); + return compact(clusterRoles); + }, + { + ...withGlobalError('Unable to get cluster roles'), + ...options, + } + ); +} + +async function getClusterRoles(environmentId: EnvironmentId) { + try { + const { data: roles } = await axios.get( + `kubernetes/${environmentId}/cluster_roles` + ); + + return roles; + } catch (e) { + throw parseAxiosError(e, 'Unable to get cluster roles'); + } +} diff --git a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/types.ts b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/types.ts new file mode 100644 index 000000000..ae8561a6c --- /dev/null +++ b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/types.ts @@ -0,0 +1,23 @@ +export type Rule = { + verbs: string[]; + apiGroups: string[]; + resources: string[]; +}; + +export type ClusterRole = { + name: string; + uid: string; + namespace: string; + resourceVersion: string; + creationDate: string; + annotations?: Record; + + rules: Rule[]; + + isUnused: boolean; + isSystem: boolean; +}; + +export type DeleteRequestPayload = { + clusterRoles: string[]; +}; diff --git a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesView.tsx b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesView.tsx new file mode 100644 index 000000000..2d85ec98b --- /dev/null +++ b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesView.tsx @@ -0,0 +1,51 @@ +import { UserCheck, Link } from 'lucide-react'; +import { useCurrentStateAndParams } from '@uirouter/react'; + +import { useUnauthorizedRedirect } from '@/react/hooks/useUnauthorizedRedirect'; + +import { PageHeader } from '@@/PageHeader'; +import { Tab, WidgetTabs, findSelectedTabIndex } from '@@/Widget/WidgetTabs'; + +import { ClusterRolesDatatable } from './ClusterRolesDatatable/ClusterRolesDatatable'; +import { ClusterRoleBindingsDatatable } from './ClusterRoleBindingsDatatable/ClusterRoleBindingsDatatable'; + +export function ClusterRolesView() { + useUnauthorizedRedirect( + { authorizations: ['K8sClusterRoleBindingsW', 'K8sClusterRolesW'] }, + { to: 'kubernetes.dashboard' } + ); + + const tabs: Tab[] = [ + { + name: 'Cluster Roles', + icon: UserCheck, + widget: , + selectedTabParam: 'clusterRoles', + }, + { + name: 'Cluster Role Bindings', + icon: Link, + widget: , + selectedTabParam: 'clusterRoleBindings', + }, + ]; + + const currentTabIndex = findSelectedTabIndex( + useCurrentStateAndParams(), + tabs + ); + + return ( + <> + + <> + +
{tabs[currentTabIndex].widget}
+ + + ); +} diff --git a/app/react/kubernetes/more-resources/ClusterRolesView/index.tsx b/app/react/kubernetes/more-resources/ClusterRolesView/index.tsx new file mode 100644 index 000000000..ce13ef99f --- /dev/null +++ b/app/react/kubernetes/more-resources/ClusterRolesView/index.tsx @@ -0,0 +1 @@ +export { ClusterRolesView } from './ClusterRolesView'; diff --git a/app/react/kubernetes/more-resources/ClusterRolesView/utils.ts b/app/react/kubernetes/more-resources/ClusterRolesView/utils.ts new file mode 100644 index 000000000..b094cf540 --- /dev/null +++ b/app/react/kubernetes/more-resources/ClusterRolesView/utils.ts @@ -0,0 +1,52 @@ +import { Row } from '@tanstack/react-table'; + +import { RoleBinding } from '../RolesView/RoleBindingsDatatable/types'; + +import { ClusterRoleBinding } from './ClusterRoleBindingsDatatable/types'; + +/** + * Transforms the rows of a table to get a unique list of namespaces to use as filter options. + * One row can have multiple subject namespaces. + * @param rows - The rows of the table. + * @param id - The ID of the column containing the subject namespaces. + * @returns An array of unique subject namespace options. + */ +export function filterNamespaceOptionsTransformer< + TData extends ClusterRoleBinding | RoleBinding, +>(rows: Row[], id: string) { + const options = new Set(); + rows.forEach(({ getValue }) => { + const value = getValue(id); + if (!value) { + return; + } + value.forEach((v) => { + if (v && v !== '-') { + options.add(v); + } + }); + }); + return Array.from(options); +} + +/** + * Filters the rows of a table based on the selected namespaces. + * @param row - The row to filter. + * @param _columnId - The ID of the column being filtered. + * @param filterValue - The selected namespaces to filter by. + * @returns True if the row should be shown, false otherwise. + */ +export function filterFn( + row: Row, + _columnId: string, + filterValue: string[] +) { + // when no filter is set, show all rows + if (filterValue.length === 0) { + return true; + } + const subjectNamespaces = row.original.subjects?.flatMap( + (sub) => sub.namespace ?? [] + ); + return filterValue.some((v) => subjectNamespaces?.includes(v)); +} diff --git a/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/RoleBindingsDatatable.tsx b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/RoleBindingsDatatable.tsx new file mode 100644 index 000000000..6083b61ef --- /dev/null +++ b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/RoleBindingsDatatable.tsx @@ -0,0 +1,175 @@ +import { Trash2, Link as LinkIcon } from 'lucide-react'; +import { useRouter } from '@uirouter/react'; +import { Row } from '@tanstack/react-table'; +import clsx from 'clsx'; + +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { useAuthorizations, Authorized } from '@/react/hooks/useUser'; +import { notifyError, notifySuccess } from '@/portainer/services/notifications'; +import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemResourceDescription'; +import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store'; +import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery'; +import { CreateFromManifestButton } from '@/react/kubernetes/components/CreateFromManifestButton'; +import { isSystemNamespace } from '@/react/kubernetes/namespaces/queries/useIsSystemNamespace'; + +import { confirmDelete } from '@@/modals/confirm'; +import { Datatable, Table, TableSettingsMenu } from '@@/datatables'; +import { LoadingButton } from '@@/buttons'; +import { useTableState } from '@@/datatables/useTableState'; + +import { DefaultDatatableSettings } from '../../../datatables/DefaultDatatableSettings'; + +import { RoleBinding } from './types'; +import { columns } from './columns'; +import { useGetAllRoleBindingsQuery } from './queries/useGetAllRoleBindingsQuery'; +import { useDeleteRoleBindingsMutation } from './queries/useDeleteRoleBindingsMutation'; + +const storageKey = 'roleBindings'; +const settingsStore = createStore(storageKey); + +export function RoleBindingsDatatable() { + const environmentId = useEnvironmentId(); + const tableState = useTableState(settingsStore, storageKey); + const namespacesQuery = useNamespacesQuery(environmentId); + const roleBindingsQuery = useGetAllRoleBindingsQuery(environmentId, { + autoRefreshRate: tableState.autoRefreshRate * 1000, + enabled: namespacesQuery.isSuccess, + }); + + const filteredRoleBindings = tableState.showSystemResources + ? roleBindingsQuery.data + : roleBindingsQuery.data?.filter( + (rb) => !isSystemNamespace(rb.namespace, namespacesQuery.data) + ); + + const { authorized: isAuthorisedToAddEdit } = useAuthorizations([ + 'K8sRoleBindingsW', + ]); + + return ( + row.uid} + isRowSelectable={(row) => !row.original.isSystem} + renderTableActions={(selectedRows) => ( + + )} + renderTableSettings={() => ( + + + + )} + description={ + + } + disableSelect={!isAuthorisedToAddEdit} + renderRow={renderRow} + data-cy="k8s-role-bindings-datatable" + /> + ); +} + +// needed to apply custom styling to the row and not globally required in the AC's for this ticket. +function renderRow(row: Row, highlightedItemId?: string) { + return ( + + cells={row.getVisibleCells()} + className={clsx('[&>td]:!py-4 [&>td]:!align-top', { + active: highlightedItemId === row.id, + })} + /> + ); +} + +interface SelectedRole { + namespace: string; + name: string; +} + +type TableActionsProps = { + selectedItems: RoleBinding[]; +}; + +function TableActions({ selectedItems }: TableActionsProps) { + const environmentId = useEnvironmentId(); + const deleteRoleBindingsMutation = + useDeleteRoleBindingsMutation(environmentId); + const router = useRouter(); + + async function handleRemoveClick(roles: SelectedRole[]) { + const confirmed = await confirmDelete( + <> +

Are you sure you want to delete the selected role binding(s)?

+
    + {roles.map((r, index) => ( +
  • + {r.namespace}/{r.name} +
  • + ))} +
+ + ); + if (!confirmed) { + return null; + } + + const payload: Record = {}; + roles.forEach((r) => { + payload[r.namespace] = payload[r.namespace] || []; + payload[r.namespace].push(r.name); + }); + + deleteRoleBindingsMutation.mutate( + { environmentId, data: payload }, + { + onSuccess: () => { + notifySuccess( + 'Role binding(s) successfully removed', + roles.map((r) => `${r.namespace}/${r.name}`).join(', ') + ); + router.stateService.reload(); + }, + onError: (error) => { + notifyError( + 'Unable to delete role bindings(s)', + error as Error, + roles.map((r) => `${r.namespace}/${r.name}`).join(', ') + ); + }, + } + ); + return roles; + } + + return ( + + handleRemoveClick(selectedItems)} + icon={Trash2} + isLoading={deleteRoleBindingsMutation.isLoading} + loadingText="Removing role bindings..." + data-cy="k8s-role-bindings-remove-button" + > + Remove + + + + + ); +} diff --git a/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/columns/created.tsx b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/columns/created.tsx new file mode 100644 index 000000000..c9c456278 --- /dev/null +++ b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/columns/created.tsx @@ -0,0 +1,12 @@ +import { formatDate } from '@/portainer/filters/filters'; + +import { columnHelper } from './helper'; + +export const created = columnHelper.accessor( + (row) => formatDate(row.creationDate), + { + header: 'Created', + id: 'created', + cell: ({ getValue }) => getValue(), + } +); diff --git a/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/columns/helper.ts b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/columns/helper.ts new file mode 100644 index 000000000..358f6b24c --- /dev/null +++ b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/columns/helper.ts @@ -0,0 +1,5 @@ +import { createColumnHelper } from '@tanstack/react-table'; + +import { RoleBinding } from '../types'; + +export const columnHelper = createColumnHelper(); diff --git a/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/columns/index.tsx b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/columns/index.tsx new file mode 100644 index 000000000..76a139238 --- /dev/null +++ b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/columns/index.tsx @@ -0,0 +1,17 @@ +import { name } from './name'; +import { roleKind } from './roleKind'; +import { roleName } from './roleName'; +import { subjectKind } from './subjectKind'; +import { subjectName } from './subjectName'; +import { subjectNamespace } from './subjectNamespace'; +import { created } from './created'; + +export const columns = [ + name, + roleKind, + roleName, + subjectKind, + subjectName, + subjectNamespace, + created, +]; diff --git a/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/columns/name.tsx b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/columns/name.tsx new file mode 100644 index 000000000..45ebb750e --- /dev/null +++ b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/columns/name.tsx @@ -0,0 +1,22 @@ +import { SystemBadge } from '@@/Badge/SystemBadge'; + +import { columnHelper } from './helper'; + +export const name = columnHelper.accessor( + (row) => { + if (row.isSystem) { + return `${row.name} system`; + } + return row.name; + }, + { + header: 'Name', + id: 'name', + cell: ({ row }) => ( +
+ {row.original.name} + {row.original.isSystem && } +
+ ), + } +); diff --git a/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/columns/roleKind.tsx b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/columns/roleKind.tsx new file mode 100644 index 000000000..dd5d34571 --- /dev/null +++ b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/columns/roleKind.tsx @@ -0,0 +1,6 @@ +import { columnHelper } from './helper'; + +export const roleKind = columnHelper.accessor('roleRef.kind', { + header: 'Role Kind', + id: 'roleKind', +}); diff --git a/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/columns/roleName.tsx b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/columns/roleName.tsx new file mode 100644 index 000000000..69fb3a6f1 --- /dev/null +++ b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/columns/roleName.tsx @@ -0,0 +1,6 @@ +import { columnHelper } from './helper'; + +export const roleName = columnHelper.accessor('roleRef.name', { + header: 'Role Name', + id: 'roleName', +}); diff --git a/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/columns/subjectKind.tsx b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/columns/subjectKind.tsx new file mode 100644 index 000000000..49c43cb34 --- /dev/null +++ b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/columns/subjectKind.tsx @@ -0,0 +1,13 @@ +import { columnHelper } from './helper'; + +export const subjectKind = columnHelper.accessor( + (row) => row.subjects?.map((sub) => sub.kind).join(', '), + { + header: 'Subject Kind', + id: 'subjectKind', + cell: ({ row }) => + row.original.subjects?.map((sub, index) => ( +
{sub.kind}
+ )) || '-', + } +); diff --git a/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/columns/subjectName.tsx b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/columns/subjectName.tsx new file mode 100644 index 000000000..b53a578eb --- /dev/null +++ b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/columns/subjectName.tsx @@ -0,0 +1,13 @@ +import { columnHelper } from './helper'; + +export const subjectName = columnHelper.accessor( + (row) => row.subjects?.map((sub) => sub.name).join(', '), + { + header: 'Subject Name', + id: 'subjectName', + cell: ({ row }) => + row.original.subjects?.map((sub, index) => ( +
{sub.name}
+ )) || '-', + } +); diff --git a/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/columns/subjectNamespace.tsx b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/columns/subjectNamespace.tsx new file mode 100644 index 000000000..b08993a63 --- /dev/null +++ b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/columns/subjectNamespace.tsx @@ -0,0 +1,45 @@ +import { Link } from '@@/Link'; +import { filterHOC } from '@@/datatables/Filter'; + +import { + filterFn, + filterNamespaceOptionsTransformer, +} from '../../../ClusterRolesView/utils'; + +import { columnHelper } from './helper'; + +export const subjectNamespace = columnHelper.accessor( + (row) => row.subjects?.flatMap((sub) => sub.namespace || '-') || [], + { + header: 'Subject Namespace', + id: 'subjectNamespace', + cell: ({ row }) => + row.original.subjects?.map((sub, index) => ( +
+ {sub.namespace ? ( + + {sub.namespace} + + ) : ( + '-' + )} +
+ )) || '-', + enableColumnFilter: true, + // use a custom filter, to remove empty namespace values + meta: { + filter: filterHOC( + 'Filter by subject namespace', + filterNamespaceOptionsTransformer + ), + }, + filterFn, + } +); diff --git a/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/index.tsx b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/index.tsx new file mode 100644 index 000000000..9a8a77a12 --- /dev/null +++ b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/index.tsx @@ -0,0 +1 @@ +export { RoleBindingsDatatable } from './RoleBindingsDatatable'; diff --git a/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/queries/query-keys.ts b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/queries/query-keys.ts new file mode 100644 index 000000000..c87ce5f5e --- /dev/null +++ b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/queries/query-keys.ts @@ -0,0 +1,6 @@ +import { EnvironmentId } from '@/react/portainer/environments/types'; + +export const queryKeys = { + list: (environmentId: EnvironmentId) => + ['environments', environmentId, 'kubernetes', 'role_bindings'] as const, +}; diff --git a/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/queries/useDeleteRoleBindingsMutation.ts b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/queries/useDeleteRoleBindingsMutation.ts new file mode 100644 index 000000000..029ee7e4b --- /dev/null +++ b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/queries/useDeleteRoleBindingsMutation.ts @@ -0,0 +1,32 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { withGlobalError, withInvalidate } from '@/react-tools/react-query'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { queryKeys } from './query-keys'; + +export function useDeleteRoleBindingsMutation(environmentId: EnvironmentId) { + const queryClient = useQueryClient(); + return useMutation(deleteRoleBindings, { + ...withInvalidate(queryClient, [queryKeys.list(environmentId)]), + ...withGlobalError('Unable to delete role bindings'), + }); +} + +export async function deleteRoleBindings({ + environmentId, + data, +}: { + environmentId: EnvironmentId; + data: Record; +}) { + try { + return await axios.post( + `kubernetes/${environmentId}/role_bindings/delete`, + data + ); + } catch (e) { + throw parseAxiosError(e, `Unable to delete role bindings`); + } +} diff --git a/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/queries/useGetAllRoleBindingsQuery.ts b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/queries/useGetAllRoleBindingsQuery.ts new file mode 100644 index 000000000..e5ddbea33 --- /dev/null +++ b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/queries/useGetAllRoleBindingsQuery.ts @@ -0,0 +1,38 @@ +import { useQuery } from '@tanstack/react-query'; + +import { withGlobalError } from '@/react-tools/react-query'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { RoleBinding } from '../types'; + +import { queryKeys } from './query-keys'; + +export function useGetAllRoleBindingsQuery( + environmentId: EnvironmentId, + options?: { autoRefreshRate?: number; enabled?: boolean } +) { + return useQuery( + queryKeys.list(environmentId), + async () => getAllRoleBindings(environmentId), + { + ...withGlobalError('Unable to get role bindings'), + refetchInterval() { + return options?.autoRefreshRate ?? false; + }, + enabled: options?.enabled, + } + ); +} + +async function getAllRoleBindings(environmentId: EnvironmentId) { + try { + const { data: roleBinding } = await axios.get( + `kubernetes/${environmentId}/role_bindings` + ); + + return roleBinding; + } catch (e) { + throw parseAxiosError(e, 'Unable to get role bindings'); + } +} diff --git a/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/types.ts b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/types.ts new file mode 100644 index 000000000..59807c588 --- /dev/null +++ b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/types.ts @@ -0,0 +1,26 @@ +export type RoleRef = { + name: string; + kind: string; + apiGroup?: string; +}; + +export type RoleSubject = { + name: string; + kind: string; + apiGroup?: string; + namespace?: string; +}; + +export type RoleBinding = { + name: string; + uid: string; + namespace: string; + resourceVersion: string; + creationDate: string; + annotations: Record | null; + + roleRef: RoleRef; + subjects: RoleSubject[] | null; + + isSystem: boolean; +}; diff --git a/app/react/kubernetes/more-resources/RolesView/RolesDatatable/RolesDatatable.tsx b/app/react/kubernetes/more-resources/RolesView/RolesDatatable/RolesDatatable.tsx new file mode 100644 index 000000000..09e183213 --- /dev/null +++ b/app/react/kubernetes/more-resources/RolesView/RolesDatatable/RolesDatatable.tsx @@ -0,0 +1,157 @@ +import { Trash2, UserCheck } from 'lucide-react'; +import { useRouter } from '@uirouter/react'; + +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { Authorized } from '@/react/hooks/useUser'; +import { notifyError, notifySuccess } from '@/portainer/services/notifications'; +import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemResourceDescription'; +import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store'; +import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery'; +import { CreateFromManifestButton } from '@/react/kubernetes/components/CreateFromManifestButton'; +import { useUnauthorizedRedirect } from '@/react/hooks/useUnauthorizedRedirect'; +import { isSystemNamespace } from '@/react/kubernetes/namespaces/queries/useIsSystemNamespace'; + +import { confirmDelete } from '@@/modals/confirm'; +import { Datatable, TableSettingsMenu } from '@@/datatables'; +import { LoadingButton } from '@@/buttons'; +import { useTableState } from '@@/datatables/useTableState'; + +import { DefaultDatatableSettings } from '../../../datatables/DefaultDatatableSettings'; + +import { columns } from './columns'; +import { Role } from './types'; +import { useGetAllRolesQuery } from './queries/useGetAllRolesQuery'; +import { useDeleteRolesMutation } from './queries/useDeleteRolesMutation'; + +const storageKey = 'roles'; +const settingsStore = createStore(storageKey); + +export function RolesDatatable() { + const environmentId = useEnvironmentId(); + const tableState = useTableState(settingsStore, storageKey); + const namespacesQuery = useNamespacesQuery(environmentId); + const rolesQuery = useGetAllRolesQuery(environmentId, { + autoRefreshRate: tableState.autoRefreshRate * 1000, + enabled: namespacesQuery.isSuccess, + }); + useUnauthorizedRedirect( + { authorizations: ['K8sRolesW'] }, + { to: 'kubernetes.dashboard' } + ); + + const filteredRoles = tableState.showSystemResources + ? rolesQuery.data + : rolesQuery.data?.filter( + (role) => !isSystemNamespace(role.namespace, namespacesQuery.data) + ); + + return ( + row.uid} + isRowSelectable={(row) => !row.original.isSystem} + renderTableActions={(selectedRows) => ( + + )} + renderTableSettings={() => ( + + + + )} + description={ + + } + data-cy="k8s-roles-datatable" + /> + ); +} + +interface SelectedRole { + namespace: string; + name: string; +} + +type TableActionsProps = { + selectedItems: Role[]; +}; + +function TableActions({ selectedItems }: TableActionsProps) { + const environmentId = useEnvironmentId(); + const deleteRolesMutation = useDeleteRolesMutation(environmentId); + const router = useRouter(); + + return ( + + handleRemoveClick(selectedItems)} + icon={Trash2} + isLoading={deleteRolesMutation.isLoading} + loadingText="Removing roles..." + data-cy="k8s-roles-removeRoleButton" + > + Remove + + + + + ); + + async function handleRemoveClick(roles: SelectedRole[]) { + const confirmed = await confirmDelete( + <> +

Are you sure you want to delete the selected role(s)?

+
    + {roles.map((s, index) => ( +
  • + {s.namespace}/{s.name} +
  • + ))} +
+ + ); + if (!confirmed) { + return null; + } + + const payload: Record = {}; + roles.forEach((r) => { + payload[r.namespace] = payload[r.namespace] || []; + payload[r.namespace].push(r.name); + }); + + deleteRolesMutation.mutate( + { environmentId, data: payload }, + { + onSuccess: () => { + notifySuccess( + 'Roles successfully removed', + roles.map((r) => `${r.namespace}/${r.name}`).join(', ') + ); + router.stateService.reload(); + }, + onError: (error) => { + notifyError( + 'Unable to delete roles', + error as Error, + roles.map((r) => `${r.namespace}/${r.name}`).join(', ') + ); + }, + } + ); + return roles; + } +} diff --git a/app/react/kubernetes/more-resources/RolesView/RolesDatatable/columns/created.tsx b/app/react/kubernetes/more-resources/RolesView/RolesDatatable/columns/created.tsx new file mode 100644 index 000000000..c9c456278 --- /dev/null +++ b/app/react/kubernetes/more-resources/RolesView/RolesDatatable/columns/created.tsx @@ -0,0 +1,12 @@ +import { formatDate } from '@/portainer/filters/filters'; + +import { columnHelper } from './helper'; + +export const created = columnHelper.accessor( + (row) => formatDate(row.creationDate), + { + header: 'Created', + id: 'created', + cell: ({ getValue }) => getValue(), + } +); diff --git a/app/react/kubernetes/more-resources/RolesView/RolesDatatable/columns/helper.ts b/app/react/kubernetes/more-resources/RolesView/RolesDatatable/columns/helper.ts new file mode 100644 index 000000000..cae915061 --- /dev/null +++ b/app/react/kubernetes/more-resources/RolesView/RolesDatatable/columns/helper.ts @@ -0,0 +1,5 @@ +import { createColumnHelper } from '@tanstack/react-table'; + +import { Role } from '../types'; + +export const columnHelper = createColumnHelper(); diff --git a/app/react/kubernetes/more-resources/RolesView/RolesDatatable/columns/index.tsx b/app/react/kubernetes/more-resources/RolesView/RolesDatatable/columns/index.tsx new file mode 100644 index 000000000..4fbbd3dba --- /dev/null +++ b/app/react/kubernetes/more-resources/RolesView/RolesDatatable/columns/index.tsx @@ -0,0 +1,5 @@ +import { name } from './name'; +import { created } from './created'; +import { namespace } from './namespace'; + +export const columns = [name, namespace, created]; diff --git a/app/react/kubernetes/more-resources/RolesView/RolesDatatable/columns/name.tsx b/app/react/kubernetes/more-resources/RolesView/RolesDatatable/columns/name.tsx new file mode 100644 index 000000000..b73bc6180 --- /dev/null +++ b/app/react/kubernetes/more-resources/RolesView/RolesDatatable/columns/name.tsx @@ -0,0 +1,28 @@ +import { SystemBadge } from '@@/Badge/SystemBadge'; +import { UnusedBadge } from '@@/Badge/UnusedBadge'; + +import { columnHelper } from './helper'; + +export const name = columnHelper.accessor( + (row) => { + let result = row.name; + if (row.isSystem) { + result += ' system'; + } + if (row.isUnused) { + result += ' unused'; + } + return result; + }, + { + header: 'Name', + id: 'name', + cell: ({ row }) => ( +
+ {row.original.name} + {row.original.isSystem && } + {row.original.isUnused && } +
+ ), + } +); diff --git a/app/react/kubernetes/more-resources/RolesView/RolesDatatable/columns/namespace.tsx b/app/react/kubernetes/more-resources/RolesView/RolesDatatable/columns/namespace.tsx new file mode 100644 index 000000000..3d2f8021d --- /dev/null +++ b/app/react/kubernetes/more-resources/RolesView/RolesDatatable/columns/namespace.tsx @@ -0,0 +1,32 @@ +import { Row } from '@tanstack/react-table'; + +import { filterHOC } from '@@/datatables/Filter'; +import { Link } from '@@/Link'; + +import { Role } from '../types'; + +import { columnHelper } from './helper'; + +export const namespace = columnHelper.accessor((row) => row.namespace, { + header: 'Namespace', + id: 'namespace', + cell: ({ getValue, row }) => ( + + {getValue()} + + ), + meta: { + filter: filterHOC('Filter by namespace'), + }, + enableColumnFilter: true, + filterFn: (row: Row, _columnId: string, filterValue: string[]) => + filterValue.length === 0 || + filterValue.includes(row.original.namespace ?? ''), +}); diff --git a/app/react/kubernetes/more-resources/RolesView/RolesDatatable/index.tsx b/app/react/kubernetes/more-resources/RolesView/RolesDatatable/index.tsx new file mode 100644 index 000000000..89baa6c54 --- /dev/null +++ b/app/react/kubernetes/more-resources/RolesView/RolesDatatable/index.tsx @@ -0,0 +1 @@ +export { RolesDatatable } from './RolesDatatable'; diff --git a/app/react/kubernetes/more-resources/RolesView/RolesDatatable/queries/query-keys.ts b/app/react/kubernetes/more-resources/RolesView/RolesDatatable/queries/query-keys.ts new file mode 100644 index 000000000..d48b61c14 --- /dev/null +++ b/app/react/kubernetes/more-resources/RolesView/RolesDatatable/queries/query-keys.ts @@ -0,0 +1,6 @@ +import { EnvironmentId } from '@/react/portainer/environments/types'; + +export const queryKeys = { + list: (environmentId: EnvironmentId) => + ['environments', environmentId, 'kubernetes', 'roles'] as const, +}; diff --git a/app/react/kubernetes/more-resources/RolesView/RolesDatatable/queries/useDeleteRolesMutation.ts b/app/react/kubernetes/more-resources/RolesView/RolesDatatable/queries/useDeleteRolesMutation.ts new file mode 100644 index 000000000..211ac8a16 --- /dev/null +++ b/app/react/kubernetes/more-resources/RolesView/RolesDatatable/queries/useDeleteRolesMutation.ts @@ -0,0 +1,29 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { withGlobalError, withInvalidate } from '@/react-tools/react-query'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { queryKeys } from './query-keys'; + +export function useDeleteRolesMutation(environmentId: EnvironmentId) { + const queryClient = useQueryClient(); + return useMutation(deleteRole, { + ...withInvalidate(queryClient, [queryKeys.list(environmentId)]), + ...withGlobalError('Unable to delete roles'), + }); +} + +export async function deleteRole({ + environmentId, + data, +}: { + environmentId: EnvironmentId; + data: Record; +}) { + try { + return await axios.post(`kubernetes/${environmentId}/roles/delete`, data); + } catch (e) { + throw parseAxiosError(e, `Unable to delete roles`); + } +} diff --git a/app/react/kubernetes/more-resources/RolesView/RolesDatatable/queries/useGetAllRolesQuery.ts b/app/react/kubernetes/more-resources/RolesView/RolesDatatable/queries/useGetAllRolesQuery.ts new file mode 100644 index 000000000..e7d692cd6 --- /dev/null +++ b/app/react/kubernetes/more-resources/RolesView/RolesDatatable/queries/useGetAllRolesQuery.ts @@ -0,0 +1,41 @@ +import { useQuery } from '@tanstack/react-query'; + +import { withGlobalError } from '@/react-tools/react-query'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { Role } from '../types'; + +const queryKeys = { + list: (environmentId: EnvironmentId) => + ['environments', environmentId, 'kubernetes', 'roles'] as const, +}; + +export function useGetAllRolesQuery( + environmentId: EnvironmentId, + options?: { autoRefreshRate?: number; enabled?: boolean } +) { + return useQuery( + queryKeys.list(environmentId), + async () => getAllRoles(environmentId), + { + ...withGlobalError('Unable to get roles'), + refetchInterval() { + return options?.autoRefreshRate ?? false; + }, + enabled: options?.enabled, + } + ); +} + +async function getAllRoles(environmentId: EnvironmentId) { + try { + const { data: roles } = await axios.get( + `kubernetes/${environmentId}/roles` + ); + + return roles; + } catch (e) { + throw parseAxiosError(e, 'Unable to get roles'); + } +} diff --git a/app/react/kubernetes/more-resources/RolesView/RolesDatatable/types.ts b/app/react/kubernetes/more-resources/RolesView/RolesDatatable/types.ts new file mode 100644 index 000000000..cd2bdddf6 --- /dev/null +++ b/app/react/kubernetes/more-resources/RolesView/RolesDatatable/types.ts @@ -0,0 +1,19 @@ +export type Rule = { + verbs: string[]; + apiGroups: string[]; + resources: string[]; +}; + +export type Role = { + name: string; + uid: string; + namespace: string; + resourceVersion: string; + creationDate: string; + annotations?: Record; + + rules: Rule[]; + + isSystem: boolean; + isUnused: boolean; +}; diff --git a/app/react/kubernetes/more-resources/RolesView/RolesView.tsx b/app/react/kubernetes/more-resources/RolesView/RolesView.tsx new file mode 100644 index 000000000..d849b543f --- /dev/null +++ b/app/react/kubernetes/more-resources/RolesView/RolesView.tsx @@ -0,0 +1,47 @@ +import { useCurrentStateAndParams } from '@uirouter/react'; +import { UserCheck, Link } from 'lucide-react'; + +import { useUnauthorizedRedirect } from '@/react/hooks/useUnauthorizedRedirect'; + +import { PageHeader } from '@@/PageHeader'; +import { WidgetTabs, Tab, findSelectedTabIndex } from '@@/Widget/WidgetTabs'; + +import { RolesDatatable } from './RolesDatatable'; +import { RoleBindingsDatatable } from './RoleBindingsDatatable'; + +export function RolesView() { + useUnauthorizedRedirect( + { authorizations: ['K8sRoleBindingsW', 'K8sRolesW'] }, + { to: 'kubernetes.dashboard' } + ); + + const tabs: Tab[] = [ + { + name: 'Roles', + icon: UserCheck, + widget: , + selectedTabParam: 'roles', + }, + { + name: 'Role Bindings', + icon: Link, + widget: , + selectedTabParam: 'roleBindings', + }, + ]; + + const currentTabIndex = findSelectedTabIndex( + useCurrentStateAndParams(), + tabs + ); + + return ( + <> + + <> + +
{tabs[currentTabIndex].widget}
+ + + ); +} diff --git a/app/react/kubernetes/more-resources/RolesView/index.tsx b/app/react/kubernetes/more-resources/RolesView/index.tsx new file mode 100644 index 000000000..d270a363e --- /dev/null +++ b/app/react/kubernetes/more-resources/RolesView/index.tsx @@ -0,0 +1 @@ +export { RolesView } from './RolesView'; diff --git a/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/ServiceAccountsDatatable.tsx b/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/ServiceAccountsDatatable.tsx new file mode 100644 index 000000000..20ddfce56 --- /dev/null +++ b/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/ServiceAccountsDatatable.tsx @@ -0,0 +1,147 @@ +import { User } from 'lucide-react'; +import { useRouter } from '@uirouter/react'; + +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { Authorized } from '@/react/hooks/useUser'; +import { notifyError, notifySuccess } from '@/portainer/services/notifications'; +import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemResourceDescription'; +import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery'; +import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store'; +import { CreateFromManifestButton } from '@/react/kubernetes/components/CreateFromManifestButton'; +import { useUnauthorizedRedirect } from '@/react/hooks/useUnauthorizedRedirect'; +import { isSystemNamespace } from '@/react/kubernetes/namespaces/queries/useIsSystemNamespace'; + +import { Datatable, TableSettingsMenu } from '@@/datatables'; +import { useTableState } from '@@/datatables/useTableState'; +import { DeleteButton } from '@@/buttons/DeleteButton'; + +import { ServiceAccount } from '../types'; +import { DefaultDatatableSettings } from '../../../datatables/DefaultDatatableSettings'; + +import { useColumns } from './columns'; +import { useDeleteServiceAccountsMutation } from './queries/useDeleteServiceAccountsMutation'; +import { useGetAllServiceAccountsQuery } from './queries/useGetAllServiceAccountsQuery'; + +const storageKey = 'serviceAccounts'; +const settingsStore = createStore(storageKey); + +export function ServiceAccountsDatatable() { + useUnauthorizedRedirect( + { authorizations: ['K8sServiceAccountsW'] }, + { to: 'kubernetes.dashboard' } + ); + + const environmentId = useEnvironmentId(); + const tableState = useTableState(settingsStore, storageKey); + const namespacesQuery = useNamespacesQuery(environmentId); + const serviceAccountsQuery = useGetAllServiceAccountsQuery(environmentId, { + refetchInterval: tableState.autoRefreshRate * 1000, + enabled: namespacesQuery.isSuccess, + }); + + const columns = useColumns(); + + const filteredServiceAccounts = tableState.showSystemResources + ? serviceAccountsQuery.data + : serviceAccountsQuery.data?.filter( + (sa) => !isSystemNamespace(sa.namespace, namespacesQuery.data) + ); + + return ( + row.uid} + isRowSelectable={(row) => !row.original.isSystem} + renderTableActions={(selectedRows) => ( + + )} + renderTableSettings={() => ( + + + + )} + description={ + + } + data-cy="k8s-service-accounts-datatable" + /> + ); +} + +interface SelectedServiceAccount { + namespace: string; + name: string; +} + +type TableActionsProps = { + selectedItems: ServiceAccount[]; +}; + +function TableActions({ selectedItems }: TableActionsProps) { + const environmentId = useEnvironmentId(); + const deleteServiceAccountsMutation = + useDeleteServiceAccountsMutation(environmentId); + const router = useRouter(); + + return ( + + handleRemoveClick(selectedItems)} + confirmMessage={ + <> +

+ Are you sure you want to delete the selected service account(s)? +

+
    + {selectedItems.map((s, index) => ( +
  • + {s.namespace}/{s.name} +
  • + ))} +
+ + } + data-cy="k8s-service-accounts-datatable-remove-button" + /> + + +
+ ); + + async function handleRemoveClick(serviceAccounts: SelectedServiceAccount[]) { + const payload: Record = {}; + serviceAccounts.forEach((sa) => { + payload[sa.namespace] = payload[sa.namespace] || []; + payload[sa.namespace].push(sa.name); + }); + + deleteServiceAccountsMutation.mutate( + { environmentId, data: payload }, + { + onSuccess: () => { + notifySuccess( + 'Service account(s) successfully removed', + serviceAccounts.map((sa) => `${sa.namespace}/${sa.name}`).join(', ') + ); + router.stateService.reload(); + }, + onError: (error) => { + notifyError( + 'Unable to delete service account(s)', + error as Error, + serviceAccounts.map((sa) => `${sa.namespace}/${sa.name}`).join(', ') + ); + }, + } + ); + } +} diff --git a/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/columns/created.tsx b/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/columns/created.tsx new file mode 100644 index 000000000..c9c456278 --- /dev/null +++ b/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/columns/created.tsx @@ -0,0 +1,12 @@ +import { formatDate } from '@/portainer/filters/filters'; + +import { columnHelper } from './helper'; + +export const created = columnHelper.accessor( + (row) => formatDate(row.creationDate), + { + header: 'Created', + id: 'created', + cell: ({ getValue }) => getValue(), + } +); diff --git a/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/columns/helper.ts b/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/columns/helper.ts new file mode 100644 index 000000000..aacd5a041 --- /dev/null +++ b/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/columns/helper.ts @@ -0,0 +1,5 @@ +import { createColumnHelper } from '@tanstack/react-table'; + +import { ServiceAccount } from '../../types'; + +export const columnHelper = createColumnHelper(); diff --git a/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/columns/index.tsx b/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/columns/index.tsx new file mode 100644 index 000000000..409ba79e0 --- /dev/null +++ b/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/columns/index.tsx @@ -0,0 +1,7 @@ +import { name } from './name'; +import { namespace } from './namespace'; +import { created } from './created'; + +export function useColumns() { + return [name, namespace, created]; +} diff --git a/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/columns/name.tsx b/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/columns/name.tsx new file mode 100644 index 000000000..16bf6ebc1 --- /dev/null +++ b/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/columns/name.tsx @@ -0,0 +1,28 @@ +import { SystemBadge } from '@@/Badge/SystemBadge'; +import { UnusedBadge } from '@@/Badge/UnusedBadge'; + +import { columnHelper } from './helper'; + +export const name = columnHelper.accessor( + (row) => { + let result = row.name; + if (row.isSystem) { + result += ' system'; + } + if (row.isUnused) { + result += ' unused'; + } + return result; + }, + { + header: 'Name', + id: 'name', + cell: ({ row }) => ( +
+
{row.original.name}
+ {row.original.isSystem && } + {!row.original.isSystem && row.original.isUnused && } +
+ ), + } +); diff --git a/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/columns/namespace.tsx b/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/columns/namespace.tsx new file mode 100644 index 000000000..dc4091d1e --- /dev/null +++ b/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/columns/namespace.tsx @@ -0,0 +1,36 @@ +import { Row } from '@tanstack/react-table'; + +import { Link } from '@@/Link'; +import { filterHOC } from '@@/datatables/Filter'; + +import { ServiceAccount } from '../../types'; + +import { columnHelper } from './helper'; + +export const namespace = columnHelper.accessor('namespace', { + header: 'Namespace', + id: 'namespace', + cell: ({ row }) => ( + + {row.original.namespace} + + ), + meta: { + filter: filterHOC('Filter by namespace'), + }, + enableColumnFilter: true, + filterFn: ( + row: Row, + _columnId: string, + filterValue: string[] + ) => + filterValue.length === 0 || + filterValue.includes(row.original.namespace ?? ''), +}); diff --git a/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/index.ts b/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/index.ts new file mode 100644 index 000000000..ce8deb74c --- /dev/null +++ b/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/index.ts @@ -0,0 +1 @@ +export { ServiceAccountsDatatable } from './ServiceAccountsDatatable'; diff --git a/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/queries/query-keys.ts b/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/queries/query-keys.ts new file mode 100644 index 000000000..cdedb0f23 --- /dev/null +++ b/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/queries/query-keys.ts @@ -0,0 +1,6 @@ +import { EnvironmentId } from '@/react/portainer/environments/types'; + +export const queryKeys = { + list: (environmentId: EnvironmentId) => + ['environments', environmentId, 'kubernetes', 'serviceaccounts'] as const, +}; diff --git a/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/queries/useDeleteServiceAccountsMutation.ts b/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/queries/useDeleteServiceAccountsMutation.ts new file mode 100644 index 000000000..a017b8d83 --- /dev/null +++ b/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/queries/useDeleteServiceAccountsMutation.ts @@ -0,0 +1,33 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { withGlobalError } from '@/react-tools/react-query'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { queryKeys } from './query-keys'; + +export function useDeleteServiceAccountsMutation(environmentId: EnvironmentId) { + const queryClient = useQueryClient(); + return useMutation(deleteServices, { + onSuccess: () => + queryClient.invalidateQueries(queryKeys.list(environmentId)), + ...withGlobalError('Unable to delete service accounts'), + }); +} + +export async function deleteServices({ + environmentId, + data, +}: { + environmentId: EnvironmentId; + data: Record; +}) { + try { + return await axios.post( + `kubernetes/${environmentId}/service_accounts/delete`, + data + ); + } catch (e) { + throw parseAxiosError(e, `Unable to delete service accounts`); + } +} diff --git a/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/queries/useGetAllServiceAccountsQuery.ts b/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/queries/useGetAllServiceAccountsQuery.ts new file mode 100644 index 000000000..ea76c1763 --- /dev/null +++ b/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/queries/useGetAllServiceAccountsQuery.ts @@ -0,0 +1,38 @@ +import { useQuery } from '@tanstack/react-query'; + +import { withGlobalError } from '@/react-tools/react-query'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { ServiceAccount } from '../../types'; + +import { queryKeys } from './query-keys'; + +export function useGetAllServiceAccountsQuery( + environmentId: EnvironmentId, + options?: { + refetchInterval?: number; + enabled?: boolean; + } +) { + return useQuery( + queryKeys.list(environmentId), + async () => getAllServiceAccounts(environmentId), + { + ...withGlobalError('Unable to get service accounts'), + ...options, + } + ); +} + +async function getAllServiceAccounts(environmentId: EnvironmentId) { + try { + const { data: services } = await axios.get( + `kubernetes/${environmentId}/service_accounts` + ); + + return services; + } catch (e) { + throw parseAxiosError(e, 'Unable to get service accounts'); + } +} diff --git a/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsView.tsx b/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsView.tsx new file mode 100644 index 000000000..53e0946b3 --- /dev/null +++ b/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsView.tsx @@ -0,0 +1,16 @@ +import { PageHeader } from '@@/PageHeader'; + +import { ServiceAccountsDatatable } from './ServiceAccountsDatatable'; + +export function ServiceAccountsView() { + return ( + <> + + + + ); +} diff --git a/app/react/kubernetes/more-resources/ServiceAccountsView/index.ts b/app/react/kubernetes/more-resources/ServiceAccountsView/index.ts new file mode 100644 index 000000000..df4c4557f --- /dev/null +++ b/app/react/kubernetes/more-resources/ServiceAccountsView/index.ts @@ -0,0 +1 @@ +export { ServiceAccountsView } from './ServiceAccountsView'; diff --git a/app/react/kubernetes/more-resources/ServiceAccountsView/types.ts b/app/react/kubernetes/more-resources/ServiceAccountsView/types.ts new file mode 100644 index 000000000..deac95ad2 --- /dev/null +++ b/app/react/kubernetes/more-resources/ServiceAccountsView/types.ts @@ -0,0 +1,10 @@ +export type ServiceAccount = { + name: string; + namespace: string; + resourceVersion: string; + uid: string; + creationDate: string; + + isSystem: boolean; + isUnused: boolean; +}; diff --git a/app/react/kubernetes/namespaces/ListView/NamespacesDatatable.tsx b/app/react/kubernetes/namespaces/ListView/NamespacesDatatable.tsx index 077602f3b..17092a653 100644 --- a/app/react/kubernetes/namespaces/ListView/NamespacesDatatable.tsx +++ b/app/react/kubernetes/namespaces/ListView/NamespacesDatatable.tsx @@ -1,12 +1,15 @@ import { Layers } from 'lucide-react'; +import { useQueryClient } from '@tanstack/react-query'; import { Authorized, useAuthorizations } from '@/react/hooks/useUser'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { pluralize } from '@/portainer/helpers/strings'; +import { notifyError, notifySuccess } from '@/portainer/services/notifications'; +import { DeleteButton } from '@@/buttons/DeleteButton'; import { refreshableSettings } from '@@/datatables/types'; import { Datatable, TableSettingsMenu } from '@@/datatables'; import { useTableStateWithStorage } from '@@/datatables/useTableState'; -import { DeleteButton } from '@@/buttons/DeleteButton'; -import { useRepeater } from '@@/datatables/useRepeater'; import { AddButton } from '@@/buttons'; import { systemResourcesSettings } from '../../datatables/SystemResourcesSettings'; @@ -17,19 +20,16 @@ import { } from '../../datatables/DefaultDatatableSettings'; import { SystemResourceDescription } from '../../datatables/SystemResourceDescription'; import { isDefaultNamespace } from '../isDefaultNamespace'; +import { useNamespacesQuery } from '../queries/useNamespacesQuery'; +import { PortainerNamespace } from '../types'; +import { useDeleteNamespaces } from '../queries/useDeleteNamespaces'; +import { queryKeys } from '../queries/queryKeys'; -import { NamespaceViewModel } from './types'; import { useColumns } from './columns/useColumns'; -export function NamespacesDatatable({ - dataset, - onRemove, - onRefresh, -}: { - dataset: Array; - onRemove(items: Array): void; - onRefresh(): void; -}) { +export function NamespacesDatatable() { + const environmentId = useEnvironmentId(); + const tableState = useTableStateWithStorage( 'kube-namespaces', 'Name', @@ -38,6 +38,11 @@ export function NamespacesDatatable({ ...refreshableSettings(set), }) ); + const namespacesQuery = useNamespacesQuery(environmentId, { + autoRefreshRate: tableState.autoRefreshRate * 1000, + withResourceQuota: true, + }); + const namespaces = Object.values(namespacesQuery.data ?? []); const hasWriteAuthQuery = useAuthorizations( 'K8sResourcePoolDetailsW', @@ -45,40 +50,30 @@ export function NamespacesDatatable({ true ); const columns = useColumns(); - useRepeater(tableState.autoRefreshRate, onRefresh); const filteredDataset = tableState.showSystemResources - ? dataset - : dataset.filter((item) => !item.Namespace.IsSystem); + ? namespaces + : namespaces.filter((namespace) => !namespace.IsSystem); return ( - data-cy="k8sNamespace-namespaceTable" dataset={filteredDataset} columns={columns} settingsManager={tableState} + isLoading={namespacesQuery.isLoading} title="Namespaces" titleIcon={Layers} - getRowId={(item) => item.Namespace.Id} + getRowId={(item) => item.Id} + disableSelect={!hasWriteAuthQuery.authorized} isRowSelectable={({ original: item }) => - hasWriteAuthQuery.authorized && - !item.Namespace.IsSystem && - !isDefaultNamespace(item.Namespace.Name) + !item.IsSystem && !isDefaultNamespace(item.Name) } renderTableActions={(selectedItems) => ( - - onRemove(selectedItems)} - disabled={selectedItems.length === 0} - data-cy="delete-namespace-button" - /> - - - Add with form - - - - + )} renderTableSettings={() => ( @@ -93,3 +88,101 @@ export function NamespacesDatatable({ /> ); } + +function TableActions({ + selectedItems, + namespaces: namespacesQueryData, +}: { + selectedItems: PortainerNamespace[]; + namespaces?: PortainerNamespace[]; +}) { + const queryClient = useQueryClient(); + const environmentId = useEnvironmentId(); + const deleteNamespacesMutation = useDeleteNamespaces(environmentId); + + const selectedNamespacePlural = pluralize(selectedItems.length, 'namespace'); + const includesTerminatingNamespace = selectedItems.some( + (ns) => ns.Status.phase === 'Terminating' + ); + const message = includesTerminatingNamespace + ? 'At least one namespace is in a terminating state. For terminating state namespaces, you may continue and force removal, but doing so without having properly cleaned up may lead to unstable and unpredictable behavior. Are you sure you wish to proceed?' + : `Do you want to remove the selected ${selectedNamespacePlural}? All the resources associated to the selected ${selectedNamespacePlural} will be removed too. Are you sure you wish to proceed?`; + + return ( + + onRemoveNamespaces(selectedItems)} + disabled={selectedItems.length === 0} + data-cy="delete-namespace-button" + confirmMessage={message} + /> + + + Add with form + + + + + ); + + function onRemoveNamespaces(selectedNamespaces: Array) { + deleteNamespacesMutation.mutate( + { + namespaceNames: selectedNamespaces.map((namespace) => namespace.Name), + }, + { + onSuccess: (resp) => { + // gather errors and deleted namespaces + const errors = resp.data?.errors || []; + const erroredNamespacePlural = pluralize(errors.length, 'namespace'); + + const selectedNamespaceNames = selectedNamespaces.map( + (ns) => ns.Name + ); + const deletedNamespaces = + resp.data?.deleted || selectedNamespaceNames; + const deletedNamespacePlural = pluralize( + deletedNamespaces.length, + 'namespace' + ); + + // notify user of success and errors + if (errors.length > 0) { + notifyError( + 'Error', + new Error( + `Failed to delete ${erroredNamespacePlural}: ${errors + .map((err) => `${err.namespaceName}: ${err.error}`) + .join(', ')}` + ) + ); + } + if (deletedNamespaces.length > 0) { + notifySuccess( + 'Success', + `Successfully deleted ${deletedNamespacePlural}: ${deletedNamespaces.join( + ', ' + )}` + ); + } + + // Plain invalidation / refetching is confusing because namespaces hang in a terminating state + // instead, optimistically update the cache manually to hide the deleting (terminating) namespaces + queryClient.setQueryData( + queryKeys.list(environmentId, { + withResourceQuota: true, + }), + () => + deletedNamespaces.reduce( + (acc, ns) => { + delete acc[ns as keyof typeof acc]; + return acc; + }, + { ...namespacesQueryData } + ) + ); + }, + } + ); + } +} diff --git a/app/react/kubernetes/namespaces/ListView/NamespacesView.tsx b/app/react/kubernetes/namespaces/ListView/NamespacesView.tsx new file mode 100644 index 000000000..e0409a32a --- /dev/null +++ b/app/react/kubernetes/namespaces/ListView/NamespacesView.tsx @@ -0,0 +1,12 @@ +import { PageHeader } from '@@/PageHeader'; + +import { NamespacesDatatable } from './NamespacesDatatable'; + +export function NamespacesView() { + return ( + <> + + + + ); +} diff --git a/app/react/kubernetes/namespaces/ListView/columns/actions.tsx b/app/react/kubernetes/namespaces/ListView/columns/actions.tsx index 5510b3cf3..0f408d4f7 100644 --- a/app/react/kubernetes/namespaces/ListView/columns/actions.tsx +++ b/app/react/kubernetes/namespaces/ListView/columns/actions.tsx @@ -7,8 +7,8 @@ import { Environment } from '@/react/portainer/environments/types'; import { Link } from '@@/Link'; import { Button } from '@@/buttons'; -import { NamespaceViewModel } from '../types'; import { isDefaultNamespace } from '../../isDefaultNamespace'; +import { PortainerNamespace } from '../../types'; import { helper } from './helper'; @@ -18,15 +18,15 @@ export const actions = helper.display({ }); function Cell({ - row: { original: item }, -}: CellContext) { + row: { original: namespace }, +}: CellContext) { const environmentQuery = useCurrentEnvironment(); if (!environmentQuery.data) { return null; } - if (!canManageAccess(item, environmentQuery.data)) { + if (!canManageAccess(namespace, environmentQuery.data)) { return '-'; } @@ -36,22 +36,24 @@ function Cell({ color="link" props={{ to: 'kubernetes.resourcePools.resourcePool.access', - params: { id: item.Namespace.Name }, + params: { + id: namespace.Name, + }, }} icon={Users} - data-cy={`manage-access-button-${item.Namespace.Name}`} + data-cy={`manage-access-button-${namespace.Name}`} > Manage access ); - function canManageAccess(item: NamespaceViewModel, environment: Environment) { - const name = item.Namespace.Name; - const isSystem = item.Namespace.IsSystem; - + function canManageAccess( + { Name, IsSystem }: PortainerNamespace, + environment: Environment + ) { return ( - !isSystem && - (!isDefaultNamespace(name) || + !IsSystem && + (!isDefaultNamespace(Name) || environment.Kubernetes.Configuration.RestrictDefaultNamespace) ); } diff --git a/app/react/kubernetes/namespaces/ListView/columns/helper.ts b/app/react/kubernetes/namespaces/ListView/columns/helper.ts index 7aecafcd7..c91401033 100644 --- a/app/react/kubernetes/namespaces/ListView/columns/helper.ts +++ b/app/react/kubernetes/namespaces/ListView/columns/helper.ts @@ -1,5 +1,5 @@ import { createColumnHelper } from '@tanstack/react-table'; -import { NamespaceViewModel } from '../types'; +import { PortainerNamespace } from '../../types'; -export const helper = createColumnHelper(); +export const helper = createColumnHelper(); diff --git a/app/react/kubernetes/namespaces/ListView/columns/useColumns.tsx b/app/react/kubernetes/namespaces/ListView/columns/useColumns.tsx index 4b2bdca47..0da487973 100644 --- a/app/react/kubernetes/namespaces/ListView/columns/useColumns.tsx +++ b/app/react/kubernetes/namespaces/ListView/columns/useColumns.tsx @@ -21,7 +21,7 @@ export function useColumns() { return useMemo( () => _.compact([ - helper.accessor('Namespace.Name', { + helper.accessor('Name', { header: 'Name', id: 'Name', cell: ({ getValue, row: { original: item } }) => { @@ -38,7 +38,7 @@ export function useColumns() { > {name} - {item.Namespace.IsSystem && ( + {item.IsSystem && ( @@ -47,14 +47,18 @@ export function useColumns() { ); }, }), - helper.accessor('Namespace.Status', { + helper.accessor('Status', { header: 'Status', cell({ getValue }) { const status = getValue(); - return {status}; + return ( + + {status.phase} + + ); - function getColor(status: string) { - switch (status.toLowerCase()) { + function getColor(status?: string) { + switch (status?.toLowerCase()) { case 'active': return 'success'; case 'terminating': @@ -65,7 +69,8 @@ export function useColumns() { } }, }), - helper.accessor('Quota', { + helper.accessor('ResourceQuota', { + header: 'Quota', cell({ getValue }) { const quota = getValue(); @@ -76,15 +81,13 @@ export function useColumns() { return Enabled; }, }), - helper.accessor('Namespace.CreationDate', { + helper.accessor('CreationDate', { header: 'Created', cell({ row: { original: item } }) { return ( <> - {isoDate(item.Namespace.CreationDate)}{' '} - {item.Namespace.ResourcePoolOwner - ? ` by ${item.Namespace.ResourcePoolOwner}` - : ''} + {isoDate(item.CreationDate)}{' '} + {item.NamespaceOwner ? ` by ${item.NamespaceOwner}` : ''} ); }, diff --git a/app/react/kubernetes/namespaces/ListView/types.ts b/app/react/kubernetes/namespaces/ListView/types.ts deleted file mode 100644 index ebc9f793a..000000000 --- a/app/react/kubernetes/namespaces/ListView/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface NamespaceViewModel { - Namespace: { - Id: string; - Name: string; - Status: string; - CreationDate: number; - ResourcePoolOwner: string; - IsSystem: boolean; - }; - Quota: number; -} diff --git a/app/react/kubernetes/namespaces/components/NamespaceInnerForm.tsx b/app/react/kubernetes/namespaces/components/NamespaceInnerForm.tsx index e9b7d3f6c..878117403 100644 --- a/app/react/kubernetes/namespaces/components/NamespaceInnerForm.tsx +++ b/app/react/kubernetes/namespaces/components/NamespaceInnerForm.tsx @@ -83,10 +83,10 @@ export function NamespaceInnerForm({ onChange={(classes) => setFieldValue('ingressClasses', classes)} values={values.ingressClasses} description="Enable the ingress controllers that users can select when publishing applications in this namespace." + noIngressControllerLabel="No ingress controllers available in the cluster. Go to the cluster setup view to configure and allow the use of ingress controllers in the cluster." view="namespace" isLoading={ingressClassesQuery.isLoading} initialValues={initialValues.ingressClasses} - noIngressControllerLabel="No ingress controllers available in the cluster. Go to the cluster setup view to configure and allow the use of ingress controllers in the cluster." /> )} diff --git a/app/react/kubernetes/namespaces/queries/queryKeys.ts b/app/react/kubernetes/namespaces/queries/queryKeys.ts new file mode 100644 index 000000000..67f2a4566 --- /dev/null +++ b/app/react/kubernetes/namespaces/queries/queryKeys.ts @@ -0,0 +1,20 @@ +import { compact } from 'lodash'; + +export const queryKeys = { + list: (environmentId: number, options?: { withResourceQuota?: boolean }) => + compact([ + 'environments', + environmentId, + 'kubernetes', + 'namespaces', + options?.withResourceQuota, + ]), + namespace: (environmentId: number, namespace: string) => + [ + 'environments', + environmentId, + 'kubernetes', + 'namespaces', + namespace, + ] as const, +}; diff --git a/app/react/kubernetes/namespaces/queries/useDeleteNamespaces.ts b/app/react/kubernetes/namespaces/queries/useDeleteNamespaces.ts new file mode 100644 index 000000000..b77cfbc57 --- /dev/null +++ b/app/react/kubernetes/namespaces/queries/useDeleteNamespaces.ts @@ -0,0 +1,47 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { withGlobalError, withInvalidate } from '@/react-tools/react-query'; + +import { queryKeys } from './queryKeys'; + +type DeleteNamespaceError = { + namespaceName: string; + error: string; +}; + +// when successful, the response will contain a list of deleted namespaces and a list of errors +type DeleteNamespacesResponse = { + deleted: string[]; + errors: DeleteNamespaceError[]; +} | null; + +// useDeleteNamespaces is a react query mutation that removes a list of namespaces, +export function useDeleteNamespaces(environmentId: number) { + const queryClient = useQueryClient(); + return useMutation( + ({ namespaceNames }: { namespaceNames: string[] }) => + deleteNamespaces(environmentId, namespaceNames), + { + ...withInvalidate(queryClient, [queryKeys.list(environmentId)]), + ...withGlobalError('Unable to delete namespaces'), + // onSuccess handled by the caller + } + ); +} + +async function deleteNamespaces( + environmentId: number, + namespaceNames: string[] +) { + try { + return await axios.delete( + `kubernetes/${environmentId}/namespaces`, + { + data: namespaceNames, + } + ); + } catch (e) { + throw parseAxiosError(e, 'Unable to delete namespace'); + } +} diff --git a/app/react/kubernetes/namespaces/queries/useIsSystemNamespace.ts b/app/react/kubernetes/namespaces/queries/useIsSystemNamespace.ts index b076b7aed..f9e806524 100644 --- a/app/react/kubernetes/namespaces/queries/useIsSystemNamespace.ts +++ b/app/react/kubernetes/namespaces/queries/useIsSystemNamespace.ts @@ -1,5 +1,7 @@ import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { PortainerNamespace } from '../types'; + import { useNamespaceQuery } from './useNamespaceQuery'; export function useIsSystemNamespace(namespace: string) { @@ -10,3 +12,12 @@ export function useIsSystemNamespace(namespace: string) { return !!query.data; } + +export function isSystemNamespace( + namespaceName: string, + namespaces?: PortainerNamespace[] +) { + return namespaces?.some( + (namespace) => namespace.Name === namespaceName && namespace.IsSystem + ); +} diff --git a/app/react/kubernetes/namespaces/queries/useNamespaceQuery.ts b/app/react/kubernetes/namespaces/queries/useNamespaceQuery.ts index 3ee707aab..b14c3d625 100644 --- a/app/react/kubernetes/namespaces/queries/useNamespaceQuery.ts +++ b/app/react/kubernetes/namespaces/queries/useNamespaceQuery.ts @@ -4,19 +4,21 @@ import axios, { parseAxiosError } from '@/portainer/services/axios'; import { notifyError } from '@/portainer/services/notifications'; import { EnvironmentId } from '@/react/portainer/environments/types'; -import { DefaultOrSystemNamespace } from '../types'; +import { PortainerNamespace } from '../types'; -export function useNamespaceQuery( +import { queryKeys } from './queryKeys'; + +export function useNamespaceQuery( environmentId: EnvironmentId, namespace: string, { select, }: { - select?(namespace: DefaultOrSystemNamespace): T; + select?(namespace: PortainerNamespace): T; } = {} ) { return useQuery( - ['environments', environmentId, 'kubernetes', 'namespaces', namespace], + queryKeys.namespace(environmentId, namespace), () => getNamespace(environmentId, namespace), { onError: (err) => { @@ -33,11 +35,11 @@ export async function getNamespace( namespace: string ) { try { - const { data: ns } = await axios.get( + const { data: ns } = await axios.get( `kubernetes/${environmentId}/namespaces/${namespace}` ); return ns; } catch (e) { - throw parseAxiosError(e as Error, 'Unable to retrieve namespace'); + throw parseAxiosError(e, 'Unable to retrieve namespace'); } } diff --git a/app/react/kubernetes/namespaces/queries/useNamespacesQuery.ts b/app/react/kubernetes/namespaces/queries/useNamespacesQuery.ts index b3937ad28..6e521e0aa 100644 --- a/app/react/kubernetes/namespaces/queries/useNamespacesQuery.ts +++ b/app/react/kubernetes/namespaces/queries/useNamespacesQuery.ts @@ -1,40 +1,24 @@ import { useQuery } from '@tanstack/react-query'; import { EnvironmentId } from '@/react/portainer/environments/types'; -import { withError } from '@/react-tools/react-query'; +import { withGlobalError } from '@/react-tools/react-query'; import axios, { parseAxiosError } from '@/portainer/services/axios'; -import { Namespaces } from '../types'; -import { getSelfSubjectAccessReview } from '../getSelfSubjectAccessReview'; +import { PortainerNamespace } from '../types'; + +import { queryKeys } from './queryKeys'; export function useNamespacesQuery( environmentId: EnvironmentId, - options?: { autoRefreshRate?: number } + options?: { autoRefreshRate?: number; withResourceQuota?: boolean } ) { return useQuery( - ['environments', environmentId, 'kubernetes', 'namespaces'], - async () => { - const namespaces = await getNamespaces(environmentId); - const namespaceNames = Object.keys(namespaces); - // use selfsubjectaccess reviews to avoid forbidden requests - const allNamespaceAccessReviews = await Promise.all( - namespaceNames.map((namespaceName) => - getSelfSubjectAccessReview(environmentId, namespaceName) - ) - ); - const allowedNamespacesNames = allNamespaceAccessReviews - .filter((accessReview) => accessReview.status.allowed) - .map((accessReview) => accessReview.spec.resourceAttributes.namespace); - const allowedNamespaces = namespaceNames.reduce((acc, namespaceName) => { - if (allowedNamespacesNames.includes(namespaceName)) { - acc[namespaceName] = namespaces[namespaceName]; - } - return acc; - }, {} as Namespaces); - return allowedNamespaces; - }, + queryKeys.list(environmentId, { + withResourceQuota: !!options?.withResourceQuota, + }), + async () => getNamespaces(environmentId, options?.withResourceQuota), { - ...withError('Unable to get namespaces.'), + ...withGlobalError('Unable to get namespaces.'), refetchInterval() { return options?.autoRefreshRate ?? false; }, @@ -43,13 +27,18 @@ export function useNamespacesQuery( } // getNamespaces is used to retrieve namespaces using the Portainer backend with caching -async function getNamespaces(environmentId: EnvironmentId) { +export async function getNamespaces( + environmentId: EnvironmentId, + withResourceQuota?: boolean +) { + const params = withResourceQuota ? { withResourceQuota } : {}; try { - const { data: namespaces } = await axios.get( - `kubernetes/${environmentId}/namespaces` + const { data: namespaces } = await axios.get( + `kubernetes/${environmentId}/namespaces`, + { params } ); return namespaces; } catch (e) { - throw parseAxiosError(e as Error, 'Unable to retrieve namespaces'); + throw parseAxiosError(e, 'Unable to retrieve namespaces'); } } diff --git a/app/react/kubernetes/namespaces/types.ts b/app/react/kubernetes/namespaces/types.ts index 9ec305647..922e74a15 100644 --- a/app/react/kubernetes/namespaces/types.ts +++ b/app/react/kubernetes/namespaces/types.ts @@ -1,8 +1,16 @@ -export interface Namespaces { - [key: string]: DefaultOrSystemNamespace; +import { NamespaceStatus, ResourceQuota } from 'kubernetes-types/core/v1'; + +export interface PortainerNamespace { + Id: string; + Name: string; + Status: NamespaceStatus; + CreationDate: number; + NamespaceOwner: string; + IsSystem: boolean; + IsDefault: boolean; + ResourceQuota?: ResourceQuota | null; } -export interface DefaultOrSystemNamespace { - IsDefault: boolean; - IsSystem: boolean; -} +// type returned via the internal portainer namespaces api, with simplified fields +// it is a record currently (legacy reasons), but it should be an array +export type Namespaces = Record; diff --git a/app/react/kubernetes/networks/services/types.ts b/app/react/kubernetes/networks/services/types.ts index ab1d24179..1ed154eba 100644 --- a/app/react/kubernetes/networks/services/types.ts +++ b/app/react/kubernetes/networks/services/types.ts @@ -26,7 +26,7 @@ export interface Service { Namespace: string; UID: string; AllocateLoadBalancerNodePorts?: boolean; - Ports: Port[]; + Ports?: Port[]; Selector?: Document; Type: string; Status?: Status; diff --git a/app/react/kubernetes/queries/useGetMetricsMutation.ts b/app/react/kubernetes/queries/useGetMetricsMutation.ts index f5bba6337..3b471d8ed 100644 --- a/app/react/kubernetes/queries/useGetMetricsMutation.ts +++ b/app/react/kubernetes/queries/useGetMetricsMutation.ts @@ -1,6 +1,6 @@ import { useMutation } from '@tanstack/react-query'; -import { getMetricsForAllNodes } from '../services/service'; +import { getMetricsForAllNodes } from '../metrics/metrics'; // use this as a mutation because the metrics request should be manually fired when the user clicks to turn the metrics toggle on export function useGetMetricsMutation() { diff --git a/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.tsx b/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.tsx index 3a81f9f75..86e717128 100644 --- a/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.tsx +++ b/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.tsx @@ -1,76 +1,65 @@ +import { useMemo } from 'react'; import { Shuffle } from 'lucide-react'; import { useRouter } from '@uirouter/react'; import clsx from 'clsx'; import { Row } from '@tanstack/react-table'; -import { useMemo } from 'react'; -import { Namespaces } from '@/react/kubernetes/namespaces/types'; +import { + Namespaces, + PortainerNamespace, +} from '@/react/kubernetes/namespaces/types'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { Authorized, useAuthorizations } from '@/react/hooks/useUser'; import { notifyError, notifySuccess } from '@/portainer/services/notifications'; import { pluralize } from '@/portainer/helpers/strings'; -import { - DefaultDatatableSettings, - TableSettings as KubeTableSettings, -} from '@/react/kubernetes/datatables/DefaultDatatableSettings'; -import { useKubeStore } from '@/react/kubernetes/datatables/default-kube-datatable-store'; +import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings'; import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemResourceDescription'; import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery'; import { CreateFromManifestButton } from '@/react/kubernetes/components/CreateFromManifestButton'; import { Datatable, Table, TableSettingsMenu } from '@@/datatables'; +import { useTableState } from '@@/datatables/useTableState'; import { DeleteButton } from '@@/buttons/DeleteButton'; -import { - type FilteredColumnsTableSettings, - filteredColumnsSettings, -} from '@@/datatables/types'; -import { mergeOptions } from '@@/datatables/extend-options/mergeOptions'; -import { withColumnFilters } from '@@/datatables/extend-options/withColumnFilters'; -import { - useMutationDeleteServices, - useServicesForCluster, -} from '../../service'; +import { useMutationDeleteServices, useClusterServices } from '../../service'; import { Service } from '../../types'; import { columns } from './columns'; +import { createStore } from './datatable-store'; const storageKey = 'k8sServicesDatatable'; -interface TableSettings - extends KubeTableSettings, - FilteredColumnsTableSettings {} +const settingsStore = createStore(storageKey); export function ServicesDatatable() { - const tableState = useKubeStore( - storageKey, - undefined, - (set) => ({ - ...filteredColumnsSettings(set), - }) - ); + const tableState = useTableState(settingsStore, storageKey); const environmentId = useEnvironmentId(); - const { data: namespaces, ...namespacesQuery } = + const { data: namespacesArray, ...namespacesQuery } = useNamespacesQuery(environmentId); - const namespaceNames = (namespaces && Object.keys(namespaces)) || []; - const { data: services, ...servicesQuery } = useServicesForCluster( + const { data: services, ...servicesQuery } = useClusterServices( environmentId, - namespaceNames, { autoRefreshRate: tableState.autoRefreshRate * 1000, - lookupApplications: true, + withApplications: true, } ); + const namespaces: Record = {}; + if (Array.isArray(namespacesArray)) { + for (let i = 0; i < namespacesArray.length; i++) { + const namespace = namespacesArray[i]; + namespaces[namespace.Name] = namespace; + } + } + const { authorized: canWrite } = useAuthorizations(['K8sServiceW']); const readOnly = !canWrite; const { authorized: canAccessSystemResources } = useAuthorizations( 'K8sAccessSystemNamespaces' ); - const filteredServices = services?.filter( (service) => (canAccessSystemResources && tableState.showSystemResources) || - !namespaces?.[service.Namespace].IsSystem + !namespaces?.[service.Namespace]?.IsSystem ); const servicesWithIsSystem = useServicesRowData( @@ -84,10 +73,11 @@ export function ServicesDatatable() { columns={columns} settingsManager={tableState} isLoading={servicesQuery.isLoading || namespacesQuery.isLoading} + emptyContentLabel="No services found" title="Services" titleIcon={Shuffle} getRowId={(row) => row.UID} - isRowSelectable={(row) => !namespaces?.[row.original.Namespace].IsSystem} + isRowSelectable={(row) => !namespaces?.[row.original.Namespace]?.IsSystem} disableSelect={readOnly} renderTableActions={(selectedRows) => ( @@ -106,9 +96,6 @@ export function ServicesDatatable() { } renderRow={servicesRenderRow} data-cy="k8s-services-datatable" - extendTableOptions={mergeOptions( - withColumnFilters(tableState.columnFilters, tableState.setColumnFilters) - )} /> ); } @@ -122,7 +109,9 @@ function useServicesRowData( () => services.map((service) => ({ ...service, - IsSystem: namespaces ? namespaces?.[service.Namespace].IsSystem : false, + IsSystem: namespaces + ? namespaces?.[service.Namespace]?.IsSystem + : false, })), [services, namespaces] ); diff --git a/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/created.tsx b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/created.tsx index d6994a3e7..2e5ba58de 100644 --- a/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/created.tsx +++ b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/created.tsx @@ -5,14 +5,14 @@ import { columnHelper } from './helper'; export const created = columnHelper.accessor( (row) => { const owner = row.Labels?.['io.portainer.kubernetes.application.owner']; - const date = formatDate(row.CreationTimestamp); + const date = formatDate(row.CreationDate); return owner ? `${date} by ${owner}` : date; }, { header: 'Created', id: 'created', cell: ({ row }) => { - const date = formatDate(row.original.CreationTimestamp); + const date = formatDate(row.original.CreationDate); const owner = row.original.Labels?.['io.portainer.kubernetes.application.owner']; diff --git a/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/ports.tsx b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/ports.tsx index 92e554460..e04c5c632 100644 --- a/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/ports.tsx +++ b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/ports.tsx @@ -4,7 +4,7 @@ import { columnHelper } from './helper'; export const ports = columnHelper.accessor( (row) => - row.Ports.map( + row.Ports?.map( (port) => `${port.Port}${port.NodePort !== 0 ? `:${port.NodePort}` : ''}/${ port.Protocol @@ -19,13 +19,13 @@ export const ports = columnHelper.accessor( ), id: 'ports', cell: ({ row }) => { - if (!row.original.Ports.length) { + if (!row.original.Ports?.length) { return '-'; } return ( <> - {row.original.Ports.map((port, index) => { + {row.original.Ports?.map((port, index) => { if (port.NodePort !== 0) { return (
@@ -44,8 +44,8 @@ export const ports = columnHelper.accessor( ); }, sortingFn: (rowA, rowB) => { - const a = rowA.original.Ports; - const b = rowB.original.Ports; + const a = rowA.original.Ports ?? []; + const b = rowB.original.Ports ?? []; if (!a.length && !b.length) return 0; diff --git a/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/targetPorts.tsx b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/targetPorts.tsx index 292cff805..a36ff1ce5 100644 --- a/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/targetPorts.tsx +++ b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/targetPorts.tsx @@ -1,12 +1,12 @@ import { columnHelper } from './helper'; export const targetPorts = columnHelper.accessor( - (row) => row.Ports.map((port) => port.TargetPort).join(','), + (row) => row.Ports?.map((port) => port.TargetPort).join(','), { header: 'Target Ports', id: 'targetPorts', cell: ({ row }) => { - const ports = row.original.Ports.map((port) => port.TargetPort); + const ports = row.original.Ports?.map((port) => port.TargetPort) ?? []; if (!ports.length) { return '-'; } @@ -14,8 +14,8 @@ export const targetPorts = columnHelper.accessor( return ports.map((port, index) =>
{port}
); }, sortingFn: (rowA, rowB) => { - const a = rowA.original.Ports; - const b = rowB.original.Ports; + const a = rowA.original.Ports ?? []; + const b = rowB.original.Ports ?? []; if (!a.length && !b.length) return 0; if (!a.length) return 1; diff --git a/app/react/kubernetes/services/service.ts b/app/react/kubernetes/services/service.ts index 98db5f0e2..a36f25db9 100644 --- a/app/react/kubernetes/services/service.ts +++ b/app/react/kubernetes/services/service.ts @@ -1,16 +1,10 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { compact } from 'lodash'; import { ServiceList } from 'kubernetes-types/core/v1'; -import { withError } from '@/react-tools/react-query'; +import { withGlobalError } from '@/react-tools/react-query'; import axios, { parseAxiosError } from '@/portainer/services/axios'; import { EnvironmentId } from '@/react/portainer/environments/types'; -import { isFulfilled } from '@/portainer/helpers/promise-utils'; -import { - Service, - NodeMetrics, - NodeMetric, -} from '@/react/kubernetes/services/types'; +import { Service } from '@/react/kubernetes/services/types'; import { parseKubernetesAxiosError } from '../axiosError'; @@ -19,32 +13,28 @@ export const queryKeys = { ['environments', environmentId, 'kubernetes', 'services'] as const, }; -export function useServicesForCluster( +/** + * Custom hook to fetch cluster services for a specific environment. + * + * @param environmentId - The ID of the environment. + * @param options - Additional options for fetching services. + * @param options.autoRefreshRate - The auto refresh rate for refetching services. + * @param options.withApplications - Whether to lookup applications for the services. + * + * @returns The result of the query. + */ +export function useClusterServices( environmentId: EnvironmentId, - namespaceNames?: string[], - options?: { autoRefreshRate?: number; lookupApplications?: boolean } + options?: { autoRefreshRate?: number; withApplications?: boolean } ) { return useQuery( queryKeys.clusterServices(environmentId), - async () => { - if (!namespaceNames?.length) { - return []; - } - const settledServicesPromise = await Promise.allSettled( - namespaceNames.map((namespace) => - getServices(environmentId, namespace, options?.lookupApplications) - ) - ); - return compact( - settledServicesPromise.filter(isFulfilled).flatMap((i) => i.value) - ); - }, + async () => getClusterServices(environmentId, options?.withApplications), { - ...withError('Unable to get services.'), + ...withGlobalError('Unable to get services.'), refetchInterval() { return options?.autoRefreshRate ?? false; }, - enabled: !!namespaceNames?.length, } ); } @@ -68,7 +58,7 @@ export function useServicesQuery( return services; }, { - ...withError('Unable to retrieve services.'), + ...withGlobalError('Unable to retrieve services.'), enabled: !!serviceNames?.length, } ); @@ -79,7 +69,7 @@ export function useMutationDeleteServices(environmentId: EnvironmentId) { return useMutation(deleteServices, { onSuccess: () => queryClient.invalidateQueries(queryKeys.clusterServices(environmentId)), - ...withError('Unable to delete service(s)'), + ...withGlobalError('Unable to delete service(s)'), }); } @@ -87,14 +77,33 @@ export function useMutationDeleteServices(environmentId: EnvironmentId) { export async function getServices( environmentId: EnvironmentId, namespace: string, - lookupApplications?: boolean + withApplications?: boolean ) { try { const { data: services } = await axios.get>( `kubernetes/${environmentId}/namespaces/${namespace}/services`, { params: { - lookupapplications: lookupApplications, + withApplications, + }, + } + ); + return services; + } catch (e) { + throw parseAxiosError(e, 'Unable to retrieve services'); + } +} + +export async function getClusterServices( + environmentId: EnvironmentId, + withApplications?: boolean +) { + try { + const { data: services } = await axios.get>( + `kubernetes/${environmentId}/services`, + { + params: { + withApplications, }, } ); @@ -158,70 +167,6 @@ export async function deleteServices({ data ); } catch (e) { - throw parseAxiosError(e as Error, 'Unable to delete service(s)'); - } -} - -export async function getMetricsForAllNodes(environmentId: EnvironmentId) { - try { - const { data: nodes } = await axios.get( - `kubernetes/${environmentId}/metrics/nodes`, - {} - ); - return nodes; - } catch (e) { - throw parseAxiosError( - e as Error, - 'Unable to retrieve metrics for all nodes' - ); - } -} - -export async function getMetricsForNode( - environmentId: EnvironmentId, - nodeName: string -) { - try { - const { data: node } = await axios.get( - `kubernetes/${environmentId}/metrics/nodes/${nodeName}`, - {} - ); - return node; - } catch (e) { - throw parseAxiosError(e as Error, 'Unable to retrieve metrics for node'); - } -} - -export async function getMetricsForAllPods( - environmentId: EnvironmentId, - namespace: string -) { - try { - const { data: pods } = await axios.get( - `kubernetes/${environmentId}/metrics/pods/namespace/${namespace}`, - {} - ); - return pods; - } catch (e) { - throw parseAxiosError( - e as Error, - 'Unable to retrieve metrics for all pods' - ); - } -} - -export async function getMetricsForPod( - environmentId: EnvironmentId, - namespace: string, - podName: string -) { - try { - const { data: pod } = await axios.get( - `kubernetes/${environmentId}/metrics/pods/namespace/${namespace}/${podName}`, - {} - ); - return pod; - } catch (e) { - throw parseAxiosError(e as Error, 'Unable to retrieve metrics for pod'); + throw parseAxiosError(e, 'Unable to delete service(s)'); } } diff --git a/app/react/kubernetes/services/types.ts b/app/react/kubernetes/services/types.ts index 4b4ceb4ae..c03bc3e69 100644 --- a/app/react/kubernetes/services/types.ts +++ b/app/react/kubernetes/services/types.ts @@ -12,9 +12,9 @@ type IngressStatus = { }; type Application = { - UID: string; + Uid: string; Name: string; - Type: string; + Kind: string; }; export type ServiceType = @@ -30,35 +30,14 @@ export type Service = { Annotations?: Record; Labels?: Record; Type: ServiceType; - Ports: Array; + Ports?: Array; Selector?: Record; ClusterIPs?: Array; IngressStatus?: Array; ExternalName?: string; ExternalIPs?: Array; - CreationTimestamp: string; + CreationDate: string; Applications?: Application[]; - IsSystem?: boolean; -}; - -export type NodeMetrics = { - items: NodeMetric[]; -}; - -export type NodeMetric = { - metadata: NodeMetricMetadata; - timestamp: Date; - usage: Usage; - window: string; -}; - -export type NodeMetricMetadata = { - creationTimestamp: Date; - name: string; -}; - -export type Usage = { - cpu: string; - memory: string; + IsSystem: boolean; }; diff --git a/app/react/kubernetes/volumes/ListView/StorageDatatable.tsx b/app/react/kubernetes/volumes/ListView/StorageDatatable.tsx index 8b501cf26..8f7d58303 100644 --- a/app/react/kubernetes/volumes/ListView/StorageDatatable.tsx +++ b/app/react/kubernetes/volumes/ListView/StorageDatatable.tsx @@ -1,6 +1,9 @@ import { createColumnHelper } from '@tanstack/react-table'; import { HardDrive } from 'lucide-react'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { humanize } from '@/portainer/filters/filters'; + import { TableSettingsMenu } from '@@/datatables'; import { BasicTableSettings, @@ -9,11 +12,12 @@ import { } from '@@/datatables/types'; import { useTableStateWithStorage } from '@@/datatables/useTableState'; import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh'; -import { useRepeater } from '@@/datatables/useRepeater'; import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable'; import { buildExpandColumn } from '@@/datatables/expand-column'; import { Link } from '@@/Link'; +import { useAllStoragesQuery } from '../queries/useVolumesQuery'; + import { StorageClassViewModel } from './types'; interface TableSettings extends BasicTableSettings, RefreshableTableSettings {} @@ -27,16 +31,11 @@ const columns = [ }), helper.accessor('size', { header: 'Usage', + cell: ({ row: { original: item } }) => <>{humanize(item.size)}, }), ]; -export function StorageDatatable({ - dataset, - onRefresh, -}: { - dataset: Array; - onRefresh: () => void; -}) { +export function StorageDatatable() { const tableState = useTableStateWithStorage( 'kubernetes.volumes.storages', 'Name', @@ -45,17 +44,21 @@ export function StorageDatatable({ }) ); - useRepeater(tableState.autoRefreshRate, onRefresh); + const envId = useEnvironmentId(); + const storagesQuery = useAllStoragesQuery(envId, { + refetchInterval: tableState.autoRefreshRate * 1000, + }); + const storages = storagesQuery.data ?? []; return ( ( - {vol.PersistentVolumeClaim.Storage} + {humanize(vol.PersistentVolumeClaim.Storage)} ))} diff --git a/app/react/kubernetes/volumes/ListView/VolumesDatatable.tsx b/app/react/kubernetes/volumes/ListView/VolumesDatatable.tsx index 65b9a39b7..612d1053d 100644 --- a/app/react/kubernetes/volumes/ListView/VolumesDatatable.tsx +++ b/app/react/kubernetes/volumes/ListView/VolumesDatatable.tsx @@ -1,6 +1,6 @@ import { Database } from 'lucide-react'; -import { useAuthorizations } from '@/react/hooks/useUser'; +import { Authorized, useAuthorizations } from '@/react/hooks/useUser'; import KubernetesVolumeHelper from '@/kubernetes/helpers/volumeHelper'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; @@ -8,7 +8,6 @@ import { refreshableSettings } from '@@/datatables/types'; import { Datatable, TableSettingsMenu } from '@@/datatables'; import { useTableStateWithStorage } from '@@/datatables/useTableState'; import { DeleteButton } from '@@/buttons/DeleteButton'; -import { useRepeater } from '@@/datatables/useRepeater'; import { systemResourcesSettings } from '../../datatables/SystemResourcesSettings'; import { CreateFromManifestButton } from '../../components/CreateFromManifestButton'; @@ -18,19 +17,13 @@ import { } from '../../datatables/DefaultDatatableSettings'; import { SystemResourceDescription } from '../../datatables/SystemResourceDescription'; import { useNamespacesQuery } from '../../namespaces/queries/useNamespacesQuery'; +import { useAllVolumesQuery } from '../queries/useVolumesQuery'; +import { isSystemNamespace } from '../../namespaces/queries/useIsSystemNamespace'; +import { useDeleteVolumes } from '../queries/useDeleteVolumes'; -import { VolumeViewModel } from './types'; import { columns } from './columns'; -export function VolumesDatatable({ - dataset, - onRemove, - onRefresh, -}: { - dataset: Array; - onRemove(items: Array): void; - onRefresh(): void; -}) { +export function VolumesDatatable() { const tableState = useTableStateWithStorage( 'kube-volumes', 'Name', @@ -40,40 +33,54 @@ export function VolumesDatatable({ }) ); - const hasWriteAuth = useAuthorizations('K8sVolumesW', undefined, true); - - useRepeater(tableState.autoRefreshRate, onRefresh); + const { authorized: hasWriteAuth } = useAuthorizations( + 'K8sVolumesW', + undefined, + true + ); const envId = useEnvironmentId(); + const deleteVolumesMutation = useDeleteVolumes(envId); const namespaceListQuery = useNamespacesQuery(envId); + const namespaces = namespaceListQuery.data ?? []; + const volumesQuery = useAllVolumesQuery(envId, { + refetchInterval: tableState.autoRefreshRate * 1000, + }); + const volumes = volumesQuery.data ?? []; - const filteredDataset = tableState.showSystemResources - ? dataset - : dataset.filter((item) => !isSystem(item)); + const filteredVolumes = tableState.showSystemResources + ? volumes + : volumes.filter( + (volume) => + !isSystemNamespace(volume.ResourcePool.Namespace.Name, namespaces) + ); return ( - hasWriteAuth && - !(isSystem(item) && !KubernetesVolumeHelper.isUsed(item)) + getRowId={(row) => row.PersistentVolumeClaim.Name} + disableSelect={!hasWriteAuth} + isRowSelectable={({ original: volume }) => + !isSystemNamespace(volume.ResourcePool.Namespace.Name, namespaces) && + !KubernetesVolumeHelper.isUsed(volume) } renderTableActions={(selectedItems) => ( - <> + onRemove(selectedItems)} + onConfirmed={() => deleteVolumesMutation.mutate(selectedItems)} disabled={selectedItems.length === 0} + isLoading={deleteVolumesMutation.isLoading} data-cy="k8s-volumes-delete-button" /> - + )} renderTableSettings={() => ( @@ -87,9 +94,4 @@ export function VolumesDatatable({ } /> ); - - function isSystem(item: VolumeViewModel) { - return !!namespaceListQuery.data?.[item.ResourcePool.Namespace.Name] - .IsSystem; - } } diff --git a/app/react/kubernetes/volumes/ListView/VolumesView.tsx b/app/react/kubernetes/volumes/ListView/VolumesView.tsx new file mode 100644 index 000000000..b0885fe12 --- /dev/null +++ b/app/react/kubernetes/volumes/ListView/VolumesView.tsx @@ -0,0 +1,40 @@ +import { useCurrentStateAndParams } from '@uirouter/react'; +import { Database, HardDrive } from 'lucide-react'; + +import { PageHeader } from '@@/PageHeader'; +import { WidgetTabs, Tab, findSelectedTabIndex } from '@@/Widget/WidgetTabs'; + +import { VolumesDatatable } from './VolumesDatatable'; +import { StorageDatatable } from './StorageDatatable'; + +export function VolumesView() { + const tabs: Tab[] = [ + { + name: 'Volumes', + icon: Database, + widget: , + selectedTabParam: 'volumes', + }, + { + name: 'Storage', + icon: HardDrive, + widget: , + selectedTabParam: 'storage', + }, + ]; + + const currentTabIndex = findSelectedTabIndex( + useCurrentStateAndParams(), + tabs + ); + + return ( + <> + + <> + +
{tabs[currentTabIndex].widget}
+ + + ); +} diff --git a/app/react/kubernetes/volumes/ListView/columns.name.tsx b/app/react/kubernetes/volumes/ListView/columns.name.tsx index 6f21f738c..e11a1ab26 100644 --- a/app/react/kubernetes/volumes/ListView/columns.name.tsx +++ b/app/react/kubernetes/volumes/ListView/columns.name.tsx @@ -15,6 +15,7 @@ import { helper } from './columns.helper'; export const name = helper.accessor('PersistentVolumeClaim.Name', { header: 'Name', + id: 'Name', cell: NameCell, }); @@ -23,10 +24,12 @@ export function NameCell({ }: CellContext) { const envId = useEnvironmentId(); const namespaceListQuery = useNamespacesQuery(envId); - const isSystem = - namespaceListQuery.data?.[item.ResourcePool.Namespace.Name].IsSystem; + const isSystem = namespaceListQuery.data?.some( + (namespace) => + namespace.Name === item.ResourcePool.Namespace.Name && namespace.IsSystem + ); return ( - <> +
} )} - +
); } diff --git a/app/react/kubernetes/volumes/ListView/types.ts b/app/react/kubernetes/volumes/ListView/types.ts index 5f160c474..9654cb5b8 100644 --- a/app/react/kubernetes/volumes/ListView/types.ts +++ b/app/react/kubernetes/volumes/ListView/types.ts @@ -1,17 +1,17 @@ import { StorageClass } from '@/kubernetes/models/storage-class/StorageClass'; import { Volume } from '@/kubernetes/models/volume/Volume'; +import { K8sVolOwningApplication } from '../types'; + export interface VolumeViewModel { - Applications: Array<{ - Name: string; - }>; + Applications: K8sVolOwningApplication[]; PersistentVolumeClaim: { Name: string; storageClass: { Name: string; }; Storage?: unknown; - CreationDate: number; + CreationDate?: string; ApplicationOwner?: string; }; ResourcePool: { @@ -22,6 +22,6 @@ export interface VolumeViewModel { } export type StorageClassViewModel = StorageClass & { - size: 0; + size: number; Volumes: Array; }; diff --git a/app/react/kubernetes/volumes/queries/query-keys.ts b/app/react/kubernetes/volumes/queries/query-keys.ts new file mode 100644 index 000000000..a5c207778 --- /dev/null +++ b/app/react/kubernetes/volumes/queries/query-keys.ts @@ -0,0 +1,16 @@ +import { EnvironmentId } from '@/react/portainer/environments/types'; + +export const queryKeys = { + volumes: (environmentId: EnvironmentId) => [ + 'environments', + environmentId, + 'kubernetes', + 'volumes', + ], + storages: (environmentId: EnvironmentId) => [ + 'environments', + environmentId, + 'kubernetes', + 'storages', + ], +}; diff --git a/app/react/kubernetes/volumes/queries/useDeleteVolumes.ts b/app/react/kubernetes/volumes/queries/useDeleteVolumes.ts new file mode 100644 index 000000000..78d5a295d --- /dev/null +++ b/app/react/kubernetes/volumes/queries/useDeleteVolumes.ts @@ -0,0 +1,60 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { EnvironmentId } from '@/react/portainer/environments/types'; +import axios from '@/portainer/services/axios'; +import { getAllSettledItems } from '@/portainer/helpers/promise-utils'; +import { withGlobalError } from '@/react-tools/react-query'; +import { notifyError, notifySuccess } from '@/portainer/services/notifications'; +import { pluralize } from '@/portainer/helpers/strings'; + +import { parseKubernetesAxiosError } from '../../axiosError'; +import { VolumeViewModel } from '../ListView/types'; + +import { queryKeys } from './query-keys'; + +export function useDeleteVolumes(environmentId: EnvironmentId) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (volumes: VolumeViewModel[]) => + deleteVolumes(volumes, environmentId), + onSuccess: ({ fulfilledItems, rejectedItems }) => { + // one error notification per rejected item + rejectedItems.forEach(({ item, reason }) => { + notifyError( + `Failed to remove volume '${item.PersistentVolumeClaim.Name}'`, + new Error(reason) + ); + }); + + // one success notification for all fulfilled items + if (fulfilledItems.length) { + notifySuccess( + `${pluralize(fulfilledItems.length, 'Volume')} successfully removed`, + fulfilledItems + .map((item) => item.PersistentVolumeClaim.Name) + .join(', ') + ); + } + queryClient.invalidateQueries(queryKeys.storages(environmentId)); + queryClient.invalidateQueries(queryKeys.volumes(environmentId)); + }, + ...withGlobalError('Unable to remove volumes'), + }); +} + +function deleteVolumes( + volumes: VolumeViewModel[], + environmentId: EnvironmentId +) { + return getAllSettledItems(volumes, deleteVolume); + + async function deleteVolume(volume: VolumeViewModel) { + try { + await axios.delete( + `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${volume.ResourcePool.Namespace.Name}/persistentvolumeclaims/${volume.PersistentVolumeClaim.Name}` + ); + } catch (error) { + throw parseKubernetesAxiosError(error, 'Unable to remove volume'); + } + } +} diff --git a/app/react/kubernetes/volumes/queries/useVolumesQuery.ts b/app/react/kubernetes/volumes/queries/useVolumesQuery.ts new file mode 100644 index 000000000..63c5b6ab5 --- /dev/null +++ b/app/react/kubernetes/volumes/queries/useVolumesQuery.ts @@ -0,0 +1,160 @@ +import { useQuery } from '@tanstack/react-query'; + +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { humanize } from '@/portainer/filters/filters'; +import { withGlobalError } from '@/react-tools/react-query'; +import axios from '@/portainer/services/axios'; +import { Volume } from '@/kubernetes/models/volume/Volume'; + +import { parseKubernetesAxiosError } from '../../axiosError'; +import { K8sVolumeInfo } from '../types'; +import { VolumeViewModel, StorageClassViewModel } from '../ListView/types'; + +import { queryKeys } from './query-keys'; + +// useQuery to get a list of all volumes in a cluster +export function useAllVolumesQuery( + environmentId: EnvironmentId, + queryOptions?: { + refetchInterval?: number; + } +) { + return useQuery( + queryKeys.volumes(environmentId), + () => getAllVolumes(environmentId, { withApplications: true }), + { + refetchInterval: queryOptions?.refetchInterval, + select: convertToVolumeViewModels, + ...withGlobalError('Unable to retrieve volumes'), + } + ); +} + +// useQuery to get a list of all volumes in a cluster +export function useAllStoragesQuery( + environmentId: EnvironmentId, + queryOptions?: { + refetchInterval?: number; + } +) { + return useQuery( + queryKeys.storages(environmentId), + () => getAllVolumes(environmentId), + { + refetchInterval: queryOptions?.refetchInterval, + select: convertToStorageClassViewModels, + ...withGlobalError('Unable to retrieve volumes'), + } + ); +} + +// get all volumes from a namespace +export async function getAllVolumes( + environmentId: EnvironmentId, + params?: { withApplications: boolean } +) { + try { + const { data } = await axios.get( + `/kubernetes/${environmentId}/volumes`, + { params } + ); + return data; + } catch (e) { + throw parseKubernetesAxiosError(e, 'Unable to retrieve volumes'); + } +} + +function convertToVolumeViewModels( + volumes: K8sVolumeInfo[] +): VolumeViewModel[] { + return volumes.map((volume) => { + const owningApplications = + volume.persistentVolumeClaim.owningApplications ?? []; + return { + Applications: owningApplications.map((app) => ({ + Name: app.Name, + Namespace: app.Namespace, + Kind: app.Kind, + })), + PersistentVolumeClaim: { + Namespace: volume.persistentVolumeClaim.namespace, + Name: volume.persistentVolumeClaim.name, + storageClass: { + Name: volume.persistentVolumeClaim.storageClass || '', + }, + Storage: humanize(volume.persistentVolumeClaim.storage), + CreationDate: volume.persistentVolumeClaim.creationDate, + ApplicationOwner: + volume.persistentVolumeClaim.owningApplications?.[0]?.Name, + }, + ResourcePool: { + Namespace: { + Name: volume.persistentVolumeClaim.namespace, + }, + }, + }; + }); +} + +function convertToStorageClassViewModels( + volumes: K8sVolumeInfo[] +): StorageClassViewModel[] { + const volumesModels = convertToVolumeModel(volumes); + + // Use reduce to create a new Map + const storageClassMap = volumesModels.reduce((acc, volume) => { + const pvcStorageClass = volume.PersistentVolumeClaim.storageClass; + const storageClassName = pvcStorageClass?.Name || 'none'; + const defaultStorageClass: StorageClassViewModel = { + Name: pvcStorageClass?.Name || 'none', + Provisioner: pvcStorageClass?.Provisioner ?? '', + ReclaimPolicy: pvcStorageClass?.ReclaimPolicy ?? '', + AllowVolumeExpansion: pvcStorageClass?.AllowVolumeExpansion || false, + size: 0, + Volumes: [], + }; + + const existingStorageClass = + acc.get(storageClassName) ?? defaultStorageClass; + + // Create a new StorageClassViewModel with updated values + const updatedStorageClass = { + ...existingStorageClass, + size: + existingStorageClass.size + (volume.PersistentVolumeClaim.Storage || 0), + Volumes: [...existingStorageClass.Volumes, volume], + }; + + // Return a new Map with the updated StorageClassViewModel + return new Map(acc).set(storageClassName, updatedStorageClass); + }, new Map()); + + // Convert the Map values to an array + return Array.from(storageClassMap.values()); +} + +function convertToVolumeModel(volumes: K8sVolumeInfo[]): Volume[] { + return volumes.map((volume) => ({ + PersistentVolumeClaim: { + Id: volume.persistentVolumeClaim.id, + Name: volume.persistentVolumeClaim.name, + PreviousName: '', + Namespace: volume.persistentVolumeClaim.namespace, + storageClass: { + Name: volume.persistentVolumeClaim.storageClass || '', + Provisioner: volume.storageClass.provisioner, + ReclaimPolicy: volume.storageClass.reclaimPolicy ?? '', + AllowVolumeExpansion: volume.storageClass.allowVolumeExpansion || false, + }, + Storage: volume.persistentVolumeClaim.storage, + CreationDate: volume.persistentVolumeClaim.creationDate, + ApplicationOwner: + volume.persistentVolumeClaim.owningApplications?.[0]?.Name ?? '', + AccessModes: volume.persistentVolumeClaim.accessModes ?? [], + ApplicationName: '', + MountPath: '', + Yaml: '', + }, + Applications: [], + })); +} diff --git a/app/react/kubernetes/volumes/types.ts b/app/react/kubernetes/volumes/types.ts new file mode 100644 index 000000000..f2b44988c --- /dev/null +++ b/app/react/kubernetes/volumes/types.ts @@ -0,0 +1,57 @@ +import { + PersistentVolumeSpec, + PersistentVolumeClaimSpec, + PersistentVolumeClaimStatus, + ObjectReference, + CSIPersistentVolumeSource, +} from 'kubernetes-types/core/v1'; + +export interface K8sVolumeInfo { + persistentVolume: K8sPersistentVolume; + persistentVolumeClaim: K8sPersistentVolumeClaim; + storageClass: K8sStorageClass; +} + +interface K8sPersistentVolume { + name?: string; + annotations?: { [key: string]: string }; + accessModes?: PersistentVolumeSpec['accessModes']; + capacity: PersistentVolumeSpec['capacity']; + claimRef?: ObjectReference; + storageClassName?: string; + persistentVolumeReclaimPolicy: PersistentVolumeSpec['persistentVolumeReclaimPolicy']; + volumeMode?: PersistentVolumeSpec['volumeMode']; + csi?: CSIPersistentVolumeSource; +} + +interface K8sPersistentVolumeClaim { + id: string; + name: string; + namespace: string; + storage: number; + creationDate: string; + accessModes?: PersistentVolumeClaimSpec['accessModes']; + volumeName: string; + resourcesRequests?: PersistentVolumeClaimSpec['resources']; + storageClass?: string; + volumeMode?: PersistentVolumeClaimSpec['volumeMode']; + owningApplications?: K8sVolOwningApplication[]; + phase: PersistentVolumeClaimStatus['phase']; +} + +interface K8sStorageClass { + name: string; + provisioner: string; + reclaimPolicy?: PersistentVolumeSpec['persistentVolumeReclaimPolicy']; + allowVolumeExpansion?: boolean; +} + +export interface K8sVolOwningApplication { + Uid?: string; + Name: string; + Namespace?: string; + Kind?: string; + Labels?: { [key: string]: string }; +} + +export type Volumes = Record; diff --git a/app/react/kubernetes/volumes/usePVCsQuery.ts b/app/react/kubernetes/volumes/usePVCsQuery.ts deleted file mode 100644 index 6e9813b73..000000000 --- a/app/react/kubernetes/volumes/usePVCsQuery.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { PersistentVolumeClaimList } from 'kubernetes-types/core/v1'; - -import { EnvironmentId } from '@/react/portainer/environments/types'; -import { withError } from '@/react-tools/react-query'; -import axios from '@/portainer/services/axios'; - -import { parseKubernetesAxiosError } from '../axiosError'; - -// useQuery to get a list of all persistent volume claims from an array of namespaces -export function usePVCsQuery( - environmentId: EnvironmentId, - namespaces?: string[] -) { - return useQuery( - ['environments', environmentId, 'kubernetes', 'pvcs'], - async () => { - if (!namespaces) { - return []; - } - const pvcs = await Promise.all( - namespaces?.map((namespace) => getPVCs(environmentId, namespace)) - ); - return pvcs.flat(); - }, - { - ...withError('Unable to retrieve perrsistent volume claims'), - enabled: !!namespaces, - } - ); -} - -// get all persistent volume claims for a namespace -export async function getPVCs(environmentId: EnvironmentId, namespace: string) { - try { - const { data } = await axios.get( - `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/persistentvolumeclaims` - ); - return data.items; - } catch (e) { - throw parseKubernetesAxiosError( - e, - 'Unable to retrieve persistent volume claims' - ); - } -} diff --git a/app/react/sidebar/KubernetesSidebar/KubernetesSidebar.tsx b/app/react/sidebar/KubernetesSidebar/KubernetesSidebar.tsx index 89c2ece27..a7d508d36 100644 --- a/app/react/sidebar/KubernetesSidebar/KubernetesSidebar.tsx +++ b/app/react/sidebar/KubernetesSidebar/KubernetesSidebar.tsx @@ -1,4 +1,12 @@ -import { Box, Edit, Layers, Lock, Network, Server } from 'lucide-react'; +import { + Box, + Edit, + Layers, + LayoutList, + Lock, + Network, + Server, +} from 'lucide-react'; import { EnvironmentId } from '@/react/portainer/environments/types'; import { Authorized } from '@/react/hooks/useUser'; @@ -92,6 +100,49 @@ export function KubernetesSidebar({ environmentId }: Props) { data-cy="k8sSidebar-volumes" /> + + + + + + + +