diff --git a/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionWebhook.cs b/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionWebhook.cs index dcf10e6b4..8ad800d01 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,53 @@ public IActionResult Convert([FromBody] ConversionRequest request) { try { - var toConverters = AvailableConversions - .Where(c => c.To == request.Request.DesiredApiVersion) - .ToList(); + 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. + 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++) { - if (obj["apiVersion"]?.GetValue() is not { } targetApiVersion || - toConverters.TrueForAll(c => c.From != targetApiVersion)) + 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."); } - var (_, _, converter, type) = toConverters.Find(c => c.From == targetApiVersion); + if (!paths.TryGetValue(sourceApiVersion, out var path)) + { + 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; + } - results.Add(converter(obj.Deserialize(type, _serializerOptions)!)); + 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); + } + + results.Add(converted); } return new ConversionResponse(request.Request.Uid, results); @@ -73,4 +106,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..0a5d21c75 --- /dev/null +++ b/test/KubeOps.Operator.Web.Test/Webhooks/Conversion/ConversionWebhook.Test.cs @@ -0,0 +1,246 @@ +// 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; + +[Trait("Area", "ConversionWebhook")] +[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 = "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 = "Returns an error when an object has no apiVersion")] + public void Convert_ReturnsError_WhenApiVersionIsMissing() + { + 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 + { + 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"}}""")!, + ], + }, + }); + + 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"); + } + + [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); + + 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; + } + } +}