@@ -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
257306func 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