From 5ebaaadf17161bf3ab4512f423cdf7bfbccd1788 Mon Sep 17 00:00:00 2001 From: Marcus Kimpenhaus Date: Thu, 11 Jun 2026 00:56:31 +0200 Subject: [PATCH 1/3] feat(transpiler): add validation for circular type references - Added `CircularTypeReferenceException` and `InvalidTypeException` to handle invalid or circular references. - Enhanced transpiler to detect circular types and fall back to opaque objects when necessary. - Updated test suite with comprehensive circular reference scenarios. closes #351 --- .../operator/building-blocks/entities.mdx | 8 +- .../CircularTypeReferenceException.cs | 39 +++ src/KubeOps.Transpiler/Crds.cs | 263 +++++++++------ .../InvalidTypeException.cs | 37 +++ .../Crds.Mlc.CircularReference.Test.cs | 310 ++++++++++++++++++ test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs | 2 +- 6 files changed, 564 insertions(+), 95 deletions(-) create mode 100644 src/KubeOps.Transpiler/CircularTypeReferenceException.cs create mode 100644 src/KubeOps.Transpiler/InvalidTypeException.cs create mode 100644 test/KubeOps.Transpiler.Test/Crds.Mlc.CircularReference.Test.cs diff --git a/docs/docs/operator/building-blocks/entities.mdx b/docs/docs/operator/building-blocks/entities.mdx index 2f0c2721..ee752589 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`. (For unusual usages such as `[EmbeddedResource]` on a scalar or collection property, this is more opaque than before — no leftover `items`/`format` are emitted.) + +:::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 `CircularTypeReferenceException` during generation. Annotate the offending property or type with `[PreserveUnknownFields]` or `[Ignore]`, or restructure the type to remove the cycle. +::: ## Example with Multiple Attributes diff --git a/src/KubeOps.Transpiler/CircularTypeReferenceException.cs b/src/KubeOps.Transpiler/CircularTypeReferenceException.cs new file mode 100644 index 00000000..09fffc75 --- /dev/null +++ b/src/KubeOps.Transpiler/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; + +/// +/// 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. +/// +public 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/Crds.cs b/src/KubeOps.Transpiler/Crds.cs index acf440d2..c4df4be8 100644 --- a/src/KubeOps.Transpiler/Crds.cs +++ b/src/KubeOps.Transpiler/Crds.cs @@ -38,6 +38,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,6 +49,69 @@ public static class Crds public static V1CustomResourceDefinition Transpile(this MetadataLoadContext context, Type type) { type = context.GetContextType(type); + try + { + return context.TranspileType(type); + } + catch (CircularTypeReferenceException ex) + { + throw new CircularTypeReferenceException( + $"Failed to transpile the CRD for entity '{type.FullName ?? type.Name}'. {ex.Message}", ex); + } + catch (InvalidTypeException ex) + { + throw new InvalidTypeException( + $"Failed to transpile the CRD for entity '{type.FullName ?? type.Name}'. {ex.Message}", ex); + } + } + + /// + /// Transpile a list of entities to CRDs and group them by version. + /// + /// The . + /// The types to convert. + /// The converted custom resource definitions. + public static IEnumerable Transpile( + this MetadataLoadContext context, + IEnumerable types) + => types + .Select(context.GetContextType) + .Where(type => type.Assembly != context.GetContextType().Assembly + && type.GetCustomAttributesData().Any() + && !type.GetCustomAttributesData().Any()) + .Select(type => (Props: context.Transpile(type), + IsStorage: type.GetCustomAttributesData().Any())) + .GroupBy(grp => grp.Props.Metadata.Name) + .Select(group => + { + if (group.Count(def => def.IsStorage) > 1) + { + throw new ArgumentException("There are multiple stored versions on an entity."); + } + + var crd = group.First().Props; + crd.Spec.Versions = group + .SelectMany(c => c.Props.Spec.Versions.Select(v => + { + v.Served = true; + v.Storage = c.IsStorage; + return v; + })) + .OrderByDescending(v => v.Name, new KubernetesVersionComparer()) + .ToList(); + + // 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)) + { + crd.Spec.Versions[0].Storage = true; + } + + return crd; + }); + + private static V1CustomResourceDefinition TranspileType(this MetadataLoadContext context, Type type) + { var (meta, scope) = context.ToEntityMetadata(type); var crd = new V1CustomResourceDefinition { Spec = new() }.Initialize(); @@ -98,7 +163,7 @@ public static V1CustomResourceDefinition Transpile(this MetadataLoadContext cont Properties = type.GetProperties() .Where(p => !IgnoredToplevelProperties.Contains(p.Name.ToLowerInvariant()) && p.GetCustomAttributeData() == null) - .Select(p => (Name: p.GetPropertyName(context), Schema: context.Map(p))) + .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() @@ -126,51 +191,6 @@ public static V1CustomResourceDefinition Transpile(this MetadataLoadContext cont return crd; } - /// - /// Transpile a list of entities to CRDs and group them by version. - /// - /// The . - /// The types to convert. - /// The converted custom resource definitions. - public static IEnumerable Transpile( - this MetadataLoadContext context, - IEnumerable types) - => types - .Select(context.GetContextType) - .Where(type => type.Assembly != context.GetContextType().Assembly - && type.GetCustomAttributesData().Any() - && !type.GetCustomAttributesData().Any()) - .Select(type => (Props: context.Transpile(type), - IsStorage: type.GetCustomAttributesData().Any())) - .GroupBy(grp => grp.Props.Metadata.Name) - .Select(group => - { - if (group.Count(def => def.IsStorage) > 1) - { - throw new ArgumentException("There are multiple stored versions on an entity."); - } - - var crd = group.First().Props; - crd.Spec.Versions = group - .SelectMany(c => c.Props.Spec.Versions.Select(v => - { - v.Served = true; - v.Storage = c.IsStorage; - return v; - })) - .OrderByDescending(v => v.Name, new KubernetesVersionComparer()) - .ToList(); - - // 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)) - { - crd.Spec.Versions[0].Storage = true; - } - - return crd; - }); - private static string GetPropertyName(this PropertyInfo prop, MetadataLoadContext context) { var name = prop.GetCustomAttributeData() switch @@ -186,16 +206,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 +228,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 +262,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 +352,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 +376,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 +390,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 +410,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 +421,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 +454,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 +503,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 +531,58 @@ private static V1JSONSchemaProps MapObjectType(this MetadataLoadContext context, }; } - return new() + if (ancestors.Contains(type)) + { + throw CircularTypeReference(type); + } + + var nextAncestors = new HashSet(ancestors) { type }; + var preservesUnknownFields = type.GetCustomAttributeData() != null; + + try { - 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 + 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 +597,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 +626,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/InvalidTypeException.cs b/src/KubeOps.Transpiler/InvalidTypeException.cs new file mode 100644 index 00000000..dbbf2a4c --- /dev/null +++ b/src/KubeOps.Transpiler/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; + +/// +/// Raised when the CRD transpiler encounters a type it cannot map to an OpenAPI schema. +/// +public 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/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..76838eaf --- /dev/null +++ b/test/KubeOps.Transpiler.Test/Crds.Mlc.CircularReference.Test.cs @@ -0,0 +1,310 @@ +// 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; + +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)] From 37c6b9904e5e99476fa25c795d7755b4bbd23f6b Mon Sep 17 00:00:00 2001 From: Marcus Kimpenhaus Date: Thu, 11 Jun 2026 01:11:08 +0200 Subject: [PATCH 2/3] refactor(transpiler): unify exception handling for CRD transpilation errors --- .../operator/building-blocks/entities.mdx | 2 +- src/KubeOps.Transpiler/Crds.cs | 169 +++++++++--------- .../CircularTypeReferenceException.cs | 4 +- .../{ => Exceptions}/InvalidTypeException.cs | 4 +- .../TranspilationFailedException.cs | 39 ++++ .../Crds.Mlc.CircularReference.Test.cs | 7 +- 6 files changed, 128 insertions(+), 97 deletions(-) rename src/KubeOps.Transpiler/{ => Exceptions}/CircularTypeReferenceException.cs (91%) rename src/KubeOps.Transpiler/{ => Exceptions}/InvalidTypeException.cs (91%) create mode 100644 src/KubeOps.Transpiler/Exceptions/TranspilationFailedException.cs diff --git a/docs/docs/operator/building-blocks/entities.mdx b/docs/docs/operator/building-blocks/entities.mdx index ee752589..25694646 100644 --- a/docs/docs/operator/building-blocks/entities.mdx +++ b/docs/docs/operator/building-blocks/entities.mdx @@ -156,7 +156,7 @@ public class EntitySpec - `[EmbeddedResource]`: Marks a property as an embedded Kubernetes resource. The property type is never traversed; the schema is always an opaque embedded `type: object`. (For unusual usages such as `[EmbeddedResource]` on a scalar or collection property, this is more opaque than before — no leftover `items`/`format` are emitted.) :::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 `CircularTypeReferenceException` during generation. Annotate the offending property or type with `[PreserveUnknownFields]` or `[Ignore]`, or restructure the type to remove the cycle. +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 diff --git a/src/KubeOps.Transpiler/Crds.cs b/src/KubeOps.Transpiler/Crds.cs index c4df4be8..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; @@ -51,16 +52,87 @@ public static V1CustomResourceDefinition Transpile(this MetadataLoadContext cont type = context.GetContextType(type); try { - return context.TranspileType(type); - } - catch (CircularTypeReferenceException ex) - { - throw new CircularTypeReferenceException( - $"Failed to transpile the CRD for entity '{type.FullName ?? type.Name}'. {ex.Message}", ex); + 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.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) + { + 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(); + + if (hasStatus || scaleAttr != 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() + { + 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()), + }, + }; + + version.AdditionalPrinterColumns = context.MapPrinterColumns(type).ToList() switch + { + { Count: > 0 } l => l, + _ => null, + }; + crd.Spec.Versions = new List { version }; + + return crd; } - catch (InvalidTypeException ex) + catch (Exception ex) when (ex is CircularTypeReferenceException or InvalidTypeException) { - throw new InvalidTypeException( + throw new TranspilationFailedException( $"Failed to transpile the CRD for entity '{type.FullName ?? type.Name}'. {ex.Message}", ex); } } @@ -110,87 +182,6 @@ public static IEnumerable Transpile( return crd; }); - private static V1CustomResourceDefinition TranspileType(this MetadataLoadContext context, Type type) - { - 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.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) - { - 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(); - - if (hasStatus || scaleAttr != 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() - { - 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()), - }, - }; - - version.AdditionalPrinterColumns = context.MapPrinterColumns(type).ToList() switch - { - { Count: > 0 } l => l, - _ => null, - }; - crd.Spec.Versions = new List { version }; - - return crd; - } - private static string GetPropertyName(this PropertyInfo prop, MetadataLoadContext context) { var name = prop.GetCustomAttributeData() switch diff --git a/src/KubeOps.Transpiler/CircularTypeReferenceException.cs b/src/KubeOps.Transpiler/Exceptions/CircularTypeReferenceException.cs similarity index 91% rename from src/KubeOps.Transpiler/CircularTypeReferenceException.cs rename to src/KubeOps.Transpiler/Exceptions/CircularTypeReferenceException.cs index 09fffc75..b9345d53 100644 --- a/src/KubeOps.Transpiler/CircularTypeReferenceException.cs +++ b/src/KubeOps.Transpiler/Exceptions/CircularTypeReferenceException.cs @@ -2,14 +2,14 @@ // 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; +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. /// -public sealed class CircularTypeReferenceException : InvalidOperationException +internal sealed class CircularTypeReferenceException : InvalidOperationException { /// /// Initializes a new instance of the class. diff --git a/src/KubeOps.Transpiler/InvalidTypeException.cs b/src/KubeOps.Transpiler/Exceptions/InvalidTypeException.cs similarity index 91% rename from src/KubeOps.Transpiler/InvalidTypeException.cs rename to src/KubeOps.Transpiler/Exceptions/InvalidTypeException.cs index dbbf2a4c..2ca519f5 100644 --- a/src/KubeOps.Transpiler/InvalidTypeException.cs +++ b/src/KubeOps.Transpiler/Exceptions/InvalidTypeException.cs @@ -2,12 +2,12 @@ // 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; +namespace KubeOps.Transpiler.Exceptions; /// /// Raised when the CRD transpiler encounters a type it cannot map to an OpenAPI schema. /// -public sealed class InvalidTypeException : InvalidOperationException +internal sealed class InvalidTypeException : InvalidOperationException { /// /// Initializes a new instance of the class. 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 index 76838eaf..4e19b606 100644 --- a/test/KubeOps.Transpiler.Test/Crds.Mlc.CircularReference.Test.cs +++ b/test/KubeOps.Transpiler.Test/Crds.Mlc.CircularReference.Test.cs @@ -10,6 +10,7 @@ using KubeOps.Abstractions.Entities; using KubeOps.Abstractions.Entities.Attributes; +using KubeOps.Transpiler.Exceptions; namespace KubeOps.Transpiler.Test; @@ -34,7 +35,7 @@ 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() + act.Should().Throw() .WithMessage("*circular*") .WithMessage($"*{nameof(CircularEntity)}*"); } @@ -68,7 +69,7 @@ public void Should_Throw_On_Circular_Type_Through_Collection() { var act = () => _mlc.Transpile(typeof(CircularThroughCollectionEntity)); - act.Should().Throw().WithMessage("*circular*"); + act.Should().Throw().WithMessage("*circular*"); } [Fact] @@ -77,7 +78,7 @@ public void Should_Throw_On_Circular_Type_Through_Dictionary_Value() { var act = () => _mlc.Transpile(typeof(CircularThroughDictionaryEntity)); - act.Should().Throw().WithMessage("*circular*"); + act.Should().Throw().WithMessage("*circular*"); } [Fact] From 0b55050a9adab39f118502e09b045c5021386b7f Mon Sep 17 00:00:00 2001 From: Marcus Kimpenhaus Date: Thu, 11 Jun 2026 01:34:03 +0200 Subject: [PATCH 3/3] docs(entities): clarify `[EmbeddedResource]` behavior in CRD generation --- docs/docs/operator/building-blocks/entities.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/operator/building-blocks/entities.mdx b/docs/docs/operator/building-blocks/entities.mdx index 25694646..8387a93e 100644 --- a/docs/docs/operator/building-blocks/entities.mdx +++ b/docs/docs/operator/building-blocks/entities.mdx @@ -153,7 +153,7 @@ public class EntitySpec - `[Ignore]`: Excludes a property or entity from CRD generation - `[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`. (For unusual usages such as `[EmbeddedResource]` on a scalar or collection property, this is more opaque than before — no leftover `items`/`format` are emitted.) +- `[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. @@ -268,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