Skip to content

Commit 0f7aac7

Browse files
authored
feat(transpiler): add schema validation for CRD attributes (#1165)
1 parent 4629d17 commit 0f7aac7

12 files changed

Lines changed: 1094 additions & 80 deletions

File tree

docs/docs/operator/building-blocks/entities.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ public class EntitySpec
137137
- `[RangeMinimum]` and `[RangeMaximum]`: Defines numeric value ranges
138138
- `[MultipleOf]`: Specifies that a number must be a multiple of a given value
139139
- `[Items]`: Defines minimum and maximum items for arrays
140-
- `[UniqueItems]`: Marks an array property as requiring unique items (`uniqueItems: true`)
140+
- `[UniqueItems]`: **Rejected by the transpiler.** Kubernetes CRD schemas do not allow `uniqueItems: true`, so applying this attribute now fails transpilation. Use `[XListType(XListType.Set)]` for Kubernetes set semantics instead
141141
- `[PropertyLimits]`: Specifies minimum and maximum number of properties for an object
142142
- `[ValidationRule]`: Defines custom validation rules using CEL expressions. Can be applied to properties and to class types — in both cases it emits `x-kubernetes-validations` on the corresponding schema node. When applied to both a class and a property of that type, the rules are merged (class rules first, then property rules). Class-level rules are also collected across the **class inheritance chain**: a rule placed on a base class is inherited by every derived entity or nested type, and all rules found along the chain are merged onto the schema node
143143

src/KubeOps.Transpiler/CrdSchemaAttributeValidator.cs

Lines changed: 425 additions & 0 deletions
Large diffs are not rendered by default.

src/KubeOps.Transpiler/Crds.cs

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ public static V1CustomResourceDefinition Transpile(
102102
};
103103
}
104104

105+
var validationRules = type.GetInheritedCustomAttributesData<ValidationRuleAttribute>().ToList();
106+
CrdSchemaAttributeValidator.ValidateValidationRuleAttributes(type, validationRules, context);
107+
105108
version.Schema = new()
106109
{
107110
OpenAPIV3Schema = new()
@@ -127,8 +130,7 @@ public static V1CustomResourceDefinition Transpile(
127130
{ Count: > 0 } list => list,
128131
_ => null,
129132
},
130-
XKubernetesValidations = context.MapValidationRules(
131-
type.GetInheritedCustomAttributesData<ValidationRuleAttribute>()),
133+
XKubernetesValidations = context.MapValidationRules(validationRules),
132134
},
133135
};
134136

@@ -240,7 +242,7 @@ private static IEnumerable<V1CustomResourceColumnDefinition> MapPrinterColumns(
240242
}
241243

242244
var mapped = context.Map(prop, EmptyAncestors);
243-
yield return new()
245+
var column = new V1CustomResourceColumnDefinition
244246
{
245247
Name = attr.GetCustomAttributeCtorArg<string>(context, 1) ?? prop.GetPropertyName(context),
246248
JsonPath = $"{path}.{prop.GetPropertyName(context)}",
@@ -253,6 +255,11 @@ private static IEnumerable<V1CustomResourceColumnDefinition> MapPrinterColumns(
253255
_ => 1,
254256
},
255257
};
258+
CrdSchemaAttributeValidator.ValidatePrinterColumn(
259+
prop,
260+
column,
261+
nameof(AdditionalPrinterColumnAttribute));
262+
yield return column;
256263
}
257264

258265
foreach (var attr in type.GetInheritedCustomAttributesData<GenericAdditionalPrinterColumnAttribute>())
@@ -288,7 +295,7 @@ private static IEnumerable<V1CustomResourceColumnDefinition> MapPrinterColumns(
288295
continue;
289296
}
290297

291-
yield return new()
298+
var column = new V1CustomResourceColumnDefinition
292299
{
293300
Name = colName,
294301
JsonPath = jsonPath,
@@ -301,6 +308,11 @@ private static IEnumerable<V1CustomResourceColumnDefinition> MapPrinterColumns(
301308
_ => 1,
302309
},
303310
};
311+
CrdSchemaAttributeValidator.ValidatePrinterColumn(
312+
type,
313+
column,
314+
nameof(GenericAdditionalPrinterColumnAttribute));
315+
yield return column;
304316
}
305317
}
306318

@@ -452,6 +464,8 @@ private static V1JSONSchemaProps Map(
452464
: propValidations;
453465
}
454466

467+
CrdSchemaAttributeValidator.Validate(prop, props, context);
468+
455469
return props;
456470
}
457471

@@ -617,6 +631,8 @@ private static V1JSONSchemaProps MapObjectType(
617631

618632
var nextAncestors = new HashSet<Type>(ancestors) { type };
619633
var preservesUnknownFields = type.GetCustomAttributeData<PreserveUnknownFieldsAttribute>() != null;
634+
var validationRules = type.GetInheritedCustomAttributesData<ValidationRuleAttribute>().ToList();
635+
CrdSchemaAttributeValidator.ValidateValidationRuleAttributes(type, validationRules, context);
620636

621637
try
622638
{
@@ -646,8 +662,7 @@ private static V1JSONSchemaProps MapObjectType(
646662
_ => null,
647663
},
648664
XKubernetesPreserveUnknownFields = preservesUnknownFields ? true : null,
649-
XKubernetesValidations = context.MapValidationRules(
650-
type.GetInheritedCustomAttributesData<ValidationRuleAttribute>()),
665+
XKubernetesValidations = context.MapValidationRules(validationRules),
651666
};
652667
}
653668
catch (Exception ex) when (preservesUnknownFields

src/KubeOps.Transpiler/README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,28 @@ foreach (var crd in crds)
9797
- **Custom Build Tasks:** Integrate CRD generation directly into your MSBuild process.
9898
- **Schema Validation Tools:** Use the generated CRD schema for validating custom resource YAML files.
9999

100+
### CRD Schema Validation
101+
102+
The transpiler performs a narrow validation pass for schema attributes that are
103+
known to produce CRDs rejected by the Kubernetes API server. This includes
104+
`uniqueItems: true`, invalid `x-kubernetes-list-*` topology combinations,
105+
invalid `x-kubernetes-map-type` placement, and malformed validation-rule
106+
metadata. Additional printer columns are also checked so their type is one of
107+
the Kubernetes-supported column types.
108+
109+
The transpiler does not try to be a full OpenAPI linter. Some OpenAPI keywords
110+
are semantically useful only on specific schema types, but Kubernetes currently
111+
accepts those CRD schemas at admission time. Those combinations remain
112+
transpilable for compatibility.
113+
114+
For a server-side check against a local cluster, generate manifests and use
115+
Kubernetes server dry-run for the generated CRD YAML files:
116+
117+
```bash
118+
kubeops generate operator MyOperator ./MyOperator.csproj --out ./k8s --format yaml
119+
kubectl apply --dry-run=server -f ./k8s/<generated-crd-file>.yaml
120+
```
121+
100122
The assembly inspection and attribute processing logic within this package is also leveraged by the KubeOps CLI (`kubeops generate operator`) command. The CLI uses this package's capabilities to find types decorated with `[EntityRbac]` attributes when generating the RBAC manifests (`Role`/`ClusterRole`) for your operator.
101123

102124
For more details on defining the C# classes themselves, see the main KubeOps documentation.

test/KubeOps.Operator.Web.Test/Webhooks/Admission/Mutation/MutationWebhook.ModelBinding.Test.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,13 @@ public async Task HandleAsync_CreateRequestWithoutObjectMetadataUid_DoesNotFailM
2222
{
2323
using var host = await TestHost.Create();
2424
var client = host.GetTestClient();
25+
var entityWithoutMetadataUid = CreateTestSpec("createvalue", "PT10M");
2526

2627
var admissionRequest = CreateAdmissionReview(
27-
uid: "issue-753-mutation-create-uid",
28+
admissionRequestUid: "issue-753-mutation-create-uid",
2829
operation: "CREATE",
2930
dryRun: false,
30-
@object: CreateTestSpec("createvalue", "PT10M"));
31+
@object: entityWithoutMetadataUid);
3132

3233
var response = await PostWebhookAsync(
3334
client,
@@ -67,7 +68,7 @@ public async Task HandleAsync_Request_BindsAndMutatesCorrectly(
6768

6869
var spec = CreateTestSpec(value, timeout);
6970
var admissionRequest = CreateAdmissionReview(
70-
uid: uid,
71+
admissionRequestUid: uid,
7172
operation: operation,
7273
dryRun: dryRun,
7374
@object: operation == "DELETE" ? null : spec,

test/KubeOps.Operator.Web.Test/Webhooks/Admission/Validation/ValidationWebhook.ModelBinding.Test.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,13 @@ public async Task HandleAsync_CreateRequestWithoutObjectMetadataUid_DoesNotFailM
2222
{
2323
using var host = await TestHost.Create();
2424
var client = host.GetTestClient();
25+
var entityWithoutMetadataUid = CreateTestSpec("createvalue", "PT10M");
2526

2627
var admissionRequest = CreateAdmissionReview(
27-
uid: "issue-753-validation-create-uid",
28+
admissionRequestUid: "issue-753-validation-create-uid",
2829
operation: "CREATE",
2930
dryRun: false,
30-
@object: CreateTestSpec("createvalue", "PT10M"));
31+
@object: entityWithoutMetadataUid);
3132

3233
var response = await PostWebhookAsync(
3334
client,
@@ -96,7 +97,7 @@ public async Task HandleAsync_Request_BindsAndValidatesCorrectly(
9697
var @object = CreateTestSpec(newValue, newTimeout);
9798
var oldObject = CreateTestSpec(oldValue, oldTimeout);
9899
var admissionRequest = CreateAdmissionReview(
99-
uid: uid,
100+
admissionRequestUid: uid,
100101
operation: operation,
101102
dryRun: dryRun,
102103
@object: operation == "DELETE" ? null : @object,

test/KubeOps.Operator.Web.Test/Webhooks/Admission/WebhookTestBase.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ protected static object CreateTestSpec(string value, string timeout)
2121
};
2222

2323
protected static object CreateAdmissionReview(
24-
string uid,
24+
string admissionRequestUid,
2525
string operation,
2626
bool dryRun,
2727
object? @object = null,
@@ -32,7 +32,7 @@ protected static object CreateAdmissionReview(
3232
kind = "AdmissionReview",
3333
request = new
3434
{
35-
uid,
35+
uid = admissionRequestUid,
3636
operation,
3737
dryRun,
3838
@object,

0 commit comments

Comments
 (0)