|
| 1 | +/* |
| 2 | +Copyright 2025 The Kubernetes Authors. |
| 3 | +
|
| 4 | +Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | +you may not use this file except in compliance with the License. |
| 6 | +You may obtain a copy of the License at |
| 7 | +
|
| 8 | + http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | +
|
| 10 | +Unless required by applicable law or agreed to in writing, software |
| 11 | +distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | +See the License for the specific language governing permissions and |
| 14 | +limitations under the License. |
| 15 | +*/ |
| 16 | + |
| 17 | +package utils |
| 18 | + |
| 19 | +import ( |
| 20 | + "context" |
| 21 | + "encoding/json" |
| 22 | + "fmt" |
| 23 | + "time" |
| 24 | + |
| 25 | + ginkgo "github.com/onsi/ginkgo/v2" |
| 26 | + "github.com/onsi/gomega" |
| 27 | + appsv1 "k8s.io/api/apps/v1" |
| 28 | + autoscaling "k8s.io/api/autoscaling/v1" |
| 29 | + apiv1 "k8s.io/api/core/v1" |
| 30 | + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
| 31 | + "k8s.io/apimachinery/pkg/types" |
| 32 | + "k8s.io/apimachinery/pkg/util/wait" |
| 33 | + "k8s.io/kubernetes/test/e2e/framework" |
| 34 | + |
| 35 | + vpa_types "k8s.io/autoscaler/vertical-pod-autoscaler/pkg/apis/autoscaling.k8s.io/v1" |
| 36 | + vpa_clientset "k8s.io/autoscaler/vertical-pod-autoscaler/pkg/client/clientset/versioned" |
| 37 | + framework_deployment "k8s.io/kubernetes/test/e2e/framework/deployment" |
| 38 | +) |
| 39 | + |
| 40 | +const ( |
| 41 | + recommenderComponent = "recommender" |
| 42 | + |
| 43 | + // RecommenderDeploymentName is VPA recommender deployment name |
| 44 | + RecommenderDeploymentName = "vpa-recommender" |
| 45 | + // RecommenderNamespace is namespace to deploy VPA recommender |
| 46 | + RecommenderNamespace = "kube-system" |
| 47 | + // PollInterval is interval for polling |
| 48 | + PollInterval = 10 * time.Second |
| 49 | + // PollTimeout is timeout for polling |
| 50 | + PollTimeout = 15 * time.Minute |
| 51 | + |
| 52 | + // DefaultHamsterReplicas is replicas of hamster deployment |
| 53 | + DefaultHamsterReplicas = int32(3) |
| 54 | + // DefaultHamsterBackoffLimit is BackoffLimit of hamster app |
| 55 | + DefaultHamsterBackoffLimit = int32(10) |
| 56 | +) |
| 57 | + |
| 58 | +// HamsterTargetRef is CrossVersionObjectReference of hamster app |
| 59 | +var HamsterTargetRef = &autoscaling.CrossVersionObjectReference{ |
| 60 | + APIVersion: "apps/v1", |
| 61 | + Kind: "Deployment", |
| 62 | + Name: "hamster-deployment", |
| 63 | +} |
| 64 | + |
| 65 | +// RecommenderLabels are labels of VPA recommender |
| 66 | +var RecommenderLabels = map[string]string{"app": "vpa-recommender"} |
| 67 | + |
| 68 | +// HamsterLabels are labels of hamster app |
| 69 | +var HamsterLabels = map[string]string{"app": "hamster"} |
| 70 | + |
| 71 | +// SIGDescribe adds sig-autoscaling tag to test description. |
| 72 | +// Takes args that are passed to ginkgo.Describe. |
| 73 | +func SIGDescribe(scenario, name string, args ...interface{}) bool { |
| 74 | + full := fmt.Sprintf("[sig-autoscaling] [VPA] [%s] [v1] %s", scenario, name) |
| 75 | + return ginkgo.Describe(full, args...) |
| 76 | +} |
| 77 | + |
| 78 | +// RecommenderE2eDescribe describes a VPA recommender e2e test. |
| 79 | +func RecommenderE2eDescribe(name string, args ...interface{}) bool { |
| 80 | + return SIGDescribe(recommenderComponent, name, args...) |
| 81 | +} |
| 82 | + |
| 83 | +// GetHamsterContainerNameByIndex returns name of i-th hamster container. |
| 84 | +func GetHamsterContainerNameByIndex(i int) string { |
| 85 | + switch { |
| 86 | + case i < 0: |
| 87 | + panic("negative index") |
| 88 | + case i == 0: |
| 89 | + return "hamster" |
| 90 | + default: |
| 91 | + return fmt.Sprintf("hamster%d", i+1) |
| 92 | + } |
| 93 | +} |
| 94 | + |
| 95 | +// GetVpaClientSet return a VpaClientSet |
| 96 | +func GetVpaClientSet(f *framework.Framework) vpa_clientset.Interface { |
| 97 | + config, err := framework.LoadConfig() |
| 98 | + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "unexpected error loading framework") |
| 99 | + return vpa_clientset.NewForConfigOrDie(config) |
| 100 | +} |
| 101 | + |
| 102 | +// InstallVPA installs a VPA object in the test cluster. |
| 103 | +func InstallVPA(f *framework.Framework, vpa *vpa_types.VerticalPodAutoscaler) { |
| 104 | + vpaClientSet := GetVpaClientSet(f) |
| 105 | + _, err := vpaClientSet.AutoscalingV1().VerticalPodAutoscalers(f.Namespace.Name).Create(context.TODO(), vpa, metav1.CreateOptions{}) |
| 106 | + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "unexpected error creating VPA") |
| 107 | + // apiserver ignore status in vpa create, so need to update status |
| 108 | + if !isStatusEmpty(&vpa.Status) { |
| 109 | + if vpa.Status.Recommendation != nil { |
| 110 | + PatchVpaRecommendation(f, vpa, vpa.Status.Recommendation) |
| 111 | + } |
| 112 | + } |
| 113 | +} |
| 114 | + |
| 115 | +func isStatusEmpty(status *vpa_types.VerticalPodAutoscalerStatus) bool { |
| 116 | + if status == nil { |
| 117 | + return true |
| 118 | + } |
| 119 | + |
| 120 | + if len(status.Conditions) == 0 && status.Recommendation == nil { |
| 121 | + return true |
| 122 | + } |
| 123 | + return false |
| 124 | +} |
| 125 | + |
| 126 | +// PatchRecord used for patch action |
| 127 | +type PatchRecord struct { |
| 128 | + Op string `json:"op,inline"` |
| 129 | + Path string `json:"path,inline"` |
| 130 | + Value interface{} `json:"value"` |
| 131 | +} |
| 132 | + |
| 133 | +// PatchVpaRecommendation installs a new recommendation for VPA object. |
| 134 | +func PatchVpaRecommendation(f *framework.Framework, vpa *vpa_types.VerticalPodAutoscaler, |
| 135 | + recommendation *vpa_types.RecommendedPodResources) { |
| 136 | + newStatus := vpa.Status.DeepCopy() |
| 137 | + newStatus.Recommendation = recommendation |
| 138 | + bytes, err := json.Marshal([]PatchRecord{{ |
| 139 | + Op: "replace", |
| 140 | + Path: "/status", |
| 141 | + Value: *newStatus, |
| 142 | + }}) |
| 143 | + gomega.Expect(err).NotTo(gomega.HaveOccurred()) |
| 144 | + _, err = GetVpaClientSet(f).AutoscalingV1().VerticalPodAutoscalers(f.Namespace.Name).Patch(context.TODO(), vpa.Name, types.JSONPatchType, bytes, metav1.PatchOptions{}, "status") |
| 145 | + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "Failed to patch VPA.") |
| 146 | +} |
| 147 | + |
| 148 | +// NewNHamstersDeployment creates a simple hamster deployment with n containers |
| 149 | +// for e2e test purposes. |
| 150 | +func NewNHamstersDeployment(f *framework.Framework, n int) *appsv1.Deployment { |
| 151 | + if n < 1 { |
| 152 | + panic("container count should be greater than 0") |
| 153 | + } |
| 154 | + d := framework_deployment.NewDeployment( |
| 155 | + "hamster-deployment", /*deploymentName*/ |
| 156 | + DefaultHamsterReplicas, /*replicas*/ |
| 157 | + HamsterLabels, /*podLabels*/ |
| 158 | + GetHamsterContainerNameByIndex(0), /*imageName*/ |
| 159 | + "registry.k8s.io/ubuntu-slim:0.14", /*image*/ |
| 160 | + appsv1.RollingUpdateDeploymentStrategyType, /*strategyType*/ |
| 161 | + ) |
| 162 | + d.ObjectMeta.Namespace = f.Namespace.Name |
| 163 | + d.Spec.Template.Spec.Containers[0].Command = []string{"/bin/sh"} |
| 164 | + d.Spec.Template.Spec.Containers[0].Args = []string{"-c", "/usr/bin/yes >/dev/null"} |
| 165 | + for i := 1; i < n; i++ { |
| 166 | + d.Spec.Template.Spec.Containers = append(d.Spec.Template.Spec.Containers, d.Spec.Template.Spec.Containers[0]) |
| 167 | + d.Spec.Template.Spec.Containers[i].Name = GetHamsterContainerNameByIndex(i) |
| 168 | + } |
| 169 | + return d |
| 170 | +} |
| 171 | + |
| 172 | +// StartDeploymentPods start and wait for a deployment to complete |
| 173 | +func StartDeploymentPods(f *framework.Framework, deployment *appsv1.Deployment) *apiv1.PodList { |
| 174 | + // Apiserver watch can lag depending on cached object count and apiserver resource usage. |
| 175 | + // We assume that watch can lag up to 5 seconds. |
| 176 | + const apiserverWatchLag = 5 * time.Second |
| 177 | + // In admission controller e2e tests a recommendation is created before deployment. |
| 178 | + // Creating deployment with size greater than 0 would create a race between information |
| 179 | + // about pods and information about deployment getting to the admission controller. |
| 180 | + // Any pods that get processed by AC before it receives information about the deployment |
| 181 | + // don't receive recommendation. |
| 182 | + // To avoid this create deployment with size 0, then scale it up to the desired size. |
| 183 | + desiredPodCount := *deployment.Spec.Replicas |
| 184 | + zero := int32(0) |
| 185 | + deployment.Spec.Replicas = &zero |
| 186 | + c, ns := f.ClientSet, f.Namespace.Name |
| 187 | + deployment, err := c.AppsV1().Deployments(ns).Create(context.TODO(), deployment, metav1.CreateOptions{}) |
| 188 | + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "when creating deployment with size 0") |
| 189 | + |
| 190 | + err = framework_deployment.WaitForDeploymentComplete(c, deployment) |
| 191 | + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "when waiting for empty deployment to create") |
| 192 | + // If admission controller receives pod before controller it will not apply recommendation and test will fail. |
| 193 | + // Wait after creating deployment to ensure VPA knows about it, then scale up. |
| 194 | + // Normally watch lag is not a problem in terms of correctness: |
| 195 | + // - Mode "Auto": created pod without assigned resources will be handled by the eviction loop. |
| 196 | + // - Mode "Initial": calculating recommendations takes more than potential ectd lag. |
| 197 | + // - Mode "Off": pods are not handled by the admission controller. |
| 198 | + // In e2e admission controller tests we want to focus on scenarios without considering watch lag. |
| 199 | + // TODO(#2631): Remove sleep when issue is fixed. |
| 200 | + time.Sleep(apiserverWatchLag) |
| 201 | + |
| 202 | + scale := autoscaling.Scale{ |
| 203 | + ObjectMeta: metav1.ObjectMeta{ |
| 204 | + Name: deployment.ObjectMeta.Name, |
| 205 | + Namespace: deployment.ObjectMeta.Namespace, |
| 206 | + }, |
| 207 | + Spec: autoscaling.ScaleSpec{ |
| 208 | + Replicas: desiredPodCount, |
| 209 | + }, |
| 210 | + } |
| 211 | + afterScale, err := c.AppsV1().Deployments(ns).UpdateScale(context.TODO(), deployment.Name, &scale, metav1.UpdateOptions{}) |
| 212 | + gomega.Expect(err).NotTo(gomega.HaveOccurred()) |
| 213 | + gomega.Expect(afterScale.Spec.Replicas).To(gomega.Equal(desiredPodCount), fmt.Sprintf("expected %d replicas after scaling", desiredPodCount)) |
| 214 | + |
| 215 | + // After scaling deployment we need to retrieve current version with updated replicas count. |
| 216 | + deployment, err = c.AppsV1().Deployments(ns).Get(context.TODO(), deployment.Name, metav1.GetOptions{}) |
| 217 | + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "when getting scaled deployment") |
| 218 | + err = framework_deployment.WaitForDeploymentComplete(c, deployment) |
| 219 | + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "when waiting for deployment to resize") |
| 220 | + |
| 221 | + podList, err := framework_deployment.GetPodsForDeployment(context.TODO(), c, deployment) |
| 222 | + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "when listing pods after deployment resize") |
| 223 | + return podList |
| 224 | +} |
| 225 | + |
| 226 | +// WaitForRecommendationPresent pools VPA object until recommendations are not empty. Returns |
| 227 | +// polled vpa object. On timeout returns error. |
| 228 | +func WaitForRecommendationPresent(c vpa_clientset.Interface, vpa *vpa_types.VerticalPodAutoscaler) (*vpa_types.VerticalPodAutoscaler, error) { |
| 229 | + return WaitForVPAMatch(c, vpa, func(vpa *vpa_types.VerticalPodAutoscaler) bool { |
| 230 | + return vpa.Status.Recommendation != nil && len(vpa.Status.Recommendation.ContainerRecommendations) != 0 |
| 231 | + }) |
| 232 | +} |
| 233 | + |
| 234 | +// WaitForVPAMatch pools VPA object until match function returns true. Returns |
| 235 | +// polled vpa object. On timeout returns error. |
| 236 | +func WaitForVPAMatch(c vpa_clientset.Interface, vpa *vpa_types.VerticalPodAutoscaler, match func(vpa *vpa_types.VerticalPodAutoscaler) bool) (*vpa_types.VerticalPodAutoscaler, error) { |
| 237 | + var polledVpa *vpa_types.VerticalPodAutoscaler |
| 238 | + err := wait.PollUntilContextTimeout(context.Background(), PollInterval, PollTimeout, true, func(ctx context.Context) (done bool, err error) { |
| 239 | + polledVpa, err = c.AutoscalingV1().VerticalPodAutoscalers(vpa.Namespace).Get(context.TODO(), vpa.Name, metav1.GetOptions{}) |
| 240 | + if err != nil { |
| 241 | + return false, err |
| 242 | + } |
| 243 | + |
| 244 | + if match(polledVpa) { |
| 245 | + return true, nil |
| 246 | + } |
| 247 | + |
| 248 | + return false, nil |
| 249 | + }) |
| 250 | + |
| 251 | + if err != nil { |
| 252 | + return nil, fmt.Errorf("error waiting for recommendation present in %v: %v", vpa.Name, err) |
| 253 | + } |
| 254 | + return polledVpa, nil |
| 255 | +} |
0 commit comments