Skip to content

Commit

Permalink
Add support for +default markers
Browse files Browse the repository at this point in the history
  • Loading branch information
liggitt committed May 3, 2024
1 parent 8ece085 commit 73cdab1
Show file tree
Hide file tree
Showing 5 changed files with 213 additions and 0 deletions.
35 changes: 35 additions & 0 deletions pkg/crd/markers/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down Expand Up @@ -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.
//
Expand Down Expand Up @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions pkg/crd/markers/zz_generated.markerhelp.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 29 additions & 0 deletions pkg/crd/testdata/cronjob_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
71 changes: 71 additions & 0 deletions pkg/crd/testdata/testdata.kubebuilder.io_cronjobs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -6886,13 +6950,20 @@ spec:
- defaultedObject
- defaultedSlice
- defaultedString
- doubleDefaultedString
- embeddedResource
- float64WithValidations
- floatWithValidations
- foo
- int32WithValidations
- intWithValidations
- jobTemplate
- kubernetesDefaultedEmptyMap
- kubernetesDefaultedEmptyObject
- kubernetesDefaultedEmptySlice
- kubernetesDefaultedObject
- kubernetesDefaultedSlice
- kubernetesDefaultedString
- mapOfInfo
- nestedMapOfInfo
- nestedStructWithSeveralFields
Expand Down
62 changes: 62 additions & 0 deletions pkg/markers/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ package markers

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"reflect"
"strconv"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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`).
Expand Down

0 comments on commit 73cdab1

Please sign in to comment.