diff --git a/docs/docs/operator/building-blocks/entities.mdx b/docs/docs/operator/building-blocks/entities.mdx index 3e3339fc..83ca94df 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 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 The `Status` property (optional) contains the current state of your resource: @@ -94,7 +96,38 @@ 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 **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 a direct required property +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; +} + +// 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 - `[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..caf4d18d 100644 --- a/src/KubeOps.Transpiler/Crds.cs +++ b/src/KubeOps.Transpiler/Crds.cs @@ -91,6 +91,20 @@ 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) + && p.PropertyType.GetProperties() + .Any(sp => sp.GetCustomAttributeData() != null + && sp.GetCustomAttributeData() == null)))) + .Select(p => p.GetPropertyName(context)) + .ToList() switch + { + { Count: > 0 } list => list, + _ => null, + }, }, }; diff --git a/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs b/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs index 6ff2ec7c..caaeb8f5 100644 --- a/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs +++ b/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs @@ -332,6 +332,66 @@ 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_Not_Set_Spec_As_Required_When_Required_Property_Is_Under_Optional_Parent() + { + var crd = _mlc.Transpile(typeof(RequiredNestedPropertyEntity)); + + crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Required.Should().BeNullOrEmpty(); + } + + [Fact] + public void Should_Set_Spec_As_Required_Via_Explicit_Class_Attribute() + { + var crd = _mlc.Transpile(typeof(RequiredSpecExplicitEntity)); + + crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Required.Should().Contain("spec"); + } + + [Fact] + public void Should_Not_Set_Spec_As_Required_When_Required_Property_Is_Inside_Optional_Collection() + { + var crd = _mlc.Transpile(typeof(RequiredCollectionItemPropertyEntity)); + + crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Required.Should().BeNullOrEmpty(); + } + + [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 +1052,90 @@ 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 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 + { + [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 {