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
{