From 008025dcdb5672bfa1f068fddb1a5dbc5f3251e6 Mon Sep 17 00:00:00 2001 From: Boshi LIAN Date: Wed, 15 Oct 2025 01:35:51 -0700 Subject: [PATCH 1/3] feat: add V2HorizontalPodAutoscaler integration test --- tests/E2E.Tests/MinikubeTests.cs | 165 +++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) diff --git a/tests/E2E.Tests/MinikubeTests.cs b/tests/E2E.Tests/MinikubeTests.cs index 3ca37888..8a7834a5 100644 --- a/tests/E2E.Tests/MinikubeTests.cs +++ b/tests/E2E.Tests/MinikubeTests.cs @@ -958,6 +958,171 @@ async Task AssertMd5sumAsync(string file, byte[] orig) } } + [MinikubeFact] + public async Task V2HorizontalPodAutoscalerTestAsync() + { + var namespaceParameter = "default"; + var deploymentName = "k8scsharp-e2e-hpa-deployment"; + var hpaName = "k8scsharp-e2e-hpa"; + + using var client = CreateClient(); + + async Task CleanupAsync() + { + var deleteOptions = new V1DeleteOptions { PropagationPolicy = "Foreground" }; + + try + { + await client.AutoscalingV2.DeleteNamespacedHorizontalPodAutoscalerAsync(hpaName, namespaceParameter, deleteOptions).ConfigureAwait(false); + } + catch (HttpOperationException e) + { + if (e.Response?.StatusCode != System.Net.HttpStatusCode.NotFound) + { + throw; + } + } + + try + { + await client.AppsV1.DeleteNamespacedDeploymentAsync(deploymentName, namespaceParameter, deleteOptions).ConfigureAwait(false); + } + catch (HttpOperationException e) + { + if (e.Response?.StatusCode != System.Net.HttpStatusCode.NotFound) + { + throw; + } + } + + var attempts = 10; + while (attempts-- > 0) + { + var hpaList = await client.AutoscalingV2.ListNamespacedHorizontalPodAutoscalerAsync(namespaceParameter).ConfigureAwait(false); + var deploymentList = await client.AppsV1.ListNamespacedDeploymentAsync(namespaceParameter).ConfigureAwait(false); + if (hpaList.Items.All(item => item.Metadata.Name != hpaName) && deploymentList.Items.All(item => item.Metadata.Name != deploymentName)) + { + break; + } + + await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false); + } + } + + try + { + await CleanupAsync().ConfigureAwait(false); + + var labels = new Dictionary { ["app"] = "k8scsharp-hpa" }; + + await client.AppsV1.CreateNamespacedDeploymentAsync( + new V1Deployment + { + Metadata = new V1ObjectMeta { Name = deploymentName, Labels = new Dictionary(labels) }, + Spec = new V1DeploymentSpec + { + Replicas = 1, + Selector = new V1LabelSelector { MatchLabels = new Dictionary(labels) }, + Template = new V1PodTemplateSpec + { + Metadata = new V1ObjectMeta { Labels = new Dictionary(labels) }, + Spec = new V1PodSpec + { + Containers = new[] + { + new V1Container + { + Name = "k8scsharp-hpa", + Image = "nginx", + Resources = new V1ResourceRequirements + { + Requests = new Dictionary + { + { "cpu", new ResourceQuantity("100m") }, + { "memory", new ResourceQuantity("128Mi") }, + }, + Limits = new Dictionary + { + { "cpu", new ResourceQuantity("200m") }, + { "memory", new ResourceQuantity("256Mi") }, + }, + }, + }, + }, + }, + }, + }, + }, + namespaceParameter).ConfigureAwait(false); + + var hpa = new V2HorizontalPodAutoscaler + { + Metadata = new V1ObjectMeta { Name = hpaName }, + Spec = new V2HorizontalPodAutoscalerSpec + { + MinReplicas = 1, + MaxReplicas = 3, + ScaleTargetRef = new V2CrossVersionObjectReference + { + ApiVersion = "apps/v1", + Kind = "Deployment", + Name = deploymentName, + }, + Metrics = new List + { + new V2MetricSpec + { + Type = "Resource", + Resource = new V2ResourceMetricSource + { + Name = "cpu", + Target = new V2MetricTarget + { + Type = "Utilization", + AverageUtilization = 50, + }, + }, + }, + }, + }, + }; + + await client.AutoscalingV2.CreateNamespacedHorizontalPodAutoscalerAsync(hpa, namespaceParameter).ConfigureAwait(false); + + var hpaList = await client.AutoscalingV2.ListNamespacedHorizontalPodAutoscalerAsync(namespaceParameter).ConfigureAwait(false); + Assert.Contains(hpaList.Items, item => item.Metadata.Name == hpaName); + + var created = await client.AutoscalingV2.ReadNamespacedHorizontalPodAutoscalerAsync(hpaName, namespaceParameter).ConfigureAwait(false); + Assert.Equal(1, created.Spec.MinReplicas); + + created.Spec.MinReplicas = 2; + await client.AutoscalingV2.ReplaceNamespacedHorizontalPodAutoscalerAsync(created, hpaName, namespaceParameter).ConfigureAwait(false); + + var updated = await client.AutoscalingV2.ReadNamespacedHorizontalPodAutoscalerAsync(hpaName, namespaceParameter).ConfigureAwait(false); + Assert.Equal(2, updated.Spec.MinReplicas); + + await client.AutoscalingV2.DeleteNamespacedHorizontalPodAutoscalerAsync(hpaName, namespaceParameter, new V1DeleteOptions { PropagationPolicy = "Foreground" }).ConfigureAwait(false); + + var retries = 10; + while (retries-- > 0) + { + hpaList = await client.AutoscalingV2.ListNamespacedHorizontalPodAutoscalerAsync(namespaceParameter).ConfigureAwait(false); + if (hpaList.Items.All(item => item.Metadata.Name != hpaName)) + { + break; + } + + await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false); + } + + Assert.DoesNotContain(hpaList.Items, item => item.Metadata.Name == hpaName); + } + finally + { + await CleanupAsync().ConfigureAwait(false); + } + } + public static IKubernetes CreateClient() { return new Kubernetes(KubernetesClientConfiguration.BuildDefaultConfig()); From 51853efc6afa79e7c559281bc3b7e29914a7e9e4 Mon Sep 17 00:00:00 2001 From: Boshi LIAN Date: Wed, 15 Oct 2025 01:36:12 -0700 Subject: [PATCH 2/3] fix: change structs to classes for IntOrString and ResourceQuantity, and handle null values in YAML converters --- src/KubernetesClient/Models/IntOrString.cs | 6 +++--- src/KubernetesClient/Models/IntOrStringYamlConverter.cs | 2 +- src/KubernetesClient/Models/ResourceQuantity.cs | 2 +- .../Models/ResourceQuantityYamlConverter.cs | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/KubernetesClient/Models/IntOrString.cs b/src/KubernetesClient/Models/IntOrString.cs index e79ef246..65e9cd48 100644 --- a/src/KubernetesClient/Models/IntOrString.cs +++ b/src/KubernetesClient/Models/IntOrString.cs @@ -1,9 +1,9 @@ namespace k8s.Models { [JsonConverter(typeof(IntOrStringJsonConverter))] - public struct IntOrString + public class IntOrString { - public string? Value { get; private init; } + public string Value { get; private init; } public static implicit operator IntOrString(int v) { @@ -17,7 +17,7 @@ public static implicit operator IntOrString(long v) public static implicit operator string(IntOrString v) { - return v.Value; + return v?.Value; } public static implicit operator IntOrString(string v) diff --git a/src/KubernetesClient/Models/IntOrStringYamlConverter.cs b/src/KubernetesClient/Models/IntOrStringYamlConverter.cs index 514f2be9..cfaa4220 100644 --- a/src/KubernetesClient/Models/IntOrStringYamlConverter.cs +++ b/src/KubernetesClient/Models/IntOrStringYamlConverter.cs @@ -35,7 +35,7 @@ public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeseria public void WriteYaml(IEmitter emitter, object value, Type type, ObjectSerializer serializer) { var obj = (IntOrString)value; - emitter?.Emit(new YamlDotNet.Core.Events.Scalar(obj.Value)); + emitter?.Emit(new YamlDotNet.Core.Events.Scalar(obj?.Value)); } } } diff --git a/src/KubernetesClient/Models/ResourceQuantity.cs b/src/KubernetesClient/Models/ResourceQuantity.cs index 6b4ff8d8..4a22517e 100644 --- a/src/KubernetesClient/Models/ResourceQuantity.cs +++ b/src/KubernetesClient/Models/ResourceQuantity.cs @@ -54,7 +54,7 @@ namespace k8s.Models /// cause implementors to also use a fixed point implementation. /// [JsonConverter(typeof(ResourceQuantityJsonConverter))] - public struct ResourceQuantity + public class ResourceQuantity { public enum SuffixFormat { diff --git a/src/KubernetesClient/Models/ResourceQuantityYamlConverter.cs b/src/KubernetesClient/Models/ResourceQuantityYamlConverter.cs index 50523ca6..1006a3cd 100644 --- a/src/KubernetesClient/Models/ResourceQuantityYamlConverter.cs +++ b/src/KubernetesClient/Models/ResourceQuantityYamlConverter.cs @@ -35,7 +35,7 @@ public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeseria public void WriteYaml(IEmitter emitter, object value, Type type, ObjectSerializer serializer) { var obj = (ResourceQuantity)value; - emitter?.Emit(new YamlDotNet.Core.Events.Scalar(obj.ToString())); + emitter?.Emit(new YamlDotNet.Core.Events.Scalar(obj?.ToString())); } } } From 270ea9d1a54c94591ee1efeca9eb489e71dda6e5 Mon Sep 17 00:00:00 2001 From: Boshi LIAN Date: Wed, 15 Oct 2025 02:04:28 -0700 Subject: [PATCH 3/3] feat: implement equality members for ResourceQuantity class --- .../Models/ResourceQuantity.cs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/KubernetesClient/Models/ResourceQuantity.cs b/src/KubernetesClient/Models/ResourceQuantity.cs index 4a22517e..eee89c67 100644 --- a/src/KubernetesClient/Models/ResourceQuantity.cs +++ b/src/KubernetesClient/Models/ResourceQuantity.cs @@ -179,6 +179,46 @@ public static implicit operator ResourceQuantity(decimal v) return new ResourceQuantity(v, 0, SuffixFormat.DecimalExponent); } + public bool Equals(ResourceQuantity other) + { + if (ReferenceEquals(null, other)) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return _unitlessValue.Equals(other._unitlessValue); + } + + public override bool Equals(object obj) + { + return Equals(obj as ResourceQuantity); + } + + public override int GetHashCode() + { + return _unitlessValue.GetHashCode(); + } + + public static bool operator ==(ResourceQuantity left, ResourceQuantity right) + { + if (left is null) + { + return right is null; + } + + return left.Equals(right); + } + + public static bool operator !=(ResourceQuantity left, ResourceQuantity right) + { + return !(left == right); + } + private sealed class Suffixer { private static readonly IReadOnlyDictionary BinSuffixes =