Skip to content

Commit 67aa3fd

Browse files
authored
feat(generator): expand CRD schema metadata support (#1201)
Adds attributes for defaults, examples, formats, and enum values in generated CRD schemas, including structured JSON and class-level defaults.Includes focused attribute validation and explicitly configures the CRD conversion strategy based on registered conversion webhooks. closes #1200
1 parent 3ea3844 commit 67aa3fd

14 files changed

Lines changed: 675 additions & 21 deletions

File tree

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

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,12 +140,22 @@ public class EntitySpec
140140
- `[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
143+
- `[EnumValues]`: Overrides the allowed string, integer, or floating-point values emitted as the OpenAPI `enum`.
144+
Values must be non-empty, unique, and compatible with the generated string, integer, number, or
145+
`IntOrString` schema
143146

144147
### Documentation Attributes
145148

146149
- `[Description]`: Adds a description to a property or entity
147150
- `[Title]`: Adds a title to a property or entity
148151
- `[ExternalDocs]`: Links to external documentation
152+
- `[DefaultValue]`: Adds an OpenAPI default value to a property or class. Set `Json = true` on a string argument to
153+
represent structured values such as objects or arrays instead of a literal string. A class-level default applies
154+
wherever that type is used in the schema, including the `spec` or `status` subresource. Class-level defaults are
155+
inherited
156+
- `[Example]`: Adds an OpenAPI example. Set `Json = true` for structured JSON examples
157+
- `[Format]`: Overrides the inferred OpenAPI format, for example `[Format("uri")]` or `[Format("email")]`.
158+
Use `[Format(null)]` to suppress an inferred format such as `int32`; empty or whitespace-only formats are rejected
149159

150160
### Display Attributes
151161

@@ -182,17 +192,30 @@ public class V1DemoEntity : CustomKubernetesEntity<V1DemoEntity.V1DemoEntitySpec
182192
public string Username { get; set; } = string.Empty;
183193

184194
[Description("Number of replicas to run")]
195+
[DefaultValue(1)]
185196
[RangeMinimum(1)]
186197
[RangeMaximum(10)]
187198
public int Replicas { get; set; } = 1;
188199

189200
[Pattern(@"^[a-z0-9-]+$")]
190201
[Description("The namespace where resources should be created")]
191202
public string? TargetNamespace { get; set; }
203+
204+
[Format("uri")]
205+
[Example("https://example.com/events")]
206+
public string? CallbackUrl { get; set; }
207+
208+
[DefaultValue("{}", Json = true)]
209+
public Dictionary<string, object> Labels { get; set; } = [];
192210
}
193211

212+
[DefaultValue("{}", Json = true)]
194213
public class V1DemoEntityStatus
195214
{
215+
[DefaultValue(0)]
216+
[Description("Last reconciled resource generation")]
217+
public long ObservedGeneration { get; set; }
218+
196219
[Description("Current state of the entity")]
197220
public string CurrentState { get; set; } = string.Empty;
198221

@@ -293,4 +316,4 @@ subresources:
293316

294317
:::note
295318
`[ScaleSubresource]` and the status subresource are controlled independently. A `Status` property activates `status: {}` regardless of `[ScaleSubresource]`, and `[ScaleSubresource]` adds `scale:` regardless of whether a `Status` property exists.
296-
:::
319+
:::

docs/docs/operator/cli.mdx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ Common options:
105105
- `--docker-image-tag`: Specify Docker image tag
106106
- `--no-ansi`: Disable ANSI colored output (useful for CI/CD pipelines)
107107

108+
Generated CRDs explicitly use `conversion.strategy: None` unless a conversion webhook is registered for the CRD.
109+
For registered conversion webhooks, the strategy is `Webhook` and includes the webhook client configuration.
110+
108111
## Cluster Information
109112

110113
The `version` command shows information about your current Kubernetes cluster:
@@ -162,4 +165,4 @@ All commands support these options:
162165
```bash
163166
# Generate resources with custom output
164167
kubeops generate operator MyOperator ./MyOperator.csproj --out ./k8s --format yaml
165-
```
168+
```
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information.
4+
5+
namespace KubeOps.Abstractions.Entities.Attributes;
6+
7+
/// <summary>
8+
/// Defines the default value of a property or type in the generated OpenAPI schema.
9+
/// </summary>
10+
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Class, Inherited = true)]
11+
public sealed class DefaultValueAttribute : Attribute
12+
{
13+
/// <summary>
14+
/// Initializes the attribute with a boolean value.
15+
/// </summary>
16+
/// <param name="value">The default value.</param>
17+
public DefaultValueAttribute(bool value) => Value = value;
18+
19+
/// <summary>
20+
/// Initializes the attribute with an integer value.
21+
/// </summary>
22+
/// <param name="value">The default value.</param>
23+
public DefaultValueAttribute(long value) => Value = value;
24+
25+
/// <summary>
26+
/// Initializes the attribute with a floating-point value.
27+
/// </summary>
28+
/// <param name="value">The default value.</param>
29+
public DefaultValueAttribute(double value) => Value = value;
30+
31+
/// <summary>
32+
/// Initializes the attribute with a string value.
33+
/// </summary>
34+
/// <param name="value">The default value or its JSON representation when <see cref="Json"/> is set.</param>
35+
public DefaultValueAttribute(string value) => Value = value;
36+
37+
/// <summary>
38+
/// Gets or sets whether the string value contains JSON that should be emitted as a structured value.
39+
/// </summary>
40+
public bool Json { get; init; }
41+
42+
/// <summary>
43+
/// Gets the configured default value.
44+
/// </summary>
45+
public object Value { get; }
46+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information.
4+
5+
namespace KubeOps.Abstractions.Entities.Attributes;
6+
7+
/// <summary>
8+
/// Overrides the allowed values generated for a property in the OpenAPI schema.
9+
/// </summary>
10+
[AttributeUsage(AttributeTargets.Property)]
11+
public sealed class EnumValuesAttribute : Attribute
12+
{
13+
/// <summary>
14+
/// Initializes the attribute with string values.
15+
/// </summary>
16+
/// <param name="values">The allowed values.</param>
17+
public EnumValuesAttribute(params string[] values) => Values = values;
18+
19+
/// <summary>
20+
/// Initializes the attribute with integer values.
21+
/// </summary>
22+
/// <param name="values">The allowed values.</param>
23+
public EnumValuesAttribute(params long[] values) => Values = values.Cast<object>().ToArray();
24+
25+
/// <summary>
26+
/// Initializes the attribute with floating-point values.
27+
/// </summary>
28+
/// <param name="values">The allowed values.</param>
29+
public EnumValuesAttribute(params double[] values) => Values = values.Cast<object>().ToArray();
30+
31+
/// <summary>
32+
/// Gets the configured allowed values.
33+
/// </summary>
34+
public IReadOnlyList<object> Values { get; }
35+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information.
4+
5+
namespace KubeOps.Abstractions.Entities.Attributes;
6+
7+
/// <summary>
8+
/// Defines an example value for a property in the generated OpenAPI schema.
9+
/// </summary>
10+
[AttributeUsage(AttributeTargets.Property)]
11+
public sealed class ExampleAttribute : Attribute
12+
{
13+
/// <summary>
14+
/// Initializes the attribute with a boolean value.
15+
/// </summary>
16+
/// <param name="value">The example value.</param>
17+
public ExampleAttribute(bool value) => Value = value;
18+
19+
/// <summary>
20+
/// Initializes the attribute with an integer value.
21+
/// </summary>
22+
/// <param name="value">The example value.</param>
23+
public ExampleAttribute(long value) => Value = value;
24+
25+
/// <summary>
26+
/// Initializes the attribute with a floating-point value.
27+
/// </summary>
28+
/// <param name="value">The example value.</param>
29+
public ExampleAttribute(double value) => Value = value;
30+
31+
/// <summary>
32+
/// Initializes the attribute with a string value.
33+
/// </summary>
34+
/// <param name="value">The example value or its JSON representation when <see cref="Json"/> is set.</param>
35+
public ExampleAttribute(string value) => Value = value;
36+
37+
/// <summary>
38+
/// Gets or sets whether the string value contains JSON that should be emitted as a structured value.
39+
/// </summary>
40+
public bool Json { get; init; }
41+
42+
/// <summary>
43+
/// Gets the configured example value.
44+
/// </summary>
45+
public object Value { get; }
46+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information.
4+
5+
namespace KubeOps.Abstractions.Entities.Attributes;
6+
7+
/// <summary>
8+
/// Overrides the OpenAPI format generated for a property.
9+
/// </summary>
10+
[AttributeUsage(AttributeTargets.Property)]
11+
public sealed class FormatAttribute(string? format) : Attribute
12+
{
13+
/// <summary>
14+
/// Gets the OpenAPI format.
15+
/// </summary>
16+
public string? Format => format;
17+
}

src/KubeOps.Abstractions/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,10 @@ By depending only on this package, you can define your entities and interfaces w
1919

2020
- Define your CRD entity classes in a separate library, shared between your operator and potentially other applications.
2121
- Build tools that need to understand KubeOps entity definitions without needing the operator runtime.
22+
23+
## CRD schema metadata
24+
25+
Entity properties can describe OpenAPI schema metadata with attributes such as `[DefaultValue]`, `[Example]`,
26+
`[Format]`, and `[EnumValues]`. String defaults and examples can set `Json = true` to represent structured JSON
27+
objects or arrays. `[DefaultValue]` can also be applied to a class to set the default wherever that type occurs in
28+
the generated schema, including the `spec` or `status` subresource.

src/KubeOps.Cli/Generators/CrdGenerator.cs

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
using System.Reflection;
66

7+
using k8s.Models;
8+
79
using KubeOps.Cli.Output;
810
using KubeOps.Cli.Transpilation;
911
using KubeOps.Transpiler;
@@ -20,29 +22,38 @@ public void Generate(ResultOutput output)
2022

2123
foreach (var crd in crds)
2224
{
23-
if (conversionWebhooks
24-
.Find(wh => crd.Spec.Group == wh.Group && crd.Spec.Names.Kind == wh.Kind) is not null)
25+
var hasConversionWebhook = conversionWebhooks
26+
.Find(wh => crd.Spec.Group == wh.Group && crd.Spec.Names.Kind == wh.Kind) is not null;
27+
28+
ConfigureConversion(crd, hasConversionWebhook, caBundle);
29+
30+
output.Add($"{crd.Metadata.Name.Replace('.', '_')}.{outputFormat.GetFileExtension()}", crd);
31+
}
32+
}
33+
34+
internal static void ConfigureConversion(
35+
V1CustomResourceDefinition crd,
36+
bool hasConversionWebhook,
37+
byte[] caBundle)
38+
{
39+
crd.Spec.Conversion = hasConversionWebhook
40+
? new()
2541
{
26-
crd.Spec.Conversion = new()
42+
Strategy = "Webhook",
43+
Webhook = new()
2744
{
28-
Strategy = "Webhook",
29-
Webhook = new()
45+
ConversionReviewVersions = ["v1"],
46+
ClientConfig = new()
3047
{
31-
ConversionReviewVersions = new[] { "v1" },
32-
ClientConfig = new()
48+
CaBundle = caBundle,
49+
Service = new()
3350
{
34-
CaBundle = caBundle,
35-
Service = new()
36-
{
37-
Path = $"/convert/{crd.Spec.Group}/{crd.Spec.Names.Plural}",
38-
Name = "service",
39-
},
51+
Path = $"/convert/{crd.Spec.Group}/{crd.Spec.Names.Plural}",
52+
Name = "service",
4053
},
4154
},
42-
};
55+
},
4356
}
44-
45-
output.Add($"{crd.Metadata.Name.Replace('.', '_')}.{outputFormat.GetFileExtension()}", crd);
46-
}
57+
: new() { Strategy = "None" };
4758
}
4859
}

src/KubeOps.Cli/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ If your operator includes webhooks (mutations or validations), additional resour
5353
- **Service**: For exposing webhook endpoints
5454
- **Secret Generators**: For managing webhook certificates
5555

56+
Generated CRDs explicitly use `conversion.strategy: None` unless a conversion webhook is registered for the CRD.
57+
When one is registered, the strategy is `Webhook` and the corresponding webhook client configuration is generated.
58+
5659
### Install
5760

5861
Installs the operator and its CRDs into a Kubernetes cluster.

src/KubeOps.Transpiler/CrdSchemaAttributeValidator.cs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ internal static class CrdSchemaAttributeValidator
4747

4848
public static void Validate(PropertyInfo prop, V1JSONSchemaProps props, MetadataLoadContext context)
4949
{
50+
ValidateFormatAttribute(prop, context);
51+
ValidateEnumValuesAttribute(prop, props);
5052
ValidateUniqueItems(prop);
5153
ValidateListTopologyAttributes(prop, props, context);
5254
ValidateMapTopologyAttribute(prop, props);
@@ -106,6 +108,66 @@ public static void ValidateValidationRuleAttributes(
106108
}
107109
}
108110

111+
private static void ValidateFormatAttribute(PropertyInfo prop, MetadataLoadContext context)
112+
{
113+
if (prop.GetCustomAttributeData<FormatAttribute>() is not { } formatAttribute)
114+
{
115+
return;
116+
}
117+
118+
var format = formatAttribute.GetCustomAttributeCtorArg<string?>(context, 0);
119+
if (format is not null && string.IsNullOrWhiteSpace(format))
120+
{
121+
throw InvalidSchemaAttribute(
122+
prop,
123+
nameof(FormatAttribute),
124+
"OpenAPI format must be null or contain at least one non-whitespace character.");
125+
}
126+
}
127+
128+
private static void ValidateEnumValuesAttribute(PropertyInfo prop, V1JSONSchemaProps props)
129+
{
130+
if (prop.GetCustomAttributeData<EnumValuesAttribute>() is null)
131+
{
132+
return;
133+
}
134+
135+
var values = props.EnumProperty ?? [];
136+
if (values.Count == 0)
137+
{
138+
throw InvalidSchemaAttribute(prop, nameof(EnumValuesAttribute), "At least one enum value is required.");
139+
}
140+
141+
if (values.Distinct().Count() != values.Count)
142+
{
143+
throw InvalidSchemaAttribute(prop, nameof(EnumValuesAttribute), "Enum values must be unique.");
144+
}
145+
146+
if (values.Any(value => !IsEnumValueCompatible(props, value)))
147+
{
148+
throw InvalidSchemaAttribute(
149+
prop,
150+
nameof(EnumValuesAttribute),
151+
$"Enum values must match the generated schema type '{props.Type ?? "integer or string"}'.");
152+
}
153+
}
154+
155+
private static bool IsEnumValueCompatible(V1JSONSchemaProps props, object value)
156+
{
157+
if (props.XKubernetesIntOrString.GetValueOrDefault())
158+
{
159+
return value is string or long;
160+
}
161+
162+
return props.Type switch
163+
{
164+
"string" => value is string,
165+
"integer" => value is long,
166+
"number" => value is long or double,
167+
_ => false,
168+
};
169+
}
170+
109171
private static void ValidateUniqueItems(PropertyInfo prop)
110172
{
111173
if (prop.GetCustomAttributeData<UniqueItemsAttribute>() is null)

0 commit comments

Comments
 (0)