Skip to content

Commit 59e19d6

Browse files
authored
feat: Introduction of ValidationRuleAttribute as support for x-kubernetes-validations in Kubernetes CRDs (#859)
- added the ability to set `ValidationRuleAttribute` on properties that will be transpiled into `x-kubernetes-validations` entries in the yaml. c# spec class: ```csharp [ValidationRule("has(self.workflow) || self.kind != 'workflow", message: "workflow object must be specified if handling is workflow")] [ValidationRule("self.workflowIdSelectors.size() > 0 || self.action == 'signal'", message: "at least one workflowIdSelector must be set")] public EventHandling EventHandling { get; set; } = new EventHandling { Kind = string.Empty, MaxParallelEvents = 10 }; ``` generated yaml: ```yaml eventHandling: properties: ... required: - kind type: object x-kubernetes-validations: - message: workflow object must be specified if handling is workflow rule: has(self.workflow) || self.kind != 'temporal-workflow - message: at least one workflowIdSelector must be set rule: self.workflowIdSelectors.size() > 0 || self.action == 'signal' ``` - added unit-tests into partial test-class
1 parent 3de2c1a commit 59e19d6

File tree

6 files changed

+192
-1
lines changed

6 files changed

+192
-1
lines changed

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,9 @@ coverage.info
3333

3434
# Docs
3535
_site
36+
37+
#OSX
38+
.DS_Store
39+
40+
#Rider
41+
.idea
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
namespace KubeOps.Abstractions.Entities.Attributes;
2+
3+
/// <summary>
4+
/// ValidationRule describes a validation according to the custom
5+
/// resource defintion validation rules
6+
/// at: https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#validation-rules.
7+
/// </summary>
8+
/// <remarks>
9+
/// This attribute is used in Kubernetes Custom Resource Definitions (CRDs) to annotate
10+
/// properties with specific validation constraints. These constraints can define the rules that
11+
/// must be adhered to when setting values for the annotated property.
12+
/// </remarks>
13+
/// <param name="rule">
14+
/// rule represents the expression which will be evaluated by CEL.
15+
/// ref: https://github.com/google/cel-spec.
16+
/// </param>
17+
/// <param name="fieldPath">
18+
/// fieldPath represents the field path returned when the validation fails.
19+
/// It must be a relative JSON path (i.e. with array notation) scoped to the location of this x-kubernetes-validations
20+
/// extension in the schema and refer to an existing field.
21+
/// </param>
22+
/// <param name="message">
23+
/// message represents the message displayed when validation fails.
24+
/// The message is required if the Rule contains line breaks.
25+
/// The message must not contain line breaks.
26+
/// If unset, the message is "failed rule: {Rule}".
27+
/// </param>
28+
/// <param name="messageExpression">
29+
/// messageExpression declares a CEL expression that evaluates to the validation failure message that is returned when this rule fails.
30+
/// Since messageExpression is used as a failure message, it must evaluate to a string. If both message and messageExpression are present
31+
/// on a rule, then messageExpression will be used if validation fails.
32+
/// </param>
33+
/// <param name="reason">
34+
/// reason provides a machine-readable validation failure reason that is returned to the caller when a request fails this validation rule.
35+
/// </param>
36+
[AttributeUsage(AttributeTargets.Property, AllowMultiple = true)]
37+
public sealed class ValidationRuleAttribute(
38+
string rule,
39+
string? fieldPath = null,
40+
string? message = null,
41+
string? messageExpression = null,
42+
string? reason = null) : Attribute
43+
{
44+
public string Rule => rule;
45+
46+
public string? Message => message;
47+
48+
public string? MessageExpression => messageExpression;
49+
50+
public string? Reason => reason;
51+
52+
public string? FieldPath => fieldPath;
53+
}

src/KubeOps.Transpiler/Crds.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,18 @@ private static V1JSONSchemaProps Map(this MetadataLoadContext context, PropertyI
278278
props.Properties = null;
279279
}
280280

281+
if (prop.GetCustomAttributesData<ValidationRuleAttribute>().ToArray() is { Length: > 0 } validations)
282+
{
283+
props.XKubernetesValidations = validations
284+
.Select(validation => new V1ValidationRule(
285+
validation.GetCustomAttributeCtorArg<string>(context, 0),
286+
fieldPath: validation.GetCustomAttributeCtorArg<string?>(context, 1),
287+
message: validation.GetCustomAttributeCtorArg<string?>(context, 2),
288+
messageExpression: validation.GetCustomAttributeCtorArg<string?>(context, 3),
289+
reason: validation.GetCustomAttributeCtorArg<string?>(context, 4)))
290+
.ToList();
291+
}
292+
281293
return props;
282294
}
283295

src/KubeOps.Transpiler/Utilities.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,18 @@ public static IEnumerable<CustomAttributeData> GetCustomAttributesData<TAttribut
5656
.GetCustomAttributes(type)
5757
.Where(a => a.AttributeType.Name == typeof(TAttribute).Name);
5858

59+
/// <summary>
60+
/// Load an enumerable of custom attributes from a read-only-reflected property.
61+
/// </summary>
62+
/// <param name="prop">The property.</param>
63+
/// <typeparam name="TAttribute">The type of the attribute to load.</typeparam>
64+
/// <returns>The custom attribute data list if any were found.</returns>
65+
public static IEnumerable<CustomAttributeData> GetCustomAttributesData<TAttribute>(this PropertyInfo prop)
66+
where TAttribute : Attribute
67+
=> CustomAttributeData
68+
.GetCustomAttributes(prop)
69+
.Where(a => a.AttributeType.Name == typeof(TAttribute).Name);
70+
5971
/// <summary>
6072
/// Load a specific named argument from a custom attribute.
6173
/// Named arguments are in the property-notation:

test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
namespace KubeOps.Transpiler.Test;
1313

14-
public class CrdsMlcTest(MlcProvider provider) : TranspilerTestBase(provider)
14+
public partial class CrdsMlcTest(MlcProvider provider) : TranspilerTestBase(provider)
1515
{
1616
[Theory]
1717
[InlineData(typeof(StringTestEntity), "string", null, null)]
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
using FluentAssertions;
2+
3+
using k8s.Models;
4+
5+
using KubeOps.Abstractions.Entities;
6+
using KubeOps.Abstractions.Entities.Attributes;
7+
8+
namespace KubeOps.Transpiler.Test;
9+
10+
public sealed partial class CrdsMlcTest
11+
{
12+
private const string Rule1 = "has(self.https) || self.kind != 'https'";
13+
private const string Message1 = "https object must be specified if kind is https";
14+
private const string FieldPath1 = ".property";
15+
private const string Reason1 = "reason";
16+
private const string MessageExpression1 = "\"https object must be specified if kind is \" + string(self.kind)";
17+
18+
private const string Rule2 = "has(self.workflow) || self.kind != 'my-workflow";
19+
private const string Message2 = "workflow must be specified if handling is workflow";
20+
21+
[Fact]
22+
public void Should_Set_Validations()
23+
{
24+
var crd = _mlc.Transpile(typeof(SingleValidateAttrEntity));
25+
26+
var specProperties = crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["property"];
27+
specProperties.XKubernetesValidations.Should().HaveCount(1);
28+
specProperties.XKubernetesValidations[0].Rule.Should().Be(Rule1);
29+
specProperties.XKubernetesValidations[0].Message.Should().Be(Message1);
30+
specProperties.XKubernetesValidations[0].MessageExpression.Should().BeNull();
31+
specProperties.XKubernetesValidations[0].FieldPath.Should().BeNull();
32+
specProperties.XKubernetesValidations[0].Reason.Should().BeNull();
33+
}
34+
35+
[Fact]
36+
public void Should_Set_MultipleValidations()
37+
{
38+
var crd = _mlc.Transpile(typeof(MultiValidateAttrEntity));
39+
40+
var specProperties = crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["property"];
41+
specProperties.XKubernetesValidations.Should().HaveCount(2);
42+
specProperties.XKubernetesValidations[0].Rule.Should().Be(Rule1);
43+
specProperties.XKubernetesValidations[0].Message.Should().Be(Message1);
44+
specProperties.XKubernetesValidations[0].MessageExpression.Should().BeNull();
45+
specProperties.XKubernetesValidations[0].FieldPath.Should().BeNull();
46+
specProperties.XKubernetesValidations[0].Reason.Should().BeNull();
47+
specProperties.XKubernetesValidations[1].Rule.Should().Be(Rule2);
48+
specProperties.XKubernetesValidations[1].Message.Should().Be(Message2);
49+
specProperties.XKubernetesValidations[1].MessageExpression.Should().BeNull();
50+
specProperties.XKubernetesValidations[1].FieldPath.Should().BeNull();
51+
specProperties.XKubernetesValidations[1].Reason.Should().BeNull();
52+
}
53+
54+
[Fact]
55+
public void Should_Omit_Validations()
56+
{
57+
var crd = _mlc.Transpile(typeof(NoValidateAttrEntity));
58+
59+
var specProperties = crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["property"];
60+
specProperties.XKubernetesValidations.Should().BeNull();
61+
}
62+
63+
[Fact]
64+
public void Should_Set_ValidationFields()
65+
{
66+
var crd = _mlc.Transpile(typeof(AllFieldsValidateAttrEntity));
67+
68+
var specProperties = crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["property"];
69+
specProperties.XKubernetesValidations.Should().HaveCount(1);
70+
specProperties.XKubernetesValidations[0].Rule.Should().Be(Rule1);
71+
specProperties.XKubernetesValidations[0].Message.Should().Be(Message1);
72+
specProperties.XKubernetesValidations[0].MessageExpression.Should().Be(MessageExpression1);
73+
specProperties.XKubernetesValidations[0].FieldPath.Should().Be(FieldPath1);
74+
specProperties.XKubernetesValidations[0].Reason.Should().Be(Reason1);
75+
}
76+
77+
#region Test Entity Classes
78+
79+
[KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")]
80+
public sealed class NoValidateAttrEntity : CustomKubernetesEntity
81+
{
82+
public string Property { get; set; } = null!;
83+
}
84+
85+
[KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")]
86+
public sealed class SingleValidateAttrEntity : CustomKubernetesEntity
87+
{
88+
[ValidationRule(Rule1, message: Message1)]
89+
public string Property { get; set; } = null!;
90+
}
91+
92+
[KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")]
93+
public sealed class MultiValidateAttrEntity : CustomKubernetesEntity
94+
{
95+
[ValidationRule(Rule1, message: Message1)]
96+
[ValidationRule(Rule2, message: Message2)]
97+
public string Property { get; set; } = null!;
98+
}
99+
100+
[KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")]
101+
public sealed class AllFieldsValidateAttrEntity : CustomKubernetesEntity
102+
{
103+
[ValidationRule(Rule1, FieldPath1, Message1, MessageExpression1, Reason1)]
104+
public string Property { get; set; } = null!;
105+
}
106+
107+
#endregion Test Entity Classes
108+
}

0 commit comments

Comments
 (0)