diff --git a/pkg/crd/markers/validation.go b/pkg/crd/markers/validation.go index 839b07f6e..9d8698aea 100644 --- a/pkg/crd/markers/validation.go +++ b/pkg/crd/markers/validation.go @@ -34,6 +34,9 @@ const ( ValidationExactlyOneOfPrefix = validationPrefix + "ExactlyOneOf" ValidationAtMostOneOfPrefix = validationPrefix + "AtMostOneOf" + + // K8sEnumTag indicates that the given type is an enum; all const values of this type are considered values in the enum + K8sEnumTag = "k8s:enum" ) // ValidationMarkers lists all available markers that affect CRD schema generation, @@ -84,6 +87,8 @@ var TypeOnlyMarkers = []*definitionWithHelp{ WithHelp(markers.SimpleHelp("CRD validation", "specifies a list of field names that must conform to the AtMostOneOf constraint.")), must(markers.MakeDefinition(ValidationExactlyOneOfPrefix, markers.DescribesType, ExactlyOneOf(nil))). WithHelp(markers.SimpleHelp("CRD validation", "specifies a list of field names that must conform to the ExactlyOneOf constraint.")), + must(markers.MakeDefinition(K8sEnumTag, markers.DescribesType, struct{}{})). + WithHelp(markers.SimpleHelp("CRD", "indicates that the given type is an enum; all const values of this type are considered values in the enum")), } // FieldOnlyMarkers list field-specific validation markers (i.e. those markers that don't make diff --git a/pkg/crd/parser_integration_test.go b/pkg/crd/parser_integration_test.go index 01ed1a951..916d14c54 100644 --- a/pkg/crd/parser_integration_test.go +++ b/pkg/crd/parser_integration_test.go @@ -186,6 +186,16 @@ var _ = Describe("CRD Generation From Parsing to CustomResourceDefinition", func }) }) + Context("Enum API", func() { + BeforeEach(func() { + pkgPaths = []string{"./enum/..."} + expPkgLen = 1 + }) + It("should successfully generate the CRD with enum validation constraints", func() { + assertCRD(pkgs[0], "Enum", "testdata.kubebuilder.io_enums.yaml") + }) + }) + Context("OneOf API with invalid marker", func() { BeforeEach(func() { pkgPaths = []string{"./oneof_error/..."} diff --git a/pkg/crd/schema.go b/pkg/crd/schema.go index efb09b7c9..d3ffe11f2 100644 --- a/pkg/crd/schema.go +++ b/pkg/crd/schema.go @@ -113,6 +113,9 @@ func (c *schemaContext) requestSchema(pkgPath, typeName string) { // infoToSchema creates a schema for the type in the given set of type information. func infoToSchema(ctx *schemaContext) *apiext.JSONSchemaProps { + if ctx.info.Markers.Get(crdmarkers.K8sEnumTag) != nil { + return enumToSchema(ctx) + } if obj := ctx.pkg.Types.Scope().Lookup(ctx.info.Name); obj != nil { switch { // If the obj implements a JSON marshaler and has a marker, use the @@ -139,6 +142,61 @@ func infoToSchema(ctx *schemaContext) *apiext.JSONSchemaProps { return typeToSchema(ctx, ctx.info.RawSpec.Type) } +func enumToSchema(ctx *schemaContext) *apiext.JSONSchemaProps { + rawType := ctx.info.RawSpec.Type + typeDef := ctx.pkg.TypesInfo.Defs[ctx.info.RawSpec.Name] + if typeDef == nil { + ctx.pkg.AddError(loader.ErrFromNode(fmt.Errorf("unknown enum type %s", ctx.info.Name), rawType)) + return &apiext.JSONSchemaProps{} + } + typeInfo := typeDef.Type() + if basicInfo, isBasic := typeInfo.Underlying().(*types.Basic); !isBasic || basicInfo.Info()&types.IsString == 0 { + ctx.pkg.AddError(loader.ErrFromNode(fmt.Errorf("enum type must be a string, not %s", typeInfo.String()), rawType)) + return &apiext.JSONSchemaProps{} + } + + var enumValues []apiext.JSON + for _, file := range ctx.pkg.Syntax { + for _, decl := range file.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok || genDecl.Tok != token.CONST { + continue + } + for _, spec := range genDecl.Specs { + valueSpec, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + for i, name := range valueSpec.Names { + obj := ctx.pkg.TypesInfo.Defs[name] + if obj == nil || obj.Type() != typeInfo { + continue + } + val := valueSpec.Values[i] + basicLit, ok := val.(*ast.BasicLit) + if !ok || basicLit.Kind != token.STRING { + continue + } + // trim quotes + value := basicLit.Value[1 : len(basicLit.Value)-1] + enumValues = append(enumValues, apiext.JSON{Raw: []byte(`"` + value + `"`)}) + } + } + } + } + + sort.Slice(enumValues, func(i, j int) bool { + return string(enumValues[i].Raw) < string(enumValues[j].Raw) + }) + + schema := &apiext.JSONSchemaProps{ + Type: "string", + Enum: enumValues, + } + applyMarkers(ctx, ctx.info.Markers, schema, rawType) + return schema +} + type schemaMarkerWithName struct { SchemaMarker SchemaMarker Name string diff --git a/pkg/crd/testdata/cronjob_types.go b/pkg/crd/testdata/cronjob_types.go index e496407b2..f9c430645 100644 --- a/pkg/crd/testdata/cronjob_types.go +++ b/pkg/crd/testdata/cronjob_types.go @@ -61,6 +61,14 @@ type CronJobSpec struct { // +optional ConcurrencyPolicy ConcurrencyPolicy `json:"concurrencyPolicy,omitempty"` + // Specifies how to treat concurrent executions of a Job. + // Valid values are: + // - "Allow" (default): allows CronJobs to run concurrently; + // - "Forbid": forbids concurrent runs, skipping next run if previous run hasn't finished yet; + // - "Replace": cancels currently running job and replaces it with a new one + // +optional + K8sConcurrencyPolicy K8sConcurrencyPolicy `json:"k8sConcurrencyPolicy,omitempty"` + // This flag tells the controller to suspend subsequent executions, it does // not apply to already started executions. Defaults to false. // +optional @@ -696,6 +704,21 @@ const ( ReplaceConcurrent ConcurrencyPolicy = "Replace" ) +// +k8s:enum +type K8sConcurrencyPolicy string + +const ( + // AllowK8sConcurrencyPolicy allows CronJobs to run concurrently. + AllowK8sConcurrencyPolicy K8sConcurrencyPolicy = "Allow" + + // ForbidK8sConcurrencyPolicy forbids concurrent runs, skipping next run if previous + // hasn't finished yet. + ForbidK8sConcurrencyPolicy K8sConcurrencyPolicy = "Forbid" + + // ReplaceK8sConcurrencyPolicy cancels currently running job and replaces it with a new one. + ReplaceK8sConcurrencyPolicy K8sConcurrencyPolicy = "Replace" +) + // StringEvenType is a type that includes an expression-based validation. // +kubebuilder:validation:XValidation:rule="self.size() % 2 == 0",message="must have even length" type StringEvenType string diff --git a/pkg/crd/testdata/enum/api.go b/pkg/crd/testdata/enum/api.go new file mode 100644 index 000000000..1dad9618b --- /dev/null +++ b/pkg/crd/testdata/enum/api.go @@ -0,0 +1,55 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// +groupName=testdata.kubebuilder.io +// +versionName=v1 +package enum + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +k8s:enum +type EnumType string + +const ( + Value1 EnumType = "Value1" + Value2 EnumType = "Value2" +) + +// +kubebuilder:object:root=true + +// Enum is a test CRD that contains an enum. +type Enum struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec EnumSpec `json:"spec,omitempty"` +} + +// EnumSpec defines the desired state of Enum +type EnumSpec struct { + Field EnumType `json:"field,omitempty"` +} + +// +kubebuilder:object:root=true + +// EnumList contains a list of Enum +type EnumList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Enum `json:"items"` +} diff --git a/pkg/crd/testdata/testdata.kubebuilder.io_cronjobs.yaml b/pkg/crd/testdata/testdata.kubebuilder.io_cronjobs.yaml index 8bbbaf1a1..5c4c69c88 100644 --- a/pkg/crd/testdata/testdata.kubebuilder.io_cronjobs.yaml +++ b/pkg/crd/testdata/testdata.kubebuilder.io_cronjobs.yaml @@ -108,6 +108,18 @@ spec: - Forbid - Replace type: string + k8sConcurrencyPolicy: + description: |- + Specifies how to treat concurrent executions of a Job. + Valid values are: + - "Allow" (default): allows CronJobs to run concurrently; + - "Forbid": forbids concurrent runs, skipping next run if previous run hasn't finished yet; + - "Replace": cancels currently running job and replaces it with a new one + enum: + - Allow + - Forbid + - Replace + type: string defaultedEmptyMap: additionalProperties: type: string diff --git a/pkg/crd/testdata/testdata.kubebuilder.io_enums.yaml b/pkg/crd/testdata/testdata.kubebuilder.io_enums.yaml new file mode 100644 index 000000000..dfa50db02 --- /dev/null +++ b/pkg/crd/testdata/testdata.kubebuilder.io_enums.yaml @@ -0,0 +1,47 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: enums.testdata.kubebuilder.io +spec: + group: testdata.kubebuilder.io + names: + kind: Enum + listKind: EnumList + plural: enums + singular: enum + scope: Namespaced + versions: + - name: v1 + schema: + openAPIV3Schema: + description: Enum is a test CRD that contains an enum. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: EnumSpec defines the desired state of Enum + properties: + field: + enum: + - Value1 + - Value2 + type: string + type: object + type: object + served: true + storage: true \ No newline at end of file