Skip to content

Commit 1e671c5

Browse files
committed
new tag symmetry and required validations
Signed-off-by: grokspawn <jordan@nimblewidget.com>
1 parent 1355ff7 commit 1e671c5

3 files changed

Lines changed: 337 additions & 24 deletions

File tree

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: 74 additions & 24 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+
version.Schema.OpenAPIV3Schema.Properties, version.Schema.OpenAPIV3Schema.Required = opconTweaksMap(channel, version.Schema.OpenAPIV3Schema.Properties, version.Schema.OpenAPIV3Schema.Required)
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 return a list of required fields according to opcon tags.
184+
// For opcon validation optional/required tags, the required list is updated accordingly.
185+
func opconTweaksMap(channel string, props map[string]apiextensionsv1.JSONSchemaProps, existingRequired []string) (map[string]apiextensionsv1.JSONSchemaProps, []string) {
186+
newRequired := slices.Clone(existingRequired)
187+
183188
for name := range props {
184189
jsonProps := props[name]
185-
p := opconTweaks(channel, name, jsonProps)
190+
p, reqStatus := opconTweaks(channel, name, jsonProps)
186191
if p == nil {
187192
delete(props, name)
188193
} else {
189194
props[name] = *p
195+
// Update required list based on tag
196+
switch reqStatus {
197+
case statusRequired:
198+
if !slices.Contains(newRequired, name) {
199+
newRequired = append(newRequired, name)
200+
}
201+
case statusOptional:
202+
newRequired = slices.DeleteFunc(newRequired, func(s string) bool { return s == name })
203+
default:
204+
// "" (unspecified) means keep existing status
205+
}
190206
}
191207
}
192-
return props
208+
return props, newRequired
193209
}
194210

195211
// Custom Opcon API Tweaks for tags prefixed with `<opcon:` that get past
196212
// the limitations of Kubebuilder annotations.
197-
func opconTweaks(channel string, name string, jsonProps apiextensionsv1.JSONSchemaProps) *apiextensionsv1.JSONSchemaProps {
213+
// Returns the modified schema and a string indicating required status where indicated by opcon tags:
214+
// "required", "optional", or "" (no decision -- preserve any non-opcon required status).
215+
216+
const (
217+
statusRequired = "required"
218+
statusOptional = "optional"
219+
statusNoOpinion = ""
220+
)
221+
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,21 @@ 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+
for _, optReqMatch := range optReqMatches {
270+
if len(optReqMatch) != 2 {
271+
log.Fatalf("Invalid %s Optional/Required tag for %s", validationPrefix, name)
272+
}
273+
274+
numValid++
275+
switch optReqMatch[1] {
276+
case "Optional":
277+
requiredStatus = statusOptional
278+
case "Required":
279+
requiredStatus = statusRequired
280+
}
281+
}
240282
}
241283

242284
if numValid < numExpressions {
@@ -246,34 +288,42 @@ func opconTweaks(channel string, name string, jsonProps apiextensionsv1.JSONSche
246288
jsonProps.Description = formatDescription(jsonProps.Description, channel, name)
247289

248290
if len(jsonProps.Properties) > 0 {
249-
jsonProps.Properties = opconTweaksMap(channel, jsonProps.Properties)
291+
jsonProps.Properties, jsonProps.Required = opconTweaksMap(channel, jsonProps.Properties, jsonProps.Required)
250292
} else if jsonProps.Items != nil && jsonProps.Items.Schema != nil {
251-
jsonProps.Items.Schema = opconTweaks(channel, name, *jsonProps.Items.Schema)
293+
jsonProps.Items.Schema, _ = opconTweaks(channel, name, *jsonProps.Items.Schema)
252294
}
253295

254-
return &jsonProps
296+
return &jsonProps, requiredStatus
255297
}
256298

257299
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)
300+
tagset := []struct {
301+
channel string
302+
start string
303+
end string
304+
}{
305+
{channel: ExperimentalChannel, start: "<opcon:standard:description>", end: "</opcon:standard:description>"},
306+
{channel: StandardChannel, start: "<opcon:experimental:description>", end: "</opcon:experimental:description>"},
307+
}
308+
for _, ts := range tagset {
309+
if channel == ts.channel && strings.Contains(description, ts.start) {
310+
regexPattern := `\n*` + regexp.QuoteMeta(ts.start) + `(?s:(.*?))` + regexp.QuoteMeta(ts.end) + `\n*`
311+
re := regexp.MustCompile(regexPattern)
312+
match := re.FindStringSubmatch(description)
313+
if len(match) != 2 {
314+
log.Fatalf("Invalid <opcon:experimental:description> tag for %s", name)
315+
}
316+
description = re.ReplaceAllString(description, "\n\n")
317+
} else {
318+
description = strings.ReplaceAll(description, ts.start, "")
319+
description = strings.ReplaceAll(description, ts.end, "")
266320
}
267-
description = re.ReplaceAllString(description, "\n\n")
268-
} else {
269-
description = strings.ReplaceAll(description, startTag, "")
270-
description = strings.ReplaceAll(description, endTag, "")
271321
}
272322

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

0 commit comments

Comments
 (0)