Skip to content

Commit

Permalink
feat: Add resource field scoped fields
Browse files Browse the repository at this point in the history
  • Loading branch information
TheSpiritXIII committed Jan 28, 2024
1 parent d80c46d commit c28336c
Show file tree
Hide file tree
Showing 13 changed files with 367 additions and 96 deletions.
12 changes: 12 additions & 0 deletions pkg/crd/markers/crd.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ var CRDMarkers = []*definitionWithHelp{

must(markers.MakeDefinition("kubebuilder:metadata", markers.DescribesType, Metadata{})).
WithHelp(Metadata{}.Help()),

must(markers.MakeDefinition("kubebuilder:field:scope", markers.DescribesField, FieldScope(""))).
WithHelp(FieldScope("").Help()),
}

// TODO: categories and singular used to be annotations types
Expand Down Expand Up @@ -388,3 +391,12 @@ func (s Metadata) ApplyToCRD(crd *apiext.CustomResourceDefinition, _ string) err

return nil
}

// +controllertools:marker:generateHelp:category=CRD
// FieldScope specifies the scope of the field. If the field scope does not match the outer-most
// resource scope, then this field is ignored and not included in the final CRD.
type FieldScope string

func (m FieldScope) Value() string {
return string(m)
}
113 changes: 62 additions & 51 deletions pkg/crd/markers/zz_generated.markerhelp.go

Large diffs are not rendered by default.

47 changes: 47 additions & 0 deletions pkg/crd/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"go/ast"
"go/token"
"go/types"
"reflect"
"strings"

apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
Expand Down Expand Up @@ -328,6 +329,11 @@ func mapToSchema(ctx *schemaContext, mapType *ast.MapType) *apiext.JSONSchemaPro
}
}

// fieldScopePropertyName is the name of the property used to sore field scope information. A more
// appropriate solution would be to use a custom extension, but that's not possible yet.
// See: https://github.com/kubernetes/kubernetes/issues/82942
const fieldScopePropertyName = "x-kubebuilder-field-scopes"

// structToSchema creates a schema for the given struct. Embedded fields are placed in AllOf,
// and can be flattened later with a Flattener.
func structToSchema(ctx *schemaContext, structType *ast.StructType) *apiext.JSONSchemaProps {
Expand All @@ -341,6 +347,7 @@ func structToSchema(ctx *schemaContext, structType *ast.StructType) *apiext.JSON
return props
}

var clusterScopedFields, namespaceScopedFields []string
for _, field := range ctx.info.Fields {
// Skip if the field is not an inline field, ignoreUnexportedFields is true, and the field is not exported
if field.Name != "" && ctx.ignoreUnexportedFields && !ast.IsExported(field.Name) {
Expand Down Expand Up @@ -372,6 +379,30 @@ func structToSchema(ctx *schemaContext, structType *ast.StructType) *apiext.JSON
fieldName := jsonOpts[0]
inline = inline || fieldName == "" // anonymous fields are inline fields in YAML/JSON

if scope := field.Markers.Get("kubebuilder:field:scope"); scope != nil {
value, ok := scope.(crdmarkers.FieldScope)
if !ok {
ctx.pkg.AddError(loader.ErrFromNode(fmt.Errorf("encountered non-string struct %q field %q scope %s", ctx.info.Name, field.Name, reflect.ValueOf(scope).Type().Name()), field.RawField))
continue
}
var scope apiext.ResourceScope
switch value {
case "":
scope = apiext.NamespaceScoped
default:
scope = apiext.ResourceScope(value)
}
switch scope {
case apiext.ClusterScoped:
clusterScopedFields = append(clusterScopedFields, fieldName)
case apiext.NamespaceScoped:
namespaceScopedFields = append(namespaceScopedFields, fieldName)
default:
ctx.pkg.AddError(loader.ErrFromNode(fmt.Errorf("invalid struct %q field %q scope %q", ctx.info.Name, field.Name, value), field.RawField))
continue
}
}

// if no default required mode is set, default to required
defaultMode := "required"
if ctx.PackageMarkers.Get("kubebuilder:validation:Optional") != nil {
Expand Down Expand Up @@ -412,6 +443,22 @@ func structToSchema(ctx *schemaContext, structType *ast.StructType) *apiext.JSON
props.Properties[fieldName] = *propSchema
}

if len(clusterScopedFields) > 0 || len(namespaceScopedFields) > 0 {
props.Properties[fieldScopePropertyName] = apiext.JSONSchemaProps{
Type: "object",
Properties: map[string]apiext.JSONSchemaProps{
string(apiext.ClusterScoped): {
Type: "array",
Required: clusterScopedFields,
},
string(apiext.NamespaceScoped): {
Type: "array",
Required: namespaceScopedFields,
},
},
}
}

return props
}

Expand Down
46 changes: 46 additions & 0 deletions pkg/crd/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package crd

import (
"fmt"
"slices"
"sort"
"strings"

Expand Down Expand Up @@ -104,6 +105,7 @@ func (p *Parser) NeedCRDFor(groupKind schema.GroupKind, maxDescLen *int) {
},
}
crd.Spec.Versions = append(crd.Spec.Versions, ver)

}

// markers are applied *after* initial generation of objects
Expand All @@ -130,6 +132,15 @@ func (p *Parser) NeedCRDFor(groupKind schema.GroupKind, maxDescLen *int) {
}
}

// Apply field-scoped resources. The markers live on the field, not in the top-level CRD, so we
// must apply them manually here.
for versionIndex := range crd.Spec.Versions {
version := &crd.Spec.Versions[versionIndex]
if err := applyFieldScopes(version.Schema.OpenAPIV3Schema, crd.Spec.Scope); err != nil {
packages[0].AddError(fmt.Errorf("CRD for %s was unable to apply field scopes", groupKind))
}
}

// fix the name if the plural was changed (this is the form the name *has* to take, so no harm in changing it).
crd.Name = crd.Spec.Names.Plural + "." + groupKind.Group

Expand Down Expand Up @@ -176,3 +187,38 @@ func (p *Parser) NeedCRDFor(groupKind schema.GroupKind, maxDescLen *int) {

p.CustomResourceDefinitions[groupKind] = crd
}

func applyFieldScopes(props *apiext.JSONSchemaProps, scope apiext.ResourceScope) error {
var removed string
if scope == apiext.NamespaceScoped {
removed = string(apiext.ClusterScoped)
} else if scope == apiext.ClusterScoped {
removed = string(apiext.NamespaceScoped)
}
if err := removeScope(props, removed); err != nil {
return err
}
return nil
}

func removeScope(props *apiext.JSONSchemaProps, scope string) error {
scopes, ok := props.Properties[fieldScopePropertyName]
if ok {
for _, item := range scopes.Properties[scope].Required {
delete(props.Properties, item)

index := slices.Index(props.Required, item)
if index == -1 {
continue
}
props.Required = slices.Delete(props.Required, index, index+1)
}
}
delete(props.Properties, fieldScopePropertyName)

for name, p := range props.Properties {
removeScope(&p, scope)
props.Properties[name] = p
}
return nil
}
52 changes: 52 additions & 0 deletions pkg/crd/testdata/gen/scope/scope.example.com_clusterfoos.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: (devel)
name: clusterfoos.scope.example.com
spec:
group: scope.example.com
names:
kind: ClusterFoo
listKind: ClusterFooList
plural: clusterfoos
singular: clusterfoo
scope: Cluster
versions:
- name: scope
schema:
openAPIV3Schema:
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:
properties:
alwaysExists:
description: This field appears regardless of scope.
type: string
existsInCluster:
description: This field only appears for cluster-scoped objects.
type: string
required:
- alwaysExists
- existsInCluster
type: object
type: object
served: true
storage: true
52 changes: 52 additions & 0 deletions pkg/crd/testdata/gen/scope/scope.example.com_namespacedfoos.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: (devel)
name: namespacedfoos.scope.example.com
spec:
group: scope.example.com
names:
kind: NamespacedFoo
listKind: NamespacedFooList
plural: namespacedfoos
singular: namespacedfoo
scope: Namespaced
versions:
- name: scope
schema:
openAPIV3Schema:
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:
properties:
alwaysExists:
description: This field appears regardless of scope.
type: string
existsInNamespaced:
description: This field only appears for namespace-scoped objects.
type: string
required:
- alwaysExists
- existsInNamespaced
type: object
type: object
served: true
storage: true
47 changes: 47 additions & 0 deletions pkg/crd/testdata/gen/scope/scope_types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
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.
*/

//go:generate ../../../../../.run-controller-gen.sh crd:crdVersions=v1 paths=. output:dir=.

// +groupName=scope.example.com
package scope

import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

type ScopeSpec struct {
// This field appears regardless of scope.
AlwaysExists string `json:"alwaysExists"`
// This field only appears for cluster-scoped objects.
// +kubebuilder:field:scope=Cluster
ExistsInCluster string `json:"existsInCluster"`
// This field only appears for namespace-scoped objects.
// +kubebuilder:field:scope=Namespaced
ExistsInNamespaced string `json:"existsInNamespaced"`
}

type NamespacedFoo struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec ScopeSpec `json:"spec,omitempty"`
}

// +kubebuilder:resource:scope=Cluster
type ClusterFoo struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec ScopeSpec `json:"spec,omitempty"`
}
14 changes: 7 additions & 7 deletions pkg/crd/zz_generated.markerhelp.go

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

4 changes: 2 additions & 2 deletions pkg/deepcopy/zz_generated.markerhelp.go

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

Loading

0 comments on commit c28336c

Please sign in to comment.