Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 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 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:
Expand All @@ -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
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;
46 changes: 46 additions & 0 deletions src/KubeOps.Transpiler/Crds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,18 @@ public static V1CustomResourceDefinition Transpile(this MetadataLoadContext cont
&& p.GetCustomAttributeData<IgnoreAttribute>() == 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<IgnoreAttribute>() == null
&& (p.PropertyType.GetCustomAttributeData<RequiredAttribute>() != null
|| (p.Name.Equals("spec", StringComparison.OrdinalIgnoreCase)
&& HasRequiredSubProperties(p.PropertyType))))
.Select(p => p.GetPropertyName(context))
.ToList() switch
{
{ Count: > 0 } list => list,
_ => null,
},
},
};

Expand Down Expand Up @@ -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<Type>? visited = null)
{
visited ??= [];
if (!visited.Add(type))
{
return false;
}

return type.GetProperties().Any(p =>
p.GetCustomAttributeData<IgnoreAttribute>() == null
&& (p.GetCustomAttributeData<RequiredAttribute>() != null
|| (p.PropertyType.IsClass
&& p.PropertyType.FullName != typeof(string).FullName
&& HasRequiredSubProperties(ResolveElementType(p.PropertyType), visited))));
}

// For generic collection types (e.g. List<T>, IEnumerable<T>), 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;
}
}
131 changes: 131 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,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()
{
Expand Down Expand Up @@ -992,6 +1055,74 @@ 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 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 IgnoreAttrEntity : CustomKubernetesEntity<IgnoreAttrEntity.EntitySpec>
{
Expand Down
Loading