mirror of
https://github.com/k3s-io/kubernetes.git
synced 2025-08-04 18:00:08 +00:00
Merge pull request #129028 from sttts/sttts-cel-test
apiextensions: add pkg/test with CEL unit test helpers
This commit is contained in:
commit
50fc400f17
149
staging/src/k8s.io/apiextensions-apiserver/pkg/test/cel.go
Normal file
149
staging/src/k8s.io/apiextensions-apiserver/pkg/test/cel.go
Normal file
@ -0,0 +1,149 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
|
||||
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
|
||||
"k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
|
||||
"k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel"
|
||||
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||
"k8s.io/apimachinery/pkg/util/yaml"
|
||||
celconfig "k8s.io/apiserver/pkg/apis/cel"
|
||||
)
|
||||
|
||||
// MustLoadManifest loads a CRD from a file and panics on error.
|
||||
func MustLoadManifest[T any](t *testing.T, pth string) *T {
|
||||
data, err := os.ReadFile(pth)
|
||||
require.NoError(t, err)
|
||||
|
||||
var crd T
|
||||
err = yaml.Unmarshal(data, &crd)
|
||||
require.NoError(t, err)
|
||||
|
||||
return &crd
|
||||
}
|
||||
|
||||
// FieldValidators extracts the CEL validators by version and JSONPath from a CRD and returns
|
||||
// a validator func for testing against samples.
|
||||
func FieldValidators(t *testing.T, crd *apiextensionsv1.CustomResourceDefinition) (validatorsByVersionByJSONPath map[string]map[string]CELValidateFunc) {
|
||||
ret := map[string]map[string]CELValidateFunc{}
|
||||
for _, v := range crd.Spec.Versions {
|
||||
var internalSchema apiextensions.JSONSchemaProps
|
||||
err := apiextensionsv1.Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps(v.Schema.OpenAPIV3Schema, &internalSchema, nil)
|
||||
require.NoError(t, err, "failed to convert JSONSchemaProps for version %s: %v", v.Name, err)
|
||||
structuralSchema, err := schema.NewStructural(&internalSchema)
|
||||
require.NoError(t, err, "failed to create StructuralSchema for version %s: %v", v.Name, err)
|
||||
|
||||
versionVals, err := findCEL(t, structuralSchema, true, field.NewPath("openAPIV3Schema"))
|
||||
require.NoError(t, err, "failed to find CEL for version %s: %v", v.Name, err)
|
||||
ret[v.Name] = versionVals
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// VersionValidatorsFromFile extracts the CEL validators by version from a CRD file and returns
|
||||
// a validator func for testing against samples.
|
||||
func VersionValidatorsFromFile(t *testing.T, crdFilePath string) map[string]CELValidateFunc {
|
||||
data, err := os.ReadFile(crdFilePath)
|
||||
require.NoError(t, err)
|
||||
|
||||
var crd apiextensionsv1.CustomResourceDefinition
|
||||
err = yaml.Unmarshal(data, &crd)
|
||||
require.NoError(t, err)
|
||||
|
||||
ret := map[string]CELValidateFunc{}
|
||||
for _, v := range crd.Spec.Versions {
|
||||
var internalSchema apiextensions.JSONSchemaProps
|
||||
err := apiextensionsv1.Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps(v.Schema.OpenAPIV3Schema, &internalSchema, nil)
|
||||
require.NoError(t, err, "failed to convert JSONSchemaProps for version %s: %v", v.Name, err)
|
||||
structuralSchema, err := schema.NewStructural(&internalSchema)
|
||||
require.NoError(t, err, "failed to create StructuralSchema for version %s: %v", v.Name, err)
|
||||
ret[v.Name] = func(obj, old interface{}) field.ErrorList {
|
||||
errs, _ := cel.NewValidator(structuralSchema, true, celconfig.RuntimeCELCostBudget).Validate(context.TODO(), nil, structuralSchema, obj, old, celconfig.PerCallLimit)
|
||||
return errs
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// VersionValidatorFromFile extracts the CEL validators for a given version from a CRD file and returns
|
||||
// a validator func for testing against samples.
|
||||
func VersionValidatorFromFile(t *testing.T, crdFilePath string, version string) (CELValidateFunc, error) {
|
||||
vals := VersionValidatorsFromFile(t, crdFilePath)
|
||||
if val, ok := vals[version]; ok {
|
||||
return val, nil
|
||||
}
|
||||
return nil, fmt.Errorf("version %s not found", version)
|
||||
}
|
||||
|
||||
// CELValidateFunc tests a sample object against a CEL validator.
|
||||
type CELValidateFunc func(obj, old interface{}) field.ErrorList
|
||||
|
||||
func findCEL(t *testing.T, s *schema.Structural, root bool, pth *field.Path) (map[string]CELValidateFunc, error) {
|
||||
ret := map[string]CELValidateFunc{}
|
||||
|
||||
if len(s.XValidations) > 0 {
|
||||
s := *s
|
||||
pth := *pth
|
||||
ret[pth.String()] = func(obj, old interface{}) field.ErrorList {
|
||||
errs, _ := cel.NewValidator(&s, root, celconfig.RuntimeCELCostBudget).Validate(context.TODO(), &pth, &s, obj, old, celconfig.PerCallLimit)
|
||||
return errs
|
||||
}
|
||||
}
|
||||
|
||||
for k, v := range s.Properties {
|
||||
v := v
|
||||
sub, err := findCEL(t, &v, false, pth.Child("properties").Child(k))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for pth, val := range sub {
|
||||
ret[pth] = val
|
||||
}
|
||||
}
|
||||
if s.Items != nil {
|
||||
sub, err := findCEL(t, s.Items, false, pth.Child("items"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for pth, val := range sub {
|
||||
ret[pth] = val
|
||||
}
|
||||
}
|
||||
if s.AdditionalProperties != nil && s.AdditionalProperties.Structural != nil {
|
||||
sub, err := findCEL(t, s.AdditionalProperties.Structural, false, pth.Child("additionalProperties"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for pth, val := range sub {
|
||||
ret[pth] = val
|
||||
}
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
@ -0,0 +1,293 @@
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
annotations:
|
||||
controller-gen.kubebuilder.io/version: v0.15.0
|
||||
name: apiexports.apis.kcp.io
|
||||
spec:
|
||||
group: apis.kcp.io
|
||||
names:
|
||||
categories:
|
||||
- kcp
|
||||
kind: APIExport
|
||||
listKind: APIExportList
|
||||
plural: apiexports
|
||||
singular: apiexport
|
||||
scope: Cluster
|
||||
versions:
|
||||
- additionalPrinterColumns:
|
||||
- jsonPath: .metadata.creationTimestamp
|
||||
name: Age
|
||||
type: date
|
||||
- jsonPath: .status.conditions[?(@.type=="VirtualWorkspaceURLsReady")].status
|
||||
name: Ready
|
||||
type: string
|
||||
name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
description: |-
|
||||
APIExport registers an API and implementation to allow consumption by others
|
||||
through APIBindings.
|
||||
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:
|
||||
identity:
|
||||
description: |-
|
||||
identity points to a secret that contains the API identity in the 'key' file.
|
||||
The API identity determines an unique etcd prefix for objects stored via this
|
||||
APIExport.
|
||||
|
||||
|
||||
Different APIExport in a workspace can share a common identity, or have different
|
||||
ones. The identity (the secret) can also be transferred to another workspace
|
||||
when the APIExport is moved.
|
||||
|
||||
|
||||
The identity is a secret of the API provider. The APIBindings referencing this APIExport
|
||||
will store a derived, non-sensitive value of this identity.
|
||||
|
||||
|
||||
The identity of an APIExport cannot be changed. A derived, non-sensitive value of
|
||||
the identity key is stored in the APIExport status and this value is immutable.
|
||||
|
||||
|
||||
The identity is defaulted. A secret with the name of the APIExport is automatically
|
||||
created.
|
||||
properties:
|
||||
secretRef:
|
||||
description: secretRef is a reference to a secret that contains
|
||||
the API identity in the 'key' file.
|
||||
properties:
|
||||
name:
|
||||
description: name is unique within a namespace to reference
|
||||
a secret resource.
|
||||
type: string
|
||||
namespace:
|
||||
description: namespace defines the space within which the
|
||||
secret name must be unique.
|
||||
type: string
|
||||
type: object
|
||||
x-kubernetes-map-type: atomic
|
||||
type: object
|
||||
latestResourceSchemas:
|
||||
description: |-
|
||||
latestResourceSchemas records the latest APIResourceSchemas that are exposed
|
||||
with this APIExport.
|
||||
|
||||
|
||||
The schemas can be changed in the life-cycle of the APIExport. These changes
|
||||
have no effect on existing APIBindings, but only on newly bound ones.
|
||||
|
||||
|
||||
For updating existing APIBindings, use an APIDeployment keeping bound
|
||||
workspaces up-to-date.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
x-kubernetes-list-type: set
|
||||
maximalPermissionPolicy:
|
||||
description: |-
|
||||
maximalPermissionPolicy will allow for a service provider to set an upper bound on what is allowed
|
||||
for a consumer of this API. If the policy is not set, no upper bound is applied,
|
||||
i.e the consuming users can do whatever the user workspace allows the user to do.
|
||||
|
||||
|
||||
The policy consists of RBAC (Cluster)Roles and (Cluster)Bindings. A request of a user in
|
||||
a workspace that binds to this APIExport via an APIBinding is additionally checked against
|
||||
these rules, with the user name and the groups prefixed with `apis.kcp.io:binding:`.
|
||||
|
||||
|
||||
For example: assume a user `adam` with groups `system:authenticated` and `a-team` binds to
|
||||
this APIExport in another workspace root:org:ws. Then a request in that workspace
|
||||
against a resource of this APIExport is authorized as every other request in that workspace,
|
||||
but in addition the RBAC policy here in the APIExport workspace has to grant access to the
|
||||
user `apis.kcp.io:binding:adam` with the groups `apis.kcp.io:binding:system:authenticated`
|
||||
and `apis.kcp.io:binding:a-team`.
|
||||
oneOf:
|
||||
- required:
|
||||
- local
|
||||
properties:
|
||||
local:
|
||||
description: local is the policy that is defined in same workspace
|
||||
as the API Export.
|
||||
type: object
|
||||
type: object
|
||||
permissionClaims:
|
||||
description: |-
|
||||
permissionClaims make resources available in APIExport's virtual workspace that are not part
|
||||
of the actual APIExport resources.
|
||||
|
||||
|
||||
PermissionClaims are optional and should be the least access necessary to complete the functions
|
||||
that the service provider needs. Access is asked for on a GroupResource + identity basis.
|
||||
|
||||
|
||||
PermissionClaims must be accepted by the user's explicit acknowledgement. Hence, when claims
|
||||
change, the respecting objects are not visible immediately.
|
||||
|
||||
|
||||
PermissionClaims overlapping with the APIExport resources are ignored.
|
||||
items:
|
||||
description: |-
|
||||
PermissionClaim identifies an object by GR and identity hash.
|
||||
Its purpose is to determine the added permissions that a service provider may
|
||||
request and that a consumer may accept and allow the service provider access to.
|
||||
properties:
|
||||
all:
|
||||
description: |-
|
||||
all claims all resources for the given group/resource.
|
||||
This is mutually exclusive with resourceSelector.
|
||||
type: boolean
|
||||
group:
|
||||
default: ""
|
||||
description: |-
|
||||
group is the name of an API group.
|
||||
For core groups this is the empty string '""'.
|
||||
pattern: ^(|[a-z0-9]([-a-z0-9]*[a-z0-9](\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*)?)$
|
||||
type: string
|
||||
identityHash:
|
||||
description: |-
|
||||
This is the identity for a given APIExport that the APIResourceSchema belongs to.
|
||||
The hash can be found on APIExport and APIResourceSchema's status.
|
||||
It will be empty for core types.
|
||||
Note that one must look this up for a particular KCP instance.
|
||||
type: string
|
||||
resource:
|
||||
description: |-
|
||||
resource is the name of the resource.
|
||||
Note: it is worth noting that you can not ask for permissions for resource provided by a CRD
|
||||
not provided by an api export.
|
||||
pattern: ^[a-z][-a-z0-9]*[a-z0-9]$
|
||||
type: string
|
||||
resourceSelector:
|
||||
description: resourceSelector is a list of claimed resource
|
||||
selectors.
|
||||
items:
|
||||
properties:
|
||||
name:
|
||||
description: |-
|
||||
name of an object within a claimed group/resource.
|
||||
It matches the metadata.name field of the underlying object.
|
||||
If namespace is unset, all objects matching that name will be claimed.
|
||||
maxLength: 253
|
||||
minLength: 1
|
||||
pattern: ^([a-z0-9][-a-z0-9_.]*)?[a-z0-9]$
|
||||
type: string
|
||||
namespace:
|
||||
description: |-
|
||||
namespace containing the named object. Matches metadata.namespace field.
|
||||
If "name" is unset, all objects from the namespace are being claimed.
|
||||
minLength: 1
|
||||
type: string
|
||||
type: object
|
||||
x-kubernetes-validations:
|
||||
- message: at least one field must be set
|
||||
rule: has(self.__namespace__) || has(self.name)
|
||||
type: array
|
||||
required:
|
||||
- resource
|
||||
type: object
|
||||
x-kubernetes-validations:
|
||||
- message: either "all" or "resourceSelector" must be set
|
||||
rule: (has(self.all) && self.all) != (has(self.resourceSelector)
|
||||
&& size(self.resourceSelector) > 0)
|
||||
type: array
|
||||
x-kubernetes-list-map-keys:
|
||||
- group
|
||||
- resource
|
||||
x-kubernetes-list-type: map
|
||||
type: object
|
||||
status:
|
||||
description: Status communicates the observed state.
|
||||
properties:
|
||||
conditions:
|
||||
description: conditions is a list of conditions that apply to the
|
||||
APIExport.
|
||||
items:
|
||||
description: Condition defines an observation of a object operational
|
||||
state.
|
||||
properties:
|
||||
lastTransitionTime:
|
||||
description: |-
|
||||
Last time the condition transitioned from one status to another.
|
||||
This should be when the underlying condition changed. If that is not known, then using the time when
|
||||
the API field changed is acceptable.
|
||||
format: date-time
|
||||
type: string
|
||||
message:
|
||||
description: |-
|
||||
A human readable message indicating details about the transition.
|
||||
This field may be empty.
|
||||
type: string
|
||||
reason:
|
||||
description: |-
|
||||
The reason for the condition's last transition in CamelCase.
|
||||
The specific API may choose whether or not this field is considered a guaranteed API.
|
||||
This field may not be empty.
|
||||
type: string
|
||||
severity:
|
||||
description: |-
|
||||
Severity provides an explicit classification of Reason code, so the users or machines can immediately
|
||||
understand the current situation and act accordingly.
|
||||
The Severity field MUST be set only when Status=False.
|
||||
type: string
|
||||
status:
|
||||
description: Status of the condition, one of True, False, Unknown.
|
||||
type: string
|
||||
type:
|
||||
description: |-
|
||||
Type of condition in CamelCase or in foo.example.com/CamelCase.
|
||||
Many .condition.type values are consistent across resources like Available, but because arbitrary conditions
|
||||
can be useful (see .node.status.conditions), the ability to deconflict is important.
|
||||
type: string
|
||||
required:
|
||||
- lastTransitionTime
|
||||
- status
|
||||
- type
|
||||
type: object
|
||||
type: array
|
||||
identityHash:
|
||||
description: |-
|
||||
identityHash is the hash of the API identity key of this APIExport. This value
|
||||
is immutable as soon as it is set.
|
||||
type: string
|
||||
virtualWorkspaces:
|
||||
description: |-
|
||||
virtualWorkspaces contains all APIExport virtual workspace URLs.
|
||||
|
||||
|
||||
Deprecated: use APIExportEndpointSlice.status.endpoints instead
|
||||
items:
|
||||
properties:
|
||||
url:
|
||||
description: url is an APIExport virtual workspace URL.
|
||||
minLength: 1
|
||||
type: string
|
||||
required:
|
||||
- url
|
||||
type: object
|
||||
type: array
|
||||
type: object
|
||||
type: object
|
||||
served: true
|
||||
storage: true
|
||||
subresources:
|
||||
status: {}
|
@ -0,0 +1,294 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
package example
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
|
||||
apitest "k8s.io/apiextensions-apiserver/pkg/test"
|
||||
)
|
||||
|
||||
func TestAPIExportPermissionClaimCELValidation(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
current, old map[string]interface{}
|
||||
wantErrs []string
|
||||
}{
|
||||
{
|
||||
name: "nothing is set",
|
||||
current: map[string]interface{}{},
|
||||
wantErrs: []string{
|
||||
"openAPIV3Schema.properties.spec.properties.permissionClaims.items: Invalid value: \"object\": either \"all\" or \"resourceSelector\" must be set",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all is true",
|
||||
current: map[string]interface{}{
|
||||
"all": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all is true, resourceSelector is nil",
|
||||
current: map[string]interface{}{
|
||||
"all": true,
|
||||
"resourceSelector": nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all is true, resourceSelector is empty",
|
||||
current: map[string]interface{}{
|
||||
"all": true,
|
||||
"resourceSelector": []interface{}{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all is true and resourceSelector is set",
|
||||
current: map[string]interface{}{
|
||||
"all": true,
|
||||
"resourceSelector": []interface{}{
|
||||
map[string]interface{}{"namespace": "foo"},
|
||||
},
|
||||
},
|
||||
wantErrs: []string{
|
||||
"openAPIV3Schema.properties.spec.properties.permissionClaims.items: Invalid value: \"object\": either \"all\" or \"resourceSelector\" must be set",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all is unset and resourceSelector is nil",
|
||||
current: map[string]interface{}{
|
||||
"resourceSelector": nil,
|
||||
},
|
||||
wantErrs: []string{
|
||||
"openAPIV3Schema.properties.spec.properties.permissionClaims.items: Invalid value: \"object\": either \"all\" or \"resourceSelector\" must be set",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all is unset and resourceSelector is empty",
|
||||
current: map[string]interface{}{
|
||||
"resourceSelector": []interface{}{},
|
||||
},
|
||||
wantErrs: []string{
|
||||
"openAPIV3Schema.properties.spec.properties.permissionClaims.items: Invalid value: \"object\": either \"all\" or \"resourceSelector\" must be set",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resourceSelector is set",
|
||||
current: map[string]interface{}{
|
||||
"resourceSelector": []interface{}{
|
||||
map[string]interface{}{"namespace": "foo"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all is false and resourceSelector is nil",
|
||||
current: map[string]interface{}{
|
||||
"all": false,
|
||||
"resourceSelector": nil,
|
||||
},
|
||||
wantErrs: []string{
|
||||
"openAPIV3Schema.properties.spec.properties.permissionClaims.items: Invalid value: \"object\": either \"all\" or \"resourceSelector\" must be set",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty resource selector",
|
||||
current: map[string]interface{}{
|
||||
"all": false,
|
||||
"resourceSelector": []interface{}{},
|
||||
},
|
||||
wantErrs: []string{
|
||||
"openAPIV3Schema.properties.spec.properties.permissionClaims.items: Invalid value: \"object\": either \"all\" or \"resourceSelector\" must be set",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "logicalcluster fine with non-empty identityHash",
|
||||
current: map[string]interface{}{
|
||||
"group": "core.kcp.io",
|
||||
"resource": "logicalclusters",
|
||||
"identityHash": "abc",
|
||||
"all": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
validators := apitest.FieldValidators(t, apitest.MustLoadManifest[apiextensionsv1.CustomResourceDefinition](t, "apiexports_crd.yaml"))
|
||||
|
||||
for _, tc := range testCases {
|
||||
pth := "openAPIV3Schema.properties.spec.properties.permissionClaims.items"
|
||||
validator, found := validators["v1alpha1"][pth]
|
||||
require.True(t, found, "failed to find validator for %s", pth)
|
||||
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
errs := validator(tc.current, tc.old)
|
||||
t.Log(errs)
|
||||
|
||||
if got := len(errs); got != len(tc.wantErrs) {
|
||||
t.Errorf("expected errors %v, got %v", len(tc.wantErrs), len(errs))
|
||||
return
|
||||
}
|
||||
|
||||
for i := range tc.wantErrs {
|
||||
got := errs[i].Error()
|
||||
if got != tc.wantErrs[i] {
|
||||
t.Errorf("want error %q, got %q", tc.wantErrs[i], got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceSelectorCELValidation(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
current, old map[string]interface{}
|
||||
wantErrs []string
|
||||
}{
|
||||
{
|
||||
name: "none is set",
|
||||
current: map[string]interface{}{
|
||||
"name": nil,
|
||||
"namespace": nil,
|
||||
},
|
||||
wantErrs: []string{
|
||||
"openAPIV3Schema.properties.spec.properties.permissionClaims.items.properties.resourceSelector.items: Invalid value: \"object\": at least one field must be set",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "namespace is set",
|
||||
current: map[string]interface{}{
|
||||
"name": nil,
|
||||
"namespace": "foo",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "name is set",
|
||||
current: map[string]interface{}{
|
||||
"name": "foo",
|
||||
"namespace": nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "both name and namespace are set",
|
||||
current: map[string]interface{}{
|
||||
"name": "foo",
|
||||
"namespace": "bar",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
validators := apitest.FieldValidators(t, apitest.MustLoadManifest[apiextensionsv1.CustomResourceDefinition](t, "apiexports_crd.yaml"))
|
||||
|
||||
for _, tc := range testCases {
|
||||
pth := "openAPIV3Schema.properties.spec.properties.permissionClaims.items.properties.resourceSelector.items"
|
||||
validator, found := validators["v1alpha1"][pth]
|
||||
require.True(t, found, "failed to find validator for %s", pth)
|
||||
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
errs := validator(tc.current, tc.old)
|
||||
t.Log(errs)
|
||||
|
||||
if got := len(errs); got != len(tc.wantErrs) {
|
||||
t.Errorf("expected errors %v, got %v", len(tc.wantErrs), len(errs))
|
||||
return
|
||||
}
|
||||
|
||||
for i := range tc.wantErrs {
|
||||
got := errs[i].Error()
|
||||
if got != tc.wantErrs[i] {
|
||||
t.Errorf("want error %q, got %q", tc.wantErrs[i], got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIExportPermissionClaimPattern(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
value string
|
||||
wantError string
|
||||
}{
|
||||
{
|
||||
name: "middle dot",
|
||||
value: "abc.123",
|
||||
},
|
||||
{
|
||||
name: "middle dash",
|
||||
value: "abc-123",
|
||||
},
|
||||
{
|
||||
name: "mixed dash and dot",
|
||||
value: "ab-c1.23",
|
||||
},
|
||||
{
|
||||
name: "dot at the end",
|
||||
value: "abc123.",
|
||||
wantError: "pattern mismatch",
|
||||
},
|
||||
{
|
||||
name: "dot at the beginning",
|
||||
value: ".abc123",
|
||||
wantError: "pattern mismatch",
|
||||
},
|
||||
{
|
||||
name: "dash at the end",
|
||||
value: "abc123-",
|
||||
wantError: "pattern mismatch",
|
||||
},
|
||||
{
|
||||
name: "dash at the beginning",
|
||||
value: "-abc123",
|
||||
wantError: "pattern mismatch",
|
||||
},
|
||||
{
|
||||
name: "uppercase",
|
||||
value: "ABC",
|
||||
wantError: "pattern mismatch",
|
||||
},
|
||||
{
|
||||
name: "invalid exclamation marks",
|
||||
value: "!!!",
|
||||
wantError: "pattern mismatch",
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
value: "",
|
||||
wantError: "pattern mismatch",
|
||||
},
|
||||
}
|
||||
|
||||
validators := apitest.PatternValidators(t, apitest.MustLoadManifest[apiextensionsv1.CustomResourceDefinition](t, "apiexports_crd.yaml"))
|
||||
|
||||
for _, tc := range testCases {
|
||||
pth := "openAPIV3Schema.properties.spec.properties.permissionClaims.items.properties.resourceSelector.items.properties.name"
|
||||
validator, found := validators["v1alpha1"][pth]
|
||||
require.True(t, found, "failed to find validator for %s", pth)
|
||||
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validator(tc.value)
|
||||
got := ""
|
||||
if err != nil {
|
||||
got = err.Error()
|
||||
}
|
||||
if got != tc.wantError {
|
||||
t.Errorf("want error %q, got %q", tc.wantError, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
102
staging/src/k8s.io/apiextensions-apiserver/pkg/test/pattern.go
Normal file
102
staging/src/k8s.io/apiextensions-apiserver/pkg/test/pattern.go
Normal file
@ -0,0 +1,102 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
package test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
|
||||
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
|
||||
"k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
|
||||
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||
)
|
||||
|
||||
// PatternValidators extracts the pattern validators by version and JSONPath from a CRD and returns
|
||||
// a validator func for testing against samples.
|
||||
func PatternValidators(t *testing.T, crd *apiextensionsv1.CustomResourceDefinition) (validatorsByVersionByJSONPath map[string]map[string]PatternValidateFunc) {
|
||||
ret := map[string]map[string]PatternValidateFunc{}
|
||||
for _, v := range crd.Spec.Versions {
|
||||
var internalSchema apiextensions.JSONSchemaProps
|
||||
err := apiextensionsv1.Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps(v.Schema.OpenAPIV3Schema, &internalSchema, nil)
|
||||
require.NoError(t, err, "failed to convert JSONSchemaProps for version %s: %v", v.Name, err)
|
||||
structuralSchema, err := schema.NewStructural(&internalSchema)
|
||||
require.NoError(t, err, "failed to create StructuralSchema for version %s: %v", v.Name, err)
|
||||
|
||||
versionVals, err := findPattern(t, structuralSchema, field.NewPath("openAPIV3Schema"))
|
||||
require.NoError(t, err, "failed to find CEL for version %s: %v", v.Name, err)
|
||||
ret[v.Name] = versionVals
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
type PatternValidateFunc func(obj interface{}) error
|
||||
|
||||
func findPattern(t *testing.T, s *schema.Structural, pth *field.Path) (map[string]PatternValidateFunc, error) {
|
||||
ret := map[string]PatternValidateFunc{}
|
||||
|
||||
if len(s.ValueValidation.Pattern) > 0 {
|
||||
s := *s
|
||||
pth := *pth
|
||||
ret[pth.String()] = func(obj interface{}) error {
|
||||
p, err := regexp.Compile(s.ValueValidation.Pattern)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.MatchString(obj.(string)) {
|
||||
return nil
|
||||
}
|
||||
return errors.New("pattern mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
for k, v := range s.Properties {
|
||||
v := v
|
||||
sub, err := findPattern(t, &v, pth.Child("properties").Child(k))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for pth, val := range sub {
|
||||
ret[pth] = val
|
||||
}
|
||||
}
|
||||
if s.Items != nil {
|
||||
sub, err := findPattern(t, s.Items, pth.Child("items"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for pth, val := range sub {
|
||||
ret[pth] = val
|
||||
}
|
||||
}
|
||||
if s.AdditionalProperties != nil && s.AdditionalProperties.Structural != nil {
|
||||
sub, err := findPattern(t, s.AdditionalProperties.Structural, pth.Child("additionalProperties"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for pth, val := range sub {
|
||||
ret[pth] = val
|
||||
}
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
Loading…
Reference in New Issue
Block a user