From b9f34748ef8395cd0fc35fa855da33882d37f938 Mon Sep 17 00:00:00 2001 From: Marcus Kimpenhaus Date: Sat, 25 Apr 2026 15:10:17 +0200 Subject: [PATCH 1/4] feat(crds): add support for marking `spec` as required via `[Required]` attribute at class or property level --- .../operator/building-blocks/entities.mdx | 23 ++- .../Entities/Attributes/RequiredAttribute.cs | 4 +- src/KubeOps.Transpiler/Crds.cs | 46 ++++++ test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs | 131 ++++++++++++++++++ 4 files changed, 202 insertions(+), 2 deletions(-) diff --git a/docs/docs/operator/building-blocks/entities.mdx b/docs/docs/operator/building-blocks/entities.mdx index 3e3339fc..8270f8e8 100644 --- a/docs/docs/operator/building-blocks/entities.mdx +++ b/docs/docs/operator/building-blocks/entities.mdx @@ -69,6 +69,8 @@ public class V1DemoEntitySpec } ``` +Because `Username` is marked `[Required]`, the Transpiler will automatically add `spec` to the top-level `required` array in the generated CRD. This means Kubernetes will reject any resource where `spec:` is omitted or null — not just resources where `username` is missing inside spec. + ### Status The `Status` property (optional) contains the current state of your resource: @@ -94,7 +96,26 @@ KubeOps provides various attributes to customize and validate your entities: ### Validation Attributes -- `[Required]`: Marks a property as required +- `[Required]`: Marks a property as required. When applied to a property inside `EntitySpec`, that field becomes required within the spec schema. Additionally: + - **Auto-inference**: if any property inside `EntitySpec` is marked `[Required]`, the Transpiler automatically marks `spec` itself as required at the top-level CRD schema — ensuring Kubernetes rejects resources where `spec:` is omitted or null. + - **Explicit class-level**: apply `[Required]` directly to the `EntitySpec` class to mark `spec` as required at the top level even when no individual sub-property carries `[Required]`. + +```csharp +// Auto-inference: spec is required because Username is required +public class EntitySpec +{ + [Required] + public string Username { get; set; } = null!; +} + +// Explicit class-level: spec is required regardless of sub-properties +[Required] +public class EntitySpec +{ + public string Username { get; set; } = string.Empty; +} +``` + - `[Pattern]`: Defines a regex pattern for string validation - `[Length]`: Specifies minimum and maximum length for strings or arrays - `[RangeMinimum]` and `[RangeMaximum]`: Defines numeric value ranges diff --git a/src/KubeOps.Abstractions/Entities/Attributes/RequiredAttribute.cs b/src/KubeOps.Abstractions/Entities/Attributes/RequiredAttribute.cs index 693a765b..38cd726c 100644 --- a/src/KubeOps.Abstractions/Entities/Attributes/RequiredAttribute.cs +++ b/src/KubeOps.Abstractions/Entities/Attributes/RequiredAttribute.cs @@ -6,6 +6,8 @@ namespace KubeOps.Abstractions.Entities.Attributes; /// /// Defines a property of a specification as required. +/// When applied to a class (e.g. an EntitySpec class), marks the corresponding top-level +/// property (e.g. "spec") as required in the generated CRD schema. /// -[AttributeUsage(AttributeTargets.Property)] +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Class)] public class RequiredAttribute : Attribute; diff --git a/src/KubeOps.Transpiler/Crds.cs b/src/KubeOps.Transpiler/Crds.cs index 7462f712..f526542d 100644 --- a/src/KubeOps.Transpiler/Crds.cs +++ b/src/KubeOps.Transpiler/Crds.cs @@ -91,6 +91,18 @@ public static V1CustomResourceDefinition Transpile(this MetadataLoadContext cont && p.GetCustomAttributeData() == null) .Select(p => (Name: p.GetPropertyName(context), Schema: context.Map(p))) .ToDictionary(t => t.Name, t => t.Schema), + Required = type.GetProperties() + .Where(p => !IgnoredToplevelProperties.Contains(p.Name.ToLowerInvariant()) + && p.GetCustomAttributeData() == null + && (p.PropertyType.GetCustomAttributeData() != null + || (p.Name.Equals("spec", StringComparison.OrdinalIgnoreCase) + && HasRequiredSubProperties(p.PropertyType)))) + .Select(p => p.GetPropertyName(context)) + .ToList() switch + { + { Count: > 0 } list => list, + _ => null, + }, }, }; @@ -518,4 +530,38 @@ private static V1JSONSchemaProps MapValueType(this MetadataLoadContext _, Type t private static ArgumentException InvalidType(Type type) => new($"The given type {type.FullName} is not a valid Kubernetes entity."); + + private static bool HasRequiredSubProperties(Type type, HashSet? visited = null) + { + visited ??= []; + if (!visited.Add(type)) + { + return false; + } + + return type.GetProperties().Any(p => + p.GetCustomAttributeData() == null + && (p.GetCustomAttributeData() != null + || (p.PropertyType.IsClass + && p.PropertyType.FullName != typeof(string).FullName + && HasRequiredSubProperties(ResolveElementType(p.PropertyType), visited)))); + } + + // For generic collection types (e.g. List, IEnumerable), returns the item type T + // so that HasRequiredSubProperties can recurse into the element type rather than the collection itself. + private static Type ResolveElementType(Type type) + { + if (!type.IsGenericType) + { + return type; + } + + var enumerableArg = type.GetInterfaces() + .Where(i => i.IsGenericType + && i.GetGenericTypeDefinition().FullName == typeof(IEnumerable<>).FullName) + .Select(i => i.GenericTypeArguments[0]) + .FirstOrDefault(); + + return enumerableArg ?? type; + } } diff --git a/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs b/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs index 6ff2ec7c..a8a79e20 100644 --- a/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs +++ b/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs @@ -332,6 +332,69 @@ public void Should_Set_Required() specProperties.Required.Should().Contain("property"); } + [Fact] + public void Should_Set_Spec_As_Required_Via_Auto_Inference_When_Spec_Has_Required_Properties() + { + var crd = _mlc.Transpile(typeof(RequiredAttrEntity)); + + var topLevel = crd.Spec.Versions.First().Schema.OpenAPIV3Schema; + topLevel.Required.Should().Contain("spec"); + } + + [Fact] + public void Should_Set_Spec_As_Required_Via_Auto_Inference_When_Nested_Type_Has_Required_Properties() + { + var crd = _mlc.Transpile(typeof(RequiredNestedPropertyEntity)); + + var topLevel = crd.Spec.Versions.First().Schema.OpenAPIV3Schema; + topLevel.Required.Should().Contain("spec"); + } + + [Fact] + public void Should_Set_Spec_As_Required_Via_Explicit_Class_Attribute() + { + var crd = _mlc.Transpile(typeof(RequiredSpecExplicitEntity)); + + var topLevel = crd.Spec.Versions.First().Schema.OpenAPIV3Schema; + topLevel.Required.Should().Contain("spec"); + } + + [Fact] + public void Should_Set_Spec_As_Required_Via_Auto_Inference_When_Collection_Item_Type_Has_Required_Properties() + { + var crd = _mlc.Transpile(typeof(RequiredCollectionItemPropertyEntity)); + + var topLevel = crd.Spec.Versions.First().Schema.OpenAPIV3Schema; + topLevel.Required.Should().Contain("spec"); + } + + [Fact] + public void Should_Not_Set_Spec_As_Required_When_Only_Required_Property_Is_Ignored() + { + var crd = _mlc.Transpile(typeof(RequiredIgnoredPropertyEntity)); + + var topLevel = crd.Spec.Versions.First().Schema.OpenAPIV3Schema; + topLevel.Required.Should().BeNullOrEmpty(); + } + + [Fact] + public void Should_Not_Set_Spec_As_Required_Without_Required_Properties_Or_Attribute() + { + var crd = _mlc.Transpile(typeof(ClassDescriptionAttrEntity)); + + var topLevel = crd.Spec.Versions.First().Schema.OpenAPIV3Schema; + topLevel.Required.Should().BeNullOrEmpty(); + } + + [Fact] + public void Should_Not_Set_Status_As_Required_Via_Auto_Inference_Even_When_Status_Has_Required_Properties() + { + var crd = _mlc.Transpile(typeof(RequiredStatusPropertyEntity)); + + var topLevel = crd.Spec.Versions.First().Schema.OpenAPIV3Schema; + topLevel.Required.Should().BeNullOrEmpty(); + } + [Fact] public void Should_Not_Contain_Ignored_Property() { @@ -992,6 +1055,74 @@ public class EntitySpec } } + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class RequiredNestedPropertyEntity : CustomKubernetesEntity + { + public class EntitySpec + { + public NestedSpec Nested { get; set; } = new(); + + public class NestedSpec + { + [Required] + public string Property { get; set; } = null!; + } + } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class RequiredSpecExplicitEntity : CustomKubernetesEntity + { + [Required] + public class EntitySpec + { + public string Property { get; set; } = string.Empty; + } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class RequiredCollectionItemPropertyEntity + : CustomKubernetesEntity + { + public class EntitySpec + { + public List Items { get; set; } = []; + + public class ItemSpec + { + [Required] + public string Property { get; set; } = null!; + } + } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class RequiredIgnoredPropertyEntity : CustomKubernetesEntity + { + public class EntitySpec + { + [Required] + [Ignore] + public string Property { get; set; } = null!; + } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class RequiredStatusPropertyEntity + : CustomKubernetesEntity + { + public class EntitySpec + { + public string Property { get; set; } = string.Empty; + } + + public class EntityStatus + { + [Required] + public string State { get; set; } = string.Empty; + } + } + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] public class IgnoreAttrEntity : CustomKubernetesEntity { From c4e6d35ee3472072fe8d5ad5ec4801590f771d91 Mon Sep 17 00:00:00 2001 From: Marcus Kimpenhaus Date: Tue, 28 Apr 2026 08:54:47 +0200 Subject: [PATCH 2/4] fix(transpiler): correct auto-inference of `spec` requirement to exclude nested `[Required]` properties under optional parents --- .../operator/building-blocks/entities.mdx | 20 ++++++++-- src/KubeOps.Transpiler/Crds.cs | 38 ++----------------- test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs | 37 ++++++++++++++---- 3 files changed, 48 insertions(+), 47 deletions(-) diff --git a/docs/docs/operator/building-blocks/entities.mdx b/docs/docs/operator/building-blocks/entities.mdx index 8270f8e8..83ca94df 100644 --- a/docs/docs/operator/building-blocks/entities.mdx +++ b/docs/docs/operator/building-blocks/entities.mdx @@ -69,7 +69,7 @@ public class V1DemoEntitySpec } ``` -Because `Username` is marked `[Required]`, the Transpiler will automatically add `spec` to the top-level `required` array in the generated CRD. This means Kubernetes will reject any resource where `spec:` is omitted or null — not just resources where `username` is missing inside spec. +Because `Username` is a direct `[Required]` property of `EntitySpec`, the Transpiler automatically adds `spec` to the top-level `required` array in the generated CRD. This means Kubernetes will reject any resource where `spec:` is omitted or null — not just resources where `username` is missing inside spec. ### Status @@ -97,11 +97,11 @@ KubeOps provides various attributes to customize and validate your entities: ### Validation Attributes - `[Required]`: Marks a property as required. When applied to a property inside `EntitySpec`, that field becomes required within the spec schema. Additionally: - - **Auto-inference**: if any property inside `EntitySpec` is marked `[Required]`, the Transpiler automatically marks `spec` itself as required at the top-level CRD schema — ensuring Kubernetes rejects resources where `spec:` is omitted or null. - - **Explicit class-level**: apply `[Required]` directly to the `EntitySpec` class to mark `spec` as required at the top level even when no individual sub-property carries `[Required]`. + - **Auto-inference**: if any **direct** property of `EntitySpec` is marked `[Required]`, the Transpiler automatically marks `spec` itself as required at the top-level CRD schema. A `[Required]` property that is nested under an optional parent does **not** trigger this — Kubernetes only validates `required` constraints when the parent object is present, so each level of the hierarchy must be annotated explicitly. + - **Explicit class-level**: apply `[Required]` directly to the `EntitySpec` class to mark `spec` as required at the top level even when no sub-property carries `[Required]`. ```csharp -// Auto-inference: spec is required because Username is required +// Auto-inference: spec is required because Username is a direct required property public class EntitySpec { [Required] @@ -114,6 +114,18 @@ public class EntitySpec { public string Username { get; set; } = string.Empty; } + +// Does NOT make spec required — [Required] is on the 2nd level under an optional parent: +public class EntitySpec +{ + public NestedSpec Nested { get; set; } = new(); // optional + + public class NestedSpec + { + [Required] // only validated when Nested is present; spec stays optional + public string Name { get; set; } = null!; + } +} ``` - `[Pattern]`: Defines a regex pattern for string validation diff --git a/src/KubeOps.Transpiler/Crds.cs b/src/KubeOps.Transpiler/Crds.cs index f526542d..caf4d18d 100644 --- a/src/KubeOps.Transpiler/Crds.cs +++ b/src/KubeOps.Transpiler/Crds.cs @@ -96,7 +96,9 @@ public static V1CustomResourceDefinition Transpile(this MetadataLoadContext cont && p.GetCustomAttributeData() == null && (p.PropertyType.GetCustomAttributeData() != null || (p.Name.Equals("spec", StringComparison.OrdinalIgnoreCase) - && HasRequiredSubProperties(p.PropertyType)))) + && p.PropertyType.GetProperties() + .Any(sp => sp.GetCustomAttributeData() != null + && sp.GetCustomAttributeData() == null)))) .Select(p => p.GetPropertyName(context)) .ToList() switch { @@ -530,38 +532,4 @@ private static V1JSONSchemaProps MapValueType(this MetadataLoadContext _, Type t private static ArgumentException InvalidType(Type type) => new($"The given type {type.FullName} is not a valid Kubernetes entity."); - - private static bool HasRequiredSubProperties(Type type, HashSet? visited = null) - { - visited ??= []; - if (!visited.Add(type)) - { - return false; - } - - return type.GetProperties().Any(p => - p.GetCustomAttributeData() == null - && (p.GetCustomAttributeData() != null - || (p.PropertyType.IsClass - && p.PropertyType.FullName != typeof(string).FullName - && HasRequiredSubProperties(ResolveElementType(p.PropertyType), visited)))); - } - - // For generic collection types (e.g. List, IEnumerable), returns the item type T - // so that HasRequiredSubProperties can recurse into the element type rather than the collection itself. - private static Type ResolveElementType(Type type) - { - if (!type.IsGenericType) - { - return type; - } - - var enumerableArg = type.GetInterfaces() - .Where(i => i.IsGenericType - && i.GetGenericTypeDefinition().FullName == typeof(IEnumerable<>).FullName) - .Select(i => i.GenericTypeArguments[0]) - .FirstOrDefault(); - - return enumerableArg ?? type; - } } diff --git a/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs b/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs index a8a79e20..55d76ab8 100644 --- a/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs +++ b/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs @@ -342,12 +342,11 @@ public void Should_Set_Spec_As_Required_Via_Auto_Inference_When_Spec_Has_Require } [Fact] - public void Should_Set_Spec_As_Required_Via_Auto_Inference_When_Nested_Type_Has_Required_Properties() + public void Should_Not_Set_Spec_As_Required_When_Required_Property_Is_Under_Optional_Parent() { var crd = _mlc.Transpile(typeof(RequiredNestedPropertyEntity)); - var topLevel = crd.Spec.Versions.First().Schema.OpenAPIV3Schema; - topLevel.Required.Should().Contain("spec"); + crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Required.Should().BeNullOrEmpty(); } [Fact] @@ -355,17 +354,23 @@ public void Should_Set_Spec_As_Required_Via_Explicit_Class_Attribute() { var crd = _mlc.Transpile(typeof(RequiredSpecExplicitEntity)); - var topLevel = crd.Spec.Versions.First().Schema.OpenAPIV3Schema; - topLevel.Required.Should().Contain("spec"); + crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Required.Should().Contain("spec"); } [Fact] - public void Should_Set_Spec_As_Required_Via_Auto_Inference_When_Collection_Item_Type_Has_Required_Properties() + public void Should_Not_Set_Spec_As_Required_When_Required_Property_Is_Inside_Optional_Collection() { var crd = _mlc.Transpile(typeof(RequiredCollectionItemPropertyEntity)); - var topLevel = crd.Spec.Versions.First().Schema.OpenAPIV3Schema; - topLevel.Required.Should().Contain("spec"); + crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Required.Should().BeNullOrEmpty(); + } + + [Fact] + public void Should_Set_Spec_As_Required_When_Direct_Property_Is_Required() + { + var crd = _mlc.Transpile(typeof(RequiredDirectPropertyEntity)); + + crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Required.Should().Contain("spec"); } [Fact] @@ -1070,6 +1075,22 @@ public class NestedSpec } } + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class RequiredDirectPropertyEntity : CustomKubernetesEntity + { + public class EntitySpec + { + [Required] + public NestedSpec Nested { get; set; } = new(); + + public class NestedSpec + { + [Required] + public string Property { get; set; } = null!; + } + } + } + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] public class RequiredSpecExplicitEntity : CustomKubernetesEntity { From 3527a66f7dcd172f1282b6eaf2276f4c66409c76 Mon Sep 17 00:00:00 2001 From: Marcus Kimpenhaus Date: Tue, 28 Apr 2026 08:57:59 +0200 Subject: [PATCH 3/4] test(crds): remove redundant test for required `spec` property --- test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs b/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs index 55d76ab8..caaeb8f5 100644 --- a/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs +++ b/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs @@ -365,14 +365,6 @@ public void Should_Not_Set_Spec_As_Required_When_Required_Property_Is_Inside_Opt crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Required.Should().BeNullOrEmpty(); } - [Fact] - public void Should_Set_Spec_As_Required_When_Direct_Property_Is_Required() - { - var crd = _mlc.Transpile(typeof(RequiredDirectPropertyEntity)); - - crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Required.Should().Contain("spec"); - } - [Fact] public void Should_Not_Set_Spec_As_Required_When_Only_Required_Property_Is_Ignored() { From 045d1d7a7c5ca751bd85f6666647a50c33ed5b95 Mon Sep 17 00:00:00 2001 From: Marcus Kimpenhaus Date: Sat, 2 May 2026 16:44:47 +0200 Subject: [PATCH 4/4] refactor(transpiler): extract `IsRequiredSpecProperty` helper for clarity in required property inference --- src/KubeOps.Transpiler/Crds.cs | 13 ++++++---- test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs | 25 +++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/KubeOps.Transpiler/Crds.cs b/src/KubeOps.Transpiler/Crds.cs index caf4d18d..60cc5ac9 100644 --- a/src/KubeOps.Transpiler/Crds.cs +++ b/src/KubeOps.Transpiler/Crds.cs @@ -94,11 +94,7 @@ public static V1CustomResourceDefinition Transpile(this MetadataLoadContext cont Required = type.GetProperties() .Where(p => !IgnoredToplevelProperties.Contains(p.Name.ToLowerInvariant()) && p.GetCustomAttributeData() == null - && (p.PropertyType.GetCustomAttributeData() != null - || (p.Name.Equals("spec", StringComparison.OrdinalIgnoreCase) - && p.PropertyType.GetProperties() - .Any(sp => sp.GetCustomAttributeData() != null - && sp.GetCustomAttributeData() == null)))) + && IsRequiredSpecProperty(p)) .Select(p => p.GetPropertyName(context)) .ToList() switch { @@ -530,6 +526,13 @@ private static V1JSONSchemaProps MapValueType(this MetadataLoadContext _, Type t _ => throw InvalidType(type), }; + private static bool IsRequiredSpecProperty(PropertyInfo prop) => + prop.Name.Equals("spec", StringComparison.OrdinalIgnoreCase) + && (prop.PropertyType.GetCustomAttributeData() != null + || prop.PropertyType.GetProperties() + .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."); } diff --git a/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs b/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs index caaeb8f5..602e42d9 100644 --- a/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs +++ b/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs @@ -392,6 +392,15 @@ public void Should_Not_Set_Status_As_Required_Via_Auto_Inference_Even_When_Statu topLevel.Required.Should().BeNullOrEmpty(); } + [Fact] + public void Should_Not_Set_Status_As_Required_Via_Explicit_Class_Attribute() + { + var crd = _mlc.Transpile(typeof(RequiredStatusExplicitEntity)); + + var topLevel = crd.Spec.Versions.First().Schema.OpenAPIV3Schema; + topLevel.Required.Should().BeNullOrEmpty(); + } + [Fact] public void Should_Not_Contain_Ignored_Property() { @@ -1136,6 +1145,22 @@ public class EntityStatus } } + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class RequiredStatusExplicitEntity + : CustomKubernetesEntity + { + public class EntitySpec + { + public string Property { get; set; } = string.Empty; + } + + [Required] + public class EntityStatus + { + public string State { get; set; } = string.Empty; + } + } + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] public class IgnoreAttrEntity : CustomKubernetesEntity {