Skip to content

Commit

Permalink
Add APIConversion support
Browse files Browse the repository at this point in the history
Signed-off-by: Andy Goldstein <andy.goldstein@redhat.com>
  • Loading branch information
ncdc committed Jan 13, 2023
1 parent 4fc4997 commit 75d5961
Show file tree
Hide file tree
Showing 52 changed files with 3,695 additions and 203 deletions.
124 changes: 124 additions & 0 deletions config/crds/apis.kcp.io_apiconversions.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.10.0
creationTimestamp: null
name: apiconversions.apis.kcp.io
spec:
group: apis.kcp.io
names:
categories:
- kcp
kind: APIConversion
listKind: APIConversionList
plural: apiconversions
singular: apiconversion
scope: Cluster
versions:
- additionalPrinterColumns:
- jsonPath: .metadata.creationTimestamp
name: Age
type: date
name: v1alpha1
schema:
openAPIV3Schema:
description: APIConversion contains rules to convert between different API
versions in an APIResourceSchema. The name must match the name of the APIResourceSchema
for the conversions to take effect.
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: Spec holds the desired state.
properties:
conversions:
description: conversions specify rules to convert between different
API versions in an APIResourceSchema.
items:
description: APIVersionConversion contains rules to convert between
two specific API versions in an APIResourceSchema. Additionally,
to avoid data loss when round-tripping from a version that contains
a new field to one that doesn't and back again, you can specify
a list of fields to preserve (these are stored in annotations).
properties:
from:
description: from is the source version.
minLength: 1
pattern: ^v[1-9][0-9]*([a-z]+[1-9][0-9]*)?$
type: string
preserve:
description: preserve contains a list of JSONPath expressions
to fields to preserve in the originating version of the object,
relative to its root, such as '.spec.name.first'.
items:
type: string
type: array
rules:
description: rules contains field-specific conversion expressions.
items:
description: APIConversionRule specifies how to convert a
single field.
properties:
destination:
description: destination is a JSONPath expression to the
field in the target version of the object, relative
to its root, such as '.spec.name.first'.
minLength: 1
type: string
field:
description: field is a JSONPath expression to the field
in the originating version of the object, relative to
its root, such as '.spec.name.first'.
minLength: 1
type: string
transformation:
description: transformation is an optional CEL expression
used to execute user-specified rules to transform the
originating field -- identified by 'self' -- to the
destination field.
type: string
required:
- destination
- field
type: object
type: array
x-kubernetes-list-map-keys:
- destination
x-kubernetes-list-type: map
to:
description: to is the target version.
minLength: 1
pattern: ^v[1-9][0-9]*([a-z]+[1-9][0-9]*)?$
type: string
required:
- from
- rules
- to
type: object
type: array
x-kubernetes-list-map-keys:
- from
- to
x-kubernetes-list-type: map
required:
- conversions
type: object
required:
- metadata
- spec
type: object
served: true
storage: true
subresources: {}
2 changes: 1 addition & 1 deletion config/crds/apis.kcp.io_apiresourceschemas.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ spec:
schema:
openAPIV3Schema:
description: "APIResourceSchema describes a resource, identified by (group,
version, resource, schema). \n A APIResourceSchema is immutable and cannot
version, resource, schema). \n An APIResourceSchema is immutable and cannot
be deleted if they are referenced by an APIExport in the same workspace."
properties:
apiVersion:
Expand Down
1 change: 1 addition & 0 deletions config/system-crds/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ func Bootstrap(ctx context.Context, crdClient apiextensionsclient.Interface, dis
{Group: apis.GroupName, Resource: "apiresourceschemas"},
{Group: apis.GroupName, Resource: "apiexportendpointslices"},
{Group: core.GroupName, Resource: "logicalclusters"},
{Group: apis.GroupName, Resource: "apiconversions"},
}

if err := wait.PollImmediateInfiniteWithContext(ctx, time.Second, func(ctx context.Context) (bool, error) {
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ require (
github.com/evanphx/json-patch v5.6.0+incompatible
github.com/fatih/color v1.12.0
github.com/go-logr/logr v1.2.3
github.com/google/cel-go v0.12.6
github.com/google/go-cmp v0.5.8
github.com/google/uuid v1.3.0
github.com/kcp-dev/apimachinery/v2 v2.0.0-alpha.0
Expand All @@ -34,6 +35,7 @@ require (
go.etcd.io/etcd/server/v3 v3.5.0
go.uber.org/multierr v1.7.0
golang.org/x/net v0.0.0-20220722155237-a158d28d115b
google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd
gopkg.in/square/go-jose.v2 v2.2.2
k8s.io/api v0.24.3
k8s.io/apiextensions-apiserver v0.24.3
Expand Down Expand Up @@ -92,7 +94,6 @@ require (
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/btree v1.0.1 // indirect
github.com/google/cel-go v0.12.6 // indirect
github.com/google/gnostic v0.5.7-v3refs // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
Expand Down Expand Up @@ -165,7 +166,6 @@ require (
golang.org/x/tools v0.1.12 // indirect
gonum.org/v1/gonum v0.6.2 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd // indirect
google.golang.org/grpc v1.46.2 // indirect
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
Expand Down
144 changes: 144 additions & 0 deletions pkg/admission/apiconversion/apiconversion_admission.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/*
Copyright 2022 The KCP 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.
*/

package apiconversion

import (
"context"
"fmt"
"io"

"github.com/kcp-dev/logicalcluster/v3"

apiextensionsinternal "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/validation/field"
"k8s.io/apiserver/pkg/admission"
genericapirequest "k8s.io/apiserver/pkg/endpoints/request"

"github.com/kcp-dev/kcp/pkg/admission/initializers"
apisv1alpha1 "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1"
kcpinformers "github.com/kcp-dev/kcp/pkg/client/informers/externalversions"
"github.com/kcp-dev/kcp/pkg/conversion"
"github.com/kcp-dev/kcp/pkg/reconciler/apis/apibinding"
)

const (
PluginName = "apis.kcp.io/APIConversion"
)

func Register(plugins *admission.Plugins) {
plugins.Register(PluginName,
func(_ io.Reader) (admission.Interface, error) {
return &apiConversionAdmission{
Handler: admission.NewHandler(admission.Create, admission.Update),
}, nil
})
}

type apiConversionAdmission struct {
*admission.Handler

getAPIResourceSchema func(clusterName logicalcluster.Name, name string) (*apisv1alpha1.APIResourceSchema, error)
}

// Ensure that the required admission interfaces are implemented.
var (
_ admission.ValidationInterface = (*apiConversionAdmission)(nil)
_ admission.InitializationValidator = (*apiConversionAdmission)(nil)
_ initializers.WantsKcpInformers = (*apiConversionAdmission)(nil)
)

// Validate ensures all the conversion rules specified in an APIConversion are correct.
func (o *apiConversionAdmission) Validate(ctx context.Context, a admission.Attributes, _ admission.ObjectInterfaces) error {
if a.GetResource().GroupResource() != apisv1alpha1.Resource("apiconversions") {
return nil
}

cluster, err := genericapirequest.ValidClusterFrom(ctx)
if err != nil {
return admission.NewForbidden(a, fmt.Errorf("error determining workspace: %w", err))
}

if cluster.Name == apibinding.SystemBoundCRDsClusterName {
// TODO(ncdc): do we also want to validate these conversions? They've already been validated once (in their
// original logical cluster).
return nil
}

u, ok := a.GetObject().(*unstructured.Unstructured)
if !ok {
return fmt.Errorf("unexpected type %T", a.GetObject())
}

apiResourceSchema, err := o.getAPIResourceSchema(cluster.Name, u.GetName())
if err != nil {
return admission.NewForbidden(a, fmt.Errorf("error getting APIResourceSchema %s: %w", u.GetName(), err))
}

apiConversion := &apisv1alpha1.APIConversion{}
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, apiConversion); err != nil {
return fmt.Errorf("failed to convert unstructured to APIConversion: %w", err)
}

structuralSchemas := map[string]*structuralschema.Structural{}
for i, v := range apiResourceSchema.Spec.Versions {
schema, err := v.GetSchema()
if err != nil {
return admission.NewForbidden(a, field.Required(field.NewPath("spec", "versions").Index(i).Child("schema"), "is required"))
}

internalJSONSchemaProps := &apiextensionsinternal.JSONSchemaProps{}
if err := apiextensionsv1.Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps(schema, internalJSONSchemaProps, nil); err != nil {
return fmt.Errorf("failed converting version %s validation to internal version: %w", v.Name, err)
}

structuralSchema, err := structuralschema.NewStructural(internalJSONSchemaProps)
if err != nil {
return fmt.Errorf("error getting structural schema for version %s: %w", v.Name, err)
}

structuralSchemas[v.Name] = structuralSchema
}

if _, err := conversion.Compile(apiConversion, structuralSchemas); err != nil {
return fmt.Errorf("error compiling conversion rules: %w", err)
}

return nil
}

// ValidateInitialization ensures the required injected fields are set.
func (o *apiConversionAdmission) ValidateInitialization() error {
if o.getAPIResourceSchema == nil {
return fmt.Errorf("getAPIResourceSchema is unset")
}

return nil
}

func (o *apiConversionAdmission) SetKcpInformers(local, global kcpinformers.SharedInformerFactory) {
o.getAPIResourceSchema = func(clusterName logicalcluster.Name, name string) (*apisv1alpha1.APIResourceSchema, error) {
apiResourceSchema, err := local.Apis().V1alpha1().APIResourceSchemas().Lister().Cluster(clusterName).Get(name)
if err == nil {
return apiResourceSchema, nil
}
return global.Apis().V1alpha1().APIResourceSchemas().Lister().Cluster(clusterName).Get(name)
}
}
2 changes: 1 addition & 1 deletion pkg/admission/apiresourceschema/admission_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ spec:
`)),
expectedErrors: []string{
"metadata.name: Invalid value: \"may.2022.cowboys.wild.west\": must match ^[a-z]([-a-z0-9]*[a-z0-9])?$ in front of .cowboys.wild.west",
"spec.versions[0].schema: Required value: schemas are required",
"spec.versions[0].schema: Required value",
"spec.versions[1].schema.openAPIV3Schema.type: Unsupported value: \"thing\": supported values: \"array\", \"boolean\", \"integer\", \"number\", \"object\", \"string\"",
"spec.versions[1].schema.openAPIV3Schema.type: Invalid value: \"thing\": must be object at the root",
"spec.names.singular: Required value",
Expand Down
3 changes: 1 addition & 2 deletions pkg/admission/apiresourceschema/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,6 @@ func ValidateAPIResourceSchemaSpec(ctx context.Context, spec *apisv1alpha1.APIRe
}

// TODO(sttts): validate predecessors
// TODO(sttts): validate conversions

return allErrs
}
Expand Down Expand Up @@ -187,7 +186,7 @@ func ValidateAPIResourceVersion(ctx context.Context, version *apisv1alpha1.APIRe
}

if len(version.Schema.Raw) == 0 || string(version.Schema.Raw) == "null" {
allErrs = append(allErrs, field.Required(fldPath.Child("schema"), "schemas are required"))
allErrs = append(allErrs, field.Required(fldPath.Child("schema"), ""))
} else {
statusEnabled := version.Subresources.Status != nil
var crdSchemaV1 apiextensionsv1.CustomResourceValidation
Expand Down
4 changes: 4 additions & 0 deletions pkg/admission/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import (

"github.com/kcp-dev/kcp/pkg/admission/apibinding"
"github.com/kcp-dev/kcp/pkg/admission/apibindingfinalizer"
"github.com/kcp-dev/kcp/pkg/admission/apiconversion"
"github.com/kcp-dev/kcp/pkg/admission/apiexport"
"github.com/kcp-dev/kcp/pkg/admission/apiexportendpointslice"
"github.com/kcp-dev/kcp/pkg/admission/apiresourceschema"
Expand Down Expand Up @@ -68,6 +69,7 @@ import (
var AllOrderedPlugins = beforeWebhooks(kubeapiserveroptions.AllOrderedPlugins,
workspacenamespacelifecycle.PluginName,
apiresourceschema.PluginName,
apiconversion.PluginName,
workspace.PluginName,
logicalclusterfinalizer.PluginName,
shard.PluginName,
Expand Down Expand Up @@ -114,6 +116,7 @@ func RegisterAllKcpAdmissionPlugins(plugins *admission.Plugins) {
logicalcluster.Register(plugins)
apiresourceschema.Register(plugins)
apiexport.Register(plugins)
apiconversion.Register(plugins)
apibinding.Register(plugins)
apibindingfinalizer.Register(plugins)
apiexportendpointslice.Register(plugins)
Expand Down Expand Up @@ -147,6 +150,7 @@ var defaultOnPluginsInKcp = sets.NewString(
logicalcluster.PluginName,
apiresourceschema.PluginName,
apiexport.PluginName,
apiconversion.PluginName,
apibinding.PluginName,
apibindingfinalizer.PluginName,
apiexportendpointslice.PluginName,
Expand Down
3 changes: 3 additions & 0 deletions pkg/apis/apis/v1alpha1/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ func addKnownTypes(scheme *runtime.Scheme) error {

&APIExportEndpointSlice{},
&APIExportEndpointSliceList{},

&APIConversion{},
&APIConversionList{},
)
metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
return nil
Expand Down
Loading

0 comments on commit 75d5961

Please sign in to comment.