Compare commits

..

1 Commits

Author SHA1 Message Date
Steven Kang b46bdac85b chore: bump 2.27.1 - rel 227 (#469) 2025-02-27 11:26:22 +13:00
43 changed files with 255 additions and 1035 deletions
-2
View File
@@ -6,10 +6,8 @@ import (
type ReadTransaction interface {
GetObject(bucketName string, key []byte, object any) error
GetRawBytes(bucketName string, key []byte) ([]byte, error)
GetAll(bucketName string, obj any, append func(o any) (any, error)) error
GetAllWithKeyPrefix(bucketName string, keyPrefix []byte, obj any, append func(o any) (any, error)) error
KeyExists(bucketName string, key []byte) (bool, error)
}
type Transaction interface {
-26
View File
@@ -244,32 +244,6 @@ func (connection *DbConnection) GetObject(bucketName string, key []byte, object
})
}
func (connection *DbConnection) GetRawBytes(bucketName string, key []byte) ([]byte, error) {
var value []byte
err := connection.ViewTx(func(tx portainer.Transaction) error {
var err error
value, err = tx.GetRawBytes(bucketName, key)
return err
})
return value, err
}
func (connection *DbConnection) KeyExists(bucketName string, key []byte) (bool, error) {
var exists bool
err := connection.ViewTx(func(tx portainer.Transaction) error {
var err error
exists, err = tx.KeyExists(bucketName, key)
return err
})
return exists, err
}
func (connection *DbConnection) getEncryptionKey() []byte {
if !connection.isEncrypted {
return nil
-28
View File
@@ -6,7 +6,6 @@ import (
dserrors "github.com/portainer/portainer/api/dataservices/errors"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
bolt "go.etcd.io/bbolt"
)
@@ -32,33 +31,6 @@ func (tx *DbTransaction) GetObject(bucketName string, key []byte, object any) er
return tx.conn.UnmarshalObject(value, object)
}
func (tx *DbTransaction) GetRawBytes(bucketName string, key []byte) ([]byte, error) {
bucket := tx.tx.Bucket([]byte(bucketName))
value := bucket.Get(key)
if value == nil {
return nil, fmt.Errorf("%w (bucket=%s, key=%s)", dserrors.ErrObjectNotFound, bucketName, keyToString(key))
}
if tx.conn.getEncryptionKey() != nil {
var err error
if value, err = decrypt(value, tx.conn.getEncryptionKey()); err != nil {
return value, errors.Wrap(err, "Failed decrypting object")
}
}
return value, nil
}
func (tx *DbTransaction) KeyExists(bucketName string, key []byte) (bool, error) {
bucket := tx.tx.Bucket([]byte(bucketName))
value := bucket.Get(key)
return value != nil, nil
}
func (tx *DbTransaction) UpdateObject(bucketName string, key []byte, object any) error {
data, err := tx.conn.MarshalObject(object)
if err != nil {
-14
View File
@@ -9,7 +9,6 @@ import (
type BaseCRUD[T any, I constraints.Integer] interface {
Create(element *T) error
Read(ID I) (*T, error)
Exists(ID I) (bool, error)
ReadAll() ([]T, error)
Update(ID I, element *T) error
Delete(ID I) error
@@ -43,19 +42,6 @@ func (service BaseDataService[T, I]) Read(ID I) (*T, error) {
})
}
func (service BaseDataService[T, I]) Exists(ID I) (bool, error) {
var exists bool
err := service.Connection.ViewTx(func(tx portainer.Transaction) error {
var err error
exists, err = service.Tx(tx).Exists(ID)
return err
})
return exists, err
}
func (service BaseDataService[T, I]) ReadAll() ([]T, error) {
var collection = make([]T, 0)
-6
View File
@@ -28,12 +28,6 @@ func (service BaseDataServiceTx[T, I]) Read(ID I) (*T, error) {
return &element, nil
}
func (service BaseDataServiceTx[T, I]) Exists(ID I) (bool, error) {
identifier := service.Connection.ConvertToKey(int(ID))
return service.Tx.KeyExists(service.Bucket, identifier)
}
func (service BaseDataServiceTx[T, I]) ReadAll() ([]T, error) {
var collection = make([]T, 0)
@@ -610,7 +610,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.27.2",
"KubectlShellImage": "portainer/kubectl-shell:2.27.1",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -943,7 +943,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.27.2\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.27.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}
+1 -1
View File
@@ -81,7 +81,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.27.2
// @version 2.27.1
// @description.markdown api-description.md
// @termsOfService
+8 -1
View File
@@ -69,6 +69,7 @@ func (handler *Handler) getApplicationsResources(w http.ResponseWriter, r *http.
// @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."
@@ -116,6 +117,12 @@ func (handler *Handler) getAllKubernetesApplications(r *http.Request) ([]models.
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")
@@ -128,7 +135,7 @@ func (handler *Handler) getAllKubernetesApplications(r *http.Request) ([]models.
return nil, httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr)
}
applications, err := cli.GetApplications(namespace, nodeName)
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")
+5 -3
View File
@@ -243,7 +243,8 @@ func (bouncer *RequestBouncer) mwCheckPortainerAuthorizations(next http.Handler,
return
}
if ok, err := bouncer.dataStore.User().Exists(tokenData.ID); !ok {
_, err = bouncer.dataStore.User().Read(tokenData.ID)
if bouncer.dataStore.IsErrObjectNotFound(err) {
httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", httperrors.ErrUnauthorized)
return
} else if err != nil {
@@ -321,8 +322,9 @@ func (bouncer *RequestBouncer) mwAuthenticateFirst(tokenLookups []tokenLookup, n
return
}
if ok, _ := bouncer.dataStore.User().Exists(token.ID); !ok {
httperror.WriteError(w, http.StatusUnauthorized, "The authorization token is invalid", httperrors.ErrUnauthorized)
user, _ := bouncer.dataStore.User().Read(token.ID)
if user == nil {
httperror.WriteError(w, http.StatusUnauthorized, "An authorization token is invalid", httperrors.ErrUnauthorized)
return
}
-8
View File
@@ -151,7 +151,6 @@ func (s *stubUserService) UsersByRole(role portainer.UserRole) ([]portainer.User
func (s *stubUserService) Create(user *portainer.User) error { return nil }
func (s *stubUserService) Update(ID portainer.UserID, user *portainer.User) error { return nil }
func (s *stubUserService) Delete(ID portainer.UserID) error { return nil }
func (s *stubUserService) Exists(ID portainer.UserID) (bool, error) { return false, nil }
// WithUsers testDatastore option that will instruct testDatastore to return provided users
func WithUsers(us []portainer.User) datastoreOption {
@@ -187,9 +186,6 @@ func (s *stubEdgeJobService) UpdateEdgeJobFunc(ID portainer.EdgeJobID, updateFun
}
func (s *stubEdgeJobService) Delete(ID portainer.EdgeJobID) error { return nil }
func (s *stubEdgeJobService) GetNextIdentifier() int { return 0 }
func (s *stubEdgeJobService) Exists(ID portainer.EdgeJobID) (bool, error) {
return false, nil
}
// WithEdgeJobs option will instruct testDatastore to return provided jobs
func WithEdgeJobs(js []portainer.EdgeJob) datastoreOption {
@@ -430,10 +426,6 @@ func (s *stubStacksService) GetNextIdentifier() int {
return len(s.stacks)
}
func (s *stubStacksService) Exists(ID portainer.StackID) (bool, error) {
return false, nil
}
// WithStacks option will instruct testDatastore to return provided stacks
func WithStacks(stacks []portainer.Stack) datastoreOption {
return func(d *testDatastore) {
+130 -280
View File
@@ -12,58 +12,45 @@ import (
labels "k8s.io/apimachinery/pkg/labels"
)
// PortainerApplicationResources contains collections of various Kubernetes resources
// associated with a Portainer application.
type PortainerApplicationResources struct {
Pods []corev1.Pod
ReplicaSets []appsv1.ReplicaSet
Deployments []appsv1.Deployment
StatefulSets []appsv1.StatefulSet
DaemonSets []appsv1.DaemonSet
Services []corev1.Service
HorizontalPodAutoscalers []autoscalingv2.HorizontalPodAutoscaler
}
// 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) ([]models.K8sApplication, error) {
func (kcl *KubeClient) GetApplications(namespace, nodeName string, withDependencies bool) ([]models.K8sApplication, error) {
if kcl.IsKubeAdmin {
return kcl.fetchApplications(namespace, nodeName)
return kcl.fetchApplications(namespace, nodeName, withDependencies)
}
return kcl.fetchApplicationsForNonAdmin(namespace, nodeName)
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) ([]models.K8sApplication, error) {
func (kcl *KubeClient) fetchApplications(namespace, nodeName string, withDependencies bool) ([]models.K8sApplication, error) {
podListOptions := metav1.ListOptions{}
if nodeName != "" {
podListOptions.FieldSelector = "spec.nodeName=" + 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
}
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
return kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, nil, nil)
}
pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
if err != nil {
return nil, err
}
applications, err := kcl.convertPodsToApplications(portainerApplicationResources)
if err != nil {
return nil, err
}
unhealthyApplications, err := fetchUnhealthyApplications(portainerApplicationResources)
if err != nil {
return nil, err
}
return append(applications, unhealthyApplications...), nil
return kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas)
}
// 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) ([]models.K8sApplication, error) {
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 {
@@ -75,24 +62,28 @@ func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string)
podListOptions.FieldSelector = "spec.nodeName=" + nodeName
}
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
if !withDependencies {
pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets(namespace, podListOptions)
if err != nil {
return nil, err
}
return kcl.convertPodsToApplications(pods, replicaSets, nil, nil, nil, nil, nil)
}
pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
if err != nil {
return nil, err
}
applications, err := kcl.convertPodsToApplications(portainerApplicationResources)
if err != nil {
return nil, err
}
unhealthyApplications, err := fetchUnhealthyApplications(portainerApplicationResources)
applications, err := kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas)
if err != nil {
return nil, err
}
nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap()
results := make([]models.K8sApplication, 0)
for _, application := range append(applications, unhealthyApplications...) {
for _, application := range applications {
if _, ok := nonAdminNamespaceSet[application.ResourcePool]; ok {
results = append(results, application)
}
@@ -102,11 +93,11 @@ func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string)
}
// convertPodsToApplications processes pods and converts them to applications, ensuring uniqueness by owner reference.
func (kcl *KubeClient) convertPodsToApplications(portainerApplicationResources PortainerApplicationResources) ([]models.K8sApplication, error) {
func (kcl *KubeClient) convertPodsToApplications(pods []corev1.Pod, replicaSets []appsv1.ReplicaSet, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, hpas []autoscalingv2.HorizontalPodAutoscaler) ([]models.K8sApplication, error) {
applications := []models.K8sApplication{}
processedOwners := make(map[string]struct{})
for _, pod := range portainerApplicationResources.Pods {
for _, pod := range pods {
if len(pod.OwnerReferences) > 0 {
ownerUID := string(pod.OwnerReferences[0].UID)
if _, exists := processedOwners[ownerUID]; exists {
@@ -115,7 +106,7 @@ func (kcl *KubeClient) convertPodsToApplications(portainerApplicationResources P
processedOwners[ownerUID] = struct{}{}
}
application, err := kcl.ConvertPodToApplication(pod, portainerApplicationResources, true)
application, err := kcl.ConvertPodToApplication(pod, replicaSets, deployments, statefulSets, daemonSets, services, hpas, true)
if err != nil {
return nil, err
}
@@ -160,9 +151,7 @@ func (kcl *KubeClient) GetApplicationNamesFromConfigMap(configMap models.K8sConf
for _, pod := range pods {
if pod.Namespace == configMap.Namespace {
if isPodUsingConfigMap(&pod, configMap.Name) {
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
ReplicaSets: replicaSets,
}, false)
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
if err != nil {
return nil, err
}
@@ -179,9 +168,7 @@ func (kcl *KubeClient) GetApplicationNamesFromSecret(secret models.K8sSecret, po
for _, pod := range pods {
if pod.Namespace == secret.Namespace {
if isPodUsingSecret(&pod, secret.Name) {
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
ReplicaSets: replicaSets,
}, false)
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
if err != nil {
return nil, err
}
@@ -194,12 +181,12 @@ func (kcl *KubeClient) GetApplicationNamesFromSecret(secret models.K8sSecret, po
}
// ConvertPodToApplication converts a pod to an application, updating owner references if necessary
func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, portainerApplicationResources PortainerApplicationResources, withResource bool) (*models.K8sApplication, error) {
func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, replicaSets []appsv1.ReplicaSet, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, hpas []autoscalingv2.HorizontalPodAutoscaler, withResource bool) (*models.K8sApplication, error) {
if isReplicaSetOwner(pod) {
updateOwnerReferenceToDeployment(&pod, portainerApplicationResources.ReplicaSets)
updateOwnerReferenceToDeployment(&pod, replicaSets)
}
application := createApplicationFromPod(&pod, portainerApplicationResources)
application := createApplication(&pod, deployments, statefulSets, daemonSets, services, hpas)
if application.ID == "" && application.Name == "" {
return nil, nil
}
@@ -216,9 +203,9 @@ func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, portainerApplicat
return &application, nil
}
// createApplicationFromPod creates a K8sApplication object from a pod
// createApplication creates a K8sApplication object from a pod
// it sets the application name, namespace, kind, image, stack id, stack name, and labels
func createApplicationFromPod(pod *corev1.Pod, portainerApplicationResources PortainerApplicationResources) models.K8sApplication {
func createApplication(pod *corev1.Pod, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, hpas []autoscalingv2.HorizontalPodAutoscaler) models.K8sApplication {
kind := "Pod"
name := pod.Name
@@ -234,172 +221,120 @@ func createApplicationFromPod(pod *corev1.Pod, portainerApplicationResources Por
switch kind {
case "Deployment":
for _, deployment := range portainerApplicationResources.Deployments {
for _, deployment := range deployments {
if deployment.Name == name && deployment.Namespace == pod.Namespace {
populateApplicationFromDeployment(&application, deployment)
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 portainerApplicationResources.StatefulSets {
for _, statefulSet := range statefulSets {
if statefulSet.Name == name && statefulSet.Namespace == pod.Namespace {
populateApplicationFromStatefulSet(&application, statefulSet)
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 portainerApplicationResources.DaemonSets {
for _, daemonSet := range daemonSets {
if daemonSet.Name == name && daemonSet.Namespace == pod.Namespace {
populateApplicationFromDaemonSet(&application, daemonSet)
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":
populateApplicationFromPod(&application, *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(portainerApplicationResources.Services) > 0 {
updateApplicationWithService(&application, portainerApplicationResources.Services)
if application.ID != "" && application.Name != "" && len(services) > 0 {
updateApplicationWithService(&application, services)
}
if application.ID != "" && application.Name != "" && len(portainerApplicationResources.HorizontalPodAutoscalers) > 0 {
updateApplicationWithHorizontalPodAutoscaler(&application, portainerApplicationResources.HorizontalPodAutoscalers)
if application.ID != "" && application.Name != "" && len(hpas) > 0 {
updateApplicationWithHorizontalPodAutoscaler(&application, hpas)
}
return application
}
// createApplicationFromDeployment creates a K8sApplication from a Deployment
func createApplicationFromDeployment(deployment appsv1.Deployment) models.K8sApplication {
var app models.K8sApplication
populateApplicationFromDeployment(&app, deployment)
return app
}
// createApplicationFromStatefulSet creates a K8sApplication from a StatefulSet
func createApplicationFromStatefulSet(statefulSet appsv1.StatefulSet) models.K8sApplication {
var app models.K8sApplication
populateApplicationFromStatefulSet(&app, statefulSet)
return app
}
// createApplicationFromDaemonSet creates a K8sApplication from a DaemonSet
func createApplicationFromDaemonSet(daemonSet appsv1.DaemonSet) models.K8sApplication {
var app models.K8sApplication
populateApplicationFromDaemonSet(&app, daemonSet)
return app
}
func populateApplicationFromDeployment(application *models.K8sApplication, deployment appsv1.Deployment) {
application.ApplicationType = "Deployment"
application.Kind = "Deployment"
application.ID = string(deployment.UID)
application.ResourcePool = deployment.Namespace
application.Name = deployment.Name
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 = 0
if deployment.Spec.Replicas != nil {
application.TotalPodsCount = int(*deployment.Spec.Replicas)
}
application.RunningPodsCount = int(deployment.Status.ReadyReplicas)
application.DeploymentType = "Replicated"
application.Metadata = &models.Metadata{
Labels: deployment.Labels,
}
// If the deployment has containers, use the first container's image
if len(deployment.Spec.Template.Spec.Containers) > 0 {
application.Image = deployment.Spec.Template.Spec.Containers[0].Image
}
}
func populateApplicationFromStatefulSet(application *models.K8sApplication, statefulSet appsv1.StatefulSet) {
application.Kind = "StatefulSet"
application.ApplicationType = "StatefulSet"
application.ID = string(statefulSet.UID)
application.ResourcePool = statefulSet.Namespace
application.Name = statefulSet.Name
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 = 0
if statefulSet.Spec.Replicas != nil {
application.TotalPodsCount = int(*statefulSet.Spec.Replicas)
}
application.RunningPodsCount = int(statefulSet.Status.ReadyReplicas)
application.DeploymentType = "Replicated"
application.Metadata = &models.Metadata{
Labels: statefulSet.Labels,
}
// If the statefulSet has containers, use the first container's image
if len(statefulSet.Spec.Template.Spec.Containers) > 0 {
application.Image = statefulSet.Spec.Template.Spec.Containers[0].Image
}
}
func populateApplicationFromDaemonSet(application *models.K8sApplication, daemonSet appsv1.DaemonSet) {
application.Kind = "DaemonSet"
application.ApplicationType = "DaemonSet"
application.ID = string(daemonSet.UID)
application.ResourcePool = daemonSet.Namespace
application.Name = daemonSet.Name
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,
}
if len(daemonSet.Spec.Template.Spec.Containers) > 0 {
application.Image = daemonSet.Spec.Template.Spec.Containers[0].Image
}
}
func populateApplicationFromPod(application *models.K8sApplication, pod corev1.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.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 the pod has containers, use the first container's image
if len(pod.Spec.Containers) > 0 {
application.Image = pod.Spec.Containers[0].Image
}
}
// 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) {
@@ -475,9 +410,7 @@ func (kcl *KubeClient) GetApplicationConfigurationOwnersFromConfigMap(configMap
for _, pod := range pods {
if pod.Namespace == configMap.Namespace {
if isPodUsingConfigMap(&pod, configMap.Name) {
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
ReplicaSets: replicaSets,
}, false)
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
if err != nil {
return nil, err
}
@@ -503,9 +436,7 @@ func (kcl *KubeClient) GetApplicationConfigurationOwnersFromSecret(secret models
for _, pod := range pods {
if pod.Namespace == secret.Namespace {
if isPodUsingSecret(&pod, secret.Name) {
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
ReplicaSets: replicaSets,
}, false)
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
if err != nil {
return nil, err
}
@@ -523,84 +454,3 @@ func (kcl *KubeClient) GetApplicationConfigurationOwnersFromSecret(secret models
return configurationOwners, nil
}
// fetchUnhealthyApplications fetches applications that failed to schedule any pods
// due to issues like missing resource limits or other scheduling constraints
func fetchUnhealthyApplications(resources PortainerApplicationResources) ([]models.K8sApplication, error) {
var unhealthyApplications []models.K8sApplication
// Process Deployments
for _, deployment := range resources.Deployments {
if hasNoScheduledPods(deployment) {
app := createApplicationFromDeployment(deployment)
addRelatedResourcesToApplication(&app, resources)
unhealthyApplications = append(unhealthyApplications, app)
}
}
// Process StatefulSets
for _, statefulSet := range resources.StatefulSets {
if hasNoScheduledPods(statefulSet) {
app := createApplicationFromStatefulSet(statefulSet)
addRelatedResourcesToApplication(&app, resources)
unhealthyApplications = append(unhealthyApplications, app)
}
}
// Process DaemonSets
for _, daemonSet := range resources.DaemonSets {
if hasNoScheduledPods(daemonSet) {
app := createApplicationFromDaemonSet(daemonSet)
addRelatedResourcesToApplication(&app, resources)
unhealthyApplications = append(unhealthyApplications, app)
}
}
return unhealthyApplications, nil
}
// addRelatedResourcesToApplication adds Services and HPA information to the application
func addRelatedResourcesToApplication(app *models.K8sApplication, resources PortainerApplicationResources) {
if app.ID == "" || app.Name == "" {
return
}
if len(resources.Services) > 0 {
updateApplicationWithService(app, resources.Services)
}
if len(resources.HorizontalPodAutoscalers) > 0 {
updateApplicationWithHorizontalPodAutoscaler(app, resources.HorizontalPodAutoscalers)
}
}
// hasNoScheduledPods checks if a workload has completely failed to schedule any pods
// it checks for no replicas desired, i.e. nothing to schedule and see if any pods are running
// if any pods exist at all (even if not ready), it returns false
func hasNoScheduledPods(obj interface{}) bool {
switch resource := obj.(type) {
case appsv1.Deployment:
if resource.Status.Replicas > 0 {
return false
}
return resource.Status.ReadyReplicas == 0 && resource.Status.AvailableReplicas == 0
case appsv1.StatefulSet:
if resource.Status.Replicas > 0 {
return false
}
return resource.Status.ReadyReplicas == 0 && resource.Status.CurrentReplicas == 0
case appsv1.DaemonSet:
if resource.Status.CurrentNumberScheduled > 0 || resource.Status.NumberMisscheduled > 0 {
return false
}
return resource.Status.NumberReady == 0 && resource.Status.DesiredNumberScheduled > 0
default:
return false
}
}
-461
View File
@@ -1,461 +0,0 @@
package cli
import (
"context"
"testing"
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/stretchr/testify/assert"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/kubernetes/fake"
)
// Helper functions to create test resources
func createTestDeployment(name, namespace string, replicas int32) *appsv1.Deployment {
return &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
UID: types.UID("deploy-" + name),
Labels: map[string]string{
"app": name,
},
},
Spec: appsv1.DeploymentSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{
"app": name,
},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
"app": name,
},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: name,
Image: "nginx:latest",
Resources: corev1.ResourceRequirements{
Limits: corev1.ResourceList{},
Requests: corev1.ResourceList{},
},
},
},
},
},
},
Status: appsv1.DeploymentStatus{
Replicas: replicas,
ReadyReplicas: replicas,
},
}
}
func createTestReplicaSet(name, namespace, deploymentName string) *appsv1.ReplicaSet {
return &appsv1.ReplicaSet{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
UID: types.UID("rs-" + name),
OwnerReferences: []metav1.OwnerReference{
{
Kind: "Deployment",
Name: deploymentName,
UID: types.UID("deploy-" + deploymentName),
},
},
},
Spec: appsv1.ReplicaSetSpec{
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{
"app": deploymentName,
},
},
},
}
}
func createTestStatefulSet(name, namespace string, replicas int32) *appsv1.StatefulSet {
return &appsv1.StatefulSet{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
UID: types.UID("sts-" + name),
Labels: map[string]string{
"app": name,
},
},
Spec: appsv1.StatefulSetSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{
"app": name,
},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
"app": name,
},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: name,
Image: "redis:latest",
Resources: corev1.ResourceRequirements{
Limits: corev1.ResourceList{},
Requests: corev1.ResourceList{},
},
},
},
},
},
},
Status: appsv1.StatefulSetStatus{
Replicas: replicas,
ReadyReplicas: replicas,
},
}
}
func createTestDaemonSet(name, namespace string) *appsv1.DaemonSet {
return &appsv1.DaemonSet{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
UID: types.UID("ds-" + name),
Labels: map[string]string{
"app": name,
},
},
Spec: appsv1.DaemonSetSpec{
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{
"app": name,
},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
"app": name,
},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: name,
Image: "fluentd:latest",
Resources: corev1.ResourceRequirements{
Limits: corev1.ResourceList{},
Requests: corev1.ResourceList{},
},
},
},
},
},
},
Status: appsv1.DaemonSetStatus{
DesiredNumberScheduled: 2,
NumberReady: 2,
},
}
}
func createTestPod(name, namespace, ownerKind, ownerName string, isRunning bool) *corev1.Pod {
phase := corev1.PodPending
if isRunning {
phase = corev1.PodRunning
}
var ownerReferences []metav1.OwnerReference
if ownerKind != "" && ownerName != "" {
ownerReferences = []metav1.OwnerReference{
{
Kind: ownerKind,
Name: ownerName,
UID: types.UID(ownerKind + "-" + ownerName),
},
}
}
return &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
UID: types.UID("pod-" + name),
OwnerReferences: ownerReferences,
Labels: map[string]string{
"app": ownerName,
},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "container-" + name,
Image: "busybox:latest",
Resources: corev1.ResourceRequirements{
Limits: corev1.ResourceList{},
Requests: corev1.ResourceList{},
},
},
},
},
Status: corev1.PodStatus{
Phase: phase,
},
}
}
func createTestService(name, namespace string, selector map[string]string) *corev1.Service {
return &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
UID: types.UID("svc-" + name),
},
Spec: corev1.ServiceSpec{
Selector: selector,
Type: corev1.ServiceTypeClusterIP,
},
}
}
func TestGetApplications(t *testing.T) {
t.Run("Admin user - Mix of deployments, statefulsets and daemonsets with and without pods", func(t *testing.T) {
// Create a fake K8s client
fakeClient := fake.NewSimpleClientset()
// Setup the test namespace
namespace := "test-namespace"
defaultNamespace := "default"
// Create resources in the test namespace
// 1. Deployment with pods
deployWithPods := createTestDeployment("deploy-with-pods", namespace, 2)
_, err := fakeClient.AppsV1().Deployments(namespace).Create(context.TODO(), deployWithPods, metav1.CreateOptions{})
assert.NoError(t, err)
replicaSet := createTestReplicaSet("rs-deploy-with-pods", namespace, "deploy-with-pods")
_, err = fakeClient.AppsV1().ReplicaSets(namespace).Create(context.TODO(), replicaSet, metav1.CreateOptions{})
assert.NoError(t, err)
pod1 := createTestPod("pod1-deploy", namespace, "ReplicaSet", "rs-deploy-with-pods", true)
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod1, metav1.CreateOptions{})
assert.NoError(t, err)
pod2 := createTestPod("pod2-deploy", namespace, "ReplicaSet", "rs-deploy-with-pods", true)
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod2, metav1.CreateOptions{})
assert.NoError(t, err)
// 2. Deployment without pods (scaled to 0)
deployNoPods := createTestDeployment("deploy-no-pods", namespace, 0)
_, err = fakeClient.AppsV1().Deployments(namespace).Create(context.TODO(), deployNoPods, metav1.CreateOptions{})
assert.NoError(t, err)
// 3. StatefulSet with pods
stsWithPods := createTestStatefulSet("sts-with-pods", namespace, 1)
_, err = fakeClient.AppsV1().StatefulSets(namespace).Create(context.TODO(), stsWithPods, metav1.CreateOptions{})
assert.NoError(t, err)
pod3 := createTestPod("pod1-sts", namespace, "StatefulSet", "sts-with-pods", true)
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod3, metav1.CreateOptions{})
assert.NoError(t, err)
// 4. StatefulSet without pods
stsNoPods := createTestStatefulSet("sts-no-pods", namespace, 0)
_, err = fakeClient.AppsV1().StatefulSets(namespace).Create(context.TODO(), stsNoPods, metav1.CreateOptions{})
assert.NoError(t, err)
// 5. DaemonSet with pods
dsWithPods := createTestDaemonSet("ds-with-pods", namespace)
_, err = fakeClient.AppsV1().DaemonSets(namespace).Create(context.TODO(), dsWithPods, metav1.CreateOptions{})
assert.NoError(t, err)
pod4 := createTestPod("pod1-ds", namespace, "DaemonSet", "ds-with-pods", true)
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod4, metav1.CreateOptions{})
assert.NoError(t, err)
pod5 := createTestPod("pod2-ds", namespace, "DaemonSet", "ds-with-pods", true)
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod5, metav1.CreateOptions{})
assert.NoError(t, err)
// 6. Naked Pod (no owner reference)
nakedPod := createTestPod("naked-pod", namespace, "", "", true)
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), nakedPod, metav1.CreateOptions{})
assert.NoError(t, err)
// 7. Resources in another namespace
deployOtherNs := createTestDeployment("deploy-other-ns", defaultNamespace, 1)
_, err = fakeClient.AppsV1().Deployments(defaultNamespace).Create(context.TODO(), deployOtherNs, metav1.CreateOptions{})
assert.NoError(t, err)
podOtherNs := createTestPod("pod-other-ns", defaultNamespace, "Deployment", "deploy-other-ns", true)
_, err = fakeClient.CoreV1().Pods(defaultNamespace).Create(context.TODO(), podOtherNs, metav1.CreateOptions{})
assert.NoError(t, err)
// 8. Add a service (dependency)
service := createTestService("svc-deploy", namespace, map[string]string{"app": "deploy-with-pods"})
_, err = fakeClient.CoreV1().Services(namespace).Create(context.TODO(), service, metav1.CreateOptions{})
assert.NoError(t, err)
// Create the KubeClient with admin privileges
kubeClient := &KubeClient{
cli: fakeClient,
instanceID: "test-instance",
IsKubeAdmin: true,
}
// Test cases
// 1. All resources, no filtering
t.Run("All resources with dependencies", func(t *testing.T) {
apps, err := kubeClient.GetApplications("", "")
assert.NoError(t, err)
// We expect 7 resources: 2 deployments + 2 statefulsets + 1 daemonset + 1 naked pod + 1 deployment in other namespace
// Note: Each controller with pods should count once, not per pod
assert.Equal(t, 7, len(apps))
// Verify one of the deployments has services attached
appsWithServices := []models.K8sApplication{}
for _, app := range apps {
if len(app.Services) > 0 {
appsWithServices = append(appsWithServices, app)
}
}
assert.Equal(t, 1, len(appsWithServices))
assert.Equal(t, "deploy-with-pods", appsWithServices[0].Name)
})
// 2. Filter by namespace
t.Run("Filter by namespace", func(t *testing.T) {
apps, err := kubeClient.GetApplications(namespace, "")
assert.NoError(t, err)
// We expect 6 resources in the test namespace
assert.Equal(t, 6, len(apps))
// Verify resources from other namespaces are not included
for _, app := range apps {
assert.Equal(t, namespace, app.ResourcePool)
}
})
})
t.Run("Non-admin user - Resources filtered by accessible namespaces", func(t *testing.T) {
// Create a fake K8s client
fakeClient := fake.NewSimpleClientset()
// Setup the test namespaces
namespace1 := "allowed-ns"
namespace2 := "restricted-ns"
// Create resources in the allowed namespace
sts1 := createTestStatefulSet("sts-allowed", namespace1, 1)
_, err := fakeClient.AppsV1().StatefulSets(namespace1).Create(context.TODO(), sts1, metav1.CreateOptions{})
assert.NoError(t, err)
pod1 := createTestPod("pod-allowed", namespace1, "StatefulSet", "sts-allowed", true)
_, err = fakeClient.CoreV1().Pods(namespace1).Create(context.TODO(), pod1, metav1.CreateOptions{})
assert.NoError(t, err)
// Add a StatefulSet without pods in the allowed namespace
stsNoPods := createTestStatefulSet("sts-no-pods-allowed", namespace1, 0)
_, err = fakeClient.AppsV1().StatefulSets(namespace1).Create(context.TODO(), stsNoPods, metav1.CreateOptions{})
assert.NoError(t, err)
// Create resources in the restricted namespace
sts2 := createTestStatefulSet("sts-restricted", namespace2, 1)
_, err = fakeClient.AppsV1().StatefulSets(namespace2).Create(context.TODO(), sts2, metav1.CreateOptions{})
assert.NoError(t, err)
pod2 := createTestPod("pod-restricted", namespace2, "StatefulSet", "sts-restricted", true)
_, err = fakeClient.CoreV1().Pods(namespace2).Create(context.TODO(), pod2, metav1.CreateOptions{})
assert.NoError(t, err)
// Create the KubeClient with non-admin privileges (only allowed namespace1)
kubeClient := &KubeClient{
cli: fakeClient,
instanceID: "test-instance",
IsKubeAdmin: false,
NonAdminNamespaces: []string{namespace1},
}
// Test that only resources from allowed namespace are returned
apps, err := kubeClient.GetApplications("", "")
assert.NoError(t, err)
// We expect 2 resources from the allowed namespace (1 sts with pod + 1 sts without pod)
assert.Equal(t, 2, len(apps))
// Verify resources are from the allowed namespace
for _, app := range apps {
assert.Equal(t, namespace1, app.ResourcePool)
assert.Equal(t, "StatefulSet", app.Kind)
}
// Verify names of returned resources
stsNames := make(map[string]bool)
for _, app := range apps {
stsNames[app.Name] = true
}
assert.True(t, stsNames["sts-allowed"], "Expected StatefulSet 'sts-allowed' was not found")
assert.True(t, stsNames["sts-no-pods-allowed"], "Expected StatefulSet 'sts-no-pods-allowed' was not found")
})
t.Run("Filter by node name", func(t *testing.T) {
// Create a fake K8s client
fakeClient := fake.NewSimpleClientset()
// Setup test namespace
namespace := "node-filter-ns"
nodeName := "worker-node-1"
// Create a deployment with pods on specific node
deploy := createTestDeployment("node-deploy", namespace, 2)
_, err := fakeClient.AppsV1().Deployments(namespace).Create(context.TODO(), deploy, metav1.CreateOptions{})
assert.NoError(t, err)
// Create ReplicaSet for the deployment
rs := createTestReplicaSet("rs-node-deploy", namespace, "node-deploy")
_, err = fakeClient.AppsV1().ReplicaSets(namespace).Create(context.TODO(), rs, metav1.CreateOptions{})
assert.NoError(t, err)
// Create 2 pods, one on the specified node, one on a different node
pod1 := createTestPod("pod-on-node", namespace, "ReplicaSet", "rs-node-deploy", true)
pod1.Spec.NodeName = nodeName
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod1, metav1.CreateOptions{})
assert.NoError(t, err)
pod2 := createTestPod("pod-other-node", namespace, "ReplicaSet", "rs-node-deploy", true)
pod2.Spec.NodeName = "worker-node-2"
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod2, metav1.CreateOptions{})
assert.NoError(t, err)
// Create the KubeClient
kubeClient := &KubeClient{
cli: fakeClient,
instanceID: "test-instance",
IsKubeAdmin: true,
}
// Test filtering by node name
apps, err := kubeClient.GetApplications(namespace, nodeName)
assert.NoError(t, err)
// We expect to find only the pod on the specified node
assert.Equal(t, 1, len(apps))
if len(apps) > 0 {
assert.Equal(t, "node-deploy", apps[0].Name)
}
})
}
+3 -3
View File
@@ -24,7 +24,7 @@ func (kcl *KubeClient) GetConfigMaps(namespace string) ([]models.K8sConfigMap, e
// 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 configMaps for non-admin user: %v", kcl.NonAdminNamespaces)
log.Debug().Msgf("Fetching volumes for non-admin user: %v", kcl.NonAdminNamespaces)
if len(kcl.NonAdminNamespaces) == 0 {
return nil, nil
@@ -102,7 +102,7 @@ func parseConfigMap(configMap *corev1.ConfigMap, withData bool) models.K8sConfig
func (kcl *KubeClient) CombineConfigMapsWithApplications(configMaps []models.K8sConfigMap) ([]models.K8sConfigMap, error) {
updatedConfigMaps := make([]models.K8sConfigMap, len(configMaps))
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{})
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)
}
@@ -110,7 +110,7 @@ func (kcl *KubeClient) CombineConfigMapsWithApplications(configMaps []models.K8s
for index, configMap := range configMaps {
updatedConfigMap := configMap
applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromConfigMap(configMap, portainerApplicationResources.Pods, portainerApplicationResources.ReplicaSets)
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)
}
+39 -35
View File
@@ -11,6 +11,7 @@ import (
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
appsv1 "k8s.io/api/apps/v1"
autoscalingv2 "k8s.io/api/autoscaling/v2"
corev1 "k8s.io/api/core/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -109,7 +110,7 @@ func (kcl *KubeClient) CreateUserShellPod(ctx context.Context, serviceAccountNam
},
}
shellPod, err := kcl.cli.CoreV1().Pods(portainerNamespace).Create(context.TODO(), podSpec, metav1.CreateOptions{})
shellPod, err := kcl.cli.CoreV1().Pods(portainerNamespace).Create(ctx, podSpec, metav1.CreateOptions{})
if err != nil {
return nil, errors.Wrap(err, "error creating shell pod")
}
@@ -157,7 +158,7 @@ func (kcl *KubeClient) waitForPodStatus(ctx context.Context, phase corev1.PodPha
case <-ctx.Done():
return ctx.Err()
default:
pod, err := kcl.cli.CoreV1().Pods(pod.Namespace).Get(context.TODO(), pod.Name, metav1.GetOptions{})
pod, err := kcl.cli.CoreV1().Pods(pod.Namespace).Get(ctx, pod.Name, metav1.GetOptions{})
if err != nil {
return err
}
@@ -171,67 +172,70 @@ func (kcl *KubeClient) waitForPodStatus(ctx context.Context, phase corev1.PodPha
}
}
// 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, []autoscalingv2.HorizontalPodAutoscaler, 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) (PortainerApplicationResources, error) {
func (kcl *KubeClient) fetchAllApplicationsListResources(namespace string, podListOptions metav1.ListOptions) ([]corev1.Pod, []appsv1.ReplicaSet, []appsv1.Deployment, []appsv1.StatefulSet, []appsv1.DaemonSet, []corev1.Service, []autoscalingv2.HorizontalPodAutoscaler, 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) (PortainerApplicationResources, error) {
func (kcl *KubeClient) fetchResourcesWithOwnerReferences(namespace string, podListOptions metav1.ListOptions, includeStatefulSets, includeDaemonSets bool) ([]corev1.Pod, []appsv1.ReplicaSet, []appsv1.Deployment, []appsv1.StatefulSet, []appsv1.DaemonSet, []corev1.Service, []autoscalingv2.HorizontalPodAutoscaler, error) {
pods, err := kcl.cli.CoreV1().Pods(namespace).List(context.Background(), podListOptions)
if err != nil {
if k8serrors.IsNotFound(err) {
return PortainerApplicationResources{}, nil
return nil, nil, nil, nil, nil, nil, nil, nil
}
return PortainerApplicationResources{}, fmt.Errorf("unable to list pods across the cluster: %w", err)
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list pods across the cluster: %w", err)
}
portainerApplicationResources := PortainerApplicationResources{
Pods: pods.Items,
}
replicaSets, err := kcl.cli.AppsV1().ReplicaSets(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil && !k8serrors.IsNotFound(err) {
return PortainerApplicationResources{}, fmt.Errorf("unable to list replica sets across the cluster: %w", err)
}
portainerApplicationResources.ReplicaSets = replicaSets.Items
deployments, err := kcl.cli.AppsV1().Deployments(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil && !k8serrors.IsNotFound(err) {
return PortainerApplicationResources{}, fmt.Errorf("unable to list deployments across the cluster: %w", err)
}
portainerApplicationResources.Deployments = deployments.Items
if includeStatefulSets {
statefulSets, err := kcl.cli.AppsV1().StatefulSets(namespace).List(context.Background(), metav1.ListOptions{})
// 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 PortainerApplicationResources{}, fmt.Errorf("unable to list stateful sets across the cluster: %w", err)
return nil, 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, nil, fmt.Errorf("unable to list deployments across the cluster: %w", err)
}
portainerApplicationResources.StatefulSets = statefulSets.Items
}
if includeDaemonSets {
daemonSets, err := kcl.cli.AppsV1().DaemonSets(namespace).List(context.Background(), metav1.ListOptions{})
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 PortainerApplicationResources{}, fmt.Errorf("unable to list daemon sets across the cluster: %w", err)
return nil, 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, nil, fmt.Errorf("unable to list daemon sets across the cluster: %w", err)
}
portainerApplicationResources.DaemonSets = daemonSets.Items
}
services, err := kcl.cli.CoreV1().Services(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil && !k8serrors.IsNotFound(err) {
return PortainerApplicationResources{}, fmt.Errorf("unable to list services across the cluster: %w", err)
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list services across the cluster: %w", err)
}
portainerApplicationResources.Services = services.Items
hpas, err := kcl.cli.AutoscalingV2().HorizontalPodAutoscalers(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil && !k8serrors.IsNotFound(err) {
return PortainerApplicationResources{}, fmt.Errorf("unable to list horizontal pod autoscalers across the cluster: %w", err)
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list horizontal pod autoscalers across the cluster: %w", err)
}
portainerApplicationResources.HorizontalPodAutoscalers = hpas.Items
return portainerApplicationResources, nil
return pods.Items, replicaSets.Items, deployments.Items, statefulSets.Items, daemonSets.Items, services.Items, hpas.Items, nil
}
// isPodUsingConfigMap checks if a pod is using a specific ConfigMap
+2 -1
View File
@@ -10,6 +10,7 @@ import (
rbacv1 "k8s.io/api/rbac/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
v1 "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.
@@ -136,7 +137,7 @@ func (kcl *KubeClient) DeleteRoles(reqs models.K8sRoleDeleteRequests) error {
for _, name := range reqs[namespace] {
client := kcl.cli.RbacV1().Roles(namespace)
role, err := client.Get(context.Background(), name, metav1.GetOptions{})
role, err := client.Get(context.Background(), name, v1.GetOptions{})
if err != nil {
if k8serrors.IsNotFound(err) {
continue
+5 -3
View File
@@ -7,9 +7,11 @@ import (
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/api/internal/errorlist"
"github.com/rs/zerolog/log"
corev1 "k8s.io/api/rbac/v1"
rbacv1 "k8s.io/api/rbac/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
v1 "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.
@@ -96,7 +98,7 @@ func (kcl *KubeClient) isSystemRoleBinding(rb *rbacv1.RoleBinding) bool {
return false
}
func (kcl *KubeClient) getRole(namespace, name string) (*rbacv1.Role, error) {
func (kcl *KubeClient) getRole(namespace, name string) (*corev1.Role, error) {
client := kcl.cli.RbacV1().Roles(namespace)
return client.Get(context.Background(), name, metav1.GetOptions{})
}
@@ -109,7 +111,7 @@ func (kcl *KubeClient) DeleteRoleBindings(reqs models.K8sRoleBindingDeleteReques
for _, name := range reqs[namespace] {
client := kcl.cli.RbacV1().RoleBindings(namespace)
roleBinding, err := client.Get(context.Background(), name, metav1.GetOptions{})
roleBinding, err := client.Get(context.Background(), name, v1.GetOptions{})
if err != nil {
if k8serrors.IsNotFound(err) {
continue
@@ -123,7 +125,7 @@ func (kcl *KubeClient) DeleteRoleBindings(reqs models.K8sRoleBindingDeleteReques
log.Error().Str("role_name", name).Msg("ignoring delete of 'system' role binding, not allowed")
}
if err := client.Delete(context.Background(), name, metav1.DeleteOptions{}); err != nil {
if err := client.Delete(context.Background(), name, v1.DeleteOptions{}); err != nil {
errors = append(errors, err)
}
}
+3 -3
View File
@@ -31,7 +31,7 @@ func (kcl *KubeClient) GetSecrets(namespace string) ([]models.K8sSecret, error)
// 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 secrets for non-admin user: %v", kcl.NonAdminNamespaces)
log.Debug().Msgf("Fetching volumes for non-admin user: %v", kcl.NonAdminNamespaces)
if len(kcl.NonAdminNamespaces) == 0 {
return nil, nil
@@ -118,7 +118,7 @@ func parseSecret(secret *corev1.Secret, withData bool) models.K8sSecret {
func (kcl *KubeClient) CombineSecretsWithApplications(secrets []models.K8sSecret) ([]models.K8sSecret, error) {
updatedSecrets := make([]models.K8sSecret, len(secrets))
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{})
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)
}
@@ -126,7 +126,7 @@ func (kcl *KubeClient) CombineSecretsWithApplications(secrets []models.K8sSecret
for index, secret := range secrets {
updatedSecret := secret
applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromSecret(secret, portainerApplicationResources.Pods, portainerApplicationResources.ReplicaSets)
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)
}
+2 -2
View File
@@ -174,7 +174,7 @@ func (kcl *KubeClient) UpdateService(namespace string, info models.K8sServiceInf
func (kcl *KubeClient) CombineServicesWithApplications(services []models.K8sServiceInfo) ([]models.K8sServiceInfo, error) {
if containsServiceWithSelector(services) {
updatedServices := make([]models.K8sServiceInfo, len(services))
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{})
pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{})
if err != nil {
return nil, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
}
@@ -182,7 +182,7 @@ func (kcl *KubeClient) CombineServicesWithApplications(services []models.K8sServ
for index, service := range services {
updatedService := service
application, err := kcl.GetApplicationFromServiceSelector(portainerApplicationResources.Pods, service, portainerApplicationResources.ReplicaSets)
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)
}
+2 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/models/kubernetes"
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/api/internal/errorlist"
corev1 "k8s.io/api/core/v1"
@@ -91,7 +92,7 @@ func (kcl *KubeClient) isSystemServiceAccount(namespace string) bool {
// DeleteServices processes a K8sServiceDeleteRequest by deleting each service
// in its given namespace.
func (kcl *KubeClient) DeleteServiceAccounts(reqs models.K8sServiceAccountDeleteRequests) error {
func (kcl *KubeClient) DeleteServiceAccounts(reqs kubernetes.K8sServiceAccountDeleteRequests) error {
var errors []error
for namespace := range reqs {
for _, serviceName := range reqs[namespace] {
+2 -6
View File
@@ -7,6 +7,7 @@ import (
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/rs/zerolog/log"
appsv1 "k8s.io/api/apps/v1"
autoscalingv2 "k8s.io/api/autoscaling/v2"
corev1 "k8s.io/api/core/v1"
storagev1 "k8s.io/api/storage/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
@@ -264,12 +265,7 @@ func (kcl *KubeClient) updateVolumesWithOwningApplications(volumes *[]models.K8s
if pod.Spec.Volumes != nil {
for _, podVolume := range pod.Spec.Volumes {
if podVolume.VolumeSource.PersistentVolumeClaim != nil && podVolume.VolumeSource.PersistentVolumeClaim.ClaimName == volume.PersistentVolumeClaim.Name && pod.Namespace == volume.PersistentVolumeClaim.Namespace {
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
ReplicaSets: replicaSetItems,
Deployments: deploymentItems,
StatefulSets: statefulSetItems,
DaemonSets: daemonSetItems,
}, false)
application, err := kcl.ConvertPodToApplication(pod, replicaSetItems, deploymentItems, statefulSetItems, daemonSetItems, []corev1.Service{}, []autoscalingv2.HorizontalPodAutoscaler{}, 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)
+2 -2
View File
@@ -1544,7 +1544,7 @@ type (
GetConfigMaps(namespace string) ([]models.K8sConfigMap, error)
GetSecrets(namespace string) ([]models.K8sSecret, error)
GetIngressControllers() (models.K8sIngressControllers, error)
GetApplications(namespace, nodename 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
@@ -1637,7 +1637,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.27.2"
APIVersion = "2.27.1"
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
APIVersionSupport = "LTS"
// Edition is what this edition of Portainer is called
+3 -10
View File
@@ -3,7 +3,6 @@ import _ from 'lodash-es';
import angular from 'angular';
import KubernetesResourcePoolConverter from 'Kubernetes/converters/resourcePool';
import KubernetesResourceQuotaHelper from 'Kubernetes/helpers/resourceQuotaHelper';
import { getNamespaces } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery';
/* @ngInject */
export function KubernetesResourcePoolService(
@@ -12,8 +11,7 @@ export function KubernetesResourcePoolService(
KubernetesNamespaceService,
KubernetesResourceQuotaService,
KubernetesIngressService,
KubernetesPortainerNamespaces,
EndpointProvider
KubernetesPortainerNamespaces
) {
return {
get,
@@ -39,14 +37,9 @@ export function KubernetesResourcePoolService(
// getting the quota for all namespaces is costly by default, so disable getting it by default
async function getAll({ getQuota = false }) {
const namespaces = await getNamespaces(EndpointProvider.endpointID());
// there is a lot of downstream logic using the angular namespace type with a '.Status' field (not '.Status.phase'), so format the status here to match this logic
const namespacesFormattedStatus = namespaces.map((namespace) => ({
...namespace,
Status: namespace.Status.phase,
}));
const namespaces = await KubernetesNamespaceService.get();
const pools = await Promise.all(
_.map(namespacesFormattedStatus, async (namespace) => {
_.map(namespaces, async (namespace) => {
const name = namespace.Name;
const pool = KubernetesResourcePoolConverter.apiToResourcePool(namespace);
if (getQuota) {
@@ -6,13 +6,13 @@ import PortainerError from '@/portainer/error';
import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods, RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
import { isTemplateVariablesEnabled, renderTemplate } from '@/react/portainer/custom-templates/components/utils';
import { getDeploymentOptions } from '@/react/portainer/environments/environment.service';
import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods';
import { editor, git, customTemplate, url, helm } from '@@/BoxSelector/common-options/build-methods';
import { parseAutoUpdateResponse, transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper';
import { confirmWebEditorDiscard } from '@@/modals/confirm';
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { KUBE_STACK_NAME_VALIDATION_REGEX } from '@/react/kubernetes/DeployView/StackName/constants';
import { confirmWebEditorDiscard } from '@@/modals/confirm';
import { editor, git, customTemplate, url, helm } from '@@/BoxSelector/common-options/build-methods';
import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods';
class KubernetesDeployController {
/* @ngInject */
+7 -7
View File
@@ -1,7 +1,7 @@
// Re-exporting so we don't have to update one meeeeellion files that are already importing these
// functions from here.
export {
pluralize,
addPlural,
grammaticallyJoin,
} from '@/react/common/string-utils';
export function pluralize(val: number, word: string, plural = `${word}s`) {
return [1, -1].includes(Number(val)) ? word : plural;
}
export function addPlural(value: number, word: string, plural = `${word}s`) {
return `${value} ${pluralize(value, word, plural)}`;
}
-27
View File
@@ -1,27 +0,0 @@
export function capitalize(s: string) {
return s.slice(0, 1).toUpperCase() + s.slice(1);
}
export function pluralize(val: number, word: string, plural = `${word}s`) {
return [1, -1].includes(Number(val)) ? word : plural;
}
export function addPlural(value: number, word: string, plural = `${word}s`) {
return `${value} ${pluralize(value, word, plural)}`;
}
/**
* Joins an array of strings into a grammatically correct sentence.
*/
export function grammaticallyJoin(
values: string[],
separator = ', ',
lastSeparator = ' and '
) {
if (values.length === 0) return '';
if (values.length === 1) return values[0];
const allButLast = values.slice(0, -1);
const last = values[values.length - 1];
return `${allButLast.join(separator)}${lastSeparator}${last}`;
}
@@ -1,5 +1,4 @@
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import {
createColumnHelper,
@@ -155,9 +154,6 @@ describe('Datatable', () => {
);
expect(screen.getByText('No data available')).toBeInTheDocument();
const selectAllCheckbox: HTMLInputElement =
screen.getByLabelText('Select all rows');
expect(selectAllCheckbox.checked).toBe(false);
});
it('selects/deselects only page rows when select all is clicked', () => {
@@ -174,7 +170,7 @@ describe('Datatable', () => {
fireEvent.click(selectAllCheckbox);
// Check if all rows on the page are selected
expect(screen.getByText('2 items selected')).toBeInTheDocument();
expect(screen.getByText('2 item(s) selected')).toBeInTheDocument();
// Deselect
fireEvent.click(selectAllCheckbox);
@@ -196,44 +192,13 @@ describe('Datatable', () => {
fireEvent.click(selectAllCheckbox, { shiftKey: true });
// Check if all rows on the page are selected
expect(screen.getByText('3 items selected')).toBeInTheDocument();
expect(screen.getByText('3 item(s) selected')).toBeInTheDocument();
// Deselect
fireEvent.click(selectAllCheckbox, { shiftKey: true });
const checkboxes: HTMLInputElement[] = screen.queryAllByRole('checkbox');
expect(checkboxes.filter((checkbox) => checkbox.checked).length).toBe(0);
});
it('shows indeterminate state and correct footer text when hidden rows are selected', async () => {
const user = userEvent.setup();
render(
<DatatableWithStore
dataset={mockData}
columns={mockColumns}
data-cy="test-table"
title="Test table with search"
/>
);
// Select Jane
const checkboxes = screen.getAllByRole('checkbox');
await user.click(checkboxes[2]); // Select the second row
// Search for John (will hide selected Jane)
const searchInput = screen.getByPlaceholderText('Search...');
await user.type(searchInput, 'John');
// Check if the footer text is correct
expect(
await screen.findByText('1 item selected (1 hidden by filters)')
).toBeInTheDocument();
// Check if the checkbox is indeterminate
const selectAllCheckbox: HTMLInputElement =
screen.getByLabelText('Select all rows');
expect(selectAllCheckbox.indeterminate).toBe(true);
expect(selectAllCheckbox.checked).toBe(false);
});
});
// Test the defaultGlobalFilterFn used in searches
@@ -171,14 +171,6 @@ export function Datatable<D extends DefaultType>({
const selectedRowModel = tableInstance.getSelectedRowModel();
const selectedItems = selectedRowModel.rows.map((row) => row.original);
const filteredItems = tableInstance
.getFilteredRowModel()
.rows.map((row) => row.original);
const hiddenSelectedItems = useMemo(
() => _.difference(selectedItems, filteredItems),
[selectedItems, filteredItems]
);
return (
<Table.Container noWidget={noWidget} aria-label={title}>
@@ -211,7 +203,6 @@ export function Datatable<D extends DefaultType>({
pageSize={tableState.pagination.pageSize}
pageCount={tableInstance.getPageCount()}
totalSelected={selectedItems.length}
totalHiddenSelected={hiddenSelectedItems.length}
/>
</Table.Container>
);
@@ -5,7 +5,6 @@ import { SelectedRowsCount } from './SelectedRowsCount';
interface Props {
totalSelected: number;
totalHiddenSelected: number;
pageSize: number;
page: number;
onPageChange(page: number): void;
@@ -15,7 +14,6 @@ interface Props {
export function DatatableFooter({
totalSelected,
totalHiddenSelected,
pageSize,
page,
onPageChange,
@@ -24,7 +22,7 @@ export function DatatableFooter({
}: Props) {
return (
<Table.Footer>
<SelectedRowsCount value={totalSelected} hidden={totalHiddenSelected} />
<SelectedRowsCount value={totalSelected} />
<PaginationControls
showAll
pageLimit={pageSize}
@@ -1,15 +1,9 @@
import { addPlural } from '@/react/common/string-utils';
interface SelectedRowsCountProps {
value: number;
hidden: number;
}
export function SelectedRowsCount({ value, hidden }: SelectedRowsCountProps) {
export function SelectedRowsCount({ value }: SelectedRowsCountProps) {
return value !== 0 ? (
<div className="infoBar">
{addPlural(value, 'item')} selected
{hidden !== 0 && ` (${hidden} hidden by filters)`}
</div>
<div className="infoBar">{value} item(s) selected</div>
) : null;
}
@@ -1,20 +1,7 @@
import { ColumnDef, Row, Table } from '@tanstack/react-table';
import { ColumnDef, Row } from '@tanstack/react-table';
import { Checkbox } from '@@/form-components/Checkbox';
function allRowsSelected<T>(table: Table<T>) {
const { rows } = table.getCoreRowModel();
return rows.length > 0 && rows.every((row) => row.getIsSelected());
}
function someRowsSelected<T>(table: Table<T>) {
return table.getCoreRowModel().rows.some((row) => row.getIsSelected());
}
function somePageRowsSelected<T>(table: Table<T>) {
return table.getRowModel().rows.some((row) => row.getIsSelected());
}
export function createSelectColumn<T>(dataCy: string): ColumnDef<T> {
let lastSelectedId = '';
@@ -24,15 +11,15 @@ export function createSelectColumn<T>(dataCy: string): ColumnDef<T> {
<Checkbox
id="select-all"
data-cy={`select-all-checkbox-${dataCy}`}
checked={allRowsSelected(table)}
indeterminate={!allRowsSelected(table) && someRowsSelected(table)}
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomeRowsSelected()}
onChange={(e) => {
// Select all rows if shift key is held down, otherwise only page rows
if (e.nativeEvent instanceof MouseEvent && e.nativeEvent.shiftKey) {
table.toggleAllRowsSelected();
table.getToggleAllRowsSelectedHandler()(e);
return;
}
table.toggleAllPageRowsSelected(!somePageRowsSelected(table));
table.getToggleAllPageRowsSelectedHandler()(e);
}}
disabled={table.getRowModel().rows.every((row) => !row.getCanSelect())}
onClick={(e) => {
@@ -42,8 +42,6 @@ export const Checkbox = forwardRef<HTMLInputElement, Props>(
resolvedRef = defaultRef;
}
// Need to check this on every render as the browser will always set the element's
// indeterminate state to false when the checkbox is clicked, even if the indeterminate prop hasn't changed
useEffect(() => {
if (resolvedRef === null || resolvedRef.current === null) {
return;
@@ -52,7 +50,7 @@ export const Checkbox = forwardRef<HTMLInputElement, Props>(
if (typeof indeterminate !== 'undefined') {
resolvedRef.current.indeterminate = indeterminate;
}
});
}, [resolvedRef, indeterminate]);
return (
<div className="md-checkbox flex items-center" title={title || label}>
@@ -57,6 +57,7 @@ export function ApplicationsDatatable({
const applicationsQuery = useApplications(environmentId, {
refetchInterval: tableState.autoRefreshRate * 1000,
namespace: tableState.namespace,
withDependencies: true,
});
const ingressesQuery = useIngresses(environmentId);
const ingresses = ingressesQuery.data ?? [];
@@ -38,6 +38,7 @@ export function ApplicationsStacksDatatable({
const applicationsQuery = useApplications(environmentId, {
refetchInterval: tableState.autoRefreshRate * 1000,
namespace: tableState.namespace,
withDependencies: true,
});
const ingressesQuery = useIngresses(environmentId);
const ingresses = ingressesQuery.data ?? [];
@@ -3,6 +3,7 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
export type GetAppsParams = {
namespace?: string;
nodeName?: string;
withDependencies?: boolean;
};
export const queryKeys = {
@@ -11,6 +11,7 @@ import { queryKeys } from './query-keys';
type GetAppsParams = {
namespace?: string;
nodeName?: string;
withDependencies?: boolean;
};
type GetAppsQueryOptions = {
+1 -1
View File
@@ -33,7 +33,7 @@ export function isExternalApplication(application: Application) {
function getDeploymentRunningPods(deployment: Deployment): number {
const availableReplicas = deployment.status?.availableReplicas ?? 0;
const totalReplicas = deployment.spec?.replicas ?? 0;
const totalReplicas = deployment.status?.replicas ?? 0;
const unavailableReplicas = deployment.status?.unavailableReplicas ?? 0;
return availableReplicas || totalReplicas - unavailableReplicas;
}
@@ -30,6 +30,7 @@ export function NamespaceAppsDatatable({ namespace }: { namespace: string }) {
const applicationsQuery = useApplications(environmentId, {
refetchInterval: tableState.autoRefreshRate * 1000,
namespace,
withDependencies: true,
});
const applications = applicationsQuery.data ?? [];
@@ -110,7 +110,6 @@ export function AppTemplatesList({
pageSize={listState.pageSize}
pageCount={Math.ceil(filteredTemplates.length / listState.pageSize)}
totalSelected={0}
totalHiddenSelected={0}
/>
</Table.Container>
);
@@ -86,7 +86,6 @@ export function CustomTemplatesList({
pageSize={listState.pageSize}
pageCount={Math.ceil(filteredTemplates.length / listState.pageSize)}
totalSelected={0}
totalHiddenSelected={0}
/>
</Table.Container>
);
+2 -2
View File
@@ -1,6 +1,6 @@
{
"docker": "v27.5.1",
"helm": "v3.17.2",
"kubectl": "v1.32.2",
"helm": "v3.17.0",
"kubectl": "v1.32.1",
"mingit": "2.48.1.1"
}
+6 -6
View File
@@ -30,7 +30,6 @@ require (
github.com/gorilla/csrf v1.7.2
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.0
github.com/hashicorp/go-version v1.7.0
github.com/hashicorp/golang-lru v0.5.4
github.com/joho/godotenv v1.4.0
github.com/jpillora/chisel v1.10.0
@@ -48,11 +47,11 @@ require (
github.com/urfave/negroni v1.0.0
github.com/viney-shih/go-lock v1.1.1
go.etcd.io/bbolt v1.3.11
golang.org/x/crypto v0.35.0
golang.org/x/crypto v0.31.0
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0
golang.org/x/mod v0.21.0
golang.org/x/oauth2 v0.23.0
golang.org/x/sync v0.11.0
golang.org/x/sync v0.10.0
gopkg.in/alecthomas/kingpin.v2 v2.2.6
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.29.2
@@ -148,6 +147,7 @@ require (
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/imdario/mergo v0.3.16 // indirect
github.com/in-toto/in-toto-golang v0.9.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
@@ -243,9 +243,9 @@ require (
go.opentelemetry.io/proto/otlp v1.1.0 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/term v0.29.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/term v0.27.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/time v0.6.0 // indirect
google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 // indirect
+10 -10
View File
@@ -695,8 +695,8 @@ golang.org/x/crypto v0.0.0-20201117144127-c1f2f97bffc9/go.mod h1:jdWPYTVW3xRLrWP
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@@ -727,8 +727,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -755,20 +755,20 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+1 -1
View File
@@ -2,7 +2,7 @@
"author": "Portainer.io",
"name": "portainer",
"homepage": "http://portainer.io",
"version": "2.27.2",
"version": "2.27.1",
"repository": {
"type": "git",
"url": "git@github.com:portainer/portainer.git"