fix(apps): update associated resources on deletion [r8s-124] (#75)

pull/12292/merge
Ali 2024-11-01 21:03:49 +13:00 committed by GitHub
parent d418784346
commit c1316532eb
15 changed files with 281 additions and 90 deletions

View File

@ -3,6 +3,7 @@ package kubernetes
import ( import (
"time" "time"
autoscalingv2 "k8s.io/api/autoscaling/v2"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
) )
@ -36,6 +37,7 @@ type K8sApplication struct {
MatchLabels map[string]string `json:"MatchLabels,omitempty"` MatchLabels map[string]string `json:"MatchLabels,omitempty"`
Labels map[string]string `json:"Labels,omitempty"` Labels map[string]string `json:"Labels,omitempty"`
Resource K8sApplicationResource `json:"Resource,omitempty"` Resource K8sApplicationResource `json:"Resource,omitempty"`
HorizontalPodAutoscaler *autoscalingv2.HorizontalPodAutoscaler `json:"HorizontalPodAutoscaler,omitempty"`
} }
type Metadata struct { type Metadata struct {

View File

@ -6,6 +6,7 @@ import (
models "github.com/portainer/portainer/api/http/models/kubernetes" models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
appsv1 "k8s.io/api/apps/v1" appsv1 "k8s.io/api/apps/v1"
autoscalingv2 "k8s.io/api/autoscaling/v2"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
labels "k8s.io/apimachinery/pkg/labels" labels "k8s.io/apimachinery/pkg/labels"
@ -31,20 +32,20 @@ func (kcl *KubeClient) fetchApplications(namespace, nodeName string, withDepende
} }
if !withDependencies { if !withDependencies {
// TODO: make sure not to fetch services in fetchAllApplicationsListResources from this call // 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 { if err != nil {
return nil, err 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 { if err != nil {
return nil, err 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. // 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 { if !withDependencies {
pods, replicaSets, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets(namespace, podListOptions) pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets(namespace, podListOptions)
if err != nil { if err != nil {
return nil, err 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 { if err != nil {
return nil, err 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 { if err != nil {
return nil, err 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. // 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{} applications := []models.K8sApplication{}
processedOwners := make(map[string]struct{}) processedOwners := make(map[string]struct{})
@ -105,7 +106,7 @@ func (kcl *KubeClient) convertPodsToApplications(pods []corev1.Pod, replicaSets
processedOwners[ownerUID] = struct{}{} 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 { if err != nil {
return nil, err return nil, err
} }
@ -150,7 +151,7 @@ func (kcl *KubeClient) GetApplicationNamesFromConfigMap(configMap models.K8sConf
for _, pod := range pods { for _, pod := range pods {
if pod.Namespace == configMap.Namespace { if pod.Namespace == configMap.Namespace {
if isPodUsingConfigMap(&pod, configMap.Name) { 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 { if err != nil {
return nil, err return nil, err
} }
@ -167,7 +168,7 @@ func (kcl *KubeClient) GetApplicationNamesFromSecret(secret models.K8sSecret, po
for _, pod := range pods { for _, pod := range pods {
if pod.Namespace == secret.Namespace { if pod.Namespace == secret.Namespace {
if isPodUsingSecret(&pod, secret.Name) { 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 { if err != nil {
return nil, err 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 // 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) { if isReplicaSetOwner(pod) {
updateOwnerReferenceToDeployment(&pod, replicaSets) updateOwnerReferenceToDeployment(&pod, replicaSets)
} }
application := createApplication(&pod, deployments, statefulSets, daemonSets, services) application := createApplication(&pod, deployments, statefulSets, daemonSets, services, hpas)
if application.ID == "" && application.Name == "" { if application.ID == "" && application.Name == "" {
return nil, nil return nil, nil
} }
@ -204,7 +205,7 @@ func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, replicaSets []app
// createApplication 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 // 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" kind := "Pod"
name := pod.Name name := pod.Name
@ -324,7 +325,11 @@ func createApplication(pod *corev1.Pod, deployments []appsv1.Deployment, statefu
} }
if application.ID != "" && application.Name != "" && len(services) > 0 { 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 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 // updateApplicationWithService updates the application with the services that match the application's selector match labels
// and are in the same namespace as the application // 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 { for _, service := range services {
serviceSelector := labels.SelectorFromSet(service.Spec.Selector) serviceSelector := labels.SelectorFromSet(service.Spec.Selector)
@ -341,8 +346,23 @@ func updateApplicationWithService(application models.K8sApplication, services []
application.Services = append(application.Services, service) 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 // 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 { for _, pod := range pods {
if pod.Namespace == configMap.Namespace { if pod.Namespace == configMap.Namespace {
if isPodUsingConfigMap(&pod, configMap.Name) { 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 { if err != nil {
return nil, err return nil, err
} }
@ -416,7 +436,7 @@ func (kcl *KubeClient) GetApplicationConfigurationOwnersFromSecret(secret models
for _, pod := range pods { for _, pod := range pods {
if pod.Namespace == secret.Namespace { if pod.Namespace == secret.Namespace {
if isPodUsingSecret(&pod, secret.Name) { 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 { if err != nil {
return nil, err return nil, err
} }

View File

@ -102,7 +102,7 @@ func parseConfigMap(configMap *corev1.ConfigMap, withData bool) models.K8sConfig
func (kcl *KubeClient) CombineConfigMapsWithApplications(configMaps []models.K8sConfigMap) ([]models.K8sConfigMap, error) { func (kcl *KubeClient) CombineConfigMapsWithApplications(configMaps []models.K8sConfigMap) ([]models.K8sConfigMap, error) {
updatedConfigMaps := make([]models.K8sConfigMap, len(configMaps)) updatedConfigMaps := make([]models.K8sConfigMap, len(configMaps))
pods, replicaSets, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{}) pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{})
if err != nil { if err != nil {
return nil, fmt.Errorf("an error occurred during the CombineConfigMapsWithApplications operation, unable to fetch pods and replica sets. Error: %w", err) return nil, fmt.Errorf("an error occurred during the CombineConfigMapsWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
} }

View File

@ -11,6 +11,7 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
appsv1 "k8s.io/api/apps/v1" appsv1 "k8s.io/api/apps/v1"
autoscalingv2 "k8s.io/api/autoscaling/v2"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors" k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 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 // 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) 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 // 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 // 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) return kcl.fetchResourcesWithOwnerReferences(namespace, podListOptions, true, true)
} }
// fetchResourcesWithOwnerReferences fetches pods and other resources based on owner references // 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) pods, err := kcl.cli.CoreV1().Pods(namespace).List(context.Background(), podListOptions)
if err != nil { if err != nil {
if k8serrors.IsNotFound(err) { 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 // if replicaSet owner reference exists, fetch the replica sets
@ -199,12 +200,12 @@ func (kcl *KubeClient) fetchResourcesWithOwnerReferences(namespace string, podLi
if containsReplicaSetOwnerReference(pods) { if containsReplicaSetOwnerReference(pods) {
replicaSets, err = kcl.cli.AppsV1().ReplicaSets(namespace).List(context.Background(), metav1.ListOptions{}) replicaSets, err = kcl.cli.AppsV1().ReplicaSets(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil && !k8serrors.IsNotFound(err) { 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{}) deployments, err = kcl.cli.AppsV1().Deployments(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil && !k8serrors.IsNotFound(err) { 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) { if includeStatefulSets && containsStatefulSetOwnerReference(pods) {
statefulSets, err = kcl.cli.AppsV1().StatefulSets(namespace).List(context.Background(), metav1.ListOptions{}) statefulSets, err = kcl.cli.AppsV1().StatefulSets(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil && !k8serrors.IsNotFound(err) { 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) { if includeDaemonSets && containsDaemonSetOwnerReference(pods) {
daemonSets, err = kcl.cli.AppsV1().DaemonSets(namespace).List(context.Background(), metav1.ListOptions{}) daemonSets, err = kcl.cli.AppsV1().DaemonSets(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil && !k8serrors.IsNotFound(err) { 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{}) services, err := kcl.cli.CoreV1().Services(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil && !k8serrors.IsNotFound(err) { 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 // isPodUsingConfigMap checks if a pod is using a specific ConfigMap

View File

@ -118,7 +118,7 @@ func parseSecret(secret *corev1.Secret, withData bool) models.K8sSecret {
func (kcl *KubeClient) CombineSecretsWithApplications(secrets []models.K8sSecret) ([]models.K8sSecret, error) { func (kcl *KubeClient) CombineSecretsWithApplications(secrets []models.K8sSecret) ([]models.K8sSecret, error) {
updatedSecrets := make([]models.K8sSecret, len(secrets)) updatedSecrets := make([]models.K8sSecret, len(secrets))
pods, replicaSets, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{}) pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{})
if err != nil { if err != nil {
return nil, fmt.Errorf("an error occurred during the CombineSecretsWithApplications operation, unable to fetch pods and replica sets. Error: %w", err) return nil, fmt.Errorf("an error occurred during the CombineSecretsWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
} }

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) { func (kcl *KubeClient) CombineServicesWithApplications(services []models.K8sServiceInfo) ([]models.K8sServiceInfo, error) {
if containsServiceWithSelector(services) { if containsServiceWithSelector(services) {
updatedServices := make([]models.K8sServiceInfo, len(services)) updatedServices := make([]models.K8sServiceInfo, len(services))
pods, replicaSets, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{}) pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{})
if err != nil { if err != nil {
return nil, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to fetch pods and replica sets. Error: %w", err) return nil, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
} }

View File

@ -7,6 +7,7 @@ import (
models "github.com/portainer/portainer/api/http/models/kubernetes" models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
appsv1 "k8s.io/api/apps/v1" appsv1 "k8s.io/api/apps/v1"
autoscalingv2 "k8s.io/api/autoscaling/v2"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
storagev1 "k8s.io/api/storage/v1" storagev1 "k8s.io/api/storage/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors" k8serrors "k8s.io/apimachinery/pkg/api/errors"
@ -264,7 +265,7 @@ func (kcl *KubeClient) updateVolumesWithOwningApplications(volumes *[]models.K8s
if pod.Spec.Volumes != nil { if pod.Spec.Volumes != nil {
for _, podVolume := range pod.Spec.Volumes { for _, podVolume := range pod.Spec.Volumes {
if podVolume.VolumeSource.PersistentVolumeClaim != nil && podVolume.VolumeSource.PersistentVolumeClaim.ClaimName == volume.PersistentVolumeClaim.Name && pod.Namespace == volume.PersistentVolumeClaim.Namespace { 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 { if err != nil {
log.Error().Err(err).Msg("Failed to convert pod to application") 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) return nil, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to convert pod to application. Error: %w", err)

View File

@ -1,4 +1,4 @@
import { RawParams } from '@uirouter/react'; import { RawParams, useCurrentStateAndParams } from '@uirouter/react';
import clsx from 'clsx'; import clsx from 'clsx';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
@ -7,7 +7,7 @@ import { Link } from '@@/Link';
export interface Tab { export interface Tab {
name: ReactNode; name: ReactNode;
icon: ReactNode; icon?: ReactNode;
widget: ReactNode; widget: ReactNode;
selectedTabParam: string; selectedTabParam: string;
} }
@ -48,7 +48,7 @@ export function WidgetTabs({ currentTabIndex, tabs }: Props) {
)} )}
data-cy={`tab-${index}`} data-cy={`tab-${index}`}
> >
<Icon icon={icon} /> {icon && <Icon icon={icon} />}
{name} {name}
</Link> </Link>
))} ))}
@ -67,5 +67,15 @@ export function findSelectedTabIndex(
const currentTabIndex = tabs.findIndex( const currentTabIndex = tabs.findIndex(
(tab) => tab.selectedTabParam === selectedTabParam (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];
} }

View File

@ -12,6 +12,7 @@ import { useAuthorizations } from '@/react/hooks/useUser';
import { isSystemNamespace } from '@/react/kubernetes/namespaces/queries/useIsSystemNamespace'; import { isSystemNamespace } from '@/react/kubernetes/namespaces/queries/useIsSystemNamespace';
import { KubernetesApplicationTypes } from '@/kubernetes/models/application/models/appConstants'; import { KubernetesApplicationTypes } from '@/kubernetes/models/application/models/appConstants';
import { useEnvironment } from '@/react/portainer/environments/queries'; import { useEnvironment } from '@/react/portainer/environments/queries';
import { useIngresses } from '@/react/kubernetes/ingresses/queries';
import { TableSettingsMenu } from '@@/datatables'; import { TableSettingsMenu } from '@@/datatables';
import { DeleteButton } from '@@/buttons/DeleteButton'; import { DeleteButton } from '@@/buttons/DeleteButton';
@ -58,6 +59,8 @@ export function ApplicationsDatatable({
namespace: tableState.namespace, namespace: tableState.namespace,
withDependencies: true, withDependencies: true,
}); });
const ingressesQuery = useIngresses(environmentId);
const ingresses = ingressesQuery.data ?? [];
const applications = useApplicationsRowData(applicationsQuery.data); const applications = useApplicationsRowData(applicationsQuery.data);
const filteredApplications = tableState.showSystemResources const filteredApplications = tableState.showSystemResources
? applications ? applications
@ -69,6 +72,7 @@ export function ApplicationsDatatable({
const removeApplicationsMutation = useDeleteApplicationsMutation({ const removeApplicationsMutation = useDeleteApplicationsMutation({
environmentId, environmentId,
stacks, stacks,
ingresses,
reportStacks: false, reportStacks: false,
}); });

View File

@ -1,3 +1,6 @@
import { Service } from 'kubernetes-types/core/v1';
import { HorizontalPodAutoscaler } from 'kubernetes-types/autoscaling/v2';
import { AppType, DeploymentType } from '../../types'; import { AppType, DeploymentType } from '../../types';
export interface ApplicationRowData extends Application { export interface ApplicationRowData extends Application {
@ -9,7 +12,7 @@ export interface Application {
Name: string; Name: string;
Image: string; Image: string;
Containers?: Array<unknown>; Containers?: Array<unknown>;
Services?: Array<unknown>; Services?: Array<Service>;
CreationDate: string; CreationDate: string;
ApplicationOwner?: string; ApplicationOwner?: string;
StackName?: string; StackName?: string;
@ -48,6 +51,7 @@ export interface Application {
MemoryLimit?: number; MemoryLimit?: number;
MemoryRequest?: number; MemoryRequest?: number;
}; };
HorizontalPodAutoscaler?: HorizontalPodAutoscaler;
} }
export enum ConfigKind { export enum ConfigKind {

View File

@ -7,6 +7,7 @@ import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { isSystemNamespace } from '@/react/kubernetes/namespaces/queries/useIsSystemNamespace'; import { isSystemNamespace } from '@/react/kubernetes/namespaces/queries/useIsSystemNamespace';
import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery'; import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery';
import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings'; import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings';
import { useIngresses } from '@/react/kubernetes/ingresses/queries';
import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable'; import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable';
import { TableSettingsMenu } from '@@/datatables'; import { TableSettingsMenu } from '@@/datatables';
@ -39,6 +40,8 @@ export function ApplicationsStacksDatatable({
namespace: tableState.namespace, namespace: tableState.namespace,
withDependencies: true, withDependencies: true,
}); });
const ingressesQuery = useIngresses(environmentId);
const ingresses = ingressesQuery.data ?? [];
const applications = applicationsQuery.data ?? []; const applications = applicationsQuery.data ?? [];
const filteredApplications = tableState.showSystemResources const filteredApplications = tableState.showSystemResources
? applications ? applications
@ -50,6 +53,7 @@ export function ApplicationsStacksDatatable({
const removeApplicationsMutation = useDeleteApplicationsMutation({ const removeApplicationsMutation = useDeleteApplicationsMutation({
environmentId, environmentId,
stacks, stacks,
ingresses,
reportStacks: true, reportStacks: true,
}); });

View File

@ -1,5 +1,8 @@
import { useQuery } from '@tanstack/react-query'; 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 { withGlobalError } from '@/react-tools/react-query';
import { EnvironmentId } from '@/react/portainer/environments/types'; import { EnvironmentId } from '@/react/portainer/environments/types';
@ -27,23 +30,15 @@ export function useApplicationHorizontalPodAutoscaler(
if (!app) { if (!app) {
return null; return null;
} }
const horizontalPodAutoscalers = const horizontalPodAutoscalers =
await getNamespaceHorizontalPodAutoscalers(environmentId, namespace); await getNamespaceHorizontalPodAutoscalers(environmentId, namespace);
const matchingHorizontalPodAutoscaler = const matchingHorizontalPodAutoscaler =
horizontalPodAutoscalers.find((horizontalPodAutoscaler) => { getMatchingHorizontalPodAutoscaler(
const scaleTargetRef = horizontalPodAutoscaler.spec?.scaleTargetRef; horizontalPodAutoscalers,
if (scaleTargetRef) { namespace,
const scaleTargetRefName = scaleTargetRef.name; appName,
const scaleTargetRefKind = scaleTargetRef.kind; app.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;
return matchingHorizontalPodAutoscaler; 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( async function getNamespaceHorizontalPodAutoscalers(
environmentId: EnvironmentId, environmentId: EnvironmentId,
namespace: string namespace: string

View File

@ -1,4 +1,5 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { HorizontalPodAutoscaler } from 'kubernetes-types/autoscaling/v2';
import { EnvironmentId } from '@/react/portainer/environments/types'; import { EnvironmentId } from '@/react/portainer/environments/types';
import axios, { parseAxiosError } from '@/portainer/services/axios'; import axios, { parseAxiosError } from '@/portainer/services/axios';
@ -10,23 +11,34 @@ import { pluralize } from '@/portainer/helpers/strings';
import { parseKubernetesAxiosError } from '../../axiosError'; import { parseKubernetesAxiosError } from '../../axiosError';
import { ApplicationRowData } from '../ListView/ApplicationsDatatable/types'; import { ApplicationRowData } from '../ListView/ApplicationsDatatable/types';
import { Stack } from '../ListView/ApplicationsStacksDatatable/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'; import { queryKeys } from './query-keys';
export function useDeleteApplicationsMutation({ export function useDeleteApplicationsMutation({
environmentId, environmentId,
stacks, stacks,
ingresses,
reportStacks, reportStacks,
}: { }: {
environmentId: EnvironmentId; environmentId: EnvironmentId;
stacks: Stack[]; stacks: Stack[];
ingresses: Ingress[];
reportStacks?: boolean; reportStacks?: boolean;
}) { }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (applications: ApplicationRowData[]) => mutationFn: (applications: ApplicationRowData[]) =>
deleteApplications(applications, stacks, environmentId), deleteApplications(applications, stacks, ingresses, environmentId),
onSuccess: ({ settledAppDeletions, settledStackDeletions }) => { // apps and stacks deletions results (success and error) are handled here
onSuccess: ({
settledAppDeletions,
settledStackDeletions,
settledIngressUpdates,
settledHpaDeletions,
}) => {
// one error notification per rejected item // one error notification per rejected item
settledAppDeletions.rejectedItems.forEach(({ item, reason }) => { settledAppDeletions.rejectedItems.forEach(({ item, reason }) => {
notifyError( notifyError(
@ -37,6 +49,18 @@ export function useDeleteApplicationsMutation({
settledStackDeletions.rejectedItems.forEach(({ item, reason }) => { settledStackDeletions.rejectedItems.forEach(({ item, reason }) => {
notifyError(`Failed to remove stack '${item.Name}'`, new Error(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 // one success notification for all fulfilled items
if (settledAppDeletions.fulfilledItems.length && !reportStacks) { if (settledAppDeletions.fulfilledItems.length && !reportStacks) {
@ -59,8 +83,13 @@ export function useDeleteApplicationsMutation({
.join(', ') .join(', ')
); );
} }
// dont notify successful ingress updates to avoid notification spam
queryClient.invalidateQueries(queryKeys.applications(environmentId)); 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'), ...withGlobalError('Unable to remove applications'),
}); });
} }
@ -68,6 +97,7 @@ export function useDeleteApplicationsMutation({
async function deleteApplications( async function deleteApplications(
applications: ApplicationRowData[], applications: ApplicationRowData[],
stacks: Stack[], stacks: Stack[],
ingresses: Ingress[],
environmentId: EnvironmentId environmentId: EnvironmentId
) { ) {
const settledAppDeletions = await getAllSettledItems( const settledAppDeletions = await getAllSettledItems(
@ -84,7 +114,83 @@ async function deleteApplications(
(stack) => deleteStack(stack, environmentId) (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<string, string[]> {
return applications.reduce<Record<string, string[]>>(
(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<string, string[]>
) {
return ingresses.reduce<Ingress[]>((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) { 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 // mutate the stacks array to remove the application
function removeApplicationFromStack( function removeApplicationFromStack(
application: ApplicationRowData, application: ApplicationRowData,

View File

@ -68,7 +68,7 @@ export async function updateIngress(
ingress: Ingress ingress: Ingress
) { ) {
try { try {
return await axios.put(buildUrl(environmentId, ingress.Namespace), ingress); await axios.put(buildUrl(environmentId, ingress.Namespace), ingress);
} catch (e) { } catch (e) {
throw parseAxiosError(e as Error, 'Unable to update an ingress'); throw parseAxiosError(e as Error, 'Unable to update an ingress');
} }

View File

@ -8,14 +8,14 @@ import { queryKeys } from './query-keys';
export function useDeleteServiceAccountsMutation(environmentId: EnvironmentId) { export function useDeleteServiceAccountsMutation(environmentId: EnvironmentId) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation(deleteServices, { return useMutation(deleteServiceAccounts, {
onSuccess: () => onSuccess: () =>
queryClient.invalidateQueries(queryKeys.list(environmentId)), queryClient.invalidateQueries(queryKeys.list(environmentId)),
...withGlobalError('Unable to delete service accounts'), ...withGlobalError('Unable to delete service accounts'),
}); });
} }
export async function deleteServices({ export async function deleteServiceAccounts({
environmentId, environmentId,
data, data,
}: { }: {