From 45b6d827e2b6f7e54646d6574e72a416cabffe55 Mon Sep 17 00:00:00 2001 From: Jerome Haltom Date: Sat, 11 Jul 2026 19:28:15 -0500 Subject: [PATCH 1/3] fix(operator-web): compose conversions through the storage version The conversion webhook only performed a single-hop conversion: it required a direct converter edge from an object's stored apiVersion to the requested desiredAPIVersion. Converters are registered hub-and-spoke (every served version converts to and from the storage version), so the API server's request to convert between two non-storage versions -- which happens whenever an object is persisted under a version that is no longer the storage version and read at a third served version -- had no direct edge and the object was silently dropped, surfacing as "conversion webhook ... returned 0 objects, expected N". Compose the conversion through intermediate versions with a breadth-first search over the registered converter graph, so a hub-and-spoke converter set is sufficient for 3+ served versions. A direct converter, when present, is still selected as the single-hop shortest path, so existing behaviour is unchanged. Closes #1193 Co-Authored-By: Claude Opus 4.8 --- .../Webhooks/Conversion/ConversionWebhook.cs | 83 +++++++- .../Conversion/ConversionWebhook.Test.cs | 182 ++++++++++++++++++ 2 files changed, 258 insertions(+), 7 deletions(-) create mode 100644 test/KubeOps.Operator.Web.Test/Webhooks/Conversion/ConversionWebhook.Test.cs diff --git a/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionWebhook.cs b/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionWebhook.cs index dcf10e6b4..569c34884 100644 --- a/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionWebhook.cs +++ b/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionWebhook.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Runtime.Versioning; using System.Text.Json; @@ -49,21 +50,23 @@ public IActionResult Convert([FromBody] ConversionRequest request) { try { - var toConverters = AvailableConversions - .Where(c => c.To == request.Request.DesiredApiVersion) - .ToList(); + var conversions = AvailableConversions.ToList(); var results = new List(); foreach (var obj in request.Request.Objects) { - if (obj["apiVersion"]?.GetValue() is not { } targetApiVersion || - toConverters.TrueForAll(c => c.From != targetApiVersion)) + if (obj["apiVersion"]?.GetValue() is not { } sourceApiVersion || + !TryBuildConversionPath(conversions, sourceApiVersion, request.Request.DesiredApiVersion, out var steps, out var sourceType)) { continue; } - var (_, _, converter, type) = toConverters.Find(c => c.From == targetApiVersion); + var converted = obj.Deserialize(sourceType, _serializerOptions)!; + foreach (var step in steps) + { + converted = step(converted); + } - results.Add(converter(obj.Deserialize(type, _serializerOptions)!)); + results.Add(converted); } return new ConversionResponse(request.Request.Uid, results); @@ -73,4 +76,70 @@ public IActionResult Convert([FromBody] ConversionRequest request) return new ConversionResponse(request.Request.Uid, e.ToString()); } } + + /// + /// Builds a chain of registered converters that transforms an object from to + /// . Converters are registered as a hub-and-spoke set (every served version converts to and + /// from the storage/hub version), so the API server can legitimately request a conversion between two versions that + /// have no direct converter — e.g. an object persisted under a version that is no longer the storage version, read + /// at a third served version. In that case the conversion is composed through intermediate versions (a + /// breadth-first search picks the shortest chain), rather than the object being silently dropped. + /// + /// The directed conversion edges available for this webhook. + /// The apiVersion the source object is encoded in. + /// The requested desiredAPIVersion. + /// The ordered conversion functions to apply to the deserialized source object. + /// The CLR type the source object must be deserialized into before applying the steps. + /// if a conversion chain exists; otherwise . + private static bool TryBuildConversionPath( + IReadOnlyList<(string To, string From, Func Converter, Type FromType)> conversions, + string from, + string to, + out IReadOnlyList> steps, + [NotNullWhen(true)] out Type? sourceType) + { + steps = []; + sourceType = null; + + // Nothing to convert; also guards against walking an edge back to the source through the hub. + if (from == to) + { + return false; + } + + // Breadth-first search over the directed conversion edges so the shortest chain is chosen. A direct converter, + // when one exists, is found as a single-hop path and therefore keeps the previous behaviour unchanged. + var queue = new Queue Converter, Type FromType)>>(); + var visited = new HashSet { from }; + + foreach (var edge in conversions.Where(c => c.From == from)) + { + queue.Enqueue([edge]); + } + + while (queue.Count > 0) + { + var path = queue.Dequeue(); + var last = path[^1]; + + if (last.To == to) + { + steps = [.. path.Select(e => e.Converter)]; + sourceType = path[0].FromType; + return true; + } + + if (!visited.Add(last.To)) + { + continue; + } + + foreach (var edge in conversions.Where(c => c.From == last.To)) + { + queue.Enqueue([.. path, edge]); + } + } + + return false; + } } diff --git a/test/KubeOps.Operator.Web.Test/Webhooks/Conversion/ConversionWebhook.Test.cs b/test/KubeOps.Operator.Web.Test/Webhooks/Conversion/ConversionWebhook.Test.cs new file mode 100644 index 000000000..339d85d8a --- /dev/null +++ b/test/KubeOps.Operator.Web.Test/Webhooks/Conversion/ConversionWebhook.Test.cs @@ -0,0 +1,182 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System.Runtime.Versioning; +using System.Text.Json.Nodes; + +using FluentAssertions; + +using k8s.Models; + +using KubeOps.Abstractions.Entities; +using KubeOps.Operator.Web.Webhooks.Conversion; + +namespace KubeOps.Operator.Web.Test.Webhooks.Conversion; + +[RequiresPreviewFeatures] +public sealed class ConversionWebhookTest +{ + private const string Group = "kubeops.test"; + + [Fact(DisplayName = "Converts between two non-storage versions by composing through the storage version")] + public void Convert_ComposesThroughStorageVersion_ForTwoNonStorageVersions() + { + // v2 has no direct converter to v1; the only registered converters are v1<->v3 and v2<->v3 (v3 = storage). + var result = Convert(Request( + desired: $"{Group}/v1", + @object: """{"apiVersion":"kubeops.test/v2","kind":"Subject","metadata":{"name":"s"},"spec":{"firstName":"Jane","lastName":"Doe"}}""")); + + var converted = result.Response.ConvertedObjects.Should().ContainSingle().Subject; + converted.Should().BeOfType().Which.Spec.FullName.Should().Be("Jane Doe"); + } + + [Fact(DisplayName = "Composes through the storage version in the reverse direction")] + public void Convert_ComposesThroughStorageVersion_ReverseDirection() + { + var result = Convert(Request( + desired: $"{Group}/v2", + @object: """{"apiVersion":"kubeops.test/v1","kind":"Subject","metadata":{"name":"s"},"spec":{"fullName":"Jane Doe"}}""")); + + var converted = result.Response.ConvertedObjects.Should().ContainSingle().Subject; + var spec = converted.Should().BeOfType().Which.Spec; + spec.FirstName.Should().Be("Jane"); + spec.LastName.Should().Be("Doe"); + } + + [Fact(DisplayName = "Direct conversion to the storage version still works")] + public void Convert_DirectHopToStorageVersion_StillWorks() + { + var result = Convert(Request( + desired: $"{Group}/v3", + @object: """{"apiVersion":"kubeops.test/v1","kind":"Subject","metadata":{"name":"s"},"spec":{"fullName":"Jane Doe"}}""")); + + var converted = result.Response.ConvertedObjects.Should().ContainSingle().Subject; + var spec = converted.Should().BeOfType().Which.Spec; + spec.FirstName.Should().Be("Jane"); + spec.LastName.Should().Be("Doe"); + } + + [Fact(DisplayName = "Direct conversion from the storage version still works")] + public void Convert_DirectHopFromStorageVersion_StillWorks() + { + var result = Convert(Request( + desired: $"{Group}/v1", + @object: """{"apiVersion":"kubeops.test/v3","kind":"Subject","metadata":{"name":"s"},"spec":{"firstName":"Jane","lastName":"Doe"}}""")); + + var converted = result.Response.ConvertedObjects.Should().ContainSingle().Subject; + converted.Should().BeOfType().Which.Spec.FullName.Should().Be("Jane Doe"); + } + + [Fact(DisplayName = "Object with an unroutable source version is skipped")] + public void Convert_SkipsObject_WithUnroutableSourceVersion() + { + var result = Convert(Request( + desired: $"{Group}/v1", + @object: """{"apiVersion":"kubeops.test/v9","kind":"Subject","metadata":{"name":"s"},"spec":{}}""")); + + result.Response.ConvertedObjects.Should().BeEmpty(); + } + + private static ConversionResponse Convert(ConversionRequest request) => + (ConversionResponse)new TestConversionWebhook().Convert(request); + + private static ConversionRequest Request(string desired, string @object) => new() + { + Request = new ConversionRequest.ConversionRequestData + { + Uid = "test-uid", + DesiredApiVersion = desired, + Objects = [JsonNode.Parse(@object)!], + }, + }; + + // Hub-and-spoke converter set exactly as the documentation prescribes: every served version converts to and from + // the storage version (v3). There is deliberately no direct v1<->v2 converter. + [RequiresPreviewFeatures] + private sealed class TestConversionWebhook : ConversionWebhook + { + protected override IEnumerable> Converters => [new V1ToV3(), new V2ToV3()]; + } + + [RequiresPreviewFeatures] + private sealed class V1ToV3 : IEntityConverter + { + public Type FromType => typeof(V1Subject); + + public Type ToType => typeof(V3Subject); + + public string FromGroupVersion => $"{Group}/v1"; + + public string ToGroupVersion => $"{Group}/v3"; + + public V3Subject Convert(object from) + { + var source = (V1Subject)from; + var parts = source.Spec.FullName.Split(' ', 2); + return new V3Subject + { + Metadata = source.Metadata, + Spec = { FirstName = parts[0], LastName = parts.Length > 1 ? parts[1] : string.Empty }, + }; + } + + public object Revert(V3Subject to) => new V1Subject + { + Metadata = to.Metadata, + Spec = { FullName = $"{to.Spec.FirstName} {to.Spec.LastName}".Trim() }, + }; + } + + [RequiresPreviewFeatures] + private sealed class V2ToV3 : IEntityConverter + { + public Type FromType => typeof(V2Subject); + + public Type ToType => typeof(V3Subject); + + public string FromGroupVersion => $"{Group}/v2"; + + public string ToGroupVersion => $"{Group}/v3"; + + public V3Subject Convert(object from) + { + var source = (V2Subject)from; + return new V3Subject { Metadata = source.Metadata, Spec = { FirstName = source.Spec.FirstName, LastName = source.Spec.LastName } }; + } + + public object Revert(V3Subject to) => + new V2Subject { Metadata = to.Metadata, Spec = { FirstName = to.Spec.FirstName, LastName = to.Spec.LastName } }; + } + + [KubernetesEntity(Group = Group, ApiVersion = "v1", Kind = "Subject")] + private sealed class V1Subject : CustomKubernetesEntity + { + public sealed class SpecDef + { + public string FullName { get; set; } = string.Empty; + } + } + + [KubernetesEntity(Group = Group, ApiVersion = "v2", Kind = "Subject")] + private sealed class V2Subject : CustomKubernetesEntity + { + public sealed class SpecDef + { + public string FirstName { get; set; } = string.Empty; + + public string LastName { get; set; } = string.Empty; + } + } + + [KubernetesEntity(Group = Group, ApiVersion = "v3", Kind = "Subject")] + private sealed class V3Subject : CustomKubernetesEntity + { + public sealed class SpecDef + { + public string FirstName { get; set; } = string.Empty; + + public string LastName { get; set; } = string.Empty; + } + } +} From 20098d53b4ecce3fbb79df65c27d38d8ee188349 Mon Sep 17 00:00:00 2001 From: Jerome Haltom Date: Sat, 11 Jul 2026 19:40:22 -0500 Subject: [PATCH 2/3] perf(operator-web): cache conversion chains per source version within a request The conversion chain depends only on the source and desired apiVersions, and the desired version is constant for a request, so the chain is now memoised by source apiVersion. A LIST response typically converts many objects sharing the same stored version; this avoids repeating the breadth-first graph search (and the scans over the conversions list) for every object. Unroutable source versions are cached as null so they are searched only once as well. Adds a batch test covering a repeated source version and an unroutable one. Co-Authored-By: Claude Opus 4.8 --- .../Webhooks/Conversion/ConversionWebhook.cs | 28 ++++++++++++++++--- .../Conversion/ConversionWebhook.Test.cs | 27 ++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionWebhook.cs b/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionWebhook.cs index 569c34884..7ddae269b 100644 --- a/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionWebhook.cs +++ b/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionWebhook.cs @@ -51,17 +51,37 @@ public IActionResult Convert([FromBody] ConversionRequest request) try { var conversions = AvailableConversions.ToList(); + + // The conversion chain depends only on the source and desired versions, and the desired version is constant + // for the whole request, so the chain is cached by source apiVersion. A LIST response typically converts + // many objects that share the same stored version, so this avoids repeating the graph search (and the scans + // over the conversions list) for every object. Unroutable source versions are cached as null so they are + // searched only once as well. + var paths = new Dictionary> Steps, Type SourceType)?>(); + var results = new List(); foreach (var obj in request.Request.Objects) { - if (obj["apiVersion"]?.GetValue() is not { } sourceApiVersion || - !TryBuildConversionPath(conversions, sourceApiVersion, request.Request.DesiredApiVersion, out var steps, out var sourceType)) + if (obj["apiVersion"]?.GetValue() is not { } sourceApiVersion) + { + continue; + } + + if (!paths.TryGetValue(sourceApiVersion, out var path)) + { + path = TryBuildConversionPath(conversions, sourceApiVersion, request.Request.DesiredApiVersion, out var steps, out var sourceType) + ? (steps, sourceType) + : null; + paths[sourceApiVersion] = path; + } + + if (path is not { } route) { continue; } - var converted = obj.Deserialize(sourceType, _serializerOptions)!; - foreach (var step in steps) + var converted = obj.Deserialize(route.SourceType, _serializerOptions)!; + foreach (var step in route.Steps) { converted = step(converted); } diff --git a/test/KubeOps.Operator.Web.Test/Webhooks/Conversion/ConversionWebhook.Test.cs b/test/KubeOps.Operator.Web.Test/Webhooks/Conversion/ConversionWebhook.Test.cs index 339d85d8a..6f9dbbcf1 100644 --- a/test/KubeOps.Operator.Web.Test/Webhooks/Conversion/ConversionWebhook.Test.cs +++ b/test/KubeOps.Operator.Web.Test/Webhooks/Conversion/ConversionWebhook.Test.cs @@ -78,6 +78,33 @@ public void Convert_SkipsObject_WithUnroutableSourceVersion() result.Response.ConvertedObjects.Should().BeEmpty(); } + [Fact(DisplayName = "Converts every routable object in a batch and skips unroutable ones")] + public void Convert_Batch_ConvertsRoutableObjectsAndSkipsUnroutable() + { + // A LIST response can carry many objects that repeat the same source version (exercising the per-request + // path cache) alongside an unroutable one (exercising the cached negative result). + var result = Convert(new ConversionRequest + { + Request = new ConversionRequest.ConversionRequestData + { + Uid = "test-uid", + DesiredApiVersion = $"{Group}/v1", + Objects = + [ + JsonNode.Parse("""{"apiVersion":"kubeops.test/v2","kind":"Subject","metadata":{"name":"a"},"spec":{"firstName":"Jane","lastName":"Doe"}}""")!, + JsonNode.Parse("""{"apiVersion":"kubeops.test/v2","kind":"Subject","metadata":{"name":"b"},"spec":{"firstName":"John","lastName":"Roe"}}""")!, + JsonNode.Parse("""{"apiVersion":"kubeops.test/v3","kind":"Subject","metadata":{"name":"c"},"spec":{"firstName":"Mary","lastName":"Sue"}}""")!, + JsonNode.Parse("""{"apiVersion":"kubeops.test/v9","kind":"Subject","metadata":{"name":"d"},"spec":{}}""")!, + ], + }, + }); + + result.Response.ConvertedObjects.Should().HaveCount(3); + result.Response.ConvertedObjects[0].Should().BeOfType().Which.Spec.FullName.Should().Be("Jane Doe"); + result.Response.ConvertedObjects[1].Should().BeOfType().Which.Spec.FullName.Should().Be("John Roe"); + result.Response.ConvertedObjects[2].Should().BeOfType().Which.Spec.FullName.Should().Be("Mary Sue"); + } + private static ConversionResponse Convert(ConversionRequest request) => (ConversionResponse)new TestConversionWebhook().Convert(request); From 5f7acbc25fe349c81b620a95deddb04866ba3c6e Mon Sep 17 00:00:00 2001 From: Marcus Kimpenhaus Date: Sun, 12 Jul 2026 11:35:26 +0200 Subject: [PATCH 3/3] fix(operator-web): reject incomplete conversion responses Reject conversion requests when an object has no valid apiVersion or no conversion path instead of returning an incomplete object list. Add ConversionWebhook test traits and coverage for invalid objects and partial batch responses. --- .../Webhooks/Conversion/ConversionWebhook.cs | 40 +++++++++------ .../Conversion/ConversionWebhook.Test.cs | 51 ++++++++++++++++--- 2 files changed, 69 insertions(+), 22 deletions(-) diff --git a/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionWebhook.cs b/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionWebhook.cs index 7ddae269b..8ad800d01 100644 --- a/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionWebhook.cs +++ b/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionWebhook.cs @@ -55,33 +55,43 @@ public IActionResult Convert([FromBody] ConversionRequest request) // The conversion chain depends only on the source and desired versions, and the desired version is constant // for the whole request, so the chain is cached by source apiVersion. A LIST response typically converts // many objects that share the same stored version, so this avoids repeating the graph search (and the scans - // over the conversions list) for every object. Unroutable source versions are cached as null so they are - // searched only once as well. - var paths = new Dictionary> Steps, Type SourceType)?>(); + // over the conversions list) for every object. + var paths = new Dictionary> Steps, Type SourceType)>(); var results = new List(); - foreach (var obj in request.Request.Objects) + for (var index = 0; index < request.Request.Objects.Length; index++) { + var obj = request.Request.Objects[index]; if (obj["apiVersion"]?.GetValue() is not { } sourceApiVersion) { - continue; + return new ConversionResponse( + request.Request.Uid, + $"Object at index {index} does not contain a valid apiVersion."); } if (!paths.TryGetValue(sourceApiVersion, out var path)) { - path = TryBuildConversionPath(conversions, sourceApiVersion, request.Request.DesiredApiVersion, out var steps, out var sourceType) - ? (steps, sourceType) - : null; + if (!TryBuildConversionPath( + conversions, + sourceApiVersion, + request.Request.DesiredApiVersion, + out var steps, + out var sourceType)) + { + return new ConversionResponse( + request.Request.Uid, + $"No conversion path exists from '{sourceApiVersion}' to " + + $"'{request.Request.DesiredApiVersion}' for object at index {index}."); + } + + path = (steps, sourceType); paths[sourceApiVersion] = path; } - if (path is not { } route) - { - continue; - } - - var converted = obj.Deserialize(route.SourceType, _serializerOptions)!; - foreach (var step in route.Steps) + var converted = obj.Deserialize(path.SourceType, _serializerOptions) + ?? throw new InvalidOperationException( + $"Object at index {index} could not be deserialized as '{path.SourceType}'."); + foreach (var step in path.Steps) { converted = step(converted); } diff --git a/test/KubeOps.Operator.Web.Test/Webhooks/Conversion/ConversionWebhook.Test.cs b/test/KubeOps.Operator.Web.Test/Webhooks/Conversion/ConversionWebhook.Test.cs index 6f9dbbcf1..0a5d21c75 100644 --- a/test/KubeOps.Operator.Web.Test/Webhooks/Conversion/ConversionWebhook.Test.cs +++ b/test/KubeOps.Operator.Web.Test/Webhooks/Conversion/ConversionWebhook.Test.cs @@ -14,6 +14,7 @@ namespace KubeOps.Operator.Web.Test.Webhooks.Conversion; +[Trait("Area", "ConversionWebhook")] [RequiresPreviewFeatures] public sealed class ConversionWebhookTest { @@ -68,21 +69,36 @@ public void Convert_DirectHopFromStorageVersion_StillWorks() converted.Should().BeOfType().Which.Spec.FullName.Should().Be("Jane Doe"); } - [Fact(DisplayName = "Object with an unroutable source version is skipped")] - public void Convert_SkipsObject_WithUnroutableSourceVersion() + [Fact(DisplayName = "Returns an error when no conversion path exists")] + public void Convert_ReturnsError_WhenSourceVersionIsUnroutable() { var result = Convert(Request( desired: $"{Group}/v1", @object: """{"apiVersion":"kubeops.test/v9","kind":"Subject","metadata":{"name":"s"},"spec":{}}""")); result.Response.ConvertedObjects.Should().BeEmpty(); + result.Response.Result.Message.Should().Contain($"{Group}/v9"); + result.Response.Result.Message.Should().Contain($"{Group}/v1"); + result.Response.Result.Message.Should().Contain("index 0"); } - [Fact(DisplayName = "Converts every routable object in a batch and skips unroutable ones")] - public void Convert_Batch_ConvertsRoutableObjectsAndSkipsUnroutable() + [Fact(DisplayName = "Returns an error when an object has no apiVersion")] + public void Convert_ReturnsError_WhenApiVersionIsMissing() { - // A LIST response can carry many objects that repeat the same source version (exercising the per-request - // path cache) alongside an unroutable one (exercising the cached negative result). + var result = Convert(Request( + desired: $"{Group}/v1", + @object: """{"kind":"Subject","metadata":{"name":"s"},"spec":{}}""")); + + result.Response.ConvertedObjects.Should().BeEmpty(); + result.Response.Result.Message.Should().Contain("apiVersion"); + result.Response.Result.Message.Should().Contain("index 0"); + } + + [Fact(DisplayName = "Converts every routable object in a batch")] + public void Convert_Batch_ConvertsRoutableObjects() + { + // A LIST response can carry many objects that repeat the same source version, exercising the per-request path + // cache. var result = Convert(new ConversionRequest { Request = new ConversionRequest.ConversionRequestData @@ -94,7 +110,6 @@ public void Convert_Batch_ConvertsRoutableObjectsAndSkipsUnroutable() JsonNode.Parse("""{"apiVersion":"kubeops.test/v2","kind":"Subject","metadata":{"name":"a"},"spec":{"firstName":"Jane","lastName":"Doe"}}""")!, JsonNode.Parse("""{"apiVersion":"kubeops.test/v2","kind":"Subject","metadata":{"name":"b"},"spec":{"firstName":"John","lastName":"Roe"}}""")!, JsonNode.Parse("""{"apiVersion":"kubeops.test/v3","kind":"Subject","metadata":{"name":"c"},"spec":{"firstName":"Mary","lastName":"Sue"}}""")!, - JsonNode.Parse("""{"apiVersion":"kubeops.test/v9","kind":"Subject","metadata":{"name":"d"},"spec":{}}""")!, ], }, }); @@ -105,6 +120,28 @@ public void Convert_Batch_ConvertsRoutableObjectsAndSkipsUnroutable() result.Response.ConvertedObjects[2].Should().BeOfType().Which.Spec.FullName.Should().Be("Mary Sue"); } + [Fact(DisplayName = "Rejects the complete batch when one object is unroutable")] + public void Convert_Batch_ReturnsError_WhenOneObjectIsUnroutable() + { + var result = Convert(new ConversionRequest + { + Request = new ConversionRequest.ConversionRequestData + { + Uid = "test-uid", + DesiredApiVersion = $"{Group}/v1", + Objects = + [ + JsonNode.Parse("""{"apiVersion":"kubeops.test/v2","kind":"Subject","metadata":{"name":"a"},"spec":{"firstName":"Jane","lastName":"Doe"}}""")!, + JsonNode.Parse("""{"apiVersion":"kubeops.test/v9","kind":"Subject","metadata":{"name":"b"},"spec":{}}""")!, + ], + }, + }); + + result.Response.ConvertedObjects.Should().BeEmpty(); + result.Response.Result.Message.Should().Contain($"{Group}/v9"); + result.Response.Result.Message.Should().Contain("index 1"); + } + private static ConversionResponse Convert(ConversionRequest request) => (ConversionResponse)new TestConversionWebhook().Convert(request);