Skip to content

Commit 91b03c7

Browse files
authored
🌱 new tag symmetry and required validations (#2358)
* new tag symmetry and required validations Signed-off-by: grokspawn <jordan@nimblewidget.com> * review updates Signed-off-by: grokspawn <jordan@nimblewidget.com> --------- Signed-off-by: grokspawn <jordan@nimblewidget.com>
1 parent 4355cde commit 91b03c7

File tree

3 files changed

+348
-23
lines changed

3 files changed

+348
-23
lines changed

hack/tools/crd-generator/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ A semi-colon separated list of enumerations, similar to the `+kubebuilder:valida
3333

3434
An XValidation scheme, similar to the `+kubebuilder:validation:XValidation` scheme, but more limited.
3535

36+
* `Optional`
37+
38+
Indicating that this field should not be listed as required in its parent.
39+
40+
* `Required`
41+
42+
Indicating that this field should be listed as required in its parent.
43+
3644
## Experimental Description
3745

3846
* Start Tag: `<opcon:experimental:description>`
@@ -44,6 +52,18 @@ All text between the tags is included in the experimental CRD, but removed from
4452
This is only useful if the field is included in the standard CRD, but there's additional meaning in
4553
the experimental CRD when feature gates are enabled.
4654

55+
## Standard Description
56+
57+
* Start Tag: `<opcon:standard:description>`
58+
* End Tag: `</opcon:standard:description>`
59+
60+
Descriptive text that is only included as part of the field description within the standard CRD.
61+
All text between the tags is included in the standard CRD, but removed from the experimental CRD.
62+
63+
This is useful if the field is included in the standard CRD and has differing meaning than when the
64+
field is used in the experimental CRD when feature gates are enabled.
65+
66+
4767
## Exclude from CRD Description
4868

4969
* Start Tag: `<opcon:util:excludeFromCRD>`

hack/tools/crd-generator/main.go

Lines changed: 81 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"log"
2424
"os"
2525
"regexp"
26+
"slices"
2627
"strings"
2728

2829
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
@@ -136,7 +137,7 @@ func runGenerator(args ...string) {
136137
if channel == StandardChannel && strings.Contains(version.Name, "alpha") {
137138
channelCrd.Spec.Versions[i].Served = false
138139
}
139-
version.Schema.OpenAPIV3Schema.Properties = opconTweaksMap(channel, version.Schema.OpenAPIV3Schema.Properties)
140+
channelCrd.Spec.Versions[i].Schema.OpenAPIV3Schema.Properties = opconTweaksMap(channel, channelCrd.Spec.Versions[i].Schema.OpenAPIV3Schema)
140141
}
141142

142143
conv, err := crd.AsVersion(*channelCrd, apiextensionsv1.SchemeGroupVersion)
@@ -179,25 +180,51 @@ func runGenerator(args ...string) {
179180
}
180181
}
181182

182-
func opconTweaksMap(channel string, props map[string]apiextensionsv1.JSONSchemaProps) map[string]apiextensionsv1.JSONSchemaProps {
183+
// Apply Opcon specific tweaks to all properties in a map, and update the parent schema's required list according to opcon tags.
184+
// For opcon validation optional/required tags, the parent schema's required list is mutated directly.
185+
// TODO: if we need to support other conditions from opconTweaks, it will likely be preferable to convey the parent schema to facilitate direct alteration.
186+
func opconTweaksMap(channel string, parentSchema *apiextensionsv1.JSONSchemaProps) map[string]apiextensionsv1.JSONSchemaProps {
187+
props := parentSchema.Properties
188+
183189
for name := range props {
184190
jsonProps := props[name]
185-
p := opconTweaks(channel, name, jsonProps)
191+
p, reqStatus := opconTweaks(channel, name, jsonProps)
186192
if p == nil {
187193
delete(props, name)
188194
} else {
189195
props[name] = *p
196+
// Update required list based on tag
197+
switch reqStatus {
198+
case statusRequired:
199+
if !slices.Contains(parentSchema.Required, name) {
200+
parentSchema.Required = append(parentSchema.Required, name)
201+
}
202+
case statusOptional:
203+
parentSchema.Required = slices.DeleteFunc(parentSchema.Required, func(s string) bool { return s == name })
204+
default:
205+
// "" (unspecified) means keep existing status
206+
}
190207
}
191208
}
192209
return props
193210
}
194211

212+
const (
213+
statusRequired = "required"
214+
statusOptional = "optional"
215+
statusNoOpinion = ""
216+
)
217+
195218
// Custom Opcon API Tweaks for tags prefixed with `<opcon:` that get past
196219
// the limitations of Kubebuilder annotations.
197-
func opconTweaks(channel string, name string, jsonProps apiextensionsv1.JSONSchemaProps) *apiextensionsv1.JSONSchemaProps {
220+
// Returns the modified schema and a string indicating required status where indicated by opcon tags:
221+
// "required", "optional", or "" (no decision -- preserve any non-opcon required status).
222+
func opconTweaks(channel string, name string, jsonProps apiextensionsv1.JSONSchemaProps) (*apiextensionsv1.JSONSchemaProps, string) {
223+
requiredStatus := statusNoOpinion
224+
198225
if channel == StandardChannel {
199226
if strings.Contains(jsonProps.Description, "<opcon:experimental>") {
200-
return nil
227+
return nil, statusNoOpinion
201228
}
202229
}
203230

@@ -219,7 +246,7 @@ func opconTweaks(channel string, name string, jsonProps apiextensionsv1.JSONSche
219246

220247
numValid++
221248
jsonProps.Enum = []apiextensionsv1.JSON{}
222-
for _, val := range strings.Split(enumMatch[1], ";") {
249+
for val := range strings.SplitSeq(enumMatch[1], ";") {
223250
jsonProps.Enum = append(jsonProps.Enum, apiextensionsv1.JSON{Raw: []byte("\"" + val + "\"")})
224251
}
225252
}
@@ -237,6 +264,28 @@ func opconTweaks(channel string, name string, jsonProps apiextensionsv1.JSONSche
237264
Rule: celMatch[2],
238265
})
239266
}
267+
optReqRe := regexp.MustCompile(validationPrefix + "(Optional|Required)>")
268+
optReqMatches := optReqRe.FindAllStringSubmatch(jsonProps.Description, 64)
269+
hasOptional := false
270+
hasRequired := false
271+
for _, optReqMatch := range optReqMatches {
272+
if len(optReqMatch) != 2 {
273+
log.Fatalf("Invalid %s Optional/Required tag for %s", validationPrefix, name)
274+
}
275+
276+
numValid++
277+
switch optReqMatch[1] {
278+
case "Optional":
279+
hasOptional = true
280+
requiredStatus = statusOptional
281+
case "Required":
282+
hasRequired = true
283+
requiredStatus = statusRequired
284+
}
285+
}
286+
if hasOptional && hasRequired {
287+
log.Fatalf("Field %s has both Optional and Required validation tags for channel %s", name, channel)
288+
}
240289
}
241290

242291
if numValid < numExpressions {
@@ -246,34 +295,43 @@ func opconTweaks(channel string, name string, jsonProps apiextensionsv1.JSONSche
246295
jsonProps.Description = formatDescription(jsonProps.Description, channel, name)
247296

248297
if len(jsonProps.Properties) > 0 {
249-
jsonProps.Properties = opconTweaksMap(channel, jsonProps.Properties)
298+
jsonProps.Properties = opconTweaksMap(channel, &jsonProps)
250299
} else if jsonProps.Items != nil && jsonProps.Items.Schema != nil {
251-
jsonProps.Items.Schema = opconTweaks(channel, name, *jsonProps.Items.Schema)
300+
jsonProps.Items.Schema, _ = opconTweaks(channel, name, *jsonProps.Items.Schema)
252301
}
253302

254-
return &jsonProps
303+
return &jsonProps, requiredStatus
255304
}
256305

257306
func formatDescription(description string, channel string, name string) string {
258-
startTag := "<opcon:experimental:description>"
259-
endTag := "</opcon:experimental:description>"
260-
if channel == StandardChannel && strings.Contains(description, startTag) {
261-
regexPattern := `\n*` + regexp.QuoteMeta(startTag) + `(?s:(.*?))` + regexp.QuoteMeta(endTag) + `\n*`
262-
re := regexp.MustCompile(regexPattern)
263-
match := re.FindStringSubmatch(description)
264-
if len(match) != 2 {
265-
log.Fatalf("Invalid <opcon:experimental:description> tag for %s", name)
307+
tagset := []struct {
308+
channel string
309+
tag string
310+
}{
311+
{channel: ExperimentalChannel, tag: "opcon:standard:description"},
312+
{channel: StandardChannel, tag: "opcon:experimental:description"},
313+
}
314+
for _, ts := range tagset {
315+
startTag := fmt.Sprintf("<%s>", ts.tag)
316+
endTag := fmt.Sprintf("</%s>", ts.tag)
317+
if channel == ts.channel && strings.Contains(description, ts.tag) {
318+
regexPattern := `\n*` + regexp.QuoteMeta(startTag) + `(?s:(.*?))` + regexp.QuoteMeta(endTag) + `\n*`
319+
re := regexp.MustCompile(regexPattern)
320+
match := re.FindStringSubmatch(description)
321+
if len(match) != 2 {
322+
log.Fatalf("Invalid %s tag for %s", startTag, name)
323+
}
324+
description = re.ReplaceAllString(description, "\n\n")
325+
} else {
326+
description = strings.ReplaceAll(description, startTag, "")
327+
description = strings.ReplaceAll(description, endTag, "")
266328
}
267-
description = re.ReplaceAllString(description, "\n\n")
268-
} else {
269-
description = strings.ReplaceAll(description, startTag, "")
270-
description = strings.ReplaceAll(description, endTag, "")
271329
}
272330

273331
// Comments within "opcon:util:excludeFromCRD" tag are not included in the generated CRD and all trailing \n operators before
274332
// and after the tags are removed and replaced with three \n operators.
275-
startTag = "<opcon:util:excludeFromCRD>"
276-
endTag = "</opcon:util:excludeFromCRD>"
333+
startTag := "<opcon:util:excludeFromCRD>"
334+
endTag := "</opcon:util:excludeFromCRD>"
277335
if strings.Contains(description, startTag) {
278336
regexPattern := `\n*` + regexp.QuoteMeta(startTag) + `(?s:(.*?))` + regexp.QuoteMeta(endTag) + `\n*`
279337
re := regexp.MustCompile(regexPattern)

0 commit comments

Comments
 (0)