Skip to content
Open
35 changes: 34 additions & 1 deletion docs/docs/operator/building-blocks/entities.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ namespace KubeOps.Abstractions.Entities.Attributes;

/// <summary>
/// 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.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Class)]
public class RequiredAttribute : Attribute;
17 changes: 17 additions & 0 deletions src/KubeOps.Transpiler/Crds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,16 @@ public static V1CustomResourceDefinition Transpile(this MetadataLoadContext cont
.Select(p => (Name: p.GetPropertyName(context), Schema: context.Map(p)))
.OrderBy(t => t.Name, StringComparer.Ordinal)
.ToDictionary(t => t.Name, t => t.Schema),
Required = type.GetProperties()
.Where(p => !IgnoredToplevelProperties.Contains(p.Name.ToLowerInvariant())
&& p.GetCustomAttributeData<IgnoreAttribute>() == null
&& IsRequiredSpecProperty(p))
.Select(p => p.GetPropertyName(context))
.ToList() switch
{
{ Count: > 0 } list => list,
_ => null,
},
},
};

Expand Down Expand Up @@ -519,6 +529,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<RequiredAttribute>() != null
|| prop.PropertyType.GetProperties()
.Any(sp => sp.GetCustomAttributeData<RequiredAttribute>() != null
&& sp.GetCustomAttributeData<IgnoreAttribute>() == null));

private static ArgumentException InvalidType(Type type) =>
new($"The given type {type.FullName} is not a valid Kubernetes entity.");
}
169 changes: 169 additions & 0 deletions test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,75 @@ 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_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()
{
Expand Down Expand Up @@ -992,6 +1061,106 @@ public class EntitySpec
}
}

[KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")]
public class RequiredNestedPropertyEntity : CustomKubernetesEntity<RequiredNestedPropertyEntity.EntitySpec>
{
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<RequiredDirectPropertyEntity.EntitySpec>
{
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<RequiredSpecExplicitEntity.EntitySpec>
{
[Required]
public class EntitySpec
{
public string Property { get; set; } = string.Empty;
}
}

[KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")]
public class RequiredCollectionItemPropertyEntity
: CustomKubernetesEntity<RequiredCollectionItemPropertyEntity.EntitySpec>
{
public class EntitySpec
{
public List<ItemSpec> 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<RequiredIgnoredPropertyEntity.EntitySpec>
{
public class EntitySpec
{
[Required]
[Ignore]
public string Property { get; set; } = null!;
}
}

[KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")]
public class RequiredStatusPropertyEntity
: CustomKubernetesEntity<RequiredStatusPropertyEntity.EntitySpec, RequiredStatusPropertyEntity.EntityStatus>
{
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 RequiredStatusExplicitEntity
: CustomKubernetesEntity<RequiredStatusExplicitEntity.EntitySpec, RequiredStatusExplicitEntity.EntityStatus>
{
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<IgnoreAttrEntity.EntitySpec>
{
Expand Down
Loading