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,
}: {