diff --git a/docs/docs/operator/building-blocks/entities.mdx b/docs/docs/operator/building-blocks/entities.mdx index 2f0c2721..8387a93e 100644 --- a/docs/docs/operator/building-blocks/entities.mdx +++ b/docs/docs/operator/building-blocks/entities.mdx @@ -152,8 +152,12 @@ public class EntitySpec ### Special Attributes - `[Ignore]`: Excludes a property or entity from CRD generation -- `[PreserveUnknownFields]`: Preserves unknown fields in the Kubernetes object -- `[EmbeddedResource]`: Marks a property as an embedded Kubernetes resource +- `[PreserveUnknownFields]`: Allows unknown fields on the annotated object (`x-kubernetes-preserve-unknown-fields: true`). The known fields are still transpiled and validated, so you keep a structural schema for what you model while permitting extra fields. Works the same whether placed on a property or on a class/type: if the type cannot be represented (it contains a circular reference or an otherwise non-transpilable member), it gracefully falls back to an opaque `type: object` with `x-kubernetes-preserve-unknown-fields: true` instead of failing — making this the recommended way to model complex, externally generated, or self-referencing types. +- `[EmbeddedResource]`: Marks a property as an embedded Kubernetes resource. The property type is never traversed; the schema is always an opaque embedded `type: object`. + +:::note +The CRD transpiler maps property types recursively. A **circular type reference** that is not opted out via `[PreserveUnknownFields]` or `[Ignore]` cannot be represented as a finite OpenAPI schema and raises a descriptive `TranspilationFailedException` during generation. Annotate the offending property or type with `[PreserveUnknownFields]` or `[Ignore]`, or restructure the type to remove the cycle. +::: ## Example with Multiple Attributes @@ -264,4 +268,4 @@ subresources: :::note `[ScaleSubresource]` and the status subresource are controlled independently. A `Status` property activates `status: {}` regardless of `[ScaleSubresource]`, and `[ScaleSubresource]` adds `scale:` regardless of whether a `Status` property exists. -::: +::: \ No newline at end of file diff --git a/src/KubeOps.Transpiler/Crds.cs b/src/KubeOps.Transpiler/Crds.cs index acf440d2..471f2aae 100644 --- a/src/KubeOps.Transpiler/Crds.cs +++ b/src/KubeOps.Transpiler/Crds.cs @@ -12,6 +12,7 @@ using KubeOps.Abstractions.Entities; using KubeOps.Abstractions.Entities.Attributes; +using KubeOps.Transpiler.Exceptions; using KubeOps.Transpiler.Kubernetes; namespace KubeOps.Transpiler; @@ -38,6 +39,8 @@ public static class Crds private static readonly string[] IgnoredToplevelProperties = ["metadata", "apiversion", "kind"]; + private static readonly IReadOnlySet EmptyAncestors = new HashSet(); + /// /// Transpile a single type to a CRD. /// @@ -47,83 +50,91 @@ public static class Crds public static V1CustomResourceDefinition Transpile(this MetadataLoadContext context, Type type) { type = context.GetContextType(type); - var (meta, scope) = context.ToEntityMetadata(type); - var crd = new V1CustomResourceDefinition { Spec = new() }.Initialize(); + try + { + var (meta, scope) = context.ToEntityMetadata(type); + var crd = new V1CustomResourceDefinition { Spec = new() }.Initialize(); - crd.Metadata.Name = $"{meta.PluralName}.{meta.Group}"; - crd.Spec.Group = meta.Group; + crd.Metadata.Name = $"{meta.PluralName}.{meta.Group}"; + crd.Spec.Group = meta.Group; - crd.Spec.Names = - new() + crd.Spec.Names = + new() + { + Kind = meta.Kind, + ListKind = meta.ListKind, + Singular = meta.SingularName, + Plural = meta.PluralName, + }; + crd.Spec.Scope = scope; + if (type.GetCustomAttributeData()?.ConstructorArguments[0].Value is + ReadOnlyCollection shortNames) { - Kind = meta.Kind, - ListKind = meta.ListKind, - Singular = meta.SingularName, - Plural = meta.PluralName, - }; - crd.Spec.Scope = scope; - if (type.GetCustomAttributeData()?.ConstructorArguments[0].Value is - ReadOnlyCollection shortNames) - { - crd.Spec.Names.ShortNames = shortNames.Select(a => a.Value?.ToString()).ToList(); - } + crd.Spec.Names.ShortNames = shortNames.Select(a => a.Value?.ToString()).ToList(); + } - var version = new V1CustomResourceDefinitionVersion { Name = meta.Version, Served = true, Storage = true }; - var hasStatus = type.GetProperty("status", BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase) != null; - var scaleAttr = type.GetCustomAttributeData(); + var version = new V1CustomResourceDefinitionVersion { Name = meta.Version, Served = true, Storage = true }; + var hasStatus = type.GetProperty("status", BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase) != null; + var scaleAttr = type.GetCustomAttributeData(); - if (hasStatus || scaleAttr != null) - { - version.Subresources = new() + if (hasStatus || scaleAttr != null) { - Status = hasStatus ? new() : null, - Scale = scaleAttr != null - ? new V1CustomResourceSubresourceScale - { - SpecReplicasPath = scaleAttr.GetCustomAttributeCtorArg(context, 0)!, - StatusReplicasPath = scaleAttr.GetCustomAttributeCtorArg(context, 1)!, - LabelSelectorPath = scaleAttr.GetCustomAttributeCtorArg(context, 2), - } - : null, - }; - } + version.Subresources = new() + { + Status = hasStatus ? new() : null, + Scale = scaleAttr != null + ? new V1CustomResourceSubresourceScale + { + SpecReplicasPath = scaleAttr.GetCustomAttributeCtorArg(context, 0)!, + StatusReplicasPath = scaleAttr.GetCustomAttributeCtorArg(context, 1)!, + LabelSelectorPath = scaleAttr.GetCustomAttributeCtorArg(context, 2), + } + : null, + }; + } - version.Schema = new() - { - OpenAPIV3Schema = new() + version.Schema = new() { - Type = Object, - Description = - type.GetCustomAttributeData()?.GetCustomAttributeCtorArg(context, 0), - Properties = type.GetProperties() - .Where(p => !IgnoredToplevelProperties.Contains(p.Name.ToLowerInvariant()) - && p.GetCustomAttributeData() == null) - .Select(p => (Name: p.GetPropertyName(context), Schema: context.Map(p))) - .OrderBy(t => t.Name, StringComparer.Ordinal) - .ToDictionary(t => t.Name, t => t.Schema), - Required = type.GetProperties() - .Where(p => !IgnoredToplevelProperties.Contains(p.Name.ToLowerInvariant()) - && p.GetCustomAttributeData() == null - && IsRequiredSpecProperty(p)) - .Select(p => p.GetPropertyName(context)) - .ToList() switch + OpenAPIV3Schema = new() { - { Count: > 0 } list => list, - _ => null, + Type = Object, + Description = + type.GetCustomAttributeData()?.GetCustomAttributeCtorArg(context, 0), + Properties = type.GetProperties() + .Where(p => !IgnoredToplevelProperties.Contains(p.Name.ToLowerInvariant()) + && p.GetCustomAttributeData() == null) + .Select(p => (Name: p.GetPropertyName(context), Schema: context.Map(p, EmptyAncestors))) + .OrderBy(t => t.Name, StringComparer.Ordinal) + .ToDictionary(t => t.Name, t => t.Schema), + Required = type.GetProperties() + .Where(p => !IgnoredToplevelProperties.Contains(p.Name.ToLowerInvariant()) + && p.GetCustomAttributeData() == null + && IsRequiredSpecProperty(p)) + .Select(p => p.GetPropertyName(context)) + .ToList() switch + { + { Count: > 0 } list => list, + _ => null, + }, + XKubernetesValidations = context.MapValidationRules( + type.GetCustomAttributesData()), }, - XKubernetesValidations = context.MapValidationRules( - type.GetCustomAttributesData()), - }, - }; + }; - version.AdditionalPrinterColumns = context.MapPrinterColumns(type).ToList() switch - { - { Count: > 0 } l => l, - _ => null, - }; - crd.Spec.Versions = new List { version }; + version.AdditionalPrinterColumns = context.MapPrinterColumns(type).ToList() switch + { + { Count: > 0 } l => l, + _ => null, + }; + crd.Spec.Versions = new List { version }; - return crd; + return crd; + } + catch (Exception ex) when (ex is CircularTypeReferenceException or InvalidTypeException) + { + throw new TranspilationFailedException( + $"Failed to transpile the CRD for entity '{type.FullName ?? type.Name}'. {ex.Message}", ex); + } } /// @@ -161,7 +172,7 @@ public static IEnumerable Transpile( .OrderByDescending(v => v.Name, new KubernetesVersionComparer()) .ToList(); - // when only one version exists, or when no StorageVersion attributes are found + // when only one version exists, or when no StorageVersion attributes are found, // the first version in the list is the stored one. if (crd.Spec.Versions.Count == 1 || !group.Any(def => def.IsStorage)) { @@ -186,16 +197,21 @@ private static IEnumerable MapPrinterColumns( this MetadataLoadContext context, Type type) { - var props = type.GetProperties().Select(p => (Prop: p, Path: string.Empty)).ToList(); + var props = type.GetProperties() + .Select(p => (Prop: p, Path: string.Empty, Ancestors: (IReadOnlySet)new HashSet { type })) + .ToList(); while (props.Count > 0) { - var (prop, path) = props[0]; + (PropertyInfo prop, string path, IReadOnlySet ancestors) = props[0]; props.RemoveAt(0); - if (prop.PropertyType.IsClass) + // Path-scoped cycle guard: only skip a type already seen on the current path, so the + // same non-circular type reused under different properties still contributes its columns. + if (prop.PropertyType.IsClass && !ancestors.Contains(prop.PropertyType)) { + IReadOnlySet childAncestors = new HashSet(ancestors) { prop.PropertyType }; props.AddRange(prop.PropertyType.GetProperties() - .Select(p => (Prop: p, Path: $"{path}.{prop.GetPropertyName(context)}"))); + .Select(p => (Prop: p, Path: $"{path}.{prop.GetPropertyName(context)}", Ancestors: childAncestors))); } if (prop.GetCustomAttributeData() is not { } attr) @@ -203,7 +219,7 @@ private static IEnumerable MapPrinterColumns( continue; } - var mapped = context.Map(prop); + var mapped = context.Map(prop, EmptyAncestors); yield return new() { Name = attr.GetCustomAttributeCtorArg(context, 1) ?? prop.GetPropertyName(context), @@ -237,9 +253,38 @@ private static IEnumerable MapPrinterColumns( } } - private static V1JSONSchemaProps Map(this MetadataLoadContext context, PropertyInfo prop) + private static V1JSONSchemaProps Map( + this MetadataLoadContext context, + PropertyInfo prop, + IReadOnlySet ancestors) { - var props = context.Map(prop.PropertyType); + var preservesUnknownFields = prop.GetCustomAttributeData() is not null; + var isEmbeddedResource = prop.GetCustomAttributeData() is not null; + + V1JSONSchemaProps props; + if (isEmbeddedResource) + { + // Embedded resources are always opaque; the type graph is never traversed. + props = new() { Type = Object, XKubernetesPreserveUnknownFields = true }; + } + else if (preservesUnknownFields) + { + // Best-effort: keep the structural schema of known fields when the type is fully + // transpilable; fall back to an opaque object when it contains a cycle or an otherwise + // non-representable member (PreserveUnknownFields opts that subtree out of validation). + try + { + props = context.Map(prop.PropertyType, ancestors); + } + catch (Exception ex) when (ex is CircularTypeReferenceException or InvalidTypeException) + { + props = new() { Type = Object }; + } + } + else + { + props = context.Map(prop.PropertyType, ancestors); + } props.Description ??= prop.GetCustomAttributeData() ?.GetCustomAttributeCtorArg(context, 0); @@ -298,12 +343,12 @@ private static V1JSONSchemaProps Map(this MetadataLoadContext context, PropertyI rangeMin.GetCustomAttributeCtorArg(context, 1); } - if (prop.GetCustomAttributeData() is not null) + if (preservesUnknownFields) { props.XKubernetesPreserveUnknownFields = true; } - if (prop.GetCustomAttributeData() is not null) + if (isEmbeddedResource) { props.XKubernetesEmbeddedResource = true; props.XKubernetesPreserveUnknownFields = true; @@ -322,7 +367,7 @@ private static V1JSONSchemaProps Map(this MetadataLoadContext context, PropertyI return props; } - private static V1JSONSchemaProps Map(this MetadataLoadContext context, Type type) + private static V1JSONSchemaProps Map(this MetadataLoadContext context, Type type, IReadOnlySet ancestors) { if (type.FullName == "System.String") { @@ -336,7 +381,7 @@ private static V1JSONSchemaProps Map(this MetadataLoadContext context, Type type if (type.Name == typeof(Nullable<>).Name && type.GenericTypeArguments.Length == 1) { - var props = context.Map(type.GenericTypeArguments[0]); + var props = context.Map(type.GenericTypeArguments[0], ancestors); props.Nullable = true; return props; } @@ -356,7 +401,7 @@ private static V1JSONSchemaProps Map(this MetadataLoadContext context, Type type .First(i => i.IsGenericType && i.GetGenericTypeDefinition().FullName == typeof(IDictionary<,>).FullName); - var additionalProperties = context.Map(dictionaryImpl.GenericTypeArguments[1]); + var additionalProperties = context.Map(dictionaryImpl.GenericTypeArguments[1], ancestors); return new() { Type = Object, AdditionalProperties = additionalProperties }; } @@ -367,13 +412,13 @@ private static V1JSONSchemaProps Map(this MetadataLoadContext context, Type type if (interfaceNames.Contains(typeof(IEnumerable<>).FullName)) { - return context.MapEnumerationType(type, interfaces); + return context.MapEnumerationType(type, interfaces, ancestors); } if (type.BaseType?.Name == nameof(CustomKubernetesEntity) || type.BaseType?.Name == typeof(CustomKubernetesEntity<>).Name) { - return context.MapObjectType(type); + return context.MapObjectType(type, ancestors); } static Type GetRootBaseType(Type type) @@ -400,7 +445,7 @@ static Type GetRootBaseType(Type type) return rootBase.FullName switch { - "System.Object" => context.MapObjectType(type), + "System.Object" => context.MapObjectType(type, ancestors), "System.ValueType" => context.MapValueType(type), "System.Enum" => new() { Type = String, EnumProperty = context.GetEnumNames(type) }, _ => throw InvalidType(type), @@ -449,7 +494,10 @@ private static IList GetEnumNames(this MetadataLoadContext context, Type #endif } - private static V1JSONSchemaProps MapObjectType(this MetadataLoadContext context, Type type) + private static V1JSONSchemaProps MapObjectType( + this MetadataLoadContext context, + Type type, + IReadOnlySet ancestors) { switch (type.FullName) { @@ -474,40 +522,58 @@ private static V1JSONSchemaProps MapObjectType(this MetadataLoadContext context, }; } - return new() + if (ancestors.Contains(type)) { - Type = Object, - Description = - type.GetCustomAttributeData() - ?.GetCustomAttributeCtorArg(context, 0), - Properties = type - .GetProperties() - .Where(p => p.GetCustomAttributeData() == null) - .Select(p => (Name: p.GetPropertyName(context), Schema: context.Map(p))) - .OrderBy(t => t.Name, StringComparer.Ordinal) - .ToDictionary(t => t.Name, t => t.Schema), - Required = type.GetProperties() - .Where(p => p.GetCustomAttributeData() != null - && p.GetCustomAttributeData() == null) - .Select(p => p.GetPropertyName(context)) - .OrderBy(name => name, StringComparer.Ordinal) - .ToList() switch + throw CircularTypeReference(type); + } + + var nextAncestors = new HashSet(ancestors) { type }; + var preservesUnknownFields = type.GetCustomAttributeData() != null; + + try + { + return new() { - { Count: > 0 } p => p, - _ => null, - }, - XKubernetesPreserveUnknownFields = - type.GetCustomAttributeData() != null ? true : null, - XKubernetesValidations = context.MapValidationRules( - type.GetCustomAttributesData()), - }; + Type = Object, + Description = + type.GetCustomAttributeData() + ?.GetCustomAttributeCtorArg(context, 0), + Properties = type + .GetProperties() + .Where(p => p.GetCustomAttributeData() == null) + .Select(p => (Name: p.GetPropertyName(context), Schema: context.Map(p, nextAncestors))) + .OrderBy(t => t.Name, StringComparer.Ordinal) + .ToDictionary(t => t.Name, t => t.Schema), + Required = type.GetProperties() + .Where(p => p.GetCustomAttributeData() != null + && p.GetCustomAttributeData() == null) + .Select(p => p.GetPropertyName(context)) + .OrderBy(name => name, StringComparer.Ordinal) + .ToList() switch + { + { Count: > 0 } p => p, + _ => null, + }, + XKubernetesPreserveUnknownFields = preservesUnknownFields ? true : null, + XKubernetesValidations = context.MapValidationRules( + type.GetCustomAttributesData()), + }; + } + catch (Exception ex) when (preservesUnknownFields + && ex is CircularTypeReferenceException or InvalidTypeException) + { + // Class-level [PreserveUnknownFields] opts the whole type out: if a member cannot + // be represented (cycle or non-transpilable type), degrade to an opaque object. + return new() { Type = Object, XKubernetesPreserveUnknownFields = true }; + } } } private static V1JSONSchemaProps MapEnumerationType( this MetadataLoadContext context, Type type, - IEnumerable interfaces) + IEnumerable interfaces, + IReadOnlySet ancestors) { Type? enumerableType = interfaces .FirstOrDefault(i => i.IsGenericType @@ -522,11 +588,11 @@ private static V1JSONSchemaProps MapEnumerationType( Type listType = enumerableType.GenericTypeArguments[0]; if (listType.IsGenericType && listType.GetGenericTypeDefinition().FullName == typeof(KeyValuePair<,>).FullName) { - var additionalProperties = context.Map(listType.GenericTypeArguments[1]); + var additionalProperties = context.Map(listType.GenericTypeArguments[1], ancestors); return new() { Type = Object, AdditionalProperties = additionalProperties }; } - var items = context.Map(listType); + var items = context.Map(listType, ancestors); return new() { Type = Array, Items = items }; } @@ -551,6 +617,10 @@ private static bool IsRequiredSpecProperty(PropertyInfo prop) => .Any(sp => sp.GetCustomAttributeData() != null && sp.GetCustomAttributeData() == null)); - private static ArgumentException InvalidType(Type type) => - new($"The given type {type.FullName} is not a valid Kubernetes entity."); + private static InvalidTypeException InvalidType(Type type) => + new($"The given type '{type.FullName ?? type.Name}' is not a valid Kubernetes entity."); + + private static CircularTypeReferenceException CircularTypeReference(Type type) => + new($"A circular type reference was detected while transpiling the CRD schema for '{type.FullName ?? type.Name}'. " + + "Break the cycle, or annotate the property with [PreserveUnknownFields] or [Ignore]."); } diff --git a/src/KubeOps.Transpiler/Exceptions/CircularTypeReferenceException.cs b/src/KubeOps.Transpiler/Exceptions/CircularTypeReferenceException.cs new file mode 100644 index 00000000..b9345d53 --- /dev/null +++ b/src/KubeOps.Transpiler/Exceptions/CircularTypeReferenceException.cs @@ -0,0 +1,39 @@ +// 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. + +namespace KubeOps.Transpiler.Exceptions; + +/// +/// Raised when the CRD transpiler detects a circular type reference that cannot be represented as a +/// finite OpenAPI schema. Derives from to preserve backwards +/// compatibility for existing catch clauses. +/// +internal sealed class CircularTypeReferenceException : InvalidOperationException +{ + /// + /// Initializes a new instance of the class. + /// + public CircularTypeReferenceException() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The error message. + public CircularTypeReferenceException(string? message) + : base(message) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The error message. + /// The exception that caused this exception. + public CircularTypeReferenceException(string? message, Exception? innerException) + : base(message, innerException) + { + } +} diff --git a/src/KubeOps.Transpiler/Exceptions/InvalidTypeException.cs b/src/KubeOps.Transpiler/Exceptions/InvalidTypeException.cs new file mode 100644 index 00000000..2ca519f5 --- /dev/null +++ b/src/KubeOps.Transpiler/Exceptions/InvalidTypeException.cs @@ -0,0 +1,37 @@ +// 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. + +namespace KubeOps.Transpiler.Exceptions; + +/// +/// Raised when the CRD transpiler encounters a type it cannot map to an OpenAPI schema. +/// +internal sealed class InvalidTypeException : InvalidOperationException +{ + /// + /// Initializes a new instance of the class. + /// + public InvalidTypeException() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The error message. + public InvalidTypeException(string? message) + : base(message) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The error message. + /// The exception that caused this exception. + public InvalidTypeException(string? message, Exception? innerException) + : base(message, innerException) + { + } +} diff --git a/src/KubeOps.Transpiler/Exceptions/TranspilationFailedException.cs b/src/KubeOps.Transpiler/Exceptions/TranspilationFailedException.cs new file mode 100644 index 00000000..e33bc15a --- /dev/null +++ b/src/KubeOps.Transpiler/Exceptions/TranspilationFailedException.cs @@ -0,0 +1,39 @@ +// 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. + +namespace KubeOps.Transpiler.Exceptions; + +/// +/// Raised when an entity cannot be transpiled into a CRD. The message is prefixed with the affected +/// entity; the concrete cause (for example a circular type reference or a non-transpilable type) is +/// available via . +/// +public sealed class TranspilationFailedException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public TranspilationFailedException() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The error message. + public TranspilationFailedException(string? message) + : base(message) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The error message. + /// The exception that caused this exception. + public TranspilationFailedException(string? message, Exception? innerException) + : base(message, innerException) + { + } +} diff --git a/test/KubeOps.Transpiler.Test/Crds.Mlc.CircularReference.Test.cs b/test/KubeOps.Transpiler.Test/Crds.Mlc.CircularReference.Test.cs new file mode 100644 index 00000000..4e19b606 --- /dev/null +++ b/test/KubeOps.Transpiler.Test/Crds.Mlc.CircularReference.Test.cs @@ -0,0 +1,311 @@ +// 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.Text.Json; + +using FluentAssertions; + +using k8s.Models; + +using KubeOps.Abstractions.Entities; +using KubeOps.Abstractions.Entities.Attributes; +using KubeOps.Transpiler.Exceptions; + +namespace KubeOps.Transpiler.Test; + +public sealed partial class CrdsMlcTest +{ + [Fact] + [Trait("Area", "CircularReferences")] + public void Should_Short_Circuit_Circular_Type_With_PreserveUnknownFields() + { + var crd = _mlc.Transpile(typeof(CircularPreserveUnknownFieldsEntity)); + + var property = crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["property"]; + property.Type.Should().Be("object"); + property.XKubernetesPreserveUnknownFields.Should().BeTrue(); + property.Properties.Should().BeNull(); + } + + [Fact] + [Trait("Area", "CircularReferences")] + public void Should_Throw_Descriptive_Exception_On_Unannotated_Circular_Type() + { + var act = () => _mlc.Transpile(typeof(CircularEntity)); + + // The exception is prefixed with the affected entity so the failure is locatable. + act.Should().Throw() + .WithMessage("*circular*") + .WithMessage($"*{nameof(CircularEntity)}*"); + } + + [Fact] + [Trait("Area", "CircularReferences")] + public void Should_Not_Throw_For_Shared_NonCircular_Type_Used_By_Siblings() + { + // Cycle detection is per recursion path: a non-recursive type referenced by two sibling + // properties is not a cycle and must transpile without throwing. + var crd = _mlc.Transpile(typeof(SharedTypeEntity)); + + var spec = crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["spec"]; + spec.Properties.Should().ContainKeys("first", "second"); + } + + [Fact] + [Trait("Area", "CircularReferences")] + public void Should_Transpile_Circular_Type_When_Back_Reference_Is_Ignored() + { + var crd = _mlc.Transpile(typeof(IgnoredBackReferenceEntity)); + + var node = crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["spec"].Properties["node"]; + node.Properties.Should().ContainKey("name"); + node.Properties.Should().NotContainKey("parent"); + } + + [Fact] + [Trait("Area", "CircularReferences")] + public void Should_Throw_On_Circular_Type_Through_Collection() + { + var act = () => _mlc.Transpile(typeof(CircularThroughCollectionEntity)); + + act.Should().Throw().WithMessage("*circular*"); + } + + [Fact] + [Trait("Area", "CircularReferences")] + public void Should_Throw_On_Circular_Type_Through_Dictionary_Value() + { + var act = () => _mlc.Transpile(typeof(CircularThroughDictionaryEntity)); + + act.Should().Throw().WithMessage("*circular*"); + } + + [Fact] + [Trait("Area", "CircularReferences")] + public void Should_Emit_Printer_Columns_For_Shared_Type_Under_Multiple_Paths() + { + // The printer-column cycle guard is path-scoped: a non-circular type reused under two + // sibling properties must still contribute a column for each path. + var crd = _mlc.Transpile(typeof(SharedPrinterColumnEntity)); + + var apc = crd.Spec.Versions[0].AdditionalPrinterColumns; + apc.Should().Contain(c => c.JsonPath == ".primary.state"); + apc.Should().Contain(c => c.JsonPath == ".secondary.state"); + } + + [Fact] + [Trait("Area", "CircularReferences")] + public void Should_Keep_Known_Properties_For_NonCircular_PreserveUnknownFields() + { + // PreserveUnknownFields on a fully transpilable type keeps the structural schema of known + // fields and additionally allows unknown ones — it does not discard the known properties. + var crd = _mlc.Transpile(typeof(PreserveUnknownFieldsKnownPropertiesEntity)); + + var property = crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["property"]; + property.Type.Should().Be("object"); + property.XKubernetesPreserveUnknownFields.Should().BeTrue(); + property.Properties.Should().ContainKey("knownField"); + } + + [Fact] + [Trait("Area", "CircularReferences")] + public void Should_Fall_Back_To_Opaque_For_NonRepresentable_PreserveUnknownFields() + { + // A non-representable member (here JsonElement) would normally throw; PreserveUnknownFields + // opts the subtree out, so it falls back to an opaque object instead of failing. + var act = () => _mlc.Transpile(typeof(PreserveUnknownFieldsNonRepresentableEntity)); + act.Should().NotThrow(); + + var property = _mlc.Transpile(typeof(PreserveUnknownFieldsNonRepresentableEntity)) + .Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["property"]; + property.Type.Should().Be("object"); + property.XKubernetesPreserveUnknownFields.Should().BeTrue(); + property.Properties.Should().BeNull(); + } + + [Fact] + [Trait("Area", "CircularReferences")] + public void Should_Keep_Known_Properties_For_NonCircular_ClassLevel_PreserveUnknownFields() + { + // Class-level [PreserveUnknownFields] keeps structural mapping of known fields plus the flag + // (same as before) — the property-level fallback does not apply to class-level annotations. + var crd = _mlc.Transpile(typeof(ClassLevelPreserveKnownPropertiesEntity)); + + var spec = crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["spec"]; + spec.XKubernetesPreserveUnknownFields.Should().BeTrue(); + spec.Properties.Should().ContainKey("knownField"); + } + + [Fact] + [Trait("Area", "CircularReferences")] + public void Should_Fall_Back_To_Opaque_For_Circular_ClassLevel_PreserveUnknownFields() + { + // Class-level [PreserveUnknownFields] opts the whole type out, consistent with property-level: + // a circular type degrades to an opaque object instead of failing. + var crd = _mlc.Transpile(typeof(CircularClassLevelPreserveEntity)); + + var spec = crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["spec"]; + spec.Type.Should().Be("object"); + spec.XKubernetesPreserveUnknownFields.Should().BeTrue(); + spec.Properties.Should().BeNull(); + } + + #region Test Entity Classes + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private sealed class CircularPreserveUnknownFieldsEntity : CustomKubernetesEntity + { + [PreserveUnknownFields] + public SelfReferencingType Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private sealed class CircularEntity : CustomKubernetesEntity + { + public sealed class EntitySpec + { + public NodeA Node { get; set; } = null!; + } + + public sealed class NodeA + { + public NodeB? Next { get; set; } + } + + public sealed class NodeB + { + public NodeA? Back { get; set; } + } + } + + private sealed class SelfReferencingType + { + public SelfReferencingType? Next { get; set; } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private sealed class SharedTypeEntity : CustomKubernetesEntity + { + public sealed class EntitySpec + { + public Shared First { get; set; } = null!; + + public Shared Second { get; set; } = null!; + } + + public sealed class Shared + { + public string Value { get; set; } = string.Empty; + } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private sealed class IgnoredBackReferenceEntity : CustomKubernetesEntity + { + public sealed class EntitySpec + { + public TreeNode Node { get; set; } = null!; + } + + public sealed class TreeNode + { + public string Name { get; set; } = string.Empty; + + [Ignore] + public TreeNode? Parent { get; set; } + } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private sealed class CircularThroughCollectionEntity + : CustomKubernetesEntity + { + public sealed class EntitySpec + { + public Branch Root { get; set; } = null!; + } + + public sealed class Branch + { + public List Leaves { get; set; } = null!; + } + + public sealed class Leaf + { + public Branch? Owner { get; set; } + } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private sealed class CircularThroughDictionaryEntity + : CustomKubernetesEntity + { + public sealed class EntitySpec + { + public Catalog Root { get; set; } = null!; + } + + public sealed class Catalog + { + public Dictionary Children { get; set; } = null!; + } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private sealed class SharedPrinterColumnEntity : CustomKubernetesEntity + { + public Holder Primary { get; set; } = null!; + + public Holder Secondary { get; set; } = null!; + + public sealed class Holder + { + [AdditionalPrinterColumn] + public string State { get; set; } = string.Empty; + } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private sealed class PreserveUnknownFieldsKnownPropertiesEntity : CustomKubernetesEntity + { + [PreserveUnknownFields] + public KnownSpec Property { get; set; } = null!; + + public sealed class KnownSpec + { + public string KnownField { get; set; } = string.Empty; + } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private sealed class PreserveUnknownFieldsNonRepresentableEntity : CustomKubernetesEntity + { + [PreserveUnknownFields] + public JsonElement Property { get; set; } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private sealed class ClassLevelPreserveKnownPropertiesEntity + : CustomKubernetesEntity + { + [PreserveUnknownFields] + public sealed class EntitySpec + { + public string KnownField { get; set; } = string.Empty; + } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private sealed class CircularClassLevelPreserveEntity + : CustomKubernetesEntity + { + [PreserveUnknownFields] + public sealed class EntitySpec + { + public EntitySpec? Self { get; set; } + } + } + + #endregion +} diff --git a/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs b/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs index 465f420c..d6f785a2 100644 --- a/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs +++ b/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs @@ -15,7 +15,7 @@ namespace KubeOps.Transpiler.Test; -public partial class CrdsMlcTest(MlcProvider provider) : TranspilerTestBase(provider) +public sealed partial class CrdsMlcTest(MlcProvider provider) : TranspilerTestBase(provider) { [Theory] [InlineData(typeof(StringTestEntity), "string", null, null)]