diff --git a/api/http/models/kubernetes/application.go b/api/http/models/kubernetes/application.go index 97d26cad9..fcb49b23d 100644 --- a/api/http/models/kubernetes/application.go +++ b/api/http/models/kubernetes/application.go @@ -3,39 +3,41 @@ package kubernetes import ( "time" + autoscalingv2 "k8s.io/api/autoscaling/v2" corev1 "k8s.io/api/core/v1" ) type K8sApplication struct { - ID string `json:"Id"` - Name string `json:"Name"` - Image string `json:"Image"` - Containers []interface{} `json:"Containers,omitempty"` - Services []corev1.Service `json:"Services"` - CreationDate time.Time `json:"CreationDate"` - ApplicationOwner string `json:"ApplicationOwner,omitempty"` - StackName string `json:"StackName,omitempty"` - ResourcePool string `json:"ResourcePool"` - ApplicationType string `json:"ApplicationType"` - Metadata *Metadata `json:"Metadata,omitempty"` - Status string `json:"Status"` - TotalPodsCount int `json:"TotalPodsCount"` - RunningPodsCount int `json:"RunningPodsCount"` - DeploymentType string `json:"DeploymentType"` - Pods []Pod `json:"Pods,omitempty"` - Configurations []Configuration `json:"Configurations,omitempty"` - LoadBalancerIPAddress string `json:"LoadBalancerIPAddress,omitempty"` - PublishedPorts []PublishedPort `json:"PublishedPorts,omitempty"` - Namespace string `json:"Namespace,omitempty"` - UID string `json:"Uid,omitempty"` - StackID string `json:"StackId,omitempty"` - ServiceID string `json:"ServiceId,omitempty"` - ServiceName string `json:"ServiceName,omitempty"` - ServiceType string `json:"ServiceType,omitempty"` - Kind string `json:"Kind,omitempty"` - MatchLabels map[string]string `json:"MatchLabels,omitempty"` - Labels map[string]string `json:"Labels,omitempty"` - Resource K8sApplicationResource `json:"Resource,omitempty"` + ID string `json:"Id"` + Name string `json:"Name"` + Image string `json:"Image"` + Containers []interface{} `json:"Containers,omitempty"` + Services []corev1.Service `json:"Services"` + CreationDate time.Time `json:"CreationDate"` + ApplicationOwner string `json:"ApplicationOwner,omitempty"` + StackName string `json:"StackName,omitempty"` + ResourcePool string `json:"ResourcePool"` + ApplicationType string `json:"ApplicationType"` + Metadata *Metadata `json:"Metadata,omitempty"` + Status string `json:"Status"` + TotalPodsCount int `json:"TotalPodsCount"` + RunningPodsCount int `json:"RunningPodsCount"` + DeploymentType string `json:"DeploymentType"` + Pods []Pod `json:"Pods,omitempty"` + Configurations []Configuration `json:"Configurations,omitempty"` + LoadBalancerIPAddress string `json:"LoadBalancerIPAddress,omitempty"` + PublishedPorts []PublishedPort `json:"PublishedPorts,omitempty"` + Namespace string `json:"Namespace,omitempty"` + UID string `json:"Uid,omitempty"` + StackID string `json:"StackId,omitempty"` + ServiceID string `json:"ServiceId,omitempty"` + ServiceName string `json:"ServiceName,omitempty"` + ServiceType string `json:"ServiceType,omitempty"` + Kind string `json:"Kind,omitempty"` + MatchLabels map[string]string `json:"MatchLabels,omitempty"` + Labels map[string]string `json:"Labels,omitempty"` + Resource K8sApplicationResource `json:"Resource,omitempty"` + HorizontalPodAutoscaler *autoscalingv2.HorizontalPodAutoscaler `json:"HorizontalPodAutoscaler,omitempty"` } type Metadata struct { diff --git a/api/kubernetes/cli/applications.go b/api/kubernetes/cli/applications.go index 90b7774e9..1694af44f 100644 --- a/api/kubernetes/cli/applications.go +++ b/api/kubernetes/cli/applications.go @@ -6,6 +6,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" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" labels "k8s.io/apimachinery/pkg/labels" @@ -31,20 +32,20 @@ func (kcl *KubeClient) fetchApplications(namespace, nodeName string, withDepende } if !withDependencies { // TODO: make sure not to fetch services in fetchAllApplicationsListResources from this call - pods, replicaSets, deployments, statefulSets, daemonSets, _, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions) + pods, replicaSets, deployments, statefulSets, daemonSets, _, _, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions) if err != nil { return nil, err } - return kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, nil) + return kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, nil, nil) } - pods, replicaSets, deployments, statefulSets, daemonSets, services, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions) + pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions) if err != nil { return nil, err } - return kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, services) + return kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas) } // fetchApplicationsForNonAdmin fetches the applications in the namespaces the user has access to. @@ -62,20 +63,20 @@ func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string, } if !withDependencies { - pods, replicaSets, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets(namespace, podListOptions) + pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets(namespace, podListOptions) if err != nil { return nil, err } - return kcl.convertPodsToApplications(pods, replicaSets, nil, nil, nil, nil) + return kcl.convertPodsToApplications(pods, replicaSets, nil, nil, nil, nil, nil) } - pods, replicaSets, deployments, statefulSets, daemonSets, services, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions) + pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions) if err != nil { return nil, err } - applications, err := kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, services) + applications, err := kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas) if err != nil { return nil, err } @@ -92,7 +93,7 @@ func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string, } // convertPodsToApplications processes pods and converts them to applications, ensuring uniqueness by owner reference. -func (kcl *KubeClient) convertPodsToApplications(pods []corev1.Pod, replicaSets []appsv1.ReplicaSet, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service) ([]models.K8sApplication, error) { +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{}) @@ -105,7 +106,7 @@ func (kcl *KubeClient) convertPodsToApplications(pods []corev1.Pod, replicaSets processedOwners[ownerUID] = struct{}{} } - application, err := kcl.ConvertPodToApplication(pod, replicaSets, deployments, statefulSets, daemonSets, services, true) + application, err := kcl.ConvertPodToApplication(pod, replicaSets, deployments, statefulSets, daemonSets, services, hpas, true) if err != nil { return nil, err } @@ -150,7 +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, replicaSets, nil, nil, nil, nil, false) + application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false) if err != nil { return nil, err } @@ -167,7 +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, replicaSets, nil, nil, nil, nil, false) + application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false) if err != nil { return nil, err } @@ -180,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, replicaSets []appsv1.ReplicaSet, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, 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, replicaSets) } - application := createApplication(&pod, deployments, statefulSets, daemonSets, services) + application := createApplication(&pod, deployments, statefulSets, daemonSets, services, hpas) if application.ID == "" && application.Name == "" { return nil, nil } @@ -204,7 +205,7 @@ func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, replicaSets []app // createApplication creates a K8sApplication object from a pod // it sets the application name, namespace, kind, image, stack id, stack name, and labels -func createApplication(pod *corev1.Pod, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service) models.K8sApplication { +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 @@ -324,7 +325,11 @@ func createApplication(pod *corev1.Pod, deployments []appsv1.Deployment, statefu } if application.ID != "" && application.Name != "" && len(services) > 0 { - return updateApplicationWithService(application, services) + updateApplicationWithService(&application, services) + } + + if application.ID != "" && application.Name != "" && len(hpas) > 0 { + updateApplicationWithHorizontalPodAutoscaler(&application, hpas) } return application @@ -332,7 +337,7 @@ func createApplication(pod *corev1.Pod, deployments []appsv1.Deployment, statefu // updateApplicationWithService updates the application with the services that match the application's selector match labels // and are in the same namespace as the application -func updateApplicationWithService(application models.K8sApplication, services []corev1.Service) models.K8sApplication { +func updateApplicationWithService(application *models.K8sApplication, services []corev1.Service) { for _, service := range services { serviceSelector := labels.SelectorFromSet(service.Spec.Selector) @@ -341,8 +346,23 @@ func updateApplicationWithService(application models.K8sApplication, services [] application.Services = append(application.Services, service) } } +} - return application +func updateApplicationWithHorizontalPodAutoscaler(application *models.K8sApplication, hpas []autoscalingv2.HorizontalPodAutoscaler) { + for _, hpa := range hpas { + // Check if HPA is in the same namespace as the application + if hpa.Namespace != application.ResourcePool { + continue + } + + // Check if the scale target ref matches the application + scaleTargetRef := hpa.Spec.ScaleTargetRef + if scaleTargetRef.Name == application.Name && scaleTargetRef.Kind == application.Kind { + hpaCopy := hpa // Create a local copy + application.HorizontalPodAutoscaler = &hpaCopy + break + } + } } // calculatePodResourceUsage calculates the resource usage for a pod in CPU cores and Bytes @@ -390,7 +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, replicaSets, nil, nil, nil, nil, false) + application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false) if err != nil { return nil, err } @@ -416,7 +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, replicaSets, nil, nil, nil, nil, false) + application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false) if err != nil { return nil, err } diff --git a/api/kubernetes/cli/configmap.go b/api/kubernetes/cli/configmap.go index b3077c8c0..57f36cf74 100644 --- a/api/kubernetes/cli/configmap.go +++ b/api/kubernetes/cli/configmap.go @@ -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)) - pods, replicaSets, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", 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) } diff --git a/api/kubernetes/cli/pod.go b/api/kubernetes/cli/pod.go index 8bec24aa9..7e6e74bad 100644 --- a/api/kubernetes/cli/pod.go +++ b/api/kubernetes/cli/pod.go @@ -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" @@ -172,24 +173,24 @@ 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, error) { +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) ([]corev1.Pod, []appsv1.ReplicaSet, []appsv1.Deployment, []appsv1.StatefulSet, []appsv1.DaemonSet, []corev1.Service, 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) ([]corev1.Pod, []appsv1.ReplicaSet, []appsv1.Deployment, []appsv1.StatefulSet, []appsv1.DaemonSet, []corev1.Service, 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 nil, nil, nil, nil, nil, nil, nil + return nil, nil, nil, nil, nil, nil, nil, nil } - return nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list pods across the cluster: %w", err) + return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list pods across the cluster: %w", err) } // if replicaSet owner reference exists, fetch the replica sets @@ -199,12 +200,12 @@ func (kcl *KubeClient) fetchResourcesWithOwnerReferences(namespace string, podLi if containsReplicaSetOwnerReference(pods) { replicaSets, err = kcl.cli.AppsV1().ReplicaSets(namespace).List(context.Background(), metav1.ListOptions{}) if err != nil && !k8serrors.IsNotFound(err) { - return nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list replica sets across the cluster: %w", err) + 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, fmt.Errorf("unable to list deployments across the cluster: %w", err) + return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list deployments across the cluster: %w", err) } } @@ -212,7 +213,7 @@ func (kcl *KubeClient) fetchResourcesWithOwnerReferences(namespace string, podLi if includeStatefulSets && containsStatefulSetOwnerReference(pods) { statefulSets, err = kcl.cli.AppsV1().StatefulSets(namespace).List(context.Background(), metav1.ListOptions{}) if err != nil && !k8serrors.IsNotFound(err) { - return nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list stateful sets across the cluster: %w", err) + return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list stateful sets across the cluster: %w", err) } } @@ -220,16 +221,21 @@ func (kcl *KubeClient) fetchResourcesWithOwnerReferences(namespace string, podLi if includeDaemonSets && containsDaemonSetOwnerReference(pods) { daemonSets, err = kcl.cli.AppsV1().DaemonSets(namespace).List(context.Background(), metav1.ListOptions{}) if err != nil && !k8serrors.IsNotFound(err) { - return nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list daemon sets across the cluster: %w", err) + return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list daemon sets across the cluster: %w", err) } } services, err := kcl.cli.CoreV1().Services(namespace).List(context.Background(), metav1.ListOptions{}) if err != nil && !k8serrors.IsNotFound(err) { - return nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list services across the cluster: %w", err) + return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list services across the cluster: %w", err) } - return pods.Items, replicaSets.Items, deployments.Items, statefulSets.Items, daemonSets.Items, services.Items, nil + hpas, err := kcl.cli.AutoscalingV2().HorizontalPodAutoscalers(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 horizontal pod autoscalers across the cluster: %w", err) + } + + 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 diff --git a/api/kubernetes/cli/secret.go b/api/kubernetes/cli/secret.go index 6a24623bd..8e38c9857 100644 --- a/api/kubernetes/cli/secret.go +++ b/api/kubernetes/cli/secret.go @@ -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)) - pods, replicaSets, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", 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) } diff --git a/api/kubernetes/cli/service.go b/api/kubernetes/cli/service.go index ef655b438..8a7ef03ab 100644 --- a/api/kubernetes/cli/service.go +++ b/api/kubernetes/cli/service.go @@ -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)) - pods, replicaSets, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", 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) } diff --git a/api/kubernetes/cli/volumes.go b/api/kubernetes/cli/volumes.go index 8a8d81f62..52ee02ab8 100644 --- a/api/kubernetes/cli/volumes.go +++ b/api/kubernetes/cli/volumes.go @@ -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,7 +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, replicaSetItems, deploymentItems, statefulSetItems, daemonSetItems, []corev1.Service{}, 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) diff --git a/app/react/components/Widget/WidgetTabs.tsx b/app/react/components/Widget/WidgetTabs.tsx index 4ccc6d81f..a01fb1753 100644 --- a/app/react/components/Widget/WidgetTabs.tsx +++ b/app/react/components/Widget/WidgetTabs.tsx @@ -1,4 +1,4 @@ -import { RawParams } from '@uirouter/react'; +import { RawParams, useCurrentStateAndParams } from '@uirouter/react'; import clsx from 'clsx'; import { ReactNode } from 'react'; @@ -7,7 +7,7 @@ import { Link } from '@@/Link'; export interface Tab { name: ReactNode; - icon: ReactNode; + icon?: ReactNode; widget: ReactNode; selectedTabParam: string; } @@ -48,7 +48,7 @@ export function WidgetTabs({ currentTabIndex, tabs }: Props) { )} data-cy={`tab-${index}`} > - + {icon && } {name} ))} @@ -67,5 +67,15 @@ export function findSelectedTabIndex( const currentTabIndex = tabs.findIndex( (tab) => tab.selectedTabParam === selectedTabParam ); - return currentTabIndex || 0; + if (currentTabIndex === -1) { + return 0; + } + return currentTabIndex; +} + +export function useCurrentTabIndex(tabs: Tab[]) { + const prarms = useCurrentStateAndParams(); + const currentTabIndex = findSelectedTabIndex(prarms, tabs); + + return [currentTabIndex]; } diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx index 799d21fcc..a12cbd20e 100644 --- a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx +++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx @@ -12,6 +12,7 @@ import { useAuthorizations } from '@/react/hooks/useUser'; import { isSystemNamespace } from '@/react/kubernetes/namespaces/queries/useIsSystemNamespace'; import { KubernetesApplicationTypes } from '@/kubernetes/models/application/models/appConstants'; import { useEnvironment } from '@/react/portainer/environments/queries'; +import { useIngresses } from '@/react/kubernetes/ingresses/queries'; import { TableSettingsMenu } from '@@/datatables'; import { DeleteButton } from '@@/buttons/DeleteButton'; @@ -58,6 +59,8 @@ export function ApplicationsDatatable({ namespace: tableState.namespace, withDependencies: true, }); + const ingressesQuery = useIngresses(environmentId); + const ingresses = ingressesQuery.data ?? []; const applications = useApplicationsRowData(applicationsQuery.data); const filteredApplications = tableState.showSystemResources ? applications @@ -69,6 +72,7 @@ export function ApplicationsDatatable({ const removeApplicationsMutation = useDeleteApplicationsMutation({ environmentId, stacks, + ingresses, reportStacks: false, }); diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/types.ts b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/types.ts index 52cb374c0..d30208e81 100644 --- a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/types.ts +++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/types.ts @@ -1,3 +1,6 @@ +import { Service } from 'kubernetes-types/core/v1'; +import { HorizontalPodAutoscaler } from 'kubernetes-types/autoscaling/v2'; + import { AppType, DeploymentType } from '../../types'; export interface ApplicationRowData extends Application { @@ -9,7 +12,7 @@ export interface Application { Name: string; Image: string; Containers?: Array; - Services?: Array; + Services?: Array; CreationDate: string; ApplicationOwner?: string; StackName?: string; @@ -48,6 +51,7 @@ export interface Application { MemoryLimit?: number; MemoryRequest?: number; }; + HorizontalPodAutoscaler?: HorizontalPodAutoscaler; } export enum ConfigKind { diff --git a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/ApplicationsStacksDatatable.tsx b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/ApplicationsStacksDatatable.tsx index 023c35aab..f95dbe19b 100644 --- a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/ApplicationsStacksDatatable.tsx +++ b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/ApplicationsStacksDatatable.tsx @@ -7,6 +7,7 @@ import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { isSystemNamespace } from '@/react/kubernetes/namespaces/queries/useIsSystemNamespace'; import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery'; import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings'; +import { useIngresses } from '@/react/kubernetes/ingresses/queries'; import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable'; import { TableSettingsMenu } from '@@/datatables'; @@ -39,6 +40,8 @@ export function ApplicationsStacksDatatable({ namespace: tableState.namespace, withDependencies: true, }); + const ingressesQuery = useIngresses(environmentId); + const ingresses = ingressesQuery.data ?? []; const applications = applicationsQuery.data ?? []; const filteredApplications = tableState.showSystemResources ? applications @@ -50,6 +53,7 @@ export function ApplicationsStacksDatatable({ const removeApplicationsMutation = useDeleteApplicationsMutation({ environmentId, stacks, + ingresses, reportStacks: true, }); diff --git a/app/react/kubernetes/applications/queries/useApplicationHorizontalPodAutoscaler.ts b/app/react/kubernetes/applications/queries/useApplicationHorizontalPodAutoscaler.ts index ac3ad9799..c64f3b361 100644 --- a/app/react/kubernetes/applications/queries/useApplicationHorizontalPodAutoscaler.ts +++ b/app/react/kubernetes/applications/queries/useApplicationHorizontalPodAutoscaler.ts @@ -1,5 +1,8 @@ import { useQuery } from '@tanstack/react-query'; -import { HorizontalPodAutoscalerList } from 'kubernetes-types/autoscaling/v1'; +import { + HorizontalPodAutoscaler, + HorizontalPodAutoscalerList, +} from 'kubernetes-types/autoscaling/v1'; import { withGlobalError } from '@/react-tools/react-query'; import { EnvironmentId } from '@/react/portainer/environments/types'; @@ -27,23 +30,15 @@ export function useApplicationHorizontalPodAutoscaler( if (!app) { return null; } - const horizontalPodAutoscalers = await getNamespaceHorizontalPodAutoscalers(environmentId, namespace); const matchingHorizontalPodAutoscaler = - horizontalPodAutoscalers.find((horizontalPodAutoscaler) => { - const scaleTargetRef = horizontalPodAutoscaler.spec?.scaleTargetRef; - if (scaleTargetRef) { - const scaleTargetRefName = scaleTargetRef.name; - const scaleTargetRefKind = scaleTargetRef.kind; - // include the horizontal pod autoscaler if the scale target ref name and kind match the application name and kind - return ( - scaleTargetRefName === app.metadata?.name && - scaleTargetRefKind === app.kind - ); - } - return false; - }) || null; + getMatchingHorizontalPodAutoscaler( + horizontalPodAutoscalers, + namespace, + appName, + app.kind || '' + ); return matchingHorizontalPodAutoscaler; }, { @@ -57,6 +52,29 @@ export function useApplicationHorizontalPodAutoscaler( ); } +export function getMatchingHorizontalPodAutoscaler( + horizontalPodAutoscalers: HorizontalPodAutoscaler[], + appNamespace: string, + appName: string, + appKind: string +) { + const matchingHorizontalPodAutoscaler = + horizontalPodAutoscalers.find((horizontalPodAutoscaler) => { + if (horizontalPodAutoscaler.metadata?.namespace !== appNamespace) { + return false; + } + const scaleTargetRef = horizontalPodAutoscaler.spec?.scaleTargetRef; + if (scaleTargetRef) { + const scaleTargetRefName = scaleTargetRef.name; + const scaleTargetRefKind = scaleTargetRef.kind; + // include the horizontal pod autoscaler if the scale target ref name and kind match the application name and kind + return scaleTargetRefName === appName && scaleTargetRefKind === appKind; + } + return false; + }) || null; + return matchingHorizontalPodAutoscaler; +} + async function getNamespaceHorizontalPodAutoscalers( environmentId: EnvironmentId, namespace: string diff --git a/app/react/kubernetes/applications/queries/useDeleteApplicationsMutation.ts b/app/react/kubernetes/applications/queries/useDeleteApplicationsMutation.ts index 732f6dbb5..0813b877b 100644 --- a/app/react/kubernetes/applications/queries/useDeleteApplicationsMutation.ts +++ b/app/react/kubernetes/applications/queries/useDeleteApplicationsMutation.ts @@ -1,4 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { HorizontalPodAutoscaler } from 'kubernetes-types/autoscaling/v2'; import { EnvironmentId } from '@/react/portainer/environments/types'; import axios, { parseAxiosError } from '@/portainer/services/axios'; @@ -10,23 +11,34 @@ import { pluralize } from '@/portainer/helpers/strings'; import { parseKubernetesAxiosError } from '../../axiosError'; import { ApplicationRowData } from '../ListView/ApplicationsDatatable/types'; import { Stack } from '../ListView/ApplicationsStacksDatatable/types'; +import { deleteServices } from '../../services/service'; +import { updateIngress } from '../../ingresses/service'; +import { Ingress } from '../../ingresses/types'; import { queryKeys } from './query-keys'; export function useDeleteApplicationsMutation({ environmentId, stacks, + ingresses, reportStacks, }: { environmentId: EnvironmentId; stacks: Stack[]; + ingresses: Ingress[]; reportStacks?: boolean; }) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (applications: ApplicationRowData[]) => - deleteApplications(applications, stacks, environmentId), - onSuccess: ({ settledAppDeletions, settledStackDeletions }) => { + deleteApplications(applications, stacks, ingresses, environmentId), + // apps and stacks deletions results (success and error) are handled here + onSuccess: ({ + settledAppDeletions, + settledStackDeletions, + settledIngressUpdates, + settledHpaDeletions, + }) => { // one error notification per rejected item settledAppDeletions.rejectedItems.forEach(({ item, reason }) => { notifyError( @@ -37,6 +49,18 @@ export function useDeleteApplicationsMutation({ settledStackDeletions.rejectedItems.forEach(({ item, reason }) => { notifyError(`Failed to remove stack '${item.Name}'`, new Error(reason)); }); + settledIngressUpdates.rejectedItems.forEach(({ item, reason }) => { + notifyError( + `Failed to update ingress paths for '${item.Name}'`, + new Error(reason) + ); + }); + settledHpaDeletions.rejectedItems.forEach(({ item, reason }) => { + notifyError( + `Failed to remove horizontal pod autoscaler for '${item.metadata?.name}'`, + new Error(reason) + ); + }); // one success notification for all fulfilled items if (settledAppDeletions.fulfilledItems.length && !reportStacks) { @@ -59,8 +83,13 @@ export function useDeleteApplicationsMutation({ .join(', ') ); } + // dont notify successful ingress updates to avoid notification spam queryClient.invalidateQueries(queryKeys.applications(environmentId)); }, + // failed service deletions are handled here + onError: (error: unknown) => { + notifyError('Unable to remove applications', error as Error); + }, ...withGlobalError('Unable to remove applications'), }); } @@ -68,6 +97,7 @@ export function useDeleteApplicationsMutation({ async function deleteApplications( applications: ApplicationRowData[], stacks: Stack[], + ingresses: Ingress[], environmentId: EnvironmentId ) { const settledAppDeletions = await getAllSettledItems( @@ -84,7 +114,83 @@ async function deleteApplications( (stack) => deleteStack(stack, environmentId) ); - return { settledAppDeletions, settledStackDeletions }; + // update associated k8s ressources + const servicesToDelete = getServicesFromApplications(applications); + // axios error handling is done inside deleteServices already + await deleteServices({ + environmentId, + data: servicesToDelete, + }); + const hpasToDelete = applications + .map((app) => app.HorizontalPodAutoscaler) + .filter((hpa) => !!hpa); + const settledHpaDeletions = await getAllSettledItems(hpasToDelete, (hpa) => + deleteHorizontalPodAutoscaler(hpa, environmentId) + ); + const updatedIngresses = getUpdatedIngressesWithRemovedPaths( + ingresses, + servicesToDelete + ); + const settledIngressUpdates = await getAllSettledItems( + updatedIngresses, + (ingress) => updateIngress(environmentId, ingress) + ); + + return { + settledAppDeletions, + settledStackDeletions, + settledIngressUpdates, + settledHpaDeletions, + }; +} + +function getServicesFromApplications( + applications: ApplicationRowData[] +): Record { + return applications.reduce>( + (namespaceServicesMap, application) => { + const serviceNames = + application.Services?.map((service) => service.metadata?.name).filter( + (name): name is string => !!name + ) || []; + if (namespaceServicesMap[application.ResourcePool]) { + return { + ...namespaceServicesMap, + [application.ResourcePool]: [ + ...namespaceServicesMap[application.ResourcePool], + ...serviceNames, + ], + }; + } + return { + ...namespaceServicesMap, + [application.ResourcePool]: serviceNames, + }; + }, + {} + ); +} + +// return a list of ingresses, which need updated because their paths should be removed for deleted services. +function getUpdatedIngressesWithRemovedPaths( + ingresses: Ingress[], + servicesToDelete: Record +) { + return ingresses.reduce((updatedIngresses, ingress) => { + if (!ingress.Paths) { + return updatedIngresses; + } + const servicesInNamespace = servicesToDelete[ingress.Namespace] || []; + // filter out the paths for services that are in the list of services to delete + const updatedIngressPaths = + ingress.Paths.filter( + (path) => !servicesInNamespace.includes(path.ServiceName) + ) ?? []; + if (updatedIngressPaths.length !== ingress.Paths?.length) { + return [...updatedIngresses, { ...ingress, Paths: updatedIngressPaths }]; + } + return updatedIngresses; + }, []); } async function deleteStack(stack: Stack, environmentId: EnvironmentId) { @@ -173,6 +279,22 @@ async function uninstallHelmApplication( } } +async function deleteHorizontalPodAutoscaler( + hpa: HorizontalPodAutoscaler, + environmentId: EnvironmentId +) { + try { + await axios.delete( + `/endpoints/${environmentId}/kubernetes/apis/autoscaling/v2/namespaces/${hpa.metadata?.namespace}/horizontalpodautoscalers/${hpa.metadata?.name}` + ); + } catch (error) { + throw parseKubernetesAxiosError( + error, + 'Unable to remove horizontal pod autoscaler' + ); + } +} + // mutate the stacks array to remove the application function removeApplicationFromStack( application: ApplicationRowData, diff --git a/app/react/kubernetes/ingresses/service.ts b/app/react/kubernetes/ingresses/service.ts index 6fa1aa0c3..ea8af85e0 100644 --- a/app/react/kubernetes/ingresses/service.ts +++ b/app/react/kubernetes/ingresses/service.ts @@ -68,7 +68,7 @@ export async function updateIngress( ingress: Ingress ) { try { - return await axios.put(buildUrl(environmentId, ingress.Namespace), ingress); + await axios.put(buildUrl(environmentId, ingress.Namespace), ingress); } catch (e) { throw parseAxiosError(e as Error, 'Unable to update an ingress'); } diff --git a/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/queries/useDeleteServiceAccountsMutation.ts b/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/queries/useDeleteServiceAccountsMutation.ts index a017b8d83..f4dfab9be 100644 --- a/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/queries/useDeleteServiceAccountsMutation.ts +++ b/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/queries/useDeleteServiceAccountsMutation.ts @@ -8,14 +8,14 @@ import { queryKeys } from './query-keys'; export function useDeleteServiceAccountsMutation(environmentId: EnvironmentId) { const queryClient = useQueryClient(); - return useMutation(deleteServices, { + return useMutation(deleteServiceAccounts, { onSuccess: () => queryClient.invalidateQueries(queryKeys.list(environmentId)), ...withGlobalError('Unable to delete service accounts'), }); } -export async function deleteServices({ +export async function deleteServiceAccounts({ environmentId, data, }: {