Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions hack/tools/crd-generator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ A semi-colon separated list of enumerations, similar to the `+kubebuilder:valida

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

* `Optional`

Indicating that this field should not be listed as required in its parent.

* `Required`

Indicating that this field should be listed as required in its parent.

## Experimental Description

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

## Standard Description

* Start Tag: `<opcon:standard:description>`
* End Tag: `</opcon:standard:description>`

Descriptive text that is only included as part of the field description within the standard CRD.
All text between the tags is included in the standard CRD, but removed from the experimental CRD.

This is useful if the field is included in the standard CRD and has differing meaning than when the
field is used in the experimental CRD when feature gates are enabled.


## Exclude from CRD Description

* Start Tag: `<opcon:util:excludeFromCRD>`
Expand Down
98 changes: 74 additions & 24 deletions hack/tools/crd-generator/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"log"
"os"
"regexp"
"slices"
"strings"

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

conv, err := crd.AsVersion(*channelCrd, apiextensionsv1.SchemeGroupVersion)
Expand Down Expand Up @@ -179,25 +180,51 @@ func runGenerator(args ...string) {
}
}

func opconTweaksMap(channel string, props map[string]apiextensionsv1.JSONSchemaProps) map[string]apiextensionsv1.JSONSchemaProps {
// Apply Opcon specific tweaks to all properties in a map, and return a list of required fields according to opcon tags.
// For opcon validation optional/required tags, the required list is updated accordingly.
func opconTweaksMap(channel string, props map[string]apiextensionsv1.JSONSchemaProps, existingRequired []string) (map[string]apiextensionsv1.JSONSchemaProps, []string) {
newRequired := slices.Clone(existingRequired)

for name := range props {
jsonProps := props[name]
p := opconTweaks(channel, name, jsonProps)
p, reqStatus := opconTweaks(channel, name, jsonProps)
if p == nil {
delete(props, name)
} else {
props[name] = *p
// Update required list based on tag
switch reqStatus {
case statusRequired:
if !slices.Contains(newRequired, name) {
newRequired = append(newRequired, name)
}
case statusOptional:
newRequired = slices.DeleteFunc(newRequired, func(s string) bool { return s == name })
default:
// "" (unspecified) means keep existing status
}
}
}
return props
return props, newRequired
}

// Custom Opcon API Tweaks for tags prefixed with `<opcon:` that get past
// the limitations of Kubebuilder annotations.
func opconTweaks(channel string, name string, jsonProps apiextensionsv1.JSONSchemaProps) *apiextensionsv1.JSONSchemaProps {
// Returns the modified schema and a string indicating required status where indicated by opcon tags:
// "required", "optional", or "" (no decision -- preserve any non-opcon required status).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of strings, would it make sense to use a *bool that can represent these three states as well?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bool has a limited range of values (even if you include nil with *bool). Using string allows for additional flexibility.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm generally aligning with CRD guidance that bools end up becoming enums/strings/complex types, and just trying to plumb through a large enough opening to allow expansion, if other decisions need to be added.

Comment on lines +213 to +214
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the documentatino for opconTweaks function? Should we move it next to the function signature?


const (
statusRequired = "required"
statusOptional = "optional"
statusNoOpinion = ""
)

func opconTweaks(channel string, name string, jsonProps apiextensionsv1.JSONSchemaProps) (*apiextensionsv1.JSONSchemaProps, string) {
requiredStatus := statusNoOpinion

if channel == StandardChannel {
if strings.Contains(jsonProps.Description, "<opcon:experimental>") {
return nil
return nil, statusNoOpinion
}
}

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

numValid++
jsonProps.Enum = []apiextensionsv1.JSON{}
for _, val := range strings.Split(enumMatch[1], ";") {
for val := range strings.SplitSeq(enumMatch[1], ";") {
jsonProps.Enum = append(jsonProps.Enum, apiextensionsv1.JSON{Raw: []byte("\"" + val + "\"")})
}
}
Expand All @@ -237,6 +264,21 @@ func opconTweaks(channel string, name string, jsonProps apiextensionsv1.JSONSche
Rule: celMatch[2],
})
}
optReqRe := regexp.MustCompile(validationPrefix + "(Optional|Required)>")
optReqMatches := optReqRe.FindAllStringSubmatch(jsonProps.Description, 64)
for _, optReqMatch := range optReqMatches {
if len(optReqMatch) != 2 {
log.Fatalf("Invalid %s Optional/Required tag for %s", validationPrefix, name)
}

numValid++
switch optReqMatch[1] {
case "Optional":
requiredStatus = statusOptional
case "Required":
requiredStatus = statusRequired
}
}
Comment on lines +267 to +281
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] If a field description contains multiple Optional/Required tags (e.g., both <opcon:standard:validation:Optional> and <opcon:standard:validation:Required>), the last one processed will determine the final status. Consider adding validation to detect and error on conflicting tags, or document that only one such tag should be used per field.

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, we should error if we try to set this a second time.

}

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

if len(jsonProps.Properties) > 0 {
jsonProps.Properties = opconTweaksMap(channel, jsonProps.Properties)
jsonProps.Properties, jsonProps.Required = opconTweaksMap(channel, jsonProps.Properties, jsonProps.Required)
} else if jsonProps.Items != nil && jsonProps.Items.Schema != nil {
jsonProps.Items.Schema = opconTweaks(channel, name, *jsonProps.Items.Schema)
jsonProps.Items.Schema, _ = opconTweaks(channel, name, *jsonProps.Items.Schema)
}

return &jsonProps
return &jsonProps, requiredStatus
}

func formatDescription(description string, channel string, name string) string {
startTag := "<opcon:experimental:description>"
endTag := "</opcon:experimental:description>"
if channel == StandardChannel && strings.Contains(description, startTag) {
regexPattern := `\n*` + regexp.QuoteMeta(startTag) + `(?s:(.*?))` + regexp.QuoteMeta(endTag) + `\n*`
re := regexp.MustCompile(regexPattern)
match := re.FindStringSubmatch(description)
if len(match) != 2 {
log.Fatalf("Invalid <opcon:experimental:description> tag for %s", name)
tagset := []struct {
channel string
start string
end string
}{
{channel: ExperimentalChannel, start: "<opcon:standard:description>", end: "</opcon:standard:description>"},
{channel: StandardChannel, start: "<opcon:experimental:description>", end: "</opcon:experimental:description>"},
}
for _, ts := range tagset {
if channel == ts.channel && strings.Contains(description, ts.start) {
regexPattern := `\n*` + regexp.QuoteMeta(ts.start) + `(?s:(.*?))` + regexp.QuoteMeta(ts.end) + `\n*`
re := regexp.MustCompile(regexPattern)
match := re.FindStringSubmatch(description)
if len(match) != 2 {
log.Fatalf("Invalid <opcon:experimental:description> tag for %s", name)
}
description = re.ReplaceAllString(description, "\n\n")
} else {
description = strings.ReplaceAll(description, ts.start, "")
description = strings.ReplaceAll(description, ts.end, "")
}
description = re.ReplaceAllString(description, "\n\n")
} else {
description = strings.ReplaceAll(description, startTag, "")
description = strings.ReplaceAll(description, endTag, "")
}

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