From 0768d89dba55931aba06813c847d77425dee8182 Mon Sep 17 00:00:00 2001 From: Marcus Kimpenhaus Date: Tue, 19 May 2026 00:27:22 +0200 Subject: [PATCH 1/6] wip --- .../Transpilation/AssemblyLoader.cs | 3 +- src/KubeOps.Transpiler/Crds.cs | 21 ++- .../InheritedAttributeCtorReader.cs | 175 ++++++++++++++++++ .../KubeOps.Transpiler.csproj | 1 + src/KubeOps.Transpiler/Utilities.cs | 54 +++++- .../Crds.Mlc.Inheritance.Test.cs | 108 +++++++++++ test/KubeOps.Transpiler.Test/Rbac.Mlc.Test.cs | 45 +++++ 7 files changed, 400 insertions(+), 7 deletions(-) create mode 100644 src/KubeOps.Transpiler/InheritedAttributeCtorReader.cs create mode 100644 test/KubeOps.Transpiler.Test/Crds.Mlc.Inheritance.Test.cs diff --git a/src/KubeOps.Cli/Transpilation/AssemblyLoader.cs b/src/KubeOps.Cli/Transpilation/AssemblyLoader.cs index 51fc7a74f..3afe06c56 100644 --- a/src/KubeOps.Cli/Transpilation/AssemblyLoader.cs +++ b/src/KubeOps.Cli/Transpilation/AssemblyLoader.cs @@ -164,7 +164,8 @@ public static IEnumerable GetEntities(this MetadataLoadContext context) => .Select(e => e.t); public static IEnumerable GetRbacAttributes(this MetadataLoadContext context) => context - .GetTypesToInspect().SelectMany(t => t.GetCustomAttributesData().Concat(t.GetCustomAttributesData())); + .GetTypesToInspect().SelectMany(t => t.GetInheritedCustomAttributesData() + .Concat(t.GetInheritedCustomAttributesData())); public static IEnumerable GetValidatedEntities(this MetadataLoadContext context) => context .GetTypesToInspect() diff --git a/src/KubeOps.Transpiler/Crds.cs b/src/KubeOps.Transpiler/Crds.cs index 1231581e6..408e48857 100644 --- a/src/KubeOps.Transpiler/Crds.cs +++ b/src/KubeOps.Transpiler/Crds.cs @@ -217,13 +217,26 @@ private static IEnumerable MapPrinterColumns( }; } - foreach (var attr in type.GetCustomAttributesData()) + foreach (var attr in type.GetInheritedCustomAttributesData()) { + string? jsonPath, colName, colType; + if (attr.ConstructorArguments.Count >= 3) + { + jsonPath = attr.GetCustomAttributeCtorArg(context, 0); + colName = attr.GetCustomAttributeCtorArg(context, 1); + colType = attr.GetCustomAttributeCtorArg(context, 2); + } + else if (!InheritedAttributeCtorReader.TryReadBaseCtorArgs( + attr.AttributeType, out jsonPath, out colName, out colType)) + { + continue; + } + yield return new() { - Name = attr.GetCustomAttributeCtorArg(context, 1), - JsonPath = attr.GetCustomAttributeCtorArg(context, 0), - Type = attr.GetCustomAttributeCtorArg(context, 2), + Name = colName, + JsonPath = jsonPath, + Type = colType, Description = attr.GetCustomAttributeNamedArg(context, "Description"), Format = attr.GetCustomAttributeNamedArg(context, "Format"), Priority = attr.GetCustomAttributeNamedArg(context, "Priority") switch diff --git a/src/KubeOps.Transpiler/InheritedAttributeCtorReader.cs b/src/KubeOps.Transpiler/InheritedAttributeCtorReader.cs new file mode 100644 index 000000000..afb64a985 --- /dev/null +++ b/src/KubeOps.Transpiler/InheritedAttributeCtorReader.cs @@ -0,0 +1,175 @@ +// 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.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; +using System.Reflection.PortableExecutable; + +namespace KubeOps.Transpiler; + +/// +/// Reads constructor-body argument values from attribute types that inherit from another attribute. +/// When [ReadyPrinterColumn] is applied (where ReadyPrinterColumnAttribute calls +/// base(".status...", "Ready", "string")), the values exist only in the constructor IL body — +/// not in the assembly metadata blob. This reader uses +/// to parse those ldstr operands from the constructor method body. +/// +internal static class InheritedAttributeCtorReader +{ + /// + /// Attempts to extract the three string arguments passed to the + /// GenericAdditionalPrinterColumnAttribute(string jsonPath, string name, string type) + /// base constructor from the IL body of 's parameterless constructor. + /// + /// The attribute type whose constructor IL to analyse. + /// The first base-ctor argument (json path). + /// The second base-ctor argument (column name). + /// The third base-ctor argument (column type string). + /// + /// when all three values were successfully extracted; + /// when the assembly is not accessible from disk, the constructor body + /// does not match the expected ldstr, ldstr, ldstr, call pattern, or any other error occurs. + /// + internal static bool TryReadBaseCtorArgs( + Type attributeType, + out string? jsonPath, + out string? name, + out string? type) + { + jsonPath = null; + name = null; + type = null; + + var location = attributeType.Assembly.Location; + if (string.IsNullOrEmpty(location) || !File.Exists(location)) + return false; + + try + { + using var peStream = File.OpenRead(location); + using var peReader = new PEReader(peStream); + var metadataReader = peReader.GetMetadataReader(); + + foreach (var typeDefHandle in metadataReader.TypeDefinitions) + { + var fullName = GetClrFullName(typeDefHandle, metadataReader); + if (fullName != attributeType.FullName) + continue; + + var typeDef = metadataReader.GetTypeDefinition(typeDefHandle); + foreach (var methodHandle in typeDef.GetMethods()) + { + var method = metadataReader.GetMethodDefinition(methodHandle); + if (metadataReader.GetString(method.Name) != ".ctor") + continue; + + var sigReader = metadataReader.GetBlobReader(method.Signature); + sigReader.ReadSignatureHeader(); + var paramCount = sigReader.ReadCompressedInteger(); + if (paramCount != 0) + continue; + + if (method.RelativeVirtualAddress == 0) + return false; + + var body = peReader.GetMethodBody(method.RelativeVirtualAddress); + var il = body.GetILContent().ToArray(); + return TryExtractLdstrArgs(il, metadataReader, out jsonPath, out name, out type); + } + } + } + catch + { + return false; + } + + return false; + } + + /// + /// Builds the CLR-style full type name (using + as the nesting separator) for a type + /// definition, matching the format returned by in + /// . + /// + private static string GetClrFullName(TypeDefinitionHandle handle, MetadataReader metadataReader) + { + var typeDef = metadataReader.GetTypeDefinition(handle); + var name = metadataReader.GetString(typeDef.Name); + + var declaringHandle = typeDef.GetDeclaringType(); + if (!declaringHandle.IsNil) + return $"{GetClrFullName(declaringHandle, metadataReader)}+{name}"; + + var ns = metadataReader.GetString(typeDef.Namespace); + return string.IsNullOrEmpty(ns) ? name : $"{ns}.{name}"; + } + + /// + /// Scans raw IL for ldstr (0x72) opcodes and collects the resolved strings. + /// The first three found before a call are returned as the base-constructor arguments. + /// Handles common IL prefixes: nop, ldarg.0, call, ret. + /// Returns false when fewer than three strings are found before a call or an unrecognised opcode + /// is encountered. + /// + private static bool TryExtractLdstrArgs( + byte[] il, + MetadataReader metadataReader, + out string? arg0, + out string? arg1, + out string? arg2) + { + arg0 = null; + arg1 = null; + arg2 = null; + + var strings = new List(3); + var i = 0; + + while (i < il.Length) + { + var opcode = il[i++]; + + switch (opcode) + { + case 0x00: // nop + case 0x02: // ldarg.0 + case 0x2A: // ret + break; + + case 0x72: // ldstr <4-byte metadata token> + { + if (i + 4 > il.Length) + return false; + + var rawToken = il[i] | (il[i + 1] << 8) | (il[i + 2] << 16) | (il[i + 3] << 24); + i += 4; + + var handle = MetadataTokens.Handle(rawToken); + if (handle.Kind == HandleKind.UserString) + strings.Add(metadataReader.GetUserString((UserStringHandle)handle)); + + break; + } + + case 0x28: // call <4-byte method token> + case 0x6F: // callvirt <4-byte method token> + i += 4; + if (strings.Count >= 3) + { + arg0 = strings[0]; + arg1 = strings[1]; + arg2 = strings[2]; + return true; + } + + break; + + default: + return false; + } + } + + return false; + } +} diff --git a/src/KubeOps.Transpiler/KubeOps.Transpiler.csproj b/src/KubeOps.Transpiler/KubeOps.Transpiler.csproj index c4aa92d9c..437a15b7b 100644 --- a/src/KubeOps.Transpiler/KubeOps.Transpiler.csproj +++ b/src/KubeOps.Transpiler/KubeOps.Transpiler.csproj @@ -15,6 +15,7 @@ + diff --git a/src/KubeOps.Transpiler/Utilities.cs b/src/KubeOps.Transpiler/Utilities.cs index ea4c753f4..b335219dd 100644 --- a/src/KubeOps.Transpiler/Utilities.cs +++ b/src/KubeOps.Transpiler/Utilities.cs @@ -14,6 +14,7 @@ public static class Utilities { /// /// Load a custom attribute from a read-only-reflected type. + /// Also matches attributes whose attribute type inherits from . /// /// The type. /// The type of the attribute to load. @@ -22,7 +23,7 @@ public static class Utilities where TAttribute : Attribute => CustomAttributeData .GetCustomAttributes(type) - .FirstOrDefault(a => a.AttributeType.Name == typeof(TAttribute).Name); + .FirstOrDefault(a => IsOrInheritsFromAttribute(a.AttributeType, typeof(TAttribute).Name)); /// /// Load a custom attribute from a read-only-reflected field. @@ -50,6 +51,9 @@ public static class Utilities /// /// Load an enumerable of custom attributes from a read-only-reflected type. + /// Also includes attributes whose attribute type inherits from . + /// Does not walk the declaring type's inheritance chain; use + /// for that. /// /// The type. /// The type of the attribute to load. @@ -58,7 +62,7 @@ public static IEnumerable GetCustomAttributesData CustomAttributeData .GetCustomAttributes(type) - .Where(a => a.AttributeType.Name == typeof(TAttribute).Name); + .Where(a => IsOrInheritsFromAttribute(a.AttributeType, typeof(TAttribute).Name)); /// /// Load an enumerable of custom attributes from a read-only-reflected property. @@ -72,6 +76,30 @@ public static IEnumerable GetCustomAttributesData a.AttributeType.Name == typeof(TAttribute).Name); + /// + /// Load an enumerable of custom attributes from a read-only-reflected type and all its base types. + /// Also includes attributes whose attribute type inherits from . + /// + /// The type. + /// The type of the attribute to load. + /// The custom attribute data list from the full class inheritance chain. + public static IEnumerable GetInheritedCustomAttributesData(this Type type) + where TAttribute : Attribute + { + var current = (Type?)type; + while (current is not null + && current.FullName is not ("System.Object" or "System.ValueType" or "System.Enum")) + { + foreach (var attr in CustomAttributeData.GetCustomAttributes(current) + .Where(a => IsOrInheritsFromAttribute(a.AttributeType, typeof(TAttribute).Name))) + { + yield return attr; + } + + current = current.BaseType; + } + } + /// /// Load a specific named argument from a custom attribute. /// Named arguments are in the property-notation: @@ -220,4 +248,26 @@ public static bool IsNullable(this Type type) public static bool IsNullable(this PropertyInfo prop) => new NullabilityInfoContext().Create(prop).ReadState == NullabilityState.Nullable || prop.PropertyType.FullName?.Contains("Nullable") == true; + + /// + /// Returns if is, or inherits from, an + /// attribute type whose simple name matches . + /// Used to support attribute-type inheritance in -reflected types, + /// where cross-context type identity cannot be compared by reference. + /// + /// The attribute type to inspect. + /// The simple (Type.Name) name of the target attribute. + /// when a match is found in the inheritance chain. + internal static bool IsOrInheritsFromAttribute(Type attributeType, string targetTypeName) + { + var current = (Type?)attributeType; + while (current is not null) + { + if (current.Name == targetTypeName) + return true; + current = current.BaseType; + } + + return false; + } } diff --git a/test/KubeOps.Transpiler.Test/Crds.Mlc.Inheritance.Test.cs b/test/KubeOps.Transpiler.Test/Crds.Mlc.Inheritance.Test.cs new file mode 100644 index 000000000..b6a336f64 --- /dev/null +++ b/test/KubeOps.Transpiler.Test/Crds.Mlc.Inheritance.Test.cs @@ -0,0 +1,108 @@ +// 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 FluentAssertions; + +using k8s.Models; + +using KubeOps.Abstractions.Entities; +using KubeOps.Abstractions.Entities.Attributes; + +namespace KubeOps.Transpiler.Test; + +public sealed partial class CrdsMlcTest +{ + // ------------------------------------------------------------------- + // #806 sub-case A: base entity class carries [GenericAdditionalPrinterColumn] + // ------------------------------------------------------------------- + + [Fact] + public void Should_Inherit_GenericPrinterColumn_From_Base_Entity_Class() + { + var crd = _mlc.Transpile(typeof(DerivedFromBasePrinterColumnEntity)); + var apc = crd.Spec.Versions[0].AdditionalPrinterColumns; + + apc.Should().NotBeNull(); + apc.Should().ContainSingle(col => + col.JsonPath == ".status.foo" && col.Name == "Foo" && col.Type == "string"); + } + + [Fact] + public void Should_Accumulate_PrinterColumns_From_All_Hierarchy_Levels() + { + var crd = _mlc.Transpile(typeof(DoublyDerivedPrinterColumnEntity)); + var apc = crd.Spec.Versions[0].AdditionalPrinterColumns; + + apc.Should().NotBeNull(); + apc.Should().Contain(col => col.Name == "Foo"); + apc.Should().Contain(col => col.Name == "Bar"); + } + + // ------------------------------------------------------------------- + // #806 sub-case B: attribute type inherits GenericAdditionalPrinterColumnAttribute + // ------------------------------------------------------------------- + + [Fact] + public void Should_Recognize_Inherited_PrinterColumn_Attribute_Type() + { + var crd = _mlc.Transpile(typeof(EntityWithInheritedPrinterColumnAttrType)); + var apc = crd.Spec.Versions[0].AdditionalPrinterColumns; + + apc.Should().NotBeNull(); + apc.Should().ContainSingle(col => + col.JsonPath == ".status.conditions[?(@.type==\"Ready\")].status" && + col.Name == "Ready" && + col.Type == "string"); + } + + [Fact] + public void Should_Support_Multiple_Inherited_PrinterColumn_Attribute_Types() + { + var crd = _mlc.Transpile(typeof(EntityWithMultipleInheritedPrinterColumnAttrTypes)); + var apc = crd.Spec.Versions[0].AdditionalPrinterColumns; + + apc.Should().NotBeNull(); + apc.Should().Contain(col => col.Name == "Ready"); + apc.Should().Contain(col => col.Name == "Reason"); + } + + // ------------------------------------------------------------------- + // Test entity classes + // ------------------------------------------------------------------- + + [GenericAdditionalPrinterColumn(".status.foo", "Foo", "string")] + private class BasePrinterColumnEntity : CustomKubernetesEntity; + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "DerivedPrinterColumn")] + private sealed class DerivedFromBasePrinterColumnEntity : BasePrinterColumnEntity; + + [GenericAdditionalPrinterColumn(".status.bar", "Bar", "string")] + private class MidPrinterColumnEntity : BasePrinterColumnEntity; + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "DoublyDerivedPrinterColumn")] + private sealed class DoublyDerivedPrinterColumnEntity : MidPrinterColumnEntity; + + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] + private sealed class ReadyPrinterColumnAttribute : GenericAdditionalPrinterColumnAttribute + { + public ReadyPrinterColumnAttribute() + : base(".status.conditions[?(@.type==\"Ready\")].status", "Ready", "string") { } + } + + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] + private sealed class ReasonPrinterColumnAttribute : GenericAdditionalPrinterColumnAttribute + { + public ReasonPrinterColumnAttribute() + : base(".status.conditions[?(@.type==\"Ready\")].reason", "Reason", "string") { } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "InheritedAttrType")] + [ReadyPrinterColumn] + private sealed class EntityWithInheritedPrinterColumnAttrType : CustomKubernetesEntity; + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "MultiInheritedAttrType")] + [ReadyPrinterColumn] + [ReasonPrinterColumn] + private sealed class EntityWithMultipleInheritedPrinterColumnAttrTypes : CustomKubernetesEntity; +} diff --git a/test/KubeOps.Transpiler.Test/Rbac.Mlc.Test.cs b/test/KubeOps.Transpiler.Test/Rbac.Mlc.Test.cs index dc5de8965..56e41ea15 100644 --- a/test/KubeOps.Transpiler.Test/Rbac.Mlc.Test.cs +++ b/test/KubeOps.Transpiler.Test/Rbac.Mlc.Test.cs @@ -136,4 +136,49 @@ public class RbacTest6 : CustomKubernetesEntity; [GenericRbac(Urls = ["url", "foobar"], Resources = ["configmaps"], Groups = ["group"], Verbs = RbacVerb.Delete | RbacVerb.Get)] public class GenericRbacTest : CustomKubernetesEntity; + + // ------------------------------------------------------------------- + // #1025: attribute on base class must be collected for derived class + // ------------------------------------------------------------------- + + [Fact] + public void Should_Inherit_EntityRbac_From_Base_Class() + { + var rules = _mlc + .Transpile(_mlc.GetContextType() + .GetInheritedCustomAttributesData()) + .ToList(); + + rules.Should().ContainSingle(r => + r.Resources.Contains("rbacbaseentitys") && + r.Verbs.Contains("get")); + } + + [Fact] + public void Should_Merge_EntityRbac_From_Full_Inheritance_Chain() + { + var rules = _mlc + .Transpile(_mlc.GetContextType() + .GetInheritedCustomAttributesData()) + .ToList(); + + // get+update are on the same entity → transpiler merges them into one rule + rules.Should().ContainSingle(r => + r.Resources.Contains("rbacbaseentitys") && + r.Verbs.Contains("get") && + r.Verbs.Contains("update")); + } + + [KubernetesEntity(Group = "test", ApiVersion = "v1")] + public class RbacBaseEntity : CustomKubernetesEntity; + + [EntityRbac(typeof(RbacBaseEntity), Verbs = RbacVerb.Get)] + public abstract class RbacBaseController; + + public class RbacInheritanceDerivedController : RbacBaseController; + + [EntityRbac(typeof(RbacBaseEntity), Verbs = RbacVerb.Update)] + public abstract class RbacMidController : RbacBaseController; + + public class RbacInheritanceDeepDerivedController : RbacMidController; } From 617b9c2475063e91b9a549cfcfffe080e1303cc0 Mon Sep 17 00:00:00 2001 From: Marcus Kimpenhaus Date: Fri, 29 May 2026 17:33:03 +0200 Subject: [PATCH 2/6] fix: addressed some minor issues --- src/KubeOps.Transpiler/InheritedAttributeCtorReader.cs | 2 +- src/KubeOps.Transpiler/Utilities.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/KubeOps.Transpiler/InheritedAttributeCtorReader.cs b/src/KubeOps.Transpiler/InheritedAttributeCtorReader.cs index afb64a985..9518f0bc6 100644 --- a/src/KubeOps.Transpiler/InheritedAttributeCtorReader.cs +++ b/src/KubeOps.Transpiler/InheritedAttributeCtorReader.cs @@ -79,7 +79,7 @@ internal static bool TryReadBaseCtorArgs( } } } - catch + catch (Exception) { return false; } diff --git a/src/KubeOps.Transpiler/Utilities.cs b/src/KubeOps.Transpiler/Utilities.cs index b335219dd..ce445e3b8 100644 --- a/src/KubeOps.Transpiler/Utilities.cs +++ b/src/KubeOps.Transpiler/Utilities.cs @@ -88,7 +88,7 @@ public static IEnumerable GetInheritedCustomAttributesData< { var current = (Type?)type; while (current is not null - && current.FullName is not ("System.Object" or "System.ValueType" or "System.Enum")) + && current.FullName is not (null or "System.Object" or "System.ValueType" or "System.Enum")) { foreach (var attr in CustomAttributeData.GetCustomAttributes(current) .Where(a => IsOrInheritsFromAttribute(a.AttributeType, typeof(TAttribute).Name))) From 0bb8712ed4d6c082c625460cbd80dfbc9709aa44 Mon Sep 17 00:00:00 2001 From: Marcus Kimpenhaus Date: Sat, 30 May 2026 09:41:17 +0200 Subject: [PATCH 3/6] refactor: refactored attribute inheritance reconginition --- .../operator/building-blocks/entities.mdx | 21 +++ .../Commands/Management/Install.cs | 2 +- .../Commands/Management/Uninstall.cs | 2 +- src/KubeOps.Cli/Generators/CrdGenerator.cs | 14 +- .../Transpilation/AssemblyLoader.cs | 68 ++++--- .../RoslynInheritedAttributeResolver.cs | 114 ++++++++++++ ...Comparer.cs => TargetFrameworkComparer.cs} | 2 +- src/KubeOps.Transpiler/Crds.cs | 55 +++++- .../IInheritedAttributeResolver.cs | 33 ++++ .../InheritedAttributeCtorReader.cs | 175 ------------------ .../KubeOps.Transpiler.csproj | 1 - .../ReflectionInheritedAttributeResolver.cs | 63 +++++++ 12 files changed, 332 insertions(+), 218 deletions(-) create mode 100644 src/KubeOps.Cli/Transpilation/RoslynInheritedAttributeResolver.cs rename src/KubeOps.Cli/Transpilation/{TfmComparer.cs => TargetFrameworkComparer.cs} (96%) create mode 100644 src/KubeOps.Transpiler/IInheritedAttributeResolver.cs delete mode 100644 src/KubeOps.Transpiler/InheritedAttributeCtorReader.cs create mode 100644 src/KubeOps.Transpiler/ReflectionInheritedAttributeResolver.cs diff --git a/docs/docs/operator/building-blocks/entities.mdx b/docs/docs/operator/building-blocks/entities.mdx index 2f0c27214..b1088e392 100644 --- a/docs/docs/operator/building-blocks/entities.mdx +++ b/docs/docs/operator/building-blocks/entities.mdx @@ -192,6 +192,27 @@ public class V1DemoEntity : CustomKubernetesEntity Handler(IAnsiConsole console, IKubernetes client }; console.WriteLine($"Install CRDs from {file.Name}."); - var crds = parser.Transpile(parser.GetEntities()).ToList(); + var crds = parser.Transpile(parser.GetEntities(), parser.GetInheritedAttributeResolver()).ToList(); if (crds.Count == 0) { console.WriteLine("No CRDs found. Exiting."); diff --git a/src/KubeOps.Cli/Commands/Management/Uninstall.cs b/src/KubeOps.Cli/Commands/Management/Uninstall.cs index 0615dbcda..b629d61f3 100644 --- a/src/KubeOps.Cli/Commands/Management/Uninstall.cs +++ b/src/KubeOps.Cli/Commands/Management/Uninstall.cs @@ -62,7 +62,7 @@ internal static async Task Handler(IAnsiConsole console, IKubernetes client }; console.WriteLine($"Uninstall CRDs from {file.Name}."); - var crds = parser.Transpile(parser.GetEntities()).ToList(); + var crds = parser.Transpile(parser.GetEntities(), parser.GetInheritedAttributeResolver()).ToList(); if (crds.Count == 0) { console.WriteLine("No CRDs found. Exiting."); diff --git a/src/KubeOps.Cli/Generators/CrdGenerator.cs b/src/KubeOps.Cli/Generators/CrdGenerator.cs index fe29571d9..e0a9ea9a0 100644 --- a/src/KubeOps.Cli/Generators/CrdGenerator.cs +++ b/src/KubeOps.Cli/Generators/CrdGenerator.cs @@ -4,20 +4,18 @@ using System.Reflection; -using k8s.Models; - using KubeOps.Cli.Output; using KubeOps.Cli.Transpilation; using KubeOps.Transpiler; namespace KubeOps.Cli.Generators; -internal class CrdGenerator(MetadataLoadContext parser, byte[] caBundle, +internal sealed class CrdGenerator(MetadataLoadContext parser, byte[] caBundle, OutputFormat outputFormat) : IConfigGenerator { public void Generate(ResultOutput output) { - var crds = parser.Transpile(parser.GetEntities()).ToList(); + var crds = parser.Transpile(parser.GetEntities(), parser.GetInheritedAttributeResolver()).ToList(); var conversionWebhooks = parser.GetConvertedEntities().ToList(); foreach (var crd in crds) @@ -25,16 +23,16 @@ public void Generate(ResultOutput output) if (conversionWebhooks .Find(wh => crd.Spec.Group == wh.Group && crd.Spec.Names.Kind == wh.Kind) is not null) { - crd.Spec.Conversion = new V1CustomResourceConversion + crd.Spec.Conversion = new() { Strategy = "Webhook", - Webhook = new V1WebhookConversion + Webhook = new() { ConversionReviewVersions = new[] { "v1" }, - ClientConfig = new Apiextensionsv1WebhookClientConfig + ClientConfig = new() { CaBundle = caBundle, - Service = new Apiextensionsv1ServiceReference + Service = new() { Path = $"/convert/{crd.Spec.Group}/{crd.Spec.Names.Plural}", Name = "service", diff --git a/src/KubeOps.Cli/Transpilation/AssemblyLoader.cs b/src/KubeOps.Cli/Transpilation/AssemblyLoader.cs index 3afe06c56..bbcd60039 100644 --- a/src/KubeOps.Cli/Transpilation/AssemblyLoader.cs +++ b/src/KubeOps.Cli/Transpilation/AssemblyLoader.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; using System.Reflection; +using System.Runtime.CompilerServices; using System.Text; using System.Text.RegularExpressions; @@ -33,11 +34,27 @@ namespace KubeOps.Cli.Transpilation; Justification = "It is the CLI that uses the libraries.")] internal static partial class AssemblyLoader { + private static readonly ConditionalWeakTable + InheritedAttributeResolvers = new(); + static AssemblyLoader() { MSBuildLocator.RegisterDefaults(); } + /// + /// Returns the Roslyn-based resolver associated with a context loaded by this loader, used to + /// recover inherited attribute property values without executing user code. Falls back to + /// reflection-based resolution for contexts not created here. + /// + /// The context previously created by or . + /// The resolver for inherited attribute property values. + public static IInheritedAttributeResolver GetInheritedAttributeResolver( + this MetadataLoadContext context) + => InheritedAttributeResolvers.TryGetValue(context, out var resolver) + ? resolver + : ReflectionInheritedAttributeResolver.Default; + public static Task ForProject( IAnsiConsole console, FileInfo projectFile) @@ -74,6 +91,9 @@ public static Task ForProject( .Concat(new[] { typeof(object).Assembly.Location }))); mlc.LoadFromByteArray(assemblyStream.ToArray()); + InheritedAttributeResolvers.AddOrUpdate( + mlc, new RoslynInheritedAttributeResolver(new[] { compilation })); + return mlc; }); @@ -81,17 +101,17 @@ public static Task ForSolution( IAnsiConsole console, FileInfo slnFile, Regex? projectFilter = null, - string? tfm = null) + string? targetFramework = null) => console.Status().StartAsync($"Compiling {slnFile.Name}...", async _ => { projectFilter ??= DefaultRegex(); - tfm ??= "latest"; + targetFramework ??= "latest"; console.MarkupLineInterpolated($"Compile solution [aqua]{slnFile.FullName}[/]."); #pragma warning disable RCS1097 console.MarkupLineInterpolated($"[grey]With project filter:[/] {projectFilter.ToString()}"); #pragma warning restore RCS1097 - console.MarkupLineInterpolated($"[grey]With Target Platform:[/] {tfm}"); + console.MarkupLineInterpolated($"[grey]With Target Platform:[/] {targetFramework}"); using var workspace = MSBuildWorkspace.Create(); workspace.SkipUnrecognizedProjects = true; @@ -103,25 +123,26 @@ public static Task ForSolution( var assemblies = await Task.WhenAll(solution.Projects .Select(p => { - var name = TfmComparer.TfmRegex().Replace(p.Name, string.Empty); - var tfm = TfmComparer.TfmRegex().Match(p.Name).Groups["tfm"].Value; - return (name, tfm, project: p); + var name = TargetFrameworkComparer.TfmRegex().Replace(p.Name, string.Empty); + var ltfm = TargetFrameworkComparer.TfmRegex().Match(p.Name).Groups["tfm"].Value; + return (Name: name, TargetFramework: ltfm, Project: p); }) - .Where(p => projectFilter.IsMatch(p.name)) - .Where(p => tfm == "latest" || p.tfm.Length == 0 || p.tfm == tfm) - .OrderByDescending(p => p.tfm, new TfmComparer()) - .GroupBy(p => p.name) + .Where(p => + projectFilter.IsMatch(p.Name) + && (targetFramework == "latest" || p.TargetFramework.Length == 0 || p.TargetFramework == targetFramework)) + .OrderByDescending(p => p.TargetFramework, new TargetFrameworkComparer()) + .GroupBy(p => p.Name) .Select(p => p.FirstOrDefault()) .Where(p => p != default) .Select(async p => { console.MarkupLineInterpolated( - p.tfm.Length > 0 - ? (FormattableString)$"Load compilation context for [aqua]{p.name}[/] [grey]{p.tfm}[/]." - : (FormattableString)$"Load compilation context for [aqua]{p.name}[/]."); + p.TargetFramework.Length > 0 + ? (FormattableString)$"Load compilation context for [aqua]{p.Name}[/] [grey]{p.TargetFramework}[/]." + : (FormattableString)$"Load compilation context for [aqua]{p.Name}[/]."); - var compilation = await p.project.GetCompilationAsync(); - console.MarkupLineInterpolated($"[green]Compilation context loaded for {p.name}.[/]"); + var compilation = await p.Project.GetCompilationAsync(); + console.MarkupLineInterpolated($"[green]Compilation context loaded for {p.Name}.[/]"); if (compilation is null) { throw new AggregateException("Compilation could not be found."); @@ -129,9 +150,9 @@ public static Task ForSolution( await using var assemblyStream = new MemoryStream(); console.MarkupLineInterpolated( - p.tfm.Length > 0 - ? (FormattableString)$"Start compilation for [aqua]{p.name}[/] [grey]{p.tfm}[/]." - : (FormattableString)$"Start compilation for [aqua]{p.name}[/]."); + p.TargetFramework.Length > 0 + ? (FormattableString)$"Start compilation for [aqua]{p.Name}[/] [grey]{p.TargetFramework}[/]." + : (FormattableString)$"Start compilation for [aqua]{p.Name}[/]."); switch (compilation.Emit(assemblyStream)) { case { Success: false, Diagnostics: var diag }: @@ -139,9 +160,10 @@ public static Task ForSolution( $"Compilation failed: {diag.Aggregate(new StringBuilder(), (sb, d) => sb.AppendLine(d.ToString()))}"); } - console.MarkupLineInterpolated($"[green]Compilation successful for {p.name}.[/]"); + console.MarkupLineInterpolated($"[green]Compilation successful for {p.Name}.[/]"); return (Assembly: assemblyStream.ToArray(), - Refs: p.project.MetadataReferences.Select(m => m.Display ?? string.Empty)); + Refs: p.Project.MetadataReferences.Select(m => m.Display ?? string.Empty), + Compilation: compilation); })); console.WriteLine(); @@ -153,6 +175,10 @@ public static Task ForSolution( mlc.LoadFromByteArray(assembly.Assembly); } + InheritedAttributeResolvers.AddOrUpdate( + mlc, + new RoslynInheritedAttributeResolver(assemblies.Select(a => a.Compilation).ToList())); + return mlc; }); @@ -191,7 +217,7 @@ public static IEnumerable GetConvertedEntities(this MetadataLoad private static IEnumerable GetTypesToInspect(this MetadataLoadContext context) => context .GetAssemblies() .SelectMany(a => a.DefinedTypes) - .Where(t => !t.IsInterface && !t.IsAbstract && !t.IsGenericType) + .Where(t => t is { IsInterface: false, IsAbstract: false, IsGenericType: false }) .OrderBy(t => t.FullName, StringComparer.Ordinal); [GeneratedRegex(".*")] diff --git a/src/KubeOps.Cli/Transpilation/RoslynInheritedAttributeResolver.cs b/src/KubeOps.Cli/Transpilation/RoslynInheritedAttributeResolver.cs new file mode 100644 index 000000000..720decbd1 --- /dev/null +++ b/src/KubeOps.Cli/Transpilation/RoslynInheritedAttributeResolver.cs @@ -0,0 +1,114 @@ +// 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.Collections.ObjectModel; + +using KubeOps.Transpiler; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace KubeOps.Cli.Transpilation; + +/// +/// Resolves inherited attribute values via the Roslyn semantic model. The CLI must not load or +/// execute the user's assembly, so reflection-based resolution is not an option here. Instead the +/// already-built is queried: the parameterless constructor's +/// : base(...) initializer is located in source, its arguments are read as compile-time +/// constants, and each constant is mapped to the base-constructor parameter — and therefore the +/// property — it initializes. No user code is executed and no IL is parsed. +/// +internal sealed class RoslynInheritedAttributeResolver(IReadOnlyList compilations) + : IInheritedAttributeResolver +{ + public bool TryResolve(Type attributeType, out IReadOnlyDictionary propertyValues) + { + propertyValues = ReadOnlyDictionary.Empty; + + if (attributeType.FullName is not { } fullName) + { + return false; + } + + foreach (var compilation in compilations) + { + if (compilation.GetTypeByMetadataName(fullName) is not { } symbol) + { + continue; + } + + var ctor = symbol.InstanceConstructors.FirstOrDefault(c => c.Parameters.IsEmpty); + if (ctor?.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax() + is not ConstructorDeclarationSyntax { Initializer.ArgumentList.Arguments.Count: > 0 } decl) + { + continue; + } + + var model = compilation.GetSemanticModel(decl.SyntaxTree); + if (model.GetSymbolInfo(decl.Initializer!).Symbol is not IMethodSymbol baseCtor) + { + continue; + } + + var values = ResolveArguments(model, decl.Initializer!.ArgumentList.Arguments, baseCtor, symbol); + if (values.Count > 0) + { + propertyValues = values; + return true; + } + } + + return false; + } + + private static Dictionary ResolveArguments( + SemanticModel model, + IReadOnlyList arguments, + IMethodSymbol baseCtor, + INamedTypeSymbol attributeSymbol) + { + var values = new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < arguments.Count; i++) + { + var argument = arguments[i]; + var constant = model.GetConstantValue(argument.Expression); + if (!constant.HasValue) + { + continue; + } + + var parameterName = argument.NameColon?.Name.Identifier.ValueText + ?? (i < baseCtor.Parameters.Length ? baseCtor.Parameters[i].Name : null); + if (FindProperty(attributeSymbol, parameterName) is not { } property) + { + continue; + } + + values[property.Name] = constant.Value; + } + + return values; + } + + private static IPropertySymbol? FindProperty(INamedTypeSymbol attributeSymbol, string? parameterName) + { + if (parameterName is null) + { + return null; + } + + for (INamedTypeSymbol? current = attributeSymbol; current is not null; current = current.BaseType) + { + var match = current.GetMembers() + .OfType() + .FirstOrDefault(p => string.Equals(p.Name, parameterName, StringComparison.OrdinalIgnoreCase)); + if (match is not null) + { + return match; + } + } + + return null; + } +} diff --git a/src/KubeOps.Cli/Transpilation/TfmComparer.cs b/src/KubeOps.Cli/Transpilation/TargetFrameworkComparer.cs similarity index 96% rename from src/KubeOps.Cli/Transpilation/TfmComparer.cs rename to src/KubeOps.Cli/Transpilation/TargetFrameworkComparer.cs index 1e619d277..1c80290a9 100644 --- a/src/KubeOps.Cli/Transpilation/TfmComparer.cs +++ b/src/KubeOps.Cli/Transpilation/TargetFrameworkComparer.cs @@ -9,7 +9,7 @@ namespace KubeOps.Cli.Transpilation; /// /// Tfm Comparer. /// -internal sealed partial class TfmComparer : IComparer +internal sealed partial class TargetFrameworkComparer : IComparer { [GeneratedRegex( "[(]?(?(?(netcoreapp|net|netstandard){1})(?[0-9]+)[.](?[0-9]+))[)]?", diff --git a/src/KubeOps.Transpiler/Crds.cs b/src/KubeOps.Transpiler/Crds.cs index 1afd3e32c..32acd9328 100644 --- a/src/KubeOps.Transpiler/Crds.cs +++ b/src/KubeOps.Transpiler/Crds.cs @@ -43,8 +43,16 @@ public static class Crds /// /// The . /// The type to convert. + /// + /// Resolver for inherited attribute property values produced inside a constructor. Defaults to + /// ; the CLI supplies a Roslyn-based + /// resolver so that no user code is executed during build-time transpilation. + /// /// The converted custom resource definition. - public static V1CustomResourceDefinition Transpile(this MetadataLoadContext context, Type type) + public static V1CustomResourceDefinition Transpile( + this MetadataLoadContext context, + Type type, + IInheritedAttributeResolver? inheritedAttributeResolver = null) { type = context.GetContextType(type); var (meta, scope) = context.ToEntityMetadata(type); @@ -116,7 +124,7 @@ public static V1CustomResourceDefinition Transpile(this MetadataLoadContext cont }, }; - version.AdditionalPrinterColumns = context.MapPrinterColumns(type).ToList() switch + version.AdditionalPrinterColumns = context.MapPrinterColumns(type, inheritedAttributeResolver).ToList() switch { { Count: > 0 } l => l, _ => null, @@ -131,16 +139,22 @@ public static V1CustomResourceDefinition Transpile(this MetadataLoadContext cont /// /// The . /// The types to convert. + /// + /// Resolver for inherited attribute property values produced inside a constructor. Defaults to + /// ; the CLI supplies a Roslyn-based + /// resolver so that no user code is executed during build-time transpilation. + /// /// The converted custom resource definitions. public static IEnumerable Transpile( this MetadataLoadContext context, - IEnumerable types) + IEnumerable types, + IInheritedAttributeResolver? inheritedAttributeResolver = null) => types .Select(context.GetContextType) .Where(type => type.Assembly != context.GetContextType().Assembly && type.GetCustomAttributesData().Any() && !type.GetCustomAttributesData().Any()) - .Select(type => (Props: context.Transpile(type), + .Select(type => (Props: context.Transpile(type, inheritedAttributeResolver), IsStorage: type.GetCustomAttributesData().Any())) .GroupBy(grp => grp.Props.Metadata.Name) .Select(group => @@ -184,8 +198,11 @@ private static string GetPropertyName(this PropertyInfo prop, MetadataLoadContex private static IEnumerable MapPrinterColumns( this MetadataLoadContext context, - Type type) + Type type, + IInheritedAttributeResolver? inheritedAttributeResolver) { + inheritedAttributeResolver ??= ReflectionInheritedAttributeResolver.Default; + var props = type.GetProperties().Select(p => (Prop: p, Path: string.Empty)).ToList(); while (props.Count > 0) { @@ -222,14 +239,32 @@ private static IEnumerable MapPrinterColumns( foreach (var attr in type.GetInheritedCustomAttributesData()) { string? jsonPath, colName, colType; + string? description, format; + PrinterColumnPriority priority; + if (attr.ConstructorArguments.Count >= 3) { jsonPath = attr.GetCustomAttributeCtorArg(context, 0); colName = attr.GetCustomAttributeCtorArg(context, 1); colType = attr.GetCustomAttributeCtorArg(context, 2); + description = attr.GetCustomAttributeNamedArg(context, "Description"); + format = attr.GetCustomAttributeNamedArg(context, "Format"); + priority = attr.GetCustomAttributeNamedArg(context, "Priority"); + } + else if (inheritedAttributeResolver.TryResolve(attr.AttributeType, out var values)) + { + jsonPath = values.GetValueOrDefault("JsonPath") as string; + colName = values.GetValueOrDefault("Name") as string; + colType = values.GetValueOrDefault("Type") as string; + description = values.GetValueOrDefault("Description") as string; + format = values.GetValueOrDefault("Format") as string; + priority = values.GetValueOrDefault("Priority") as PrinterColumnPriority? ?? default; + if (jsonPath is null || colName is null || colType is null) + { + continue; + } } - else if (!InheritedAttributeCtorReader.TryReadBaseCtorArgs( - attr.AttributeType, out jsonPath, out colName, out colType)) + else { continue; } @@ -239,9 +274,9 @@ private static IEnumerable MapPrinterColumns( Name = colName, JsonPath = jsonPath, Type = colType, - Description = attr.GetCustomAttributeNamedArg(context, "Description"), - Format = attr.GetCustomAttributeNamedArg(context, "Format"), - Priority = attr.GetCustomAttributeNamedArg(context, "Priority") switch + Description = description, + Format = format, + Priority = priority switch { PrinterColumnPriority.StandardView => 0, _ => 1, diff --git a/src/KubeOps.Transpiler/IInheritedAttributeResolver.cs b/src/KubeOps.Transpiler/IInheritedAttributeResolver.cs new file mode 100644 index 000000000..73715e2a0 --- /dev/null +++ b/src/KubeOps.Transpiler/IInheritedAttributeResolver.cs @@ -0,0 +1,33 @@ +// 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; + +/// +/// Recovers the effective property values of an attribute type whose values are produced inside +/// its constructor rather than supplied at the application site. The classic case is a reusable, +/// named attribute that derives from a configurable base attribute and forwards constants to the +/// base constructor (e.g. ReadyPrinterColumnAttribute : GenericAdditionalPrinterColumnAttribute). +/// Such values are not present in the attribute-application metadata blob, so they cannot be read +/// through . +/// +/// +/// The seam is deliberately attribute-agnostic: it returns every resolved property keyed by name, +/// so any current or future CRD-shaping attribute can recover inherited values through the same +/// mechanism. Each host supplies the strongest tool it has — real reflection at runtime (the +/// attribute is instantiated) or the Roslyn semantic model at build time (the base-constructor +/// constants are read from source). +/// +public interface IInheritedAttributeResolver +{ + /// + /// Attempts to materialize the effective property values of . + /// + /// The (read-only reflected) attribute type to inspect. + /// + /// The resolved property values keyed by property name (ordinal). Empty when resolution fails. + /// + /// when at least one value was resolved; otherwise . + bool TryResolve(Type attributeType, out IReadOnlyDictionary propertyValues); +} diff --git a/src/KubeOps.Transpiler/InheritedAttributeCtorReader.cs b/src/KubeOps.Transpiler/InheritedAttributeCtorReader.cs deleted file mode 100644 index 9518f0bc6..000000000 --- a/src/KubeOps.Transpiler/InheritedAttributeCtorReader.cs +++ /dev/null @@ -1,175 +0,0 @@ -// 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.Reflection.Metadata; -using System.Reflection.Metadata.Ecma335; -using System.Reflection.PortableExecutable; - -namespace KubeOps.Transpiler; - -/// -/// Reads constructor-body argument values from attribute types that inherit from another attribute. -/// When [ReadyPrinterColumn] is applied (where ReadyPrinterColumnAttribute calls -/// base(".status...", "Ready", "string")), the values exist only in the constructor IL body — -/// not in the assembly metadata blob. This reader uses -/// to parse those ldstr operands from the constructor method body. -/// -internal static class InheritedAttributeCtorReader -{ - /// - /// Attempts to extract the three string arguments passed to the - /// GenericAdditionalPrinterColumnAttribute(string jsonPath, string name, string type) - /// base constructor from the IL body of 's parameterless constructor. - /// - /// The attribute type whose constructor IL to analyse. - /// The first base-ctor argument (json path). - /// The second base-ctor argument (column name). - /// The third base-ctor argument (column type string). - /// - /// when all three values were successfully extracted; - /// when the assembly is not accessible from disk, the constructor body - /// does not match the expected ldstr, ldstr, ldstr, call pattern, or any other error occurs. - /// - internal static bool TryReadBaseCtorArgs( - Type attributeType, - out string? jsonPath, - out string? name, - out string? type) - { - jsonPath = null; - name = null; - type = null; - - var location = attributeType.Assembly.Location; - if (string.IsNullOrEmpty(location) || !File.Exists(location)) - return false; - - try - { - using var peStream = File.OpenRead(location); - using var peReader = new PEReader(peStream); - var metadataReader = peReader.GetMetadataReader(); - - foreach (var typeDefHandle in metadataReader.TypeDefinitions) - { - var fullName = GetClrFullName(typeDefHandle, metadataReader); - if (fullName != attributeType.FullName) - continue; - - var typeDef = metadataReader.GetTypeDefinition(typeDefHandle); - foreach (var methodHandle in typeDef.GetMethods()) - { - var method = metadataReader.GetMethodDefinition(methodHandle); - if (metadataReader.GetString(method.Name) != ".ctor") - continue; - - var sigReader = metadataReader.GetBlobReader(method.Signature); - sigReader.ReadSignatureHeader(); - var paramCount = sigReader.ReadCompressedInteger(); - if (paramCount != 0) - continue; - - if (method.RelativeVirtualAddress == 0) - return false; - - var body = peReader.GetMethodBody(method.RelativeVirtualAddress); - var il = body.GetILContent().ToArray(); - return TryExtractLdstrArgs(il, metadataReader, out jsonPath, out name, out type); - } - } - } - catch (Exception) - { - return false; - } - - return false; - } - - /// - /// Builds the CLR-style full type name (using + as the nesting separator) for a type - /// definition, matching the format returned by in - /// . - /// - private static string GetClrFullName(TypeDefinitionHandle handle, MetadataReader metadataReader) - { - var typeDef = metadataReader.GetTypeDefinition(handle); - var name = metadataReader.GetString(typeDef.Name); - - var declaringHandle = typeDef.GetDeclaringType(); - if (!declaringHandle.IsNil) - return $"{GetClrFullName(declaringHandle, metadataReader)}+{name}"; - - var ns = metadataReader.GetString(typeDef.Namespace); - return string.IsNullOrEmpty(ns) ? name : $"{ns}.{name}"; - } - - /// - /// Scans raw IL for ldstr (0x72) opcodes and collects the resolved strings. - /// The first three found before a call are returned as the base-constructor arguments. - /// Handles common IL prefixes: nop, ldarg.0, call, ret. - /// Returns false when fewer than three strings are found before a call or an unrecognised opcode - /// is encountered. - /// - private static bool TryExtractLdstrArgs( - byte[] il, - MetadataReader metadataReader, - out string? arg0, - out string? arg1, - out string? arg2) - { - arg0 = null; - arg1 = null; - arg2 = null; - - var strings = new List(3); - var i = 0; - - while (i < il.Length) - { - var opcode = il[i++]; - - switch (opcode) - { - case 0x00: // nop - case 0x02: // ldarg.0 - case 0x2A: // ret - break; - - case 0x72: // ldstr <4-byte metadata token> - { - if (i + 4 > il.Length) - return false; - - var rawToken = il[i] | (il[i + 1] << 8) | (il[i + 2] << 16) | (il[i + 3] << 24); - i += 4; - - var handle = MetadataTokens.Handle(rawToken); - if (handle.Kind == HandleKind.UserString) - strings.Add(metadataReader.GetUserString((UserStringHandle)handle)); - - break; - } - - case 0x28: // call <4-byte method token> - case 0x6F: // callvirt <4-byte method token> - i += 4; - if (strings.Count >= 3) - { - arg0 = strings[0]; - arg1 = strings[1]; - arg2 = strings[2]; - return true; - } - - break; - - default: - return false; - } - } - - return false; - } -} diff --git a/src/KubeOps.Transpiler/KubeOps.Transpiler.csproj b/src/KubeOps.Transpiler/KubeOps.Transpiler.csproj index 437a15b7b..c4aa92d9c 100644 --- a/src/KubeOps.Transpiler/KubeOps.Transpiler.csproj +++ b/src/KubeOps.Transpiler/KubeOps.Transpiler.csproj @@ -15,7 +15,6 @@ - diff --git a/src/KubeOps.Transpiler/ReflectionInheritedAttributeResolver.cs b/src/KubeOps.Transpiler/ReflectionInheritedAttributeResolver.cs new file mode 100644 index 000000000..72f51bc71 --- /dev/null +++ b/src/KubeOps.Transpiler/ReflectionInheritedAttributeResolver.cs @@ -0,0 +1,63 @@ +// 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.Collections.ObjectModel; +using System.Reflection; + +namespace KubeOps.Transpiler; + +/// +/// Resolves inherited attribute values by instantiating the attribute via real reflection. This is +/// the correct mechanism whenever the attribute's assembly is already loaded into the current +/// process (operator runtime, unit tests): the constructor runs, executes its base(...) call, +/// and the resulting property values are read back. It does not rely on the IL layout of the +/// constructor and works for any attribute type regardless of which properties it exposes. +/// +public sealed class ReflectionInheritedAttributeResolver : IInheritedAttributeResolver +{ + /// + /// A shared, stateless default instance. + /// + public static readonly ReflectionInheritedAttributeResolver Default = new(); + + /// + public bool TryResolve(Type attributeType, out IReadOnlyDictionary propertyValues) + { + propertyValues = ReadOnlyDictionary.Empty; + + var runtimeType = ResolveLoadedType(attributeType); + if (runtimeType is null) + { + return false; + } + + if (Activator.CreateInstance(runtimeType, nonPublic: true) is not Attribute instance) + { + return false; + } + + propertyValues = runtimeType + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p is { CanRead: true } && p.GetIndexParameters().Length == 0) + .ToDictionary(p => p.Name, p => p.GetValue(instance), StringComparer.Ordinal); + return propertyValues.Count > 0; + } + + private static Type? ResolveLoadedType(Type readOnlyReflectedType) + { + if (readOnlyReflectedType.AssemblyQualifiedName is { } aqn && Type.GetType(aqn, throwOnError: false) is { } byAqn) + { + return byAqn; + } + + if (readOnlyReflectedType.FullName is not { } fullName) + { + return null; + } + + return AppDomain.CurrentDomain.GetAssemblies() + .Select(assembly => assembly.GetType(fullName, throwOnError: false)) + .FirstOrDefault(t => t is not null); + } +} From ce73022813f4fe28eb5a7551f04ea5734e4e50fa Mon Sep 17 00:00:00 2001 From: Marcus Kimpenhaus Date: Sat, 30 May 2026 09:47:20 +0200 Subject: [PATCH 4/6] docs: deleted unnecessary technical details from docs --- docs/docs/operator/building-blocks/entities.mdx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/docs/operator/building-blocks/entities.mdx b/docs/docs/operator/building-blocks/entities.mdx index b1088e392..5d315b5b5 100644 --- a/docs/docs/operator/building-blocks/entities.mdx +++ b/docs/docs/operator/building-blocks/entities.mdx @@ -211,8 +211,6 @@ public sealed class ReadyPrinterColumnAttribute : GenericAdditionalPrinterColumn public class V1DemoEntity : CustomKubernetesEntity { } ``` -The base-constructor arguments are not stored in attribute metadata. They are recovered without parsing IL: at runtime the operator instantiates the attribute via reflection, and the CLI reads them from the Roslyn semantic model of your compiled project. Attributes defined in pre-built, referenced assemblies (no source available to the CLI) are therefore only resolved at runtime — prefer defining such reusable attributes in your operator project. - ## Scale Subresource The `[ScaleSubresource]` attribute enables the Kubernetes [scale subresource](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#scale-subresource) on a CRD. This allows [HorizontalPodAutoscalers (HPAs)](https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/) to manage the replica count of your custom resource. @@ -285,4 +283,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 From e5304e5534546cd4089240c83b64f41dc6e6cfe7 Mon Sep 17 00:00:00 2001 From: Marcus Kimpenhaus Date: Sat, 30 May 2026 10:08:13 +0200 Subject: [PATCH 5/6] feat: applied inheritance to validation rule attributes --- .../operator/building-blocks/entities.mdx | 2 +- src/KubeOps.Transpiler/Crds.cs | 4 +- .../Crds.Mlc.ValidationRule.Test.cs | 49 +++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/docs/docs/operator/building-blocks/entities.mdx b/docs/docs/operator/building-blocks/entities.mdx index 5d315b5b5..e054b5aa7 100644 --- a/docs/docs/operator/building-blocks/entities.mdx +++ b/docs/docs/operator/building-blocks/entities.mdx @@ -137,7 +137,7 @@ public class EntitySpec - `[RangeMinimum]` and `[RangeMaximum]`: Defines numeric value ranges - `[MultipleOf]`: Specifies that a number must be a multiple of a given value - `[Items]`: Defines minimum and maximum items for arrays -- `[ValidationRule]`: Defines custom validation rules using CEL expressions. Can be applied to properties and to class types — in both cases it emits `x-kubernetes-validations` on the corresponding schema node. When applied to both a class and a property of that type, the rules are merged (class rules first, then property rules) +- `[ValidationRule]`: Defines custom validation rules using CEL expressions. Can be applied to properties and to class types — in both cases it emits `x-kubernetes-validations` on the corresponding schema node. When applied to both a class and a property of that type, the rules are merged (class rules first, then property rules). Class-level rules are also collected across the **class inheritance chain**: a rule placed on a base class is inherited by every derived entity or nested type, and all rules found along the chain are merged onto the schema node ### Documentation Attributes diff --git a/src/KubeOps.Transpiler/Crds.cs b/src/KubeOps.Transpiler/Crds.cs index 32acd9328..dad846cc1 100644 --- a/src/KubeOps.Transpiler/Crds.cs +++ b/src/KubeOps.Transpiler/Crds.cs @@ -120,7 +120,7 @@ public static V1CustomResourceDefinition Transpile( _ => null, }, XKubernetesValidations = context.MapValidationRules( - type.GetCustomAttributesData()), + type.GetInheritedCustomAttributesData()), }, }; @@ -547,7 +547,7 @@ private static V1JSONSchemaProps MapObjectType(this MetadataLoadContext context, XKubernetesPreserveUnknownFields = type.GetCustomAttributeData() != null ? true : null, XKubernetesValidations = context.MapValidationRules( - type.GetCustomAttributesData()), + type.GetInheritedCustomAttributesData()), }; } } diff --git a/test/KubeOps.Transpiler.Test/Crds.Mlc.ValidationRule.Test.cs b/test/KubeOps.Transpiler.Test/Crds.Mlc.ValidationRule.Test.cs index 3823f02c9..b0d32cee9 100644 --- a/test/KubeOps.Transpiler.Test/Crds.Mlc.ValidationRule.Test.cs +++ b/test/KubeOps.Transpiler.Test/Crds.Mlc.ValidationRule.Test.cs @@ -111,6 +111,26 @@ public void Should_Merge_Class_And_Property_Validations() nestedSchema.XKubernetesValidations[1].Rule.Should().Be(Rule2); } + [Fact] + public void Should_Inherit_And_Merge_Class_Validations_From_Base_Entity() + { + var crd = _mlc.Transpile(typeof(DerivedClassValidateAttrEntity)); + + var schema = crd.Spec.Versions[0].Schema.OpenAPIV3Schema; + schema.XKubernetesValidations.Should().HaveCount(2); + schema.XKubernetesValidations.Select(v => v.Rule).Should().Contain([Rule1, Rule2]); + } + + [Fact] + public void Should_Inherit_And_Merge_Class_Validations_On_Nested_Class() + { + var crd = _mlc.Transpile(typeof(NestedDerivedClassValidateAttrEntity)); + + var nestedSchema = crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["nested"]; + nestedSchema.XKubernetesValidations.Should().HaveCount(2); + nestedSchema.XKubernetesValidations.Select(v => v.Rule).Should().Contain([Rule1, Rule2]); + } + [Fact] public void Should_Set_ValidationFields() { @@ -173,6 +193,35 @@ public sealed class NestedWithClassRule } } + [ValidationRule(Rule1, message: Message1)] + public abstract class BaseValidateAttrEntity : CustomKubernetesEntity + { + public string Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + [ValidationRule(Rule2, message: Message2)] + public sealed class DerivedClassValidateAttrEntity : BaseValidateAttrEntity + { + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public sealed class NestedDerivedClassValidateAttrEntity : CustomKubernetesEntity + { + public DerivedNestedObject Nested { get; set; } = new(); + + [ValidationRule(Rule1, message: Message1)] + public abstract class BaseNestedObject + { + public string Name { get; set; } = null!; + } + + [ValidationRule(Rule2, message: Message2)] + public sealed class DerivedNestedObject : BaseNestedObject + { + } + } + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] public sealed class SingleValidateAttrEntity : CustomKubernetesEntity { From 8ceba4172cf0232b3d948782b4c6ec2cc795acb9 Mon Sep 17 00:00:00 2001 From: Marcus Kimpenhaus Date: Sat, 30 May 2026 10:23:46 +0200 Subject: [PATCH 6/6] chore: added traits to transpiler unit tests --- .../Crds.Mlc.Inheritance.Test.cs | 18 ++--- ...Rbac.Mlc.Test.cs => Crds.Mlc.Rbac.Test.cs} | 81 ++++++++++--------- .../Crds.Mlc.Scale.Test.cs | 9 +++ test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs | 80 +++++++++++++++--- .../Crds.Mlc.ValidationRule.Test.cs | 10 +++ .../Entities.Mlc.Test.cs | 2 +- test/KubeOps.Transpiler.Test/Entities.Test.cs | 2 +- test/KubeOps.Transpiler.Test/MlcProvider.cs | 2 +- 8 files changed, 141 insertions(+), 63 deletions(-) rename test/KubeOps.Transpiler.Test/{Rbac.Mlc.Test.cs => Crds.Mlc.Rbac.Test.cs} (92%) diff --git a/test/KubeOps.Transpiler.Test/Crds.Mlc.Inheritance.Test.cs b/test/KubeOps.Transpiler.Test/Crds.Mlc.Inheritance.Test.cs index b6a336f64..26b189c52 100644 --- a/test/KubeOps.Transpiler.Test/Crds.Mlc.Inheritance.Test.cs +++ b/test/KubeOps.Transpiler.Test/Crds.Mlc.Inheritance.Test.cs @@ -13,10 +13,7 @@ namespace KubeOps.Transpiler.Test; public sealed partial class CrdsMlcTest { - // ------------------------------------------------------------------- - // #806 sub-case A: base entity class carries [GenericAdditionalPrinterColumn] - // ------------------------------------------------------------------- - + [Trait("Area", "Inheritance")] [Fact] public void Should_Inherit_GenericPrinterColumn_From_Base_Entity_Class() { @@ -28,6 +25,7 @@ public void Should_Inherit_GenericPrinterColumn_From_Base_Entity_Class() col.JsonPath == ".status.foo" && col.Name == "Foo" && col.Type == "string"); } + [Trait("Area", "Inheritance")] [Fact] public void Should_Accumulate_PrinterColumns_From_All_Hierarchy_Levels() { @@ -39,10 +37,7 @@ public void Should_Accumulate_PrinterColumns_From_All_Hierarchy_Levels() apc.Should().Contain(col => col.Name == "Bar"); } - // ------------------------------------------------------------------- - // #806 sub-case B: attribute type inherits GenericAdditionalPrinterColumnAttribute - // ------------------------------------------------------------------- - + [Trait("Area", "Inheritance")] [Fact] public void Should_Recognize_Inherited_PrinterColumn_Attribute_Type() { @@ -56,6 +51,7 @@ public void Should_Recognize_Inherited_PrinterColumn_Attribute_Type() col.Type == "string"); } + [Trait("Area", "Inheritance")] [Fact] public void Should_Support_Multiple_Inherited_PrinterColumn_Attribute_Types() { @@ -67,9 +63,7 @@ public void Should_Support_Multiple_Inherited_PrinterColumn_Attribute_Types() apc.Should().Contain(col => col.Name == "Reason"); } - // ------------------------------------------------------------------- - // Test entity classes - // ------------------------------------------------------------------- + #region Test Entity Classes [GenericAdditionalPrinterColumn(".status.foo", "Foo", "string")] private class BasePrinterColumnEntity : CustomKubernetesEntity; @@ -105,4 +99,6 @@ private sealed class EntityWithInheritedPrinterColumnAttrType : CustomKubernetes [ReadyPrinterColumn] [ReasonPrinterColumn] private sealed class EntityWithMultipleInheritedPrinterColumnAttrTypes : CustomKubernetesEntity; + + #endregion } diff --git a/test/KubeOps.Transpiler.Test/Rbac.Mlc.Test.cs b/test/KubeOps.Transpiler.Test/Crds.Mlc.Rbac.Test.cs similarity index 92% rename from test/KubeOps.Transpiler.Test/Rbac.Mlc.Test.cs rename to test/KubeOps.Transpiler.Test/Crds.Mlc.Rbac.Test.cs index 56e41ea15..a6f2e75fa 100644 --- a/test/KubeOps.Transpiler.Test/Rbac.Mlc.Test.cs +++ b/test/KubeOps.Transpiler.Test/Crds.Mlc.Rbac.Test.cs @@ -11,8 +11,9 @@ namespace KubeOps.Transpiler.Test; -public class RbacMlcTest(MlcProvider provider) : TranspilerTestBase(provider) +public sealed partial class CrdsMlcTest { + [Trait("Area", "Rbac")] [Fact] public void Should_Create_Generic_Policy() { @@ -23,9 +24,10 @@ public void Should_Create_Generic_Policy() role.Resources.Should().Contain("configmaps"); role.NonResourceURLs.Should().Contain("url"); role.NonResourceURLs.Should().Contain("foobar"); - role.Verbs.Should().Contain(new[] { "get", "delete" }); + role.Verbs.Should().Contain(["get", "delete"]); } + [Trait("Area", "Rbac")] [Fact] public void Should_Calculate_Max_Verbs_For_Types() { @@ -33,9 +35,10 @@ public void Should_Calculate_Max_Verbs_For_Types() .Transpile(_mlc.GetContextType().GetCustomAttributesData()).ToList() .First(); role.Resources.Should().Contain("rbactest1s"); - role.Verbs.Should().Contain(new[] { "get", "update", "delete" }); + role.Verbs.Should().Contain(["get", "update", "delete"]); } + [Trait("Area", "Rbac")] [Fact] public void Should_Correctly_Calculate_All_Verb() { @@ -46,6 +49,7 @@ public void Should_Correctly_Calculate_All_Verb() role.Verbs.Should().Contain("*").And.HaveCount(1); } + [Trait("Area", "Rbac")] [Fact] public void Should_Group_Same_Types_Together() { @@ -60,6 +64,7 @@ public void Should_Group_Same_Types_Together() roles.Should().HaveCount(2); } + [Trait("Area", "Rbac")] [Fact] public void Should_Group_Types_With_Same_Verbs_Together() { @@ -79,6 +84,7 @@ public void Should_Group_Types_With_Same_Verbs_Together() roles.Should().HaveCount(2); } + [Trait("Area", "Rbac")] [Fact] public void Should_Not_Mix_ApiGroups() { @@ -87,15 +93,48 @@ public void Should_Not_Mix_ApiGroups() roles.Should().HaveCount(5); } + [Trait("Area", "Rbac")] [Fact] public void Should_Correctly_Calculate_All_Verbs_Explicitly() { var role = _mlc .Transpile(_mlc.GetContextType().GetCustomAttributesData()).ToList().First(); role.Resources.Should().Contain("leases"); - role.Verbs.Should().Contain(new[] { "get", "list", "watch", "create", "update", "patch", "delete" }); + role.Verbs.Should().Contain(["get", "list", "watch", "create", "update", "patch", "delete"]); } + [Trait("Area", "Rbac")] + [Fact] + public void Should_Inherit_EntityRbac_From_Base_Class() + { + var rules = _mlc + .Transpile(_mlc.GetContextType() + .GetInheritedCustomAttributesData()) + .ToList(); + + rules.Should().ContainSingle(r => + r.Resources.Contains("rbacbaseentitys") && + r.Verbs.Contains("get")); + } + + [Trait("Area", "Rbac")] + [Fact] + public void Should_Merge_EntityRbac_From_Full_Inheritance_Chain() + { + var rules = _mlc + .Transpile(_mlc.GetContextType() + .GetInheritedCustomAttributesData()) + .ToList(); + + // get+update are on the same entity → transpiler merges them into one rule + rules.Should().ContainSingle(r => + r.Resources.Contains("rbacbaseentitys") && + r.Verbs.Contains("get") && + r.Verbs.Contains("update")); + } + + #region Test Entity Classes + [KubernetesEntity(Group = "test", ApiVersion = "v1")] [EntityRbac(typeof(RbacTest1), Verbs = RbacVerb.Get)] [EntityRbac(typeof(RbacTest1), Verbs = RbacVerb.Update)] @@ -137,38 +176,6 @@ public class RbacTest6 : CustomKubernetesEntity; Verbs = RbacVerb.Delete | RbacVerb.Get)] public class GenericRbacTest : CustomKubernetesEntity; - // ------------------------------------------------------------------- - // #1025: attribute on base class must be collected for derived class - // ------------------------------------------------------------------- - - [Fact] - public void Should_Inherit_EntityRbac_From_Base_Class() - { - var rules = _mlc - .Transpile(_mlc.GetContextType() - .GetInheritedCustomAttributesData()) - .ToList(); - - rules.Should().ContainSingle(r => - r.Resources.Contains("rbacbaseentitys") && - r.Verbs.Contains("get")); - } - - [Fact] - public void Should_Merge_EntityRbac_From_Full_Inheritance_Chain() - { - var rules = _mlc - .Transpile(_mlc.GetContextType() - .GetInheritedCustomAttributesData()) - .ToList(); - - // get+update are on the same entity → transpiler merges them into one rule - rules.Should().ContainSingle(r => - r.Resources.Contains("rbacbaseentitys") && - r.Verbs.Contains("get") && - r.Verbs.Contains("update")); - } - [KubernetesEntity(Group = "test", ApiVersion = "v1")] public class RbacBaseEntity : CustomKubernetesEntity; @@ -181,4 +188,6 @@ public class RbacInheritanceDerivedController : RbacBaseController; public abstract class RbacMidController : RbacBaseController; public class RbacInheritanceDeepDerivedController : RbacMidController; + + #endregion } diff --git a/test/KubeOps.Transpiler.Test/Crds.Mlc.Scale.Test.cs b/test/KubeOps.Transpiler.Test/Crds.Mlc.Scale.Test.cs index 65564cac3..497905c31 100644 --- a/test/KubeOps.Transpiler.Test/Crds.Mlc.Scale.Test.cs +++ b/test/KubeOps.Transpiler.Test/Crds.Mlc.Scale.Test.cs @@ -13,6 +13,7 @@ namespace KubeOps.Transpiler.Test; public sealed partial class CrdsMlcTest { + [Trait("Area", "Scale")] [Fact] public void Should_Not_Add_Scale_SubResource_If_Absent() { @@ -24,6 +25,7 @@ public void Should_Not_Add_Scale_SubResource_If_Absent() subresources.Scale.Should().BeNull(); } + [Trait("Area", "Scale")] [Fact] public void Should_Add_Scale_SubResource_With_Required_Paths() { @@ -37,6 +39,7 @@ public void Should_Add_Scale_SubResource_With_Required_Paths() subresources.Scale.LabelSelectorPath.Should().BeNull(); } + [Trait("Area", "Scale")] [Fact] public void Should_Add_Scale_Without_Status_SubResource() { @@ -47,6 +50,7 @@ public void Should_Add_Scale_Without_Status_SubResource() subresources.Status.Should().BeNull(); } + [Trait("Area", "Scale")] [Fact] public void Should_Add_Scale_SubResource_With_Label_Selector_Path() { @@ -60,6 +64,7 @@ public void Should_Add_Scale_SubResource_With_Label_Selector_Path() subresources.Scale.LabelSelectorPath.Should().Be(".status.selector"); } + [Trait("Area", "Scale")] [Fact] public void Should_Add_Both_Scale_And_Status_SubResources() { @@ -73,6 +78,8 @@ public void Should_Add_Both_Scale_And_Status_SubResources() subresources.Status.Should().NotBeNull(); } + #region Test Entity Classes + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] [ScaleSubresource(".spec.replicas", ".status.replicas")] public sealed class EntityWithScaleSubresource : CustomKubernetesEntity @@ -108,4 +115,6 @@ public sealed class EntityStatus public int Replicas { get; set; } } } + + #endregion } diff --git a/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs b/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs index 465f420cf..555eb07ab 100644 --- a/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs +++ b/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs @@ -17,6 +17,7 @@ namespace KubeOps.Transpiler.Test; public partial class CrdsMlcTest(MlcProvider provider) : TranspilerTestBase(provider) { + [Trait("Area", "General")] [Theory] [InlineData(typeof(StringTestEntity), "string", null, null)] [InlineData(typeof(NullableStringTestEntity), "string", null, true)] @@ -66,6 +67,7 @@ public void Should_Transpile_Entity_Type_Correctly(Type type, string? expectedTy prop.Nullable.Should().Be(isNullable); } + [Trait("Area", "General")] [Theory] [InlineData(typeof(StringArrayEntity), "string", null)] [InlineData(typeof(NullableStringArrayEntity), "string", null)] @@ -83,6 +85,7 @@ public void Should_Set_Correct_Array_Type(Type type, string expectedType, bool? prop.Nullable.Should().Be(isNullable); } + [Trait("Area", "General")] [Theory] [InlineData(typeof(DictionaryEntity), "string", null)] [InlineData(typeof(EnumerableKeyPairsEntity), "string", null)] @@ -94,64 +97,67 @@ public void Should_Set_Correct_Dictionary_Additional_Properties_Type(Type type, prop.Nullable.Should().Be(isNullable); } + [Trait("Area", "General")] [Fact] public void Should_Ignore_Entity() { - var crds = _mlc.Transpile(new[] { typeof(IgnoredEntity) }); + var crds = _mlc.Transpile([typeof(IgnoredEntity)]); crds.Count().Should().Be(0); } + [Trait("Area", "General")] [Fact] public void Should_Ignore_NonEntity() { - var crds = _mlc.Transpile(new[] { typeof(NonEntity) }); + var crds = _mlc.Transpile([typeof(NonEntity)]); crds.Count().Should().Be(0); } + [Trait("Area", "General")] [Fact] public void Should_Ignore_Kubernetes_Entities() { - var crds = _mlc.Transpile(new[] { typeof(V1Pod) }); + var crds = _mlc.Transpile([typeof(V1Pod)]); crds.Count().Should().Be(0); } + [Trait("Area", "General")] [Fact] public void Should_Set_Highest_Version_As_Storage() { - var crds = _mlc.Transpile(new[] - { + var crds = _mlc.Transpile([ typeof(V1Alpha1VersionedEntity), typeof(V1Beta1VersionedEntity), typeof(V2Beta2VersionedEntity), typeof(V2VersionedEntity), typeof(V1VersionedEntity), typeof(V1AttributeVersionedEntity), typeof(V2AttributeVersionedEntity), - }); + ]); var crd = crds.First(c => c.Spec.Names.Kind == "VersionedEntity"); crd.Spec.Versions.Count(v => v.Storage).Should().Be(1); crd.Spec.Versions.First(v => v.Storage).Name.Should().Be("v2"); } + [Trait("Area", "General")] [Fact] public void Should_Set_Storage_When_Attribute_Is_Set() { - var crds = _mlc.Transpile(new[] - { + var crds = _mlc.Transpile([ typeof(V1Alpha1VersionedEntity), typeof(V1Beta1VersionedEntity), typeof(V2Beta2VersionedEntity), typeof(V2VersionedEntity), typeof(V1VersionedEntity), typeof(V1AttributeVersionedEntity), typeof(V2AttributeVersionedEntity), - }); + ]); var crd = crds.First(c => c.Spec.Names.Kind == "AttributeVersionedEntity"); crd.Spec.Versions.Count(v => v.Storage).Should().Be(1); crd.Spec.Versions.First(v => v.Storage).Name.Should().Be("v1"); } + [Trait("Area", "General")] [Fact] public void Should_Add_Multiple_Versions_To_Crd() { - var crds = _mlc.Transpile(new[] - { + var crds = _mlc.Transpile([ typeof(V1Alpha1VersionedEntity), typeof(V1Beta1VersionedEntity), typeof(V2Beta2VersionedEntity), typeof(V2VersionedEntity), typeof(V1VersionedEntity), typeof(V1AttributeVersionedEntity), typeof(V2AttributeVersionedEntity), - }).ToList(); + ]).ToList(); crds .First(c => c.Spec.Names.Kind == "VersionedEntity") .Spec.Versions.Should() @@ -162,6 +168,7 @@ public void Should_Add_Multiple_Versions_To_Crd() .HaveCount(2); } + [Trait("Area", "General")] [Fact] public void Should_Use_Correct_CRD() { @@ -177,6 +184,7 @@ public void Should_Use_Correct_CRD() crd.Spec.Scope.Should().Be(scope); } + [Trait("Area", "General")] [Fact] public void Should_Not_Add_Status_SubResource_If_Absent() { @@ -184,6 +192,7 @@ public void Should_Not_Add_Status_SubResource_If_Absent() crd.Spec.Versions[0].Subresources?.Status?.Should().BeNull(); } + [Trait("Area", "General")] [Fact] public void Should_Add_Status_SubResource_If_Present() { @@ -191,6 +200,7 @@ public void Should_Add_Status_SubResource_If_Present() crd.Spec.Versions[0].Subresources.Status.Should().NotBeNull(); } + [Trait("Area", "General")] [Fact] public void Should_Add_ShortNames_To_Crd() { @@ -198,9 +208,10 @@ public void Should_Add_ShortNames_To_Crd() crd.Spec.Names.ShortNames.Should() .NotBeNull() .And - .Contain(new[] { "foo", "bar", "baz" }); + .Contain(["foo", "bar", "baz"]); } + [Trait("Area", "General")] [Fact] public void Should_Set_Description_On_Class() { @@ -210,6 +221,7 @@ public void Should_Set_Description_On_Class() specProperties.Description.Should().NotBe(""); } + [Trait("Area", "General")] [Fact] public void Should_Set_Description() { @@ -219,6 +231,7 @@ public void Should_Set_Description() specProperties.Description.Should().NotBe(""); } + [Trait("Area", "General")] [Fact] public void Should_Set_ExternalDocs() { @@ -228,6 +241,7 @@ public void Should_Set_ExternalDocs() specProperties.ExternalDocs.Url.Should().NotBe(""); } + [Trait("Area", "General")] [Fact] public void Should_Set_ExternalDocs_Description() { @@ -237,6 +251,7 @@ public void Should_Set_ExternalDocs_Description() specProperties.ExternalDocs.Description.Should().NotBe(""); } + [Trait("Area", "General")] [Fact] public void Should_Set_Items_Information() { @@ -250,6 +265,7 @@ public void Should_Set_Items_Information() specProperties.MinItems.Should().Be(13); } + [Trait("Area", "General")] [Fact] public void Should_Set_Length_Information() { @@ -261,6 +277,7 @@ public void Should_Set_Length_Information() specProperties.MaxLength.Should().Be(42); } + [Trait("Area", "General")] [Fact] public void Should_Set_MinLengthOnly_Information() { @@ -272,6 +289,7 @@ public void Should_Set_MinLengthOnly_Information() specProperties.MaxLength.Should().BeNull(); } + [Trait("Area", "General")] [Fact] public void Should_Set_MaxLengthOnly_Information() { @@ -283,6 +301,7 @@ public void Should_Set_MaxLengthOnly_Information() specProperties.MaxLength.Should().Be(42); } + [Trait("Area", "General")] [Fact] public void Should_Set_MultipleOf() { @@ -293,6 +312,7 @@ public void Should_Set_MultipleOf() specProperties.MultipleOf.Should().Be(2); } + [Trait("Area", "General")] [Fact] public void Should_Set_Pattern() { @@ -303,6 +323,7 @@ public void Should_Set_Pattern() specProperties.Pattern.Should().Be(@"/\d*/"); } + [Trait("Area", "General")] [Fact] public void Should_Set_RangeMinimum() { @@ -314,6 +335,7 @@ public void Should_Set_RangeMinimum() specProperties.ExclusiveMinimum.Should().BeTrue(); } + [Trait("Area", "General")] [Fact] public void Should_Set_RangeMaximum() { @@ -325,6 +347,7 @@ public void Should_Set_RangeMaximum() specProperties.ExclusiveMaximum.Should().BeTrue(); } + [Trait("Area", "General")] [Fact] public void Should_Set_Required() { @@ -334,6 +357,7 @@ public void Should_Set_Required() specProperties.Required.Should().Contain("property"); } + [Trait("Area", "General")] [Fact] public void Should_Set_Spec_As_Required_Via_Auto_Inference_When_Spec_Has_Required_Properties() { @@ -343,6 +367,7 @@ public void Should_Set_Spec_As_Required_Via_Auto_Inference_When_Spec_Has_Require topLevel.Required.Should().Contain("spec"); } + [Trait("Area", "General")] [Fact] public void Should_Not_Set_Spec_As_Required_When_Required_Property_Is_Under_Optional_Parent() { @@ -351,6 +376,7 @@ public void Should_Not_Set_Spec_As_Required_When_Required_Property_Is_Under_Opti crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Required.Should().BeNullOrEmpty(); } + [Trait("Area", "General")] [Fact] public void Should_Set_Spec_As_Required_Via_Explicit_Class_Attribute() { @@ -359,6 +385,7 @@ public void Should_Set_Spec_As_Required_Via_Explicit_Class_Attribute() crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Required.Should().Contain("spec"); } + [Trait("Area", "General")] [Fact] public void Should_Not_Set_Spec_As_Required_When_Required_Property_Is_Inside_Optional_Collection() { @@ -367,6 +394,7 @@ public void Should_Not_Set_Spec_As_Required_When_Required_Property_Is_Inside_Opt crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Required.Should().BeNullOrEmpty(); } + [Trait("Area", "General")] [Fact] public void Should_Not_Set_Spec_As_Required_When_Only_Required_Property_Is_Ignored() { @@ -376,6 +404,7 @@ public void Should_Not_Set_Spec_As_Required_When_Only_Required_Property_Is_Ignor topLevel.Required.Should().BeNullOrEmpty(); } + [Trait("Area", "General")] [Fact] public void Should_Not_Set_Spec_As_Required_Without_Required_Properties_Or_Attribute() { @@ -385,6 +414,7 @@ public void Should_Not_Set_Spec_As_Required_Without_Required_Properties_Or_Attri topLevel.Required.Should().BeNullOrEmpty(); } + [Trait("Area", "General")] [Fact] public void Should_Not_Set_Status_As_Required_Via_Auto_Inference_Even_When_Status_Has_Required_Properties() { @@ -394,6 +424,7 @@ public void Should_Not_Set_Status_As_Required_Via_Auto_Inference_Even_When_Statu topLevel.Required.Should().BeNullOrEmpty(); } + [Trait("Area", "General")] [Fact] public void Should_Not_Set_Status_As_Required_Via_Explicit_Class_Attribute() { @@ -403,6 +434,7 @@ public void Should_Not_Set_Status_As_Required_Via_Explicit_Class_Attribute() topLevel.Required.Should().BeNullOrEmpty(); } + [Trait("Area", "General")] [Fact] public void Should_Not_Contain_Ignored_Property() { @@ -412,6 +444,7 @@ public void Should_Not_Contain_Ignored_Property() specProperties.Properties.Should().NotContainKey("property"); } + [Trait("Area", "General")] [Fact] public void Should_Set_Preserve_Unknown_Fields() { @@ -421,6 +454,7 @@ public void Should_Set_Preserve_Unknown_Fields() specProperties.XKubernetesPreserveUnknownFields.Should().BeTrue(); } + [Trait("Area", "General")] [Fact] public void Should_Set_EmbeddedResource_Fields() { @@ -430,6 +464,7 @@ public void Should_Set_EmbeddedResource_Fields() specProperties.XKubernetesEmbeddedResource.Should().BeTrue(); } + [Trait("Area", "General")] [Fact] public void Should_Set_Preserve_Unknown_Fields_On_Dictionaries() { @@ -439,6 +474,7 @@ public void Should_Set_Preserve_Unknown_Fields_On_Dictionaries() specProperties.XKubernetesPreserveUnknownFields.Should().BeTrue(); } + [Trait("Area", "General")] [Fact] public void Should_Set_Preserve_Unknown_Fields_On_Classes() { @@ -448,6 +484,7 @@ public void Should_Set_Preserve_Unknown_Fields_On_Classes() specProperties.XKubernetesPreserveUnknownFields.Should().BeTrue(); } + [Trait("Area", "General")] [Fact] public void Should_Set_Preserve_Unknown_Fields_On_System_Object() { @@ -457,6 +494,7 @@ public void Should_Set_Preserve_Unknown_Fields_On_System_Object() specProperties.XKubernetesPreserveUnknownFields.Should().BeTrue(); } + [Trait("Area", "General")] [Fact] public void Should_Set_Preserve_Unknown_Fields_On_ObjectLists() { @@ -466,6 +504,7 @@ public void Should_Set_Preserve_Unknown_Fields_On_ObjectLists() specProperties.XKubernetesPreserveUnknownFields.Should().BeTrue(); } + [Trait("Area", "General")] [Fact] public void Should_Not_Set_Preserve_Unknown_Fields_On_Generic_Dictionaries() { @@ -475,6 +514,7 @@ public void Should_Not_Set_Preserve_Unknown_Fields_On_Generic_Dictionaries() specProperties.XKubernetesPreserveUnknownFields.Should().BeNull(); } + [Trait("Area", "General")] [Fact] public void Should_Not_Set_Preserve_Unknown_Fields_On_KeyValuePair_Enumerable() { @@ -484,6 +524,7 @@ public void Should_Not_Set_Preserve_Unknown_Fields_On_KeyValuePair_Enumerable() specProperties.XKubernetesPreserveUnknownFields.Should().BeNull(); } + [Trait("Area", "General")] [Fact] public void Should_Not_Set_Properties_On_Dictionaries() { @@ -493,6 +534,7 @@ public void Should_Not_Set_Properties_On_Dictionaries() specProperties.Properties.Should().BeNull(); } + [Trait("Area", "General")] [Fact] public void Should_Not_Set_Properties_On_Generic_Dictionaries() { @@ -502,6 +544,7 @@ public void Should_Not_Set_Properties_On_Generic_Dictionaries() specProperties.Properties.Should().BeNull(); } + [Trait("Area", "General")] [Fact] public void Should_Not_Set_Properties_On_KeyValuePair_Enumerable() { @@ -511,6 +554,7 @@ public void Should_Not_Set_Properties_On_KeyValuePair_Enumerable() specProperties.Properties.Should().BeNull(); } + [Trait("Area", "General")] [Fact] public void Should_Set_AdditionalProperties_On_Dictionaries_For_Value_type() { @@ -520,6 +564,7 @@ public void Should_Set_AdditionalProperties_On_Dictionaries_For_Value_type() specProperties.AdditionalProperties.Should().NotBeNull(); } + [Trait("Area", "General")] [Fact] public void Should_Set_AdditionalProperties_On_KeyValuePair_For_Value_type() { @@ -529,6 +574,7 @@ public void Should_Set_AdditionalProperties_On_KeyValuePair_For_Value_type() specProperties.AdditionalProperties.Should().NotBeNull(); } + [Trait("Area", "General")] [Fact] public void Should_Set_IntOrString() { @@ -539,6 +585,7 @@ public void Should_Set_IntOrString() specProperties.XKubernetesIntOrString.Should().BeTrue(); } + [Trait("Area", "General")] [Fact] public void Should_Use_PropertyName_From_JsonPropertyAttribute() { @@ -548,6 +595,7 @@ public void Should_Use_PropertyName_From_JsonPropertyAttribute() specProperties.Should().Contain(p => p.Key == "otherName"); } + [Trait("Area", "General")] [Fact] public void Must_Not_Contain_Ignored_TopLevel_Properties() { @@ -557,6 +605,7 @@ public void Must_Not_Contain_Ignored_TopLevel_Properties() specProperties.Should().NotContainKeys("metadata", "apiVersion", "kind"); } + [Trait("Area", "General")] [Fact] public void Should_Add_AdditionalPrinterColumns() { @@ -565,6 +614,7 @@ public void Should_Add_AdditionalPrinterColumns() apc.Should().ContainSingle(def => def.JsonPath == ".property"); } + [Trait("Area", "General")] [Fact] public void Should_Add_AdditionalPrinterColumns_With_Prio() { @@ -573,6 +623,7 @@ public void Should_Add_AdditionalPrinterColumns_With_Prio() apc.Should().ContainSingle(def => def.JsonPath == ".property" && def.Priority == 1); } + [Trait("Area", "General")] [Fact] public void Should_Add_AdditionalPrinterColumns_With_Name() { @@ -581,6 +632,7 @@ public void Should_Add_AdditionalPrinterColumns_With_Name() apc.Should().ContainSingle(def => def.JsonPath == ".property" && def.Name == "OtherName"); } + [Trait("Area", "General")] [Fact] public void Should_Add_GenericAdditionalPrinterColumns() { @@ -591,6 +643,7 @@ public void Should_Add_GenericAdditionalPrinterColumns() apc.Should().ContainSingle(def => def.JsonPath == ".metadata.namespace" && def.Name == "Namespace"); } + [Trait("Area", "General")] [Fact] public void Should_Correctly_Use_Entity_Scope_Attribute() { @@ -601,6 +654,7 @@ public void Should_Correctly_Use_Entity_Scope_Attribute() clusterCrd.Spec.Scope.Should().Be("Cluster"); } + [Trait("Area", "General")] [Fact] public void Should_Correctly_Get_Enum_Value_From_JsonStringEnumMemberNameAttribute() { diff --git a/test/KubeOps.Transpiler.Test/Crds.Mlc.ValidationRule.Test.cs b/test/KubeOps.Transpiler.Test/Crds.Mlc.ValidationRule.Test.cs index b0d32cee9..824b56039 100644 --- a/test/KubeOps.Transpiler.Test/Crds.Mlc.ValidationRule.Test.cs +++ b/test/KubeOps.Transpiler.Test/Crds.Mlc.ValidationRule.Test.cs @@ -22,6 +22,7 @@ public sealed partial class CrdsMlcTest private const string Rule2 = "has(self.workflow) || self.kind != 'my-workflow"; private const string Message2 = "workflow must be specified if handling is workflow"; + [Trait("Area", "ValidationRule")] [Fact] public void Should_Set_Validations() { @@ -36,6 +37,7 @@ public void Should_Set_Validations() specProperties.XKubernetesValidations[0].Reason.Should().BeNull(); } + [Trait("Area", "ValidationRule")] [Fact] public void Should_Set_MultipleValidations() { @@ -55,6 +57,7 @@ public void Should_Set_MultipleValidations() specProperties.XKubernetesValidations[1].Reason.Should().BeNull(); } + [Trait("Area", "ValidationRule")] [Fact] public void Should_Omit_Validations() { @@ -64,6 +67,7 @@ public void Should_Omit_Validations() specProperties.XKubernetesValidations.Should().BeNull(); } + [Trait("Area", "ValidationRule")] [Fact] public void Should_Set_Validations_On_Root_Class() { @@ -78,6 +82,7 @@ public void Should_Set_Validations_On_Root_Class() schema.XKubernetesValidations[0].Reason.Should().BeNull(); } + [Trait("Area", "ValidationRule")] [Fact] public void Should_Set_Multiple_Validations_On_Root_Class() { @@ -89,6 +94,7 @@ public void Should_Set_Multiple_Validations_On_Root_Class() schema.XKubernetesValidations[1].Rule.Should().Be(Rule2); } + [Trait("Area", "ValidationRule")] [Fact] public void Should_Set_Validations_On_Nested_Class() { @@ -100,6 +106,7 @@ public void Should_Set_Validations_On_Nested_Class() nestedSchema.XKubernetesValidations[0].Message.Should().Be(Message1); } + [Trait("Area", "ValidationRule")] [Fact] public void Should_Merge_Class_And_Property_Validations() { @@ -111,6 +118,7 @@ public void Should_Merge_Class_And_Property_Validations() nestedSchema.XKubernetesValidations[1].Rule.Should().Be(Rule2); } + [Trait("Area", "ValidationRule")] [Fact] public void Should_Inherit_And_Merge_Class_Validations_From_Base_Entity() { @@ -121,6 +129,7 @@ public void Should_Inherit_And_Merge_Class_Validations_From_Base_Entity() schema.XKubernetesValidations.Select(v => v.Rule).Should().Contain([Rule1, Rule2]); } + [Trait("Area", "ValidationRule")] [Fact] public void Should_Inherit_And_Merge_Class_Validations_On_Nested_Class() { @@ -131,6 +140,7 @@ public void Should_Inherit_And_Merge_Class_Validations_On_Nested_Class() nestedSchema.XKubernetesValidations.Select(v => v.Rule).Should().Contain([Rule1, Rule2]); } + [Trait("Area", "ValidationRule")] [Fact] public void Should_Set_ValidationFields() { diff --git a/test/KubeOps.Transpiler.Test/Entities.Mlc.Test.cs b/test/KubeOps.Transpiler.Test/Entities.Mlc.Test.cs index c93c8be2e..f1e01c608 100644 --- a/test/KubeOps.Transpiler.Test/Entities.Mlc.Test.cs +++ b/test/KubeOps.Transpiler.Test/Entities.Mlc.Test.cs @@ -11,7 +11,7 @@ namespace KubeOps.Transpiler.Test; -public class EntitiesMlcTest(MlcProvider provider) : TranspilerTestBase(provider) +public sealed class EntitiesMlcTest(MlcProvider provider) : TranspilerTestBase(provider) { [Theory] [InlineData(typeof(NamespaceEntity), "Namespaced", "namespaceentity", "namespaceentities", "testing.dev/v1")] diff --git a/test/KubeOps.Transpiler.Test/Entities.Test.cs b/test/KubeOps.Transpiler.Test/Entities.Test.cs index 239372a35..dc25eb5c8 100644 --- a/test/KubeOps.Transpiler.Test/Entities.Test.cs +++ b/test/KubeOps.Transpiler.Test/Entities.Test.cs @@ -11,7 +11,7 @@ namespace KubeOps.Transpiler.Test; -public class EntitiesTest +public sealed class EntitiesTest { [Theory] [InlineData(typeof(NamespaceEntity), "Namespaced", "namespaceentity", "namespaceentities", "testing.dev/v1")] diff --git a/test/KubeOps.Transpiler.Test/MlcProvider.cs b/test/KubeOps.Transpiler.Test/MlcProvider.cs index 648cd4b26..35c242bbc 100644 --- a/test/KubeOps.Transpiler.Test/MlcProvider.cs +++ b/test/KubeOps.Transpiler.Test/MlcProvider.cs @@ -10,7 +10,7 @@ namespace KubeOps.Transpiler.Test; -public class MlcProvider : IAsyncLifetime +public sealed class MlcProvider : IAsyncLifetime { static MlcProvider() {