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 Feb 12, 2024
1 parent b67d943 commit 41f7c86
Show file tree
Hide file tree
Showing 14 changed files with 592 additions and 96 deletions.
12 changes: 12 additions & 0 deletions pkg/crd/markers/crd.go
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
Expand Up @@ -22,6 +22,7 @@ import (
"go/ast"
"go/token"
"go/types"
"reflect"
"sort"
"strings"

Expand Down Expand Up @@ -333,6 +334,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 @@ -346,6 +352,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 @@ -377,6 +384,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 @@ -417,6 +448,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: "object",
Required: clusterScopedFields,
},
string(apiext.NamespaceScoped): {
Type: "object",
Required: namespaceScopedFields,
},
},
}
}

return props
}

Expand Down
46 changes: 46 additions & 0 deletions pkg/crd/spec.go
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
}

0 comments on commit 41f7c86

Please sign in to comment.