diff --git a/pkg/crd/markers/validation.go b/pkg/crd/markers/validation.go index 06d1cab6d..a3b0d0476 100644 --- a/pkg/crd/markers/validation.go +++ b/pkg/crd/markers/validation.go @@ -91,6 +91,8 @@ var FieldOnlyMarkers = []*definitionWithHelp{ must(markers.MakeAnyTypeDefinition("kubebuilder:default", markers.DescribesField, Default{})). WithHelp(Default{}.Help()), + must(markers.MakeAnyTypeJSONDefinition("default", markers.DescribesField, KubernetesDefault{})). + WithHelp(KubernetesDefault{}.Help()), must(markers.MakeAnyTypeDefinition("kubebuilder:example", markers.DescribesField, Example{})). WithHelp(Example{}.Help()), @@ -239,6 +241,19 @@ type Default struct { Value interface{} } +// +controllertools:marker:generateHelp:category="CRD validation" +// Default sets the default value for this field. +// +// A default value will be accepted as any value valid for the +// field. Formatting for common types include: boolean: `true`, string: +// `"Cluster"`, numerical: `1.24`, array: `[1,2]`, object: `{"policy": +// "delete"}`). Defaults should be defined in pruned form, and only best-effort +// validation will be performed. Full validation of a default requires +// submission of the containing CRD to an apiserver. +type KubernetesDefault struct { + Value interface{} +} + // +controllertools:marker:generateHelp:category="CRD validation" // Example sets the example value for this field. // @@ -503,6 +518,26 @@ func (m Default) ApplyToSchema(schema *apiext.JSONSchemaProps) error { return nil } +func (m Default) ApplyPriority() ApplyPriority { + // explicitly go after +default markers, so kubebuilder-specific defaults get applied last and stomp + return 10 +} + +// Defaults are only valid CRDs created with the v1 API +func (m KubernetesDefault) ApplyToSchema(schema *apiext.JSONSchemaProps) error { + marshalledDefault, err := json.Marshal(m.Value) + if err != nil { + return err + } + schema.Default = &apiext.JSON{Raw: marshalledDefault} + return nil +} + +func (m KubernetesDefault) ApplyPriority() ApplyPriority { + // explicitly go before +kubebuilder:default markers, so kubebuilder-specific defaults get applied last and stomp + return 9 +} + func (m Example) ApplyToSchema(schema *apiext.JSONSchemaProps) error { marshalledExample, err := json.Marshal(m.Value) if err != nil { diff --git a/pkg/crd/markers/zz_generated.markerhelp.go b/pkg/crd/markers/zz_generated.markerhelp.go index 73432ebd4..ecd17d421 100644 --- a/pkg/crd/markers/zz_generated.markerhelp.go +++ b/pkg/crd/markers/zz_generated.markerhelp.go @@ -116,6 +116,22 @@ func (Format) Help() *markers.DefinitionHelp { } } +func (KubernetesDefault) Help() *markers.DefinitionHelp { + return &markers.DefinitionHelp{ + Category: "CRD validation", + DetailedHelp: markers.DetailedHelp{ + Summary: "Default sets the default value for this field.", + Details: "A default value will be accepted as any value valid for the\nfield. Formatting for common types include: boolean: `true`, string:\n`\"Cluster\"`, numerical: `1.24`, array: `[1,2]`, object: `{\"policy\":\n\"delete\"}`). Defaults should be defined in pruned form, and only best-effort\nvalidation will be performed. Full validation of a default requires\nsubmission of the containing CRD to an apiserver.", + }, + FieldHelp: map[string]markers.DetailedHelp{ + "Value": { + Summary: "", + Details: "", + }, + }, + } +} + func (ListMapKey) Help() *markers.DefinitionHelp { return &markers.DefinitionHelp{ Category: "CRD processing", diff --git a/pkg/crd/testdata/cronjob_types.go b/pkg/crd/testdata/cronjob_types.go index 015197316..6468a2e3c 100644 --- a/pkg/crd/testdata/cronjob_types.go +++ b/pkg/crd/testdata/cronjob_types.go @@ -133,6 +133,35 @@ type CronJobSpec struct { // +kubebuilder:default={} DefaultedEmptyObject EmpiableObject `json:"defaultedEmptyObject"` + // This tests that kubebuilder defaulting takes precedence. + // +kubebuilder:default="kubebuilder-default" + // +default="kubernetes-default" + DoubleDefaultedString string `json:"doubleDefaultedString"` + + // This tests that primitive defaulting can be performed. + // +default="forty-two" + KubernetesDefaultedString string `json:"kubernetesDefaultedString"` + + // This tests that slice defaulting can be performed. + // +default=["a","b"] + KubernetesDefaultedSlice []string `json:"kubernetesDefaultedSlice"` + + // This tests that object defaulting can be performed. + // +default=[{"nested": {"foo": "baz", "bar": true}},{"nested": {"bar": false}}] + KubernetesDefaultedObject []RootObject `json:"kubernetesDefaultedObject"` + + // This tests that empty slice defaulting can be performed. + // +default=[] + KubernetesDefaultedEmptySlice []string `json:"kubernetesDefaultedEmptySlice"` + + // This tests that an empty object defaulting can be performed on a map. + // +default={} + KubernetesDefaultedEmptyMap map[string]string `json:"kubernetesDefaultedEmptyMap"` + + // This tests that an empty object defaulting can be performed on an object. + // +default={} + KubernetesDefaultedEmptyObject EmpiableObject `json:"kubernetesDefaultedEmptyObject"` + // This tests that pattern validator is properly applied. // +kubebuilder:validation:Pattern=`^$|^((https):\/\/?)[^\s()<>]+(?:\([\w\d]+\)|([^[:punct:]\s]|\/?))$` PatternObject string `json:"patternObject"` diff --git a/pkg/crd/testdata/testdata.kubebuilder.io_cronjobs.yaml b/pkg/crd/testdata/testdata.kubebuilder.io_cronjobs.yaml index 757f7333d..61a585de9 100644 --- a/pkg/crd/testdata/testdata.kubebuilder.io_cronjobs.yaml +++ b/pkg/crd/testdata/testdata.kubebuilder.io_cronjobs.yaml @@ -172,6 +172,70 @@ spec: description: This tests that primitive defaulting can be performed. example: forty-two type: string + doubleDefaultedString: + default: kubebuilder-default + description: This tests that kubebuilder defaulting takes precedence. + type: string + kubernetesDefaultedEmptyMap: + additionalProperties: + type: string + default: {} + description: This tests that an empty object defaulting can be performed + on a map. + type: object + kubernetesDefaultedEmptyObject: + default: {} + description: This tests that an empty object defaulting can be performed + on an object. + properties: + bar: + type: string + foo: + default: forty-two + type: string + type: object + kubernetesDefaultedEmptySlice: + default: [] + description: This tests that empty slice defaulting can be performed. + items: + type: string + type: array + kubernetesDefaultedObject: + default: + - nested: + bar: true + foo: baz + - nested: + bar: false + description: This tests that object defaulting can be performed. + items: + properties: + nested: + properties: + bar: + type: boolean + foo: + type: string + required: + - bar + - foo + type: object + required: + - nested + type: object + type: array + kubernetesDefaultedSlice: + default: + - a + - b + description: This tests that slice defaulting can be performed. + items: + type: string + type: array + kubernetesDefaultedString: + default: forty-two + description: This tests that primitive defaulting can be performed. + type: string embeddedResource: type: object x-kubernetes-embedded-resource: true @@ -6886,6 +6950,7 @@ spec: - defaultedObject - defaultedSlice - defaultedString + - doubleDefaultedString - embeddedResource - float64WithValidations - floatWithValidations @@ -6893,6 +6958,12 @@ spec: - int32WithValidations - intWithValidations - jobTemplate + - kubernetesDefaultedEmptyMap + - kubernetesDefaultedEmptyObject + - kubernetesDefaultedEmptySlice + - kubernetesDefaultedObject + - kubernetesDefaultedSlice + - kubernetesDefaultedString - mapOfInfo - nestedMapOfInfo - nestedStructWithSeveralFields diff --git a/pkg/markers/parse.go b/pkg/markers/parse.go index 259bff027..67658ca50 100644 --- a/pkg/markers/parse.go +++ b/pkg/markers/parse.go @@ -18,6 +18,8 @@ package markers import ( "bytes" + "encoding/json" + "errors" "fmt" "reflect" "strconv" @@ -726,6 +728,11 @@ type Definition struct { // Strict indicates that this definition should error out when parsing if // not all non-optional fields were seen. Strict bool + + // parseJSON indicates the value should be parsed using json parsing. + // This means strings must be quoted, arrays must use [] notation, etc. + // This is only allowed when AnonymousField() is true. + parseJSON bool } // AnonymousField indicates that the definition has one field, @@ -824,6 +831,49 @@ func parserScanner(raw string, err func(*sc.Scanner, string)) *sc.Scanner { // raw marker in the form `+a:b:c=arg,d=arg` into an output object of the // type specified in the definition. func (d *Definition) Parse(rawMarker string) (interface{}, error) { + if d.parseJSON { + return d.parseWithJSON(rawMarker) + } else { + return d.parseWithScanner(rawMarker) + } +} + +func (d *Definition) parseWithJSON(rawMarker string) (interface{}, error) { + _, _, fields := splitMarker(rawMarker) + + out := reflect.Indirect(reflect.New(d.Output)) + + var errs []error + if !d.AnonymousField() { + errs = append(errs, errors.New("parseJSON requires anonymous definition")) + return out.Interface(), loader.MaybeErrList(errs) + } + + // might still be a struct that something fiddled with, so double check + structFieldName := d.FieldNames[""] + outTarget := out + if structFieldName != "" { + // it's a struct field mapped to an anonymous marker + outTarget = out.FieldByName(structFieldName) + if !outTarget.CanSet() { + errs = append(errs, fmt.Errorf("cannot set field %q (might not exist)", structFieldName)) + return out.Interface(), loader.MaybeErrList(errs) + } + } + + fmt.Println("parse", fields, "into", outTarget) + + var i any + if err := json.Unmarshal([]byte(fields), &i); err != nil { + errs = append(errs, err) + } else { + castAndSet(outTarget, reflect.ValueOf(i)) + } + + return out.Interface(), loader.MaybeErrList(errs) +} + +func (d *Definition) parseWithScanner(rawMarker string) (interface{}, error) { name, anonName, fields := splitMarker(rawMarker) out := reflect.Indirect(reflect.New(d.Output)) @@ -952,6 +1002,18 @@ func MakeAnyTypeDefinition(name string, target TargetType, output interface{}) ( return defn, nil } +// MakeAnyTypeJSONDefinition constructs a definition for an output struct with a +// field named `Value` of type `interface{}`. The argument to the marker will +// be parsed as AnyType using json parsing and assigned to the field named `Value`. +func MakeAnyTypeJSONDefinition(name string, target TargetType, output interface{}) (*Definition, error) { + defn, err := MakeAnyTypeDefinition(name, target, output) + if err != nil { + return nil, err + } + defn.parseJSON = true + return defn, nil +} + // splitMarker takes a marker in the form of `+a:b:c=arg,d=arg` and splits it // into the name (`a:b`), the name if it's not a struct (`a:b:c`), and the parts // that are definitely fields (`arg,d=arg`).