mirror of https://github.com/portainer/portainer
refactor(k8s): namespace core logic
parent
6486a5d971
commit
3acbc4bbc5
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -127,7 +127,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")
|
||||
}
|
||||
|
@ -186,7 +186,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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
package kubernetes
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
@ -9,13 +9,14 @@ import (
|
|||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
"github.com/portainer/portainer/api/http/rbacutils"
|
||||
"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,7 +50,6 @@ 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("/dashboard", httperror.LoggerHandler(h.getKubernetesDashboard)).Methods(http.MethodGet)
|
||||
|
@ -67,6 +67,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
|
|||
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.getKubernetesNamespaces)).Methods(http.MethodGet)
|
||||
endpointRouter.Handle("/namespaces/count", httperror.LoggerHandler(h.getKubernetesNamespacesCount)).Methods(http.MethodGet)
|
||||
endpointRouter.Handle("/namespace/{namespace}", httperror.LoggerHandler(h.deleteKubernetesNamespace)).Methods(http.MethodDelete)
|
||||
endpointRouter.Handle("/namespaces/{namespace}", httperror.LoggerHandler(h.getKubernetesNamespace)).Methods(http.MethodGet)
|
||||
|
||||
|
@ -88,55 +89,36 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
|
|||
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 +126,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,16 +145,30 @@ 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, nonAdminNamespaces, err := rbacutils.IsAdmin(user, endpoint, handler.DataStore, handler.KubernetesClientFactory)
|
||||
if err != nil {
|
||||
httperror.InternalServerError("an error occurred during the kubeClientMiddleware operation, unable to check if user is an admin and retrieve non-admin namespaces. Error: ", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -208,7 +204,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
|
|||
)
|
||||
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)
|
||||
return
|
||||
|
|
|
@ -57,7 +57,7 @@ func (handler *Handler) getKubernetesIngressControllers(w http.ResponseWriter, r
|
|||
)
|
||||
}
|
||||
|
||||
cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint)
|
||||
cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError(
|
||||
"Unable to create Kubernetes client",
|
||||
|
@ -303,7 +303,7 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter
|
|||
)
|
||||
}
|
||||
|
||||
cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint)
|
||||
cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError(
|
||||
"Unable to create Kubernetes client",
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package kubernetes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
|
@ -9,9 +10,9 @@ import (
|
|||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
// @id getKubernetesNamespaces
|
||||
// @summary Get a list of kubernetes namespaces
|
||||
// @description Get a list of all kubernetes namespaces in the cluster
|
||||
// @id GetKubernetesNamespaces
|
||||
// @summary Get a list of kubernetes namespaces within the given Portainer environment
|
||||
// @description Get a list of all kubernetes namespaces within the given environment (Endpoint). The Endpoint ID must be a valid Portainer environment identifier.
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags kubernetes
|
||||
// @security ApiKeyAuth
|
||||
|
@ -19,14 +20,20 @@ import (
|
|||
// @accept json
|
||||
// @produce json
|
||||
// @param id path int true "Environment (Endpoint) identifier"
|
||||
// @success 200 {object} map[string]portainer.K8sNamespaceInfo "Success"
|
||||
// @param withResourceQuota query boolean false "When set to True, include the resource quota information as part of the Namespace information. It is set to false by default"
|
||||
// @success 200 {object} map[string]portaineree.K8sNamespaceInfo "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 500 "Server error"
|
||||
// @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", false)
|
||||
if err != nil {
|
||||
return httperror.BadRequest("an error occurred during the getKubernetesNamespaces operation, invalid query parameter withResourceQuota. Error: ", err)
|
||||
}
|
||||
|
||||
cli, httpErr := handler.getProxyKubeClient(r)
|
||||
if httpErr != nil {
|
||||
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()
|
||||
|
@ -34,10 +41,43 @@ func (handler *Handler) getKubernetesNamespaces(w http.ResponseWriter, r *http.R
|
|||
return httperror.InternalServerError("Unable to retrieve namespaces", err)
|
||||
}
|
||||
|
||||
// if withResourceQuota is set to true, grab all resource quotas associated with the namespaces
|
||||
if withResourceQuota {
|
||||
return cli.CombineNamespacesWithResourceQuotas(namespaces, w)
|
||||
}
|
||||
|
||||
return response.JSON(w, namespaces)
|
||||
}
|
||||
|
||||
// @id getKubernetesNamespace
|
||||
// @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 (Endpoint), including the system namespaces. 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
|
||||
// @tags kubernetes
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param id path int true "Environment (Endpoint) identifier"
|
||||
// @success 200 {integer} integer "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 500 "Server error"
|
||||
// @router /kubernetes/{id}/namespaces/count [get]
|
||||
func (handler *Handler) getKubernetesNamespacesCount(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
cli, httpErr := handler.getProxyKubeClient(r)
|
||||
if httpErr != nil {
|
||||
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 {
|
||||
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 kubernetes namespace details
|
||||
// @description Get kubernetes namespace details for the provided namespace within the given environment
|
||||
// @description **Access policy**: authenticated
|
||||
|
@ -47,28 +87,36 @@ func (handler *Handler) getKubernetesNamespaces(w http.ResponseWriter, r *http.R
|
|||
// @accept json
|
||||
// @produce json
|
||||
// @param id path int true "Environment (Endpoint) identifier"
|
||||
// @param namespace path string true "Namespace"
|
||||
// @success 200 {object} portainer.K8sNamespaceInfo "Success"
|
||||
// @param namespace path string true "The namespace name to get details for"
|
||||
// @param withResourceQuota query boolean false "When set to True, include the resource quota information as part of the Namespace information. It is set to false by default"
|
||||
// @success 200 {object} portaineree.K8sNamespaceInfo "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 500 "Server error"
|
||||
// @router /kubernetes/{id}/namespaces/{namespace} [get]
|
||||
func (handler *Handler) getKubernetesNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
ns, err := request.RetrieveRouteVariableValue(r, "namespace")
|
||||
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
|
||||
if err != nil {
|
||||
return httperror.BadRequest(
|
||||
"Invalid namespace identifier route variable",
|
||||
err,
|
||||
)
|
||||
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", false)
|
||||
if err != nil {
|
||||
return httperror.BadRequest(fmt.Sprintf("an error occurred during the getKubernetesNamespace operation for the namespace %s, invalid query parameter withResourceQuota. Error: ", namespace), err)
|
||||
}
|
||||
|
||||
namespace, err := cli.GetNamespace(ns)
|
||||
cli, httpErr := handler.getProxyKubeClient(r)
|
||||
if httpErr != nil {
|
||||
return httperror.InternalServerError(fmt.Sprintf("an error occurred during the getKubernetesNamespace operation for the namespace %s, unable to get a Kubernetes client for the user. Error: ", namespace), httpErr)
|
||||
}
|
||||
|
||||
namespaceInfo, err := cli.GetNamespace(namespace)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve namespace", err)
|
||||
return httperror.InternalServerError(fmt.Sprintf("an error occurred during the getKubernetesNamespace operation, unable to get the namespace: %s. Error: ", namespace), err)
|
||||
}
|
||||
|
||||
// if withResourceQuota is set to true, grab a resource quota associated to the namespace
|
||||
if withResourceQuota {
|
||||
return cli.CombineNamespaceWithResourceQuota(namespaceInfo, w)
|
||||
}
|
||||
|
||||
return response.JSON(w, namespace)
|
||||
|
|
|
@ -31,7 +31,7 @@ func (handler *Handler) getKubernetesNodesLimits(w http.ResponseWriter, r *http.
|
|||
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("Unable to create Kubernetes client", err)
|
||||
}
|
||||
|
@ -50,7 +50,7 @@ func (handler *Handler) getKubernetesMaxResourceLimits(w http.ResponseWriter, r
|
|||
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)
|
||||
}
|
||||
|
|
|
@ -51,7 +51,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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -125,7 +125,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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -42,7 +42,7 @@ 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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
package rbacutils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
)
|
||||
|
||||
// IsAdmin checks if user is a Portainer Admin based on the user's access policies
|
||||
// if the user is not in the UserAccessPolicies map, then the user is an admin
|
||||
// otherwise, the user defaults to a standard user in CE
|
||||
// for non-admin users, allowed namespaces will be returned
|
||||
func IsAdmin(user *portainer.User, endpoint *portainer.Endpoint, dataStore dataservices.DataStore, clientFactory *cli.ClientFactory) (bool, []string, error) {
|
||||
if user == nil {
|
||||
return false, nil, fmt.Errorf("an error occurred during the IsAdmin operation, user is nil. Unable to check if user is an admin")
|
||||
}
|
||||
|
||||
if len(endpoint.UserAccessPolicies) > 0 {
|
||||
_, ok := endpoint.UserAccessPolicies[user.ID]
|
||||
if ok {
|
||||
nonAdminNamespaces, err := cli.GetNonAdminNamespaces(int(user.ID), endpoint, clientFactory)
|
||||
if err != nil {
|
||||
return false, nil, fmt.Errorf("an error occurred during the IsAdmin operation, unable to retrieve non-admin namespaces. Error: %v", err)
|
||||
}
|
||||
return false, nonAdminNamespaces, nil
|
||||
}
|
||||
}
|
||||
|
||||
if len(endpoint.TeamAccessPolicies) > 0 {
|
||||
teamMemberships, err := dataStore.TeamMembership().TeamMembershipsByUserID(user.ID)
|
||||
if err != nil {
|
||||
return false, nil, fmt.Errorf("an error occurred during the IsAdmin operation, unable to retrieve user team memberships to fetch allowed namespace access. Error: %v", err)
|
||||
}
|
||||
|
||||
for _, teamMembership := range teamMemberships {
|
||||
_, ok := endpoint.TeamAccessPolicies[teamMembership.TeamID]
|
||||
if ok {
|
||||
nonAdminNamespaces, err := cli.GetNonAdminNamespaces(int(user.ID), endpoint, clientFactory)
|
||||
if err != nil {
|
||||
return false, nil, fmt.Errorf("an error occurred during the IsAdmin operation, unable to retrieve non-admin namespaces. Error: %v", err)
|
||||
}
|
||||
return false, nonAdminNamespaces, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil, nil
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ package cli
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
|
@ -30,15 +31,12 @@ func (kcl *KubeClient) NamespaceAccessPoliciesDeleteNamespace(ns string) error {
|
|||
// 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 {
|
||||
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 +107,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 +116,27 @@ 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 GetNonAdminNamespaces(userID int, endpoint *portainer.Endpoint, clientFactory *ClientFactory) ([]string, error) {
|
||||
kcl, err := clientFactory.GetPrivilegedKubeClient(endpoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("an error occurred during the getNonAdminNamespaces operation, unable to get privileged kube client: %w", err)
|
||||
}
|
||||
|
||||
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{}
|
||||
for namespace, accessPolicy := range accessPolicies {
|
||||
if hasUserAccessToNamespace(userID, nil, accessPolicy) {
|
||||
if !(endpoint.Kubernetes.Configuration.RestrictDefaultNamespace && namespace == "default") {
|
||||
nonAdminNamespaces = append(nonAdminNamespaces, namespace)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nonAdminNamespaces, nil
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
@ -42,9 +41,11 @@ type (
|
|||
|
||||
// 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 +71,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 +80,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,54 +114,56 @@ 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
|
||||
}
|
||||
|
||||
return &KubeClient{
|
||||
cli: cli,
|
||||
instanceID: factory.instanceID,
|
||||
cli: cli,
|
||||
instanceID: factory.instanceID,
|
||||
isKubeAdmin: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
@ -235,8 +228,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 +244,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 +259,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 +287,8 @@ func buildLocalConfig() (*rest.Config, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
config.QPS = DefaultKubeClientQPS
|
||||
config.Burst = DefaultKubeClientBurst
|
||||
config.QPS = defaultKubeClientQPS
|
||||
config.Burst = defaultKubeClientBurst
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
@ -17,6 +18,8 @@ import (
|
|||
|
||||
const (
|
||||
systemNamespaceLabel = "io.portainer.kubernetes.namespace.system"
|
||||
namespaceOwnerLabel = "io.portainer.kubernetes.resourcepool.owner"
|
||||
namespaceNameLabel = "io.portainer.kubernetes.resourcepool.name"
|
||||
)
|
||||
|
||||
func defaultSystemNamespaces() map[string]struct{} {
|
||||
|
@ -30,17 +33,38 @@ func defaultSystemNamespaces() map[string]struct{} {
|
|||
|
||||
// GetNamespaces gets the namespaces in the current k8s environment(endpoint).
|
||||
func (kcl *KubeClient) GetNamespaces() (map[string]portainer.K8sNamespaceInfo, error) {
|
||||
if kcl.isKubeAdmin {
|
||||
return kcl.fetchNamespacesForAdmin()
|
||||
}
|
||||
return kcl.fetchNamespacesForNonAdmin()
|
||||
}
|
||||
|
||||
// fetchNamespacesForAdmin gets the namespaces in the current k8s environment(endpoint) for the admin user.
|
||||
// The kube client must have cluster scope read access to do this.
|
||||
func (kcl *KubeClient) fetchNamespacesForAdmin() (map[string]portainer.K8sNamespaceInfo, error) {
|
||||
namespaces, err := kcl.cli.CoreV1().Namespaces().List(context.TODO(), metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("failed 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
|
||||
}
|
||||
|
||||
for _, ns := range namespaces.Items {
|
||||
results[ns.Name] = portainer.K8sNamespaceInfo{
|
||||
IsSystem: isSystemNamespace(ns),
|
||||
IsDefault: ns.Name == defaultNamespace,
|
||||
// fetchNamespacesForNonAdmin gets the namespaces in the current k8s environment(endpoint) for the non-admin user.
|
||||
func (kcl *KubeClient) fetchNamespacesForNonAdmin() (map[string]portainer.K8sNamespaceInfo, error) {
|
||||
results := make(map[string]portainer.K8sNamespaceInfo)
|
||||
log.Debug().Msgf("Fetching namespaces for non-admin user: %v", kcl.nonAdminNamespaces)
|
||||
if len(kcl.nonAdminNamespaces) > 0 {
|
||||
for _, ns := range kcl.nonAdminNamespaces {
|
||||
namespace, err := kcl.GetNamespace(ns)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get namespace %s for the non-admin user: %w", ns, err)
|
||||
}
|
||||
results[ns] = namespace
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -54,19 +78,27 @@ func (kcl *KubeClient) GetNamespace(name string) (portainer.K8sNamespaceInfo, er
|
|||
return portainer.K8sNamespaceInfo{}, err
|
||||
}
|
||||
|
||||
result := portainer.K8sNamespaceInfo{
|
||||
IsSystem: isSystemNamespace(*namespace),
|
||||
IsDefault: namespace.Name == defaultNamespace,
|
||||
}
|
||||
return parseNamespace(namespace), nil
|
||||
}
|
||||
|
||||
return result, nil
|
||||
// parseNamespace converts a k8s namespace object to a portainer namespace object.
|
||||
func parseNamespace(namespace *v1.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,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateNamespace creates a new ingress in a given namespace in a k8s endpoint.
|
||||
func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceDetails) 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
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
portaineree "github.com/portainer/portainer/api"
|
||||
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).
|
||||
// The kube client must have cluster scope read access to do this.
|
||||
func (kcl *KubeClient) GetResourceQuotas(namespace string) (*[]corev1.ResourceQuota, error) {
|
||||
if kcl.isKubeAdmin {
|
||||
return kcl.fetchResourceQuotasForAdmin()
|
||||
}
|
||||
return kcl.fetchResourceQuotasForNonAdmin()
|
||||
}
|
||||
|
||||
// fetchResourceQuotasForAdmin gets the resource quotas in the current k8s environment(endpoint) for an admin user.
|
||||
// The kube client must have cluster scope read access to do this.
|
||||
func (kcl *KubeClient) fetchResourceQuotasForAdmin() (*[]corev1.ResourceQuota, error) {
|
||||
resourceQuotas, err := kcl.cli.CoreV1().ResourceQuotas("").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
|
||||
}
|
||||
|
||||
// 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() (*[]corev1.ResourceQuota, error) {
|
||||
resourceQuotas := []corev1.ResourceQuota{}
|
||||
for _, namespace := range kcl.nonAdminNamespaces {
|
||||
resourceQuota, err := kcl.GetResourceQuota(namespace, "portainer-rq-"+namespace)
|
||||
if err != nil && !k8serrors.IsNotFound(err) {
|
||||
return nil, fmt.Errorf("an error occured, failed to get a resource quota %s of the namespace %s for the non-admin user: %w", resourceQuota.Name, namespace, err)
|
||||
}
|
||||
|
||||
if resourceQuota.Namespace != "" && resourceQuota.Name != "" {
|
||||
resourceQuotas = append(resourceQuotas, *resourceQuota)
|
||||
}
|
||||
}
|
||||
|
||||
return &resourceQuotas, 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]portaineree.K8sNamespaceInfo, resourceQuotas []corev1.ResourceQuota) map[string]portaineree.K8sNamespaceInfo {
|
||||
namespacesWithQuota := map[string]portaineree.K8sNamespaceInfo{}
|
||||
|
||||
for _, namespace := range namespaces {
|
||||
namespace.ResourceQuota = kcl.GetResourceQuotaFromNamespace(namespace, resourceQuotas)
|
||||
namespacesWithQuota[namespace.Name] = namespace
|
||||
}
|
||||
|
||||
return namespacesWithQuota
|
||||
}
|
||||
|
||||
// GetResourceQuotaFromNamespace updates the namespace.ResourceQuota field with the resource quota information.
|
||||
// The resource quota is matched with the namespace and prefixed with "portainer-rq-".
|
||||
func (kcl *KubeClient) GetResourceQuotaFromNamespace(namespace portaineree.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
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
portaineree "github.com/portainer/portainer/api"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
)
|
||||
|
||||
// CombineNamespacesWithResourceQuotas combines namespaces with resource quotas where matching is based on "portainer-rq-"+namespace.Name
|
||||
func (kcl *KubeClient) CombineNamespacesWithResourceQuotas(namespaces map[string]portaineree.K8sNamespaceInfo, w http.ResponseWriter) *httperror.HandlerError {
|
||||
resourceQuotas, err := kcl.GetResourceQuotas("")
|
||||
if err != nil {
|
||||
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, namespaces)
|
||||
}
|
||||
|
||||
// CombineNamespaceWithResourceQuota combines a namespace with a resource quota prefixed with "portainer-rq-"+namespace.Name
|
||||
func (kcl *KubeClient) CombineNamespaceWithResourceQuota(namespace portaineree.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)
|
||||
}
|
||||
namespace.ResourceQuota = resourceQuota
|
||||
|
||||
return response.JSON(w, namespace)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -592,8 +592,14 @@ type (
|
|||
JobType int
|
||||
|
||||
K8sNamespaceInfo struct {
|
||||
IsSystem bool `json:"IsSystem"`
|
||||
IsDefault bool `json:"IsDefault"`
|
||||
Id string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Status v1.NamespaceStatus `json:"Status"`
|
||||
CreationDate string `json:"CreationDate"`
|
||||
NamespaceOwner string `json:"NamespaceOwner"`
|
||||
IsSystem bool `json:"IsSystem"`
|
||||
IsDefault bool `json:"IsDefault"`
|
||||
ResourceQuota *v1.ResourceQuota `json:"ResourceQuota"`
|
||||
}
|
||||
|
||||
K8sNodeLimits struct {
|
||||
|
|
|
@ -459,10 +459,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: {
|
||||
|
|
|
@ -13,6 +13,7 @@ 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';
|
||||
|
||||
export const viewsModule = angular
|
||||
.module('portainer.kubernetes.react.views', [])
|
||||
|
@ -20,6 +21,10 @@ export const viewsModule = angular
|
|||
'kubernetesCreateNamespaceView',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(CreateNamespaceView))), [])
|
||||
)
|
||||
.component(
|
||||
'kubernetesNamespacesView',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(NamespacesView))), [])
|
||||
)
|
||||
.component(
|
||||
'kubernetesServicesView',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(ServicesView))), [])
|
||||
|
|
|
@ -3,8 +3,7 @@ import PortainerError from 'Portainer/error';
|
|||
import { KubernetesCommonParams } from 'Kubernetes/models/common/params';
|
||||
import KubernetesNamespaceConverter from 'Kubernetes/converters/namespace';
|
||||
import { updateNamespaces } from 'Kubernetes/store/namespace';
|
||||
import $allSettled from 'Portainer/services/allSettled';
|
||||
import { getSelfSubjectAccessReview } from '@/react/kubernetes/namespaces/getSelfSubjectAccessReview';
|
||||
import { getNamespaces } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery.ts';
|
||||
|
||||
class KubernetesNamespaceService {
|
||||
/* @ngInject */
|
||||
|
@ -68,17 +67,16 @@ 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 namespacesWithAccess = await getNamespaces(this.$state.params.endpointId);
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -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 { Namespaces, 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<NamespaceViewModel>;
|
||||
onRemove(items: Array<NamespaceViewModel>): void;
|
||||
onRefresh(): void;
|
||||
}) {
|
||||
export function NamespacesDatatable() {
|
||||
const environmentId = useEnvironmentId();
|
||||
|
||||
const tableState = useTableStateWithStorage<TableSettings>(
|
||||
'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,31 @@ 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 (
|
||||
<Datatable
|
||||
<Datatable<PortainerNamespace>
|
||||
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}
|
||||
isRowSelectable={({ original: item }) =>
|
||||
hasWriteAuthQuery.authorized &&
|
||||
!item.Namespace.IsSystem &&
|
||||
!isDefaultNamespace(item.Namespace.Name)
|
||||
!item.IsSystem &&
|
||||
!isDefaultNamespace(item.Name)
|
||||
}
|
||||
renderTableActions={(selectedItems) => (
|
||||
<Authorized authorizations="K8sResourcePoolDetailsW" adminOnlyCE>
|
||||
<DeleteButton
|
||||
onClick={() => onRemove(selectedItems)}
|
||||
disabled={selectedItems.length === 0}
|
||||
data-cy="delete-namespace-button"
|
||||
/>
|
||||
|
||||
<AddButton color="secondary" data-cy="add-namespace-form-button">
|
||||
Add with form
|
||||
</AddButton>
|
||||
|
||||
<CreateFromManifestButton data-cy="k8s-namespaces-deploy-button" />
|
||||
</Authorized>
|
||||
<TableActions
|
||||
selectedItems={selectedItems}
|
||||
namespaces={namespacesQuery.data}
|
||||
/>
|
||||
)}
|
||||
renderTableSettings={() => (
|
||||
<TableSettingsMenu>
|
||||
|
@ -93,3 +89,101 @@ export function NamespacesDatatable({
|
|||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableActions({
|
||||
selectedItems,
|
||||
namespaces: namespacesQueryData,
|
||||
}: {
|
||||
selectedItems: PortainerNamespace[];
|
||||
namespaces?: Namespaces;
|
||||
}) {
|
||||
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 (
|
||||
<Authorized authorizations="K8sResourcePoolDetailsW" adminOnlyCE>
|
||||
<DeleteButton
|
||||
onConfirmed={() => onRemoveNamespaces(selectedItems)}
|
||||
disabled={selectedItems.length === 0}
|
||||
data-cy="delete-namespace-button"
|
||||
confirmMessage={message}
|
||||
/>
|
||||
|
||||
<AddButton color="secondary" data-cy="add-namespace-form-button">
|
||||
Add with form
|
||||
</AddButton>
|
||||
|
||||
<CreateFromManifestButton data-cy="k8s-namespaces-deploy-button" />
|
||||
</Authorized>
|
||||
);
|
||||
|
||||
function onRemoveNamespaces(selectedNamespaces: Array<PortainerNamespace>) {
|
||||
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];
|
||||
return acc;
|
||||
},
|
||||
{ ...namespacesQueryData }
|
||||
)
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
import { PageHeader } from '@@/PageHeader';
|
||||
|
||||
import { NamespacesDatatable } from './NamespacesDatatable';
|
||||
|
||||
export function NamespacesView() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Namespace list" breadcrumbs="Namespaces" reload />
|
||||
<NamespacesDatatable />
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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<NamespaceViewModel, unknown>) {
|
||||
row: { original: namespace },
|
||||
}: CellContext<PortainerNamespace, unknown>) {
|
||||
const environmentQuery = useCurrentEnvironment();
|
||||
|
||||
if (!environmentQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!canManageAccess(item, environmentQuery.data)) {
|
||||
if (!canManageAccess(namespace, environmentQuery.data)) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
|
@ -36,22 +36,25 @@ function Cell({
|
|||
color="link"
|
||||
props={{
|
||||
to: 'kubernetes.resourcePools.resourcePool.access',
|
||||
params: { id: item.Namespace.Name },
|
||||
params: {
|
||||
id: namespace.Name,
|
||||
},
|
||||
'data-cy': `manage-access-link-${namespace.Name}`,
|
||||
}}
|
||||
icon={Users}
|
||||
data-cy={`manage-access-button-${item.Namespace.Name}`}
|
||||
data-cy={`manage-access-button-${namespace.Name}`}
|
||||
>
|
||||
Manage access
|
||||
</Button>
|
||||
);
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { NamespaceViewModel } from '../types';
|
||||
import { PortainerNamespace } from '../../types';
|
||||
|
||||
export const helper = createColumnHelper<NamespaceViewModel>();
|
||||
export const helper = createColumnHelper<PortainerNamespace>();
|
||||
|
|
|
@ -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}
|
||||
</Link>
|
||||
{item.Namespace.IsSystem && (
|
||||
{item.IsSystem && (
|
||||
<span className="ml-2">
|
||||
<SystemBadge />
|
||||
</span>
|
||||
|
@ -47,14 +47,18 @@ export function useColumns() {
|
|||
);
|
||||
},
|
||||
}),
|
||||
helper.accessor('Namespace.Status', {
|
||||
helper.accessor('Status', {
|
||||
header: 'Status',
|
||||
cell({ getValue }) {
|
||||
const status = getValue();
|
||||
return <StatusBadge color={getColor(status)}>{status}</StatusBadge>;
|
||||
return (
|
||||
<StatusBadge color={getColor(status.phase)}>
|
||||
{status.phase}
|
||||
</StatusBadge>
|
||||
);
|
||||
|
||||
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 <Badge type="warn">Enabled</Badge>;
|
||||
},
|
||||
}),
|
||||
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}` : ''}
|
||||
</>
|
||||
);
|
||||
},
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
export interface NamespaceViewModel {
|
||||
Namespace: {
|
||||
Id: string;
|
||||
Name: string;
|
||||
Status: string;
|
||||
CreationDate: number;
|
||||
ResourcePoolOwner: string;
|
||||
IsSystem: boolean;
|
||||
};
|
||||
Quota: number;
|
||||
}
|
|
@ -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."
|
||||
/>
|
||||
</FormSection>
|
||||
)}
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
export const queryKeys = {
|
||||
list: (
|
||||
environmentId: number,
|
||||
{ withResourceQuota }: { withResourceQuota: boolean }
|
||||
) => [
|
||||
'environments',
|
||||
environmentId,
|
||||
'kubernetes',
|
||||
'namespaces',
|
||||
{ withResourceQuota },
|
||||
],
|
||||
};
|
|
@ -0,0 +1,44 @@
|
|||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
|
||||
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),
|
||||
{
|
||||
...withError('Unable to delete namespaces'),
|
||||
// onSuccess handled by the caller
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteNamespaces(
|
||||
environmentId: number,
|
||||
namespaceNames: string[]
|
||||
) {
|
||||
try {
|
||||
return await axios.delete<DeleteNamespacesResponse>(
|
||||
`kubernetes/${environmentId}/namespaces`,
|
||||
{
|
||||
data: namespaceNames,
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e, 'Unable to delete namespace');
|
||||
}
|
||||
}
|
|
@ -4,15 +4,15 @@ 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<T = DefaultOrSystemNamespace>(
|
||||
export function useNamespaceQuery<T = PortainerNamespace>(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
{
|
||||
select,
|
||||
}: {
|
||||
select?(namespace: DefaultOrSystemNamespace): T;
|
||||
select?(namespace: PortainerNamespace): T;
|
||||
} = {}
|
||||
) {
|
||||
return useQuery(
|
||||
|
@ -33,7 +33,7 @@ export async function getNamespace(
|
|||
namespace: string
|
||||
) {
|
||||
try {
|
||||
const { data: ns } = await axios.get<DefaultOrSystemNamespace>(
|
||||
const { data: ns } = await axios.get<PortainerNamespace>(
|
||||
`kubernetes/${environmentId}/namespaces/${namespace}`
|
||||
);
|
||||
return ns;
|
||||
|
|
|
@ -5,33 +5,22 @@ import { withError } from '@/react-tools/react-query';
|
|||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { Namespaces } from '../types';
|
||||
import { getSelfSubjectAccessReview } from '../getSelfSubjectAccessReview';
|
||||
|
||||
import { queryKeys } from './queryKeys';
|
||||
|
||||
export function useNamespacesQuery(
|
||||
environmentId: EnvironmentId,
|
||||
options?: { autoRefreshRate?: number }
|
||||
options?: { autoRefreshRate?: number; withResourceQuota?: boolean }
|
||||
) {
|
||||
return useQuery(
|
||||
['environments', environmentId, 'kubernetes', 'namespaces'],
|
||||
queryKeys.list(environmentId, {
|
||||
withResourceQuota: !!options?.withResourceQuota,
|
||||
}),
|
||||
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)
|
||||
)
|
||||
return await getNamespaces(
|
||||
environmentId,
|
||||
options?.withResourceQuota
|
||||
);
|
||||
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;
|
||||
},
|
||||
{
|
||||
...withError('Unable to get namespaces.'),
|
||||
|
@ -43,10 +32,15 @@ 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<Namespaces>(
|
||||
`kubernetes/${environmentId}/namespaces`
|
||||
`kubernetes/${environmentId}/namespaces`,
|
||||
{ params }
|
||||
);
|
||||
return namespaces;
|
||||
} catch (e) {
|
||||
|
|
|
@ -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<string, PortainerNamespace>;
|
||||
|
|
Loading…
Reference in New Issue