diff --git a/go.mod b/go.mod index 861a3573..23e34957 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ replace ( require ( github.com/adrg/xdg v0.4.0 github.com/golang/mock v1.6.0 + github.com/google/gnostic-models v0.6.8 github.com/gorilla/mux v1.8.0 github.com/gorilla/websocket v1.5.0 github.com/pborman/uuid v1.2.1 @@ -28,6 +29,7 @@ require ( github.com/urfave/cli v1.22.14 github.com/urfave/cli/v2 v2.25.7 golang.org/x/sync v0.5.0 + gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.28.6 k8s.io/apiextensions-apiserver v0.28.6 k8s.io/apimachinery v0.28.6 @@ -56,7 +58,6 @@ require ( github.com/go-openapi/swag v0.22.3 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.3 // indirect - github.com/google/gnostic-models v0.6.8 // indirect github.com/google/go-cmp v0.5.9 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/uuid v1.3.0 // indirect @@ -102,7 +103,6 @@ require ( google.golang.org/protobuf v1.31.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/component-base v0.28.6 // indirect k8s.io/klog/v2 v2.100.1 // indirect k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 // indirect diff --git a/pkg/resources/schema.go b/pkg/resources/schema.go index 4bb18b8f..96cdcb50 100644 --- a/pkg/resources/schema.go +++ b/pkg/resources/schema.go @@ -16,7 +16,6 @@ import ( "github.com/rancher/steve/pkg/resources/formatters" "github.com/rancher/steve/pkg/resources/userpreferences" "github.com/rancher/steve/pkg/schema" - steveschema "github.com/rancher/steve/pkg/schema" "github.com/rancher/steve/pkg/stores/proxy" "github.com/rancher/steve/pkg/summarycache" corecontrollers "github.com/rancher/wrangler/v2/pkg/generated/controllers/core/v1" @@ -25,7 +24,7 @@ import ( ) func DefaultSchemas(ctx context.Context, baseSchema *types.APISchemas, ccache clustercache.ClusterCache, - cg proxy.ClientGetter, schemaFactory steveschema.Factory, serverVersion string) error { + cg proxy.ClientGetter, schemaFactory schema.Factory, serverVersion string) error { counts.Register(baseSchema, ccache) subscribe.Register(baseSchema, func(apiOp *types.APIRequest) *types.APISchemas { user, ok := request.UserFrom(apiOp.Context()) diff --git a/pkg/schema/converter/crd.go b/pkg/schema/converter/crd.go index 5aa06f21..db5e031a 100644 --- a/pkg/schema/converter/crd.go +++ b/pkg/schema/converter/crd.go @@ -27,7 +27,9 @@ var ( } ) -func AddCustomResources(crd apiextv1.CustomResourceDefinitionClient, schemas map[string]*types.APISchema) error { +// addCustomResources uses the openAPISchema defined on CRDs to provide field definitions to previously discovered schemas. +// Note that this function does not create new schemas - it only adds details to resources already present in the schemas map. +func addCustomResources(crd apiextv1.CustomResourceDefinitionClient, schemas map[string]*types.APISchema) error { crds, err := crd.List(metav1.ListOptions{}) if err != nil { return nil @@ -41,14 +43,14 @@ func AddCustomResources(crd apiextv1.CustomResourceDefinitionClient, schemas map group, kind := crd.Spec.Group, crd.Status.AcceptedNames.Kind for _, version := range crd.Spec.Versions { - forVersion(&crd, group, kind, version, schemas) + forVersion(group, kind, version, schemas) } } return nil } -func forVersion(crd *v1.CustomResourceDefinition, group, kind string, version v1.CustomResourceDefinitionVersion, schemasMap map[string]*types.APISchema) { +func forVersion(group, kind string, version v1.CustomResourceDefinitionVersion, schemasMap map[string]*types.APISchema) { var versionColumns []table.Column for _, col := range version.AdditionalPrinterColumns { versionColumns = append(versionColumns, table.Column{ @@ -73,18 +75,6 @@ func forVersion(crd *v1.CustomResourceDefinition, group, kind string, version v1 attributes.SetColumns(schema, versionColumns) } if version.Schema != nil && version.Schema.OpenAPIV3Schema != nil { - if fieldsSchema := modelV3ToSchema(id, crd.Spec.Versions[0].Schema.OpenAPIV3Schema, schemasMap); fieldsSchema != nil { - for k, v := range staticFields { - fieldsSchema.ResourceFields[k] = v - } - for k, v := range fieldsSchema.ResourceFields { - if schema.ResourceFields == nil { - schema.ResourceFields = map[string]schemas.Field{} - } - if _, ok := schema.ResourceFields[k]; !ok { - schema.ResourceFields[k] = v - } - } - } + schema.Description = version.Schema.OpenAPIV3Schema.Description } } diff --git a/pkg/schema/converter/crd_test.go b/pkg/schema/converter/crd_test.go new file mode 100644 index 00000000..52297c8c --- /dev/null +++ b/pkg/schema/converter/crd_test.go @@ -0,0 +1,307 @@ +package converter + +import ( + "fmt" + "testing" + + "github.com/golang/mock/gomock" + "github.com/rancher/apiserver/pkg/types" + "github.com/rancher/steve/pkg/schema/table" + "github.com/rancher/wrangler/v2/pkg/generic/fake" + wranglerSchema "github.com/rancher/wrangler/v2/pkg/schemas" + "github.com/stretchr/testify/assert" + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +func TestAddCustomResources(t *testing.T) { + tests := []struct { + name string + crds []v1.CustomResourceDefinition + preFilledSchemas []string // crds to pre-create schemas for + crdError error + wantError bool + desiredSchema map[string]*types.APISchema + }{ + { + name: "one crd - all different field types", + crds: []v1.CustomResourceDefinition{ + { + Status: v1.CustomResourceDefinitionStatus{ + AcceptedNames: v1.CustomResourceDefinitionNames{ + Plural: "testResources", + Singular: "testResource", + Kind: "TestResource", + }, + }, + Spec: v1.CustomResourceDefinitionSpec{ + Group: "testGroup", + Versions: []v1.CustomResourceDefinitionVersion{ + { + Name: "v1", + AdditionalPrinterColumns: []v1.CustomResourceColumnDefinition{ + { + Name: "TestColumn", + JSONPath: "TestPath", + Type: "TestType", + Format: "TestFormat", + }, + }, + Schema: &v1.CustomResourceValidation{ + OpenAPIV3Schema: &v1.JSONSchemaProps{ + Description: "Test Resource for unit tests", + Required: []string{"required"}, + Properties: map[string]v1.JSONSchemaProps{ + "required": { + Description: "Required Property", + Type: "string", + }, + "numberField": { + Description: "NumberField - Not Required Property", + Type: "number", + }, + "stringField": { + Description: "StringField - Not Required Property", + Type: "string", + }, + "nullArrayField": { + Description: "ArrayField with no type - Not Required Property", + Type: "array", + }, + "objectArrayField": { + Description: "ArrayField with an object type - Not Required Property", + Type: "array", + Items: &v1.JSONSchemaPropsOrArray{ + Schema: &v1.JSONSchemaProps{ + Type: "object", + }, + }, + }, + "objectArrayJSONField": { + Description: "ArrayField with an object type defined in JSONSchemas - Not Required Property", + Type: "array", + Items: &v1.JSONSchemaPropsOrArray{ + JSONSchemas: []v1.JSONSchemaProps{ + { + Type: "object", + }, + }, + }, + }, + "stringArrayField": { + Description: "ArrayField with a string type - Not Required Property", + Type: "array", + Items: &v1.JSONSchemaPropsOrArray{ + Schema: &v1.JSONSchemaProps{ + Type: "string", + }, + }, + }, + "stringArrayJSONField": { + Description: "ArrayField with a string type defined in JSONSchemas - Not Required Property", + Type: "array", + Items: &v1.JSONSchemaPropsOrArray{ + JSONSchemas: []v1.JSONSchemaProps{ + { + Type: "string", + }, + }, + }, + }, + "stringArrayBothField": { + Description: "ArrayField with a string type defined in both Schema and JSONSchemas - Not Required Property", + Type: "array", + Items: &v1.JSONSchemaPropsOrArray{ + Schema: &v1.JSONSchemaProps{ + Type: "string", + }, + JSONSchemas: []v1.JSONSchemaProps{ + { + Type: "object", + }, + }, + }, + }, + "nullObjectField": { + Description: "ObjectField with no type - Not Required Property", + Type: "object", + }, + "additionalPropertiesObjectField": { + Description: "ObjectField with a type in additionalProperties - Not Required Property", + Type: "object", + AdditionalProperties: &v1.JSONSchemaPropsOrBool{ + Schema: &v1.JSONSchemaProps{ + Type: "string", + }, + }, + }, + "nestedObjectField": { + Description: "ObjectField with an object type in additionalProperties - Not Required Property", + Type: "object", + AdditionalProperties: &v1.JSONSchemaPropsOrBool{ + Schema: &v1.JSONSchemaProps{ + Type: "object", + }, + }, + }, + "actions": { + Description: "Reserved field - Not Required Property", + Type: "string", + }, + }, + }, + }, + }, + }, + Names: v1.CustomResourceDefinitionNames{ + Plural: "testResources", + Singular: "testResource", + Kind: "TestResource", + }, + }, + }, + }, + preFilledSchemas: []string{"testgroup.v1.testresource"}, + wantError: false, + desiredSchema: map[string]*types.APISchema{ + "testgroup.v1.testresource": { + Schema: &wranglerSchema.Schema{ + ID: "testgroup.v1.testresource", + Attributes: map[string]interface{}{ + "columns": []table.Column{ + { + Name: "TestColumn", + Field: "TestPath", + Type: "TestType", + Format: "TestFormat", + }, + }, + }, + Description: "Test Resource for unit tests", + }, + }, + }, + }, + { + name: "crd list error - early break, no error", + crds: []v1.CustomResourceDefinition{}, + crdError: fmt.Errorf("unable to list crds"), + wantError: false, + desiredSchema: map[string]*types.APISchema{}, + }, + { + name: "skip resource - no plural name", + crds: []v1.CustomResourceDefinition{ + { + Status: v1.CustomResourceDefinitionStatus{ + AcceptedNames: v1.CustomResourceDefinitionNames{ + Singular: "testResource", + Kind: "TestResource", + }, + }, + Spec: v1.CustomResourceDefinitionSpec{ + Group: "testGroup", + Versions: []v1.CustomResourceDefinitionVersion{ + { + Schema: &v1.CustomResourceValidation{ + OpenAPIV3Schema: &v1.JSONSchemaProps{ + Description: "Test Resource for unit tests", + Required: []string{"required"}, + Properties: map[string]v1.JSONSchemaProps{ + "required": { + Description: "Required Property", + Type: "string", + }, + }, + }, + }, + }, + }, + Names: v1.CustomResourceDefinitionNames{ + Singular: "testResource", + Kind: "TestResource", + }, + }, + }, + }, + preFilledSchemas: []string{"testgroup.v1.testresource"}, + wantError: false, + desiredSchema: map[string]*types.APISchema{ + "testgroup.v1.testresource": { + Schema: &wranglerSchema.Schema{ + ID: "testgroup.v1.testresource", + }, + }, + }, + }, + { + name: "skip resource - no pre-defined schema", + crds: []v1.CustomResourceDefinition{ + { + Status: v1.CustomResourceDefinitionStatus{ + AcceptedNames: v1.CustomResourceDefinitionNames{ + Plural: "testResources", + Singular: "testResource", + Kind: "TestResource", + }, + }, + Spec: v1.CustomResourceDefinitionSpec{ + Group: "testGroup", + Versions: []v1.CustomResourceDefinitionVersion{ + { + Schema: &v1.CustomResourceValidation{ + OpenAPIV3Schema: &v1.JSONSchemaProps{ + Description: "Test Resource for unit tests", + Required: []string{"required"}, + Properties: map[string]v1.JSONSchemaProps{ + "required": { + Description: "Required Property", + Type: "string", + }, + }, + }, + }, + }, + }, + Names: v1.CustomResourceDefinitionNames{ + Plural: "testResources", + Singular: "testResource", + Kind: "TestResource", + }, + }, + }, + }, + wantError: false, + desiredSchema: map[string]*types.APISchema{}, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + fakeClient := fake.NewMockNonNamespacedClientInterface[*v1.CustomResourceDefinition, *v1.CustomResourceDefinitionList](ctrl) + var crds *v1.CustomResourceDefinitionList + if test.crds != nil { + crds = &v1.CustomResourceDefinitionList{ + Items: test.crds, + } + } + fakeClient.EXPECT().List(gomock.Any()).Return(crds, test.crdError) + schemas := map[string]*types.APISchema{} + for i := range test.preFilledSchemas { + schemas[test.preFilledSchemas[i]] = &types.APISchema{ + Schema: &wranglerSchema.Schema{ + ID: test.preFilledSchemas[i], + }, + } + } + err := addCustomResources(fakeClient, schemas) + if test.wantError { + assert.Error(t, err, "expected an error but there was no error") + } else { + assert.NoError(t, err, "got an unexpected error") + } + assert.Equal(t, test.desiredSchema, schemas) + }) + } +} diff --git a/pkg/schema/converter/description.go b/pkg/schema/converter/description.go new file mode 100644 index 00000000..11d784b3 --- /dev/null +++ b/pkg/schema/converter/description.go @@ -0,0 +1,45 @@ +package converter + +import ( + "github.com/rancher/apiserver/pkg/types" + "github.com/sirupsen/logrus" + "k8s.io/client-go/discovery" + "k8s.io/kube-openapi/pkg/util/proto" +) + +// addDescription adds a description to all schemas in schemas using the openapi v2 definitions from k8s. +// Will not add new schemas, only mutate existing ones. Returns an error if the definitions could not be retrieved. +func addDescription(client discovery.DiscoveryInterface, schemas map[string]*types.APISchema) error { + openapi, err := client.OpenAPISchema() + if err != nil { + return err + } + + models, err := proto.NewOpenAPIData(openapi) + if err != nil { + return err + } + + for _, modelName := range models.ListModels() { + model := models.LookupModel(modelName) + if k, ok := model.(*proto.Kind); ok { + gvk := GetGVKForKind(k) + if gvk == nil { + // kind was not for top level gvk, we can skip this resource + logrus.Tracef("when adding schema descriptions, will not add description for kind %s, which is not a top level resource", k.Path.String()) + continue + } + schemaID := GVKToVersionedSchemaID(*gvk) + schema, ok := schemas[schemaID] + // some kinds have a gvk but don't correspond to a schema (like a podList). We can + // skip these resources as well + if !ok { + logrus.Tracef("when adding schema descriptions, will not add description for ID %s, which is not in schemas", schemaID) + continue + } + schema.Description = k.GetDescription() + } + } + + return nil +} diff --git a/pkg/schema/converter/description_test.go b/pkg/schema/converter/description_test.go new file mode 100644 index 00000000..62f4cfa8 --- /dev/null +++ b/pkg/schema/converter/description_test.go @@ -0,0 +1,151 @@ +package converter + +import ( + "fmt" + "testing" + + openapiv2 "github.com/google/gnostic-models/openapiv2" + "github.com/rancher/apiserver/pkg/types" + "github.com/rancher/wrangler/v2/pkg/schemas" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestAddDescription(t *testing.T) { + gvkExtensionMap := map[any]any{ + gvkExtensionGroup: "management.cattle.io", + gvkExtensionVersion: "v3", + gvkExtensionKind: "GlobalRole", + } + gvkExtensionSlice := []any{gvkExtensionMap} + extensionSliceYaml, err := yaml.Marshal(gvkExtensionSlice) + require.NoError(t, err) + gvkSchema := openapiv2.NamedSchema{ + Name: "GlobalRoles", + Value: &openapiv2.Schema{ + Description: "GlobalRoles are Global permissions in Rancher", + Type: &openapiv2.TypeItem{ + Value: []string{"object"}, + }, + Properties: &openapiv2.Properties{ + AdditionalProperties: []*openapiv2.NamedSchema{}, + }, + VendorExtension: []*openapiv2.NamedAny{ + { + Name: gvkExtensionName, + Value: &openapiv2.Any{ + Yaml: string(extensionSliceYaml), + }, + }, + }, + }, + } + noGVKSchema := openapiv2.NamedSchema{ + Name: "GlobalRoleSpec", + Value: &openapiv2.Schema{ + Description: "The Spec of a GlobalRole", + Type: &openapiv2.TypeItem{ + Value: []string{"object"}, + }, + Properties: &openapiv2.Properties{ + AdditionalProperties: []*openapiv2.NamedSchema{}, + }, + }, + } + errorSchema := openapiv2.NamedSchema{ + Name: "InvalidResource", + Value: &openapiv2.Schema{ + Description: "Resource that is invalid due to multiple types", + Type: &openapiv2.TypeItem{ + Value: []string{"object", "map"}, + }, + }, + } + tests := []struct { + name string + documentSchemas []*openapiv2.NamedSchema + clientErr error + inputSchemas map[string]*types.APISchema + wantSchemas map[string]*types.APISchema + wantErr bool + }{ + { + name: "basic gvk schema", + documentSchemas: []*openapiv2.NamedSchema{&gvkSchema}, + inputSchemas: map[string]*types.APISchema{ + "management.cattle.io.v3.globalrole": { + Schema: &schemas.Schema{}, + }, + }, + wantSchemas: map[string]*types.APISchema{ + "management.cattle.io.v3.globalrole": { + Schema: &schemas.Schema{ + Description: gvkSchema.Value.Description, + }, + }, + }, + }, + { + name: "kind has a gvk, but no schema", + documentSchemas: []*openapiv2.NamedSchema{&gvkSchema}, + inputSchemas: map[string]*types.APISchema{ + "management.cattle.io.v3.otherschema": { + Schema: &schemas.Schema{}, + }, + }, + wantSchemas: map[string]*types.APISchema{ + "management.cattle.io.v3.otherschema": { + Schema: &schemas.Schema{}, + }, + }, + }, + { + name: "schema without gvk", + documentSchemas: []*openapiv2.NamedSchema{&noGVKSchema}, + inputSchemas: map[string]*types.APISchema{ + "management.cattle.io.v3.globalrole": { + Schema: &schemas.Schema{}, + }, + }, + wantSchemas: map[string]*types.APISchema{ + "management.cattle.io.v3.globalrole": { + Schema: &schemas.Schema{}, + }, + }, + }, + { + name: "discovery error", + clientErr: fmt.Errorf("server not available"), + wantErr: true, + }, + { + name: "invalid models", + documentSchemas: []*openapiv2.NamedSchema{&errorSchema}, + wantErr: true, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + document := openapiv2.Document{ + Definitions: &openapiv2.Definitions{ + AdditionalProperties: test.documentSchemas, + }, + } + fakeDiscovery := fakeDiscovery{ + Document: &document, + DocumentErr: test.clientErr, + } + gotErr := addDescription(&fakeDiscovery, test.inputSchemas) + if test.wantErr { + require.Error(t, gotErr) + } else { + require.NoError(t, gotErr) + } + // inputSchemas are modified in place + require.Equal(t, test.wantSchemas, test.inputSchemas) + }) + } +} diff --git a/pkg/schema/converter/discovery.go b/pkg/schema/converter/discovery.go index e2337a26..d6e55fd2 100644 --- a/pkg/schema/converter/discovery.go +++ b/pkg/schema/converter/discovery.go @@ -22,7 +22,9 @@ var ( } ) -func AddDiscovery(client discovery.DiscoveryInterface, schemasMap map[string]*types.APISchema) error { +// addDiscovery uses a k8s discovery client to create very basic schemas for all registered groups/resources. Other +// functions, such as addCustomResources are used to add more details to these schemas later on. +func addDiscovery(client discovery.DiscoveryInterface, schemasMap map[string]*types.APISchema) error { groups, resourceLists, err := client.ServerGroupsAndResources() if gd, ok := err.(*discovery.ErrGroupDiscoveryFailed); ok { logrus.Errorf("Failed to read API for groups %v", gd.Groups) diff --git a/pkg/schema/converter/discovery_test.go b/pkg/schema/converter/discovery_test.go new file mode 100644 index 00000000..b55a2d1d --- /dev/null +++ b/pkg/schema/converter/discovery_test.go @@ -0,0 +1,419 @@ +package converter + +import ( + "fmt" + "testing" + + openapiv2 "github.com/google/gnostic-models/openapiv2" + "github.com/rancher/apiserver/pkg/types" + wranglerSchema "github.com/rancher/wrangler/v2/pkg/schemas" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/version" + "k8s.io/client-go/discovery" + "k8s.io/client-go/openapi" + restclient "k8s.io/client-go/rest" +) + +func TestAddDiscovery(t *testing.T) { + tests := []struct { + name string + discoveryErr error + groups []schema.GroupVersion + groupVersionOverride bool + resources map[schema.GroupVersion][]metav1.APIResource + wantError bool + desiredSchema map[string]*types.APISchema + }{ + { + name: "basic test case, one schema", + groups: []schema.GroupVersion{{Group: "TestGroup", Version: "v1"}}, + resources: map[schema.GroupVersion][]metav1.APIResource{ + {Group: "TestGroup", Version: "v1"}: { + { + + Name: "testResources", + SingularName: "testResource", + Kind: "TestResource", + Namespaced: true, + Verbs: metav1.Verbs{"get"}, + }, + }, + }, + wantError: false, + desiredSchema: map[string]*types.APISchema{ + "testgroup.v1.testresource": { + Schema: &wranglerSchema.Schema{ + ID: "testgroup.v1.testresource", + PluralName: "TestGroup.v1.testResources", + Attributes: map[string]interface{}{ + "group": "TestGroup", + "version": "v1", + "kind": "TestResource", + "resource": "testResources", + "verbs": []string{"get"}, + "namespaced": true, + }, + }, + }, + }, + }, + { + name: "discovery error but still got some information", + discoveryErr: &discovery.ErrGroupDiscoveryFailed{Groups: map[schema.GroupVersion]error{ + schema.GroupVersion{ + Group: "NotFound", + Version: "v1", + }: fmt.Errorf("group Not found"), + }, + }, + groups: []schema.GroupVersion{{Group: "TestGroup", Version: "v1"}}, + resources: map[schema.GroupVersion][]metav1.APIResource{ + {Group: "TestGroup", Version: "v1"}: { + { + + Name: "testResources", + SingularName: "testResource", + Kind: "TestResource", + Namespaced: true, + Verbs: metav1.Verbs{"get"}, + }, + }, + }, + wantError: false, + desiredSchema: map[string]*types.APISchema{ + "testgroup.v1.testresource": { + Schema: &wranglerSchema.Schema{ + ID: "testgroup.v1.testresource", + PluralName: "TestGroup.v1.testResources", + Attributes: map[string]interface{}{ + "group": "TestGroup", + "version": "v1", + "kind": "TestResource", + "resource": "testResources", + "verbs": []string{"get"}, + "namespaced": true, + }, + }, + }, + }, + }, + { + name: "discovery error, not partial", + discoveryErr: fmt.Errorf("cluster unavailable"), + groups: []schema.GroupVersion{{Group: "TestGroup", Version: "v1"}}, + resources: map[schema.GroupVersion][]metav1.APIResource{ + {Group: "TestGroup", Version: "v1"}: { + { + Name: "testResources", + SingularName: "testResource", + Kind: "TestResource", + Namespaced: true, + Verbs: metav1.Verbs{"get"}, + }, + }, + }, + wantError: true, + desiredSchema: map[string]*types.APISchema{}, + }, + { + name: "bad group version", + groups: []schema.GroupVersion{{Group: "Invalid/Group", Version: "v2"}}, + resources: map[schema.GroupVersion][]metav1.APIResource{ + {Group: "Invalid/Group", Version: "v2"}: { + { + Name: "testResources", + SingularName: "testResource", + Kind: "TestResource", + Namespaced: true, + Verbs: metav1.Verbs{"get"}, + }, + }, + }, + wantError: true, + desiredSchema: map[string]*types.APISchema{ + "core..testresource": { + Schema: &wranglerSchema.Schema{ + ID: "core..testresource", + PluralName: "core..testResources", + Attributes: map[string]interface{}{ + "group": "", + "version": "", + "kind": "TestResource", + "resource": "testResources", + "verbs": []string{"get"}, + "namespaced": true, + }, + }, + }, + }, + }, + { + name: "override groups and versions", + groups: []schema.GroupVersion{{Group: "autoscaling", Version: "v1"}, {Group: "extensions", Version: "v1"}}, + groupVersionOverride: true, + resources: map[schema.GroupVersion][]metav1.APIResource{ + {Group: "autoscaling", Version: "v1"}: { + { + Name: "testAutoscalings", + SingularName: "testAutoscaling", + Kind: "TestAutoscaling", + Namespaced: true, + Verbs: metav1.Verbs{"get"}, + }, + }, + {Group: "extensions", Version: "v1"}: { + { + Name: "testExtensions", + SingularName: "testExtension", + Kind: "TestExtension", + Namespaced: true, + Verbs: metav1.Verbs{"get"}, + }, + }, + }, + wantError: false, + desiredSchema: map[string]*types.APISchema{ + "autoscaling.v1.testautoscaling": { + Schema: &wranglerSchema.Schema{ + ID: "autoscaling.v1.testautoscaling", + PluralName: "autoscaling.v1.testAutoscalings", + Attributes: map[string]interface{}{ + "group": "autoscaling", + "version": "v1", + "kind": "TestAutoscaling", + "resource": "testAutoscalings", + "verbs": []string{"get"}, + "namespaced": true, + "preferredVersion": "v2beta2", + }, + }, + }, + "extensions.v1.testextension": { + Schema: &wranglerSchema.Schema{ + ID: "extensions.v1.testextension", + PluralName: "extensions.v1.testExtensions", + Attributes: map[string]interface{}{ + "group": "extensions", + "version": "v1", + "kind": "TestExtension", + "resource": "testExtensions", + "verbs": []string{"get"}, + "namespaced": true, + "preferredGroup": "apps", + }, + }, + }, + }, + }, + { + name: "eligible for override, but override version not found", + groups: []schema.GroupVersion{{Group: "autoscaling", Version: "v1"}}, + groupVersionOverride: false, + resources: map[schema.GroupVersion][]metav1.APIResource{ + {Group: "autoscaling", Version: "v1"}: { + { + Name: "testAutoscalings", + SingularName: "testAutoscaling", + Kind: "TestAutoscaling", + Namespaced: true, + Verbs: metav1.Verbs{"get"}, + }, + }, + }, + wantError: false, + desiredSchema: map[string]*types.APISchema{ + "autoscaling.v1.testautoscaling": { + Schema: &wranglerSchema.Schema{ + ID: "autoscaling.v1.testautoscaling", + PluralName: "autoscaling.v1.testAutoscalings", + Attributes: map[string]interface{}{ + "group": "autoscaling", + "version": "v1", + "kind": "TestAutoscaling", + "resource": "testAutoscalings", + "verbs": []string{"get"}, + "namespaced": true, + }, + }, + }, + }, + }, + { + name: "skip resource with / silently", + groups: []schema.GroupVersion{{Group: "TestGroup", Version: "v1"}}, + resources: map[schema.GroupVersion][]metav1.APIResource{ + {Group: "TestGroup", Version: "v1"}: { + { + Name: "test/Resources", + SingularName: "test/Resource", + Kind: "Test/Resource", + Namespaced: true, + Verbs: metav1.Verbs{"get"}, + }, + { + Name: "testResources", + SingularName: "testResource", + Kind: "TestResource", + Namespaced: false, + Verbs: metav1.Verbs{"get"}, + }, + }, + }, + wantError: false, + desiredSchema: map[string]*types.APISchema{ + "testgroup.v1.testresource": { + Schema: &wranglerSchema.Schema{ + ID: "testgroup.v1.testresource", + PluralName: "TestGroup.v1.testResources", + Attributes: map[string]interface{}{ + "group": "TestGroup", + "version": "v1", + "kind": "TestResource", + "resource": "testResources", + "verbs": []string{"get"}, + "namespaced": false, + }, + }, + }, + }, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + testDiscovery := fakeDiscovery{} + for _, gvr := range test.groups { + gvr := gvr + testDiscovery.AddGroup(gvr.Group, gvr.Version, test.groupVersionOverride) + } + for gvr, resourceSlice := range test.resources { + for _, resource := range resourceSlice { + resource := resource + testDiscovery.AddResource(gvr.Group, gvr.Version, resource) + } + } + testDiscovery.GroupResourcesErr = test.discoveryErr + schemas := map[string]*types.APISchema{} + err := addDiscovery(&testDiscovery, schemas) + if test.wantError { + assert.Error(t, err, "expected an error but did not get one") + } else { + assert.NoError(t, err, "got an error but did not expect one") + } + assert.Equal(t, test.desiredSchema, schemas, "schemas were not as expected") + }) + } +} + +type fakeDiscovery struct { + Groups []*metav1.APIGroup + Resources []*metav1.APIResourceList + Document *openapiv2.Document + GroupResourcesErr error + DocumentErr error +} + +// ServerGroupsAndResources is the only method we actually need for the test - just returns what is on the struct +func (f *fakeDiscovery) ServerGroupsAndResources() ([]*metav1.APIGroup, []*metav1.APIResourceList, error) { + return f.Groups, f.Resources, f.GroupResourcesErr +} + +func (f *fakeDiscovery) AddGroup(groupName string, preferredVersion string, includeOverrideVersion bool) { + if f.Groups == nil { + f.Groups = []*metav1.APIGroup{} + } + groupVersion := fmt.Sprintf("%s/%s", groupName, preferredVersion) + found := -1 + for i := range f.Groups { + if f.Groups[i].Name == groupName { + found = i + } + } + group := metav1.APIGroup{ + Name: groupName, + PreferredVersion: metav1.GroupVersionForDiscovery{ + GroupVersion: groupVersion, + Version: preferredVersion, + }, + Versions: []metav1.GroupVersionForDiscovery{ + { + GroupVersion: groupVersion, + Version: preferredVersion, + }, + }, + } + + // if we should include override versions in list of versions, figure out if we have an override and add it + if includeOverrideVersion { + if override, ok := preferredVersionOverride[groupVersion]; ok { + group.Versions = append(group.Versions, metav1.GroupVersionForDiscovery{ + GroupVersion: fmt.Sprintf("%s/%s", groupName, override), + Version: override, + }) + } + } + if found >= 0 { + f.Groups[found] = &group + } else { + f.Groups = append(f.Groups, &group) + } +} + +func (f *fakeDiscovery) AddResource(group, version string, resource metav1.APIResource) { + if f.Resources == nil { + f.Resources = []*metav1.APIResourceList{} + } + groupVersion := fmt.Sprintf("%s/%s", group, version) + found := -1 + // first, find the APIResourceList for our group + for i := range f.Resources { + if f.Resources[i].GroupVersion == groupVersion { + found = i + } + } + + if found >= 0 { + currentResourceList := f.Resources[found] + resourceFound := -1 + // next, find the APIResource for our resource + for i := range currentResourceList.APIResources { + if currentResourceList.APIResources[i].Name == resource.Name { + resourceFound = i + } + } + if resourceFound >= 0 { + currentResourceList.APIResources[resourceFound] = resource + } else { + currentResourceList.APIResources = append(currentResourceList.APIResources, resource) + } + f.Resources[found] = currentResourceList + } else { + currentResourceList := &metav1.APIResourceList{ + GroupVersion: groupVersion, + APIResources: []metav1.APIResource{resource}, + } + f.Resources = append(f.Resources, currentResourceList) + } +} + +// The rest of these methods are just here to conform to discovery.DiscoveryInterface +func (f *fakeDiscovery) RESTClient() restclient.Interface { return nil } +func (f *fakeDiscovery) ServerGroups() (*metav1.APIGroupList, error) { return nil, nil } +func (f *fakeDiscovery) ServerResourcesForGroupVersion(groupVersion string) (*metav1.APIResourceList, error) { + return nil, nil +} +func (f *fakeDiscovery) ServerPreferredResources() ([]*metav1.APIResourceList, error) { + return nil, nil +} +func (f *fakeDiscovery) ServerPreferredNamespacedResources() ([]*metav1.APIResourceList, error) { + return nil, nil +} +func (f *fakeDiscovery) ServerVersion() (*version.Info, error) { return nil, nil } +func (f *fakeDiscovery) OpenAPISchema() (*openapiv2.Document, error) { + return f.Document, f.DocumentErr +} +func (f *fakeDiscovery) OpenAPIV3() openapi.Client { return nil } +func (f *fakeDiscovery) WithLegacy() discovery.DiscoveryInterface { return f } diff --git a/pkg/schema/converter/k8stonorman.go b/pkg/schema/converter/k8stonorman.go index ee6e47ed..6e9097fd 100644 --- a/pkg/schema/converter/k8stonorman.go +++ b/pkg/schema/converter/k8stonorman.go @@ -1,3 +1,5 @@ +// Package converter is responsible for converting the types registered with a k8s server to schemas which can be used +// by the UI (and other consumers) to discover the resources available and the current user's permissions. package converter import ( @@ -5,9 +7,18 @@ import ( "strings" "github.com/rancher/apiserver/pkg/types" + "github.com/rancher/norman/types/convert" v1 "github.com/rancher/wrangler/v2/pkg/generated/controllers/apiextensions.k8s.io/v1" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/discovery" + "k8s.io/kube-openapi/pkg/util/proto" +) + +const ( + gvkExtensionName = "x-kubernetes-group-version-kind" + gvkExtensionGroup = "group" + gvkExtensionVersion = "version" + gvkExtensionKind = "kind" ) func GVKToVersionedSchemaID(gvk schema.GroupVersionKind) string { @@ -38,18 +49,40 @@ func GVRToPluralName(gvr schema.GroupVersionResource) string { return fmt.Sprintf("%s.%s", gvr.Group, gvr.Resource) } +// GetGVKForKind attempts to retrieve a GVK for a given Kind. Not all kind represent top level resources, +// so this function may return nil if the kind did not have a gvk extension +func GetGVKForKind(kind *proto.Kind) *schema.GroupVersionKind { + extensions, ok := kind.Extensions[gvkExtensionName].([]any) + if !ok { + return nil + } + for _, extension := range extensions { + if gvkExtension, ok := extension.(map[any]any); ok { + gvk := schema.GroupVersionKind{ + Group: convert.ToString(gvkExtension[gvkExtensionGroup]), + Version: convert.ToString(gvkExtension[gvkExtensionVersion]), + Kind: convert.ToString(gvkExtension[gvkExtensionKind]), + } + return &gvk + } + } + return nil +} + +// ToSchemas creates the schemas for a K8s server, using client to discover groups/resources, and crd to potentially +// add additional information about new fields/resources. Mostly ties together addDiscovery and addCustomResources. func ToSchemas(crd v1.CustomResourceDefinitionClient, client discovery.DiscoveryInterface) (map[string]*types.APISchema, error) { result := map[string]*types.APISchema{} - if err := AddOpenAPI(client, result); err != nil { + if err := addDiscovery(client, result); err != nil { return nil, err } - if err := AddDiscovery(client, result); err != nil { + if err := addCustomResources(crd, result); err != nil { return nil, err } - if err := AddCustomResources(crd, result); err != nil { + if err := addDescription(client, result); err != nil { return nil, err } diff --git a/pkg/schema/converter/k8stonorman_test.go b/pkg/schema/converter/k8stonorman_test.go new file mode 100644 index 00000000..f739c39f --- /dev/null +++ b/pkg/schema/converter/k8stonorman_test.go @@ -0,0 +1,572 @@ +package converter + +import ( + "fmt" + "testing" + + "github.com/golang/mock/gomock" + openapiv2 "github.com/google/gnostic-models/openapiv2" + "github.com/rancher/apiserver/pkg/types" + "github.com/rancher/steve/pkg/schema/table" + "github.com/rancher/wrangler/v2/pkg/generic/fake" + wranglerSchema "github.com/rancher/wrangler/v2/pkg/schemas" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/kube-openapi/pkg/util/proto" +) + +func TestToSchemas(t *testing.T) { + gvkExtensionMap := map[any]any{ + gvkExtensionGroup: "TestGroup", + gvkExtensionVersion: "v1", + gvkExtensionKind: "TestResource", + } + gvkExtensionSlice := []any{gvkExtensionMap} + extensionSliceYaml, err := yaml.Marshal(gvkExtensionSlice) + require.NoError(t, err) + gvkSchema := openapiv2.NamedSchema{ + Name: "TestResources", + Value: &openapiv2.Schema{ + Description: "TestResources are test resource created for unit tests", + Type: &openapiv2.TypeItem{ + Value: []string{"object"}, + }, + Properties: &openapiv2.Properties{ + AdditionalProperties: []*openapiv2.NamedSchema{}, + }, + VendorExtension: []*openapiv2.NamedAny{ + { + Name: gvkExtensionName, + Value: &openapiv2.Any{ + Yaml: string(extensionSliceYaml), + }, + }, + }, + }, + } + tests := []struct { + name string + groups []schema.GroupVersion + resources map[schema.GroupVersion][]metav1.APIResource + crds []v1.CustomResourceDefinition + document *openapiv2.Document + discoveryErr error + documentErr error + crdErr error + wantError bool + desiredSchema map[string]*types.APISchema + }{ + { + name: "crd listed in discovery, defined in crds", + groups: []schema.GroupVersion{{Group: "TestGroup", Version: "v1"}}, + resources: map[schema.GroupVersion][]metav1.APIResource{ + {Group: "TestGroup", Version: "v1"}: { + { + + Name: "testResources", + SingularName: "testResource", + Kind: "TestResource", + Namespaced: true, + Verbs: metav1.Verbs{"get"}, + }, + }, + }, + crds: []v1.CustomResourceDefinition{ + { + Status: v1.CustomResourceDefinitionStatus{ + AcceptedNames: v1.CustomResourceDefinitionNames{ + Plural: "testResources", + Singular: "testResource", + Kind: "TestResource", + }, + }, + Spec: v1.CustomResourceDefinitionSpec{ + Group: "TestGroup", + Versions: []v1.CustomResourceDefinitionVersion{ + { + Name: "v1", + AdditionalPrinterColumns: []v1.CustomResourceColumnDefinition{ + { + Name: "TestColumn", + JSONPath: "TestPath", + Type: "TestType", + Format: "TestFormat", + }, + }, + Schema: &v1.CustomResourceValidation{ + OpenAPIV3Schema: &v1.JSONSchemaProps{ + Description: "Test Resource for unit tests", + Required: []string{"required"}, + Properties: map[string]v1.JSONSchemaProps{ + "required": { + Description: "Required Property", + Type: "string", + }, + "numberField": { + Description: "NumberField - Not Required Property", + Type: "number", + }, + "nullArrayField": { + Description: "ArrayField with no type - Not Required Property", + Type: "array", + }, + "nullObjectField": { + Description: "ObjectField with no type - Not Required Property", + Type: "object", + }, + "actions": { + Description: "Reserved field - Not Required Property", + Type: "string", + }, + }, + }, + }, + }, + }, + Names: v1.CustomResourceDefinitionNames{ + Plural: "testResources", + Singular: "testResource", + Kind: "TestResource", + }, + }, + }, + }, + wantError: false, + desiredSchema: map[string]*types.APISchema{ + "testgroup.v1.testresource": { + Schema: &wranglerSchema.Schema{ + ID: "testgroup.v1.testresource", + PluralName: "TestGroup.v1.testResources", + Attributes: map[string]interface{}{ + "group": "TestGroup", + "version": "v1", + "kind": "TestResource", + "resource": "testResources", + "verbs": []string{"get"}, + "namespaced": true, + "columns": []table.Column{ + { + Name: "TestColumn", + Field: "TestPath", + Type: "TestType", + Format: "TestFormat", + }, + }, + }, + Description: "Test Resource for unit tests", + }, + }, + }, + }, + { + name: "listed in discovery, not defined in crds", + crds: []v1.CustomResourceDefinition{}, + groups: []schema.GroupVersion{{Group: "TestGroup", Version: "v1"}}, + resources: map[schema.GroupVersion][]metav1.APIResource{ + {Group: "TestGroup", Version: "v1"}: { + { + + Name: "testResources", + SingularName: "testResource", + Kind: "TestResource", + Namespaced: true, + Verbs: metav1.Verbs{"get"}, + }, + }, + }, + wantError: false, + desiredSchema: map[string]*types.APISchema{ + "testgroup.v1.testresource": { + Schema: &wranglerSchema.Schema{ + ID: "testgroup.v1.testresource", + PluralName: "TestGroup.v1.testResources", + Attributes: map[string]interface{}{ + "group": "TestGroup", + "version": "v1", + "kind": "TestResource", + "resource": "testResources", + "verbs": []string{"get"}, + "namespaced": true, + }, + }, + }, + }, + }, + { + name: "defined in crds, but not in discovery", + groups: []schema.GroupVersion{}, + resources: map[schema.GroupVersion][]metav1.APIResource{}, + crds: []v1.CustomResourceDefinition{ + { + Status: v1.CustomResourceDefinitionStatus{ + AcceptedNames: v1.CustomResourceDefinitionNames{ + Plural: "testResources", + Singular: "testResource", + Kind: "TestResource", + }, + }, + Spec: v1.CustomResourceDefinitionSpec{ + Group: "TestGroup", + Versions: []v1.CustomResourceDefinitionVersion{ + { + Name: "v1", + AdditionalPrinterColumns: []v1.CustomResourceColumnDefinition{ + { + Name: "TestColumn", + JSONPath: "TestPath", + Type: "TestType", + Format: "TestFormat", + }, + }, + Schema: &v1.CustomResourceValidation{ + OpenAPIV3Schema: &v1.JSONSchemaProps{ + Description: "Test Resource for unit tests", + Required: []string{"required"}, + Properties: map[string]v1.JSONSchemaProps{ + "required": { + Description: "Required Property", + Type: "string", + }, + "numberField": { + Description: "NumberField - Not Required Property", + Type: "number", + }, + "nullArrayField": { + Description: "ArrayField with no type - Not Required Property", + Type: "array", + }, + "nullObjectField": { + Description: "ObjectField with no type - Not Required Property", + Type: "object", + }, + "actions": { + Description: "Reserved field - Not Required Property", + Type: "string", + }, + }, + }, + }, + }, + }, + Names: v1.CustomResourceDefinitionNames{ + Plural: "testResources", + Singular: "testResource", + Kind: "TestResource", + }, + }, + }, + }, + wantError: false, + desiredSchema: map[string]*types.APISchema{}, + }, + { + name: "discovery error", + groups: []schema.GroupVersion{}, + resources: map[schema.GroupVersion][]metav1.APIResource{}, + discoveryErr: fmt.Errorf("server is down, can't use discovery"), + crds: []v1.CustomResourceDefinition{}, + wantError: true, + desiredSchema: nil, + }, + { + name: "crd error", + groups: []schema.GroupVersion{{Group: "TestGroup", Version: "v1"}}, + resources: map[schema.GroupVersion][]metav1.APIResource{ + {Group: "TestGroup", Version: "v1"}: { + { + + Name: "testResources", + SingularName: "testResource", + Kind: "TestResource", + Namespaced: true, + Verbs: metav1.Verbs{"get"}, + }, + }, + }, + crdErr: fmt.Errorf("unable to use crd client, insufficient permissions"), + crds: []v1.CustomResourceDefinition{}, + wantError: false, + desiredSchema: map[string]*types.APISchema{ + "testgroup.v1.testresource": { + Schema: &wranglerSchema.Schema{ + ID: "testgroup.v1.testresource", + PluralName: "TestGroup.v1.testResources", + Attributes: map[string]interface{}{ + "group": "TestGroup", + "version": "v1", + "kind": "TestResource", + "resource": "testResources", + "verbs": []string{"get"}, + "namespaced": true, + }, + }, + }, + }, + }, + { + name: "adding descriptions", + groups: []schema.GroupVersion{{Group: "TestGroup", Version: "v1"}}, + resources: map[schema.GroupVersion][]metav1.APIResource{ + {Group: "TestGroup", Version: "v1"}: { + { + + Name: "testResources", + SingularName: "testResource", + Kind: "TestResource", + Namespaced: true, + Verbs: metav1.Verbs{"get"}, + }, + }, + }, + crdErr: nil, + crds: []v1.CustomResourceDefinition{}, + document: &openapiv2.Document{ + Definitions: &openapiv2.Definitions{ + AdditionalProperties: []*openapiv2.NamedSchema{&gvkSchema}, + }, + }, + wantError: false, + desiredSchema: map[string]*types.APISchema{ + "testgroup.v1.testresource": { + Schema: &wranglerSchema.Schema{ + ID: "testgroup.v1.testresource", + Description: gvkSchema.Value.Description, + PluralName: "TestGroup.v1.testResources", + Attributes: map[string]interface{}{ + "group": "TestGroup", + "version": "v1", + "kind": "TestResource", + "resource": "testResources", + "verbs": []string{"get"}, + "namespaced": true, + }, + }, + }, + }, + }, + { + name: "descriptions error", + groups: []schema.GroupVersion{{Group: "TestGroup", Version: "v1"}}, + resources: map[schema.GroupVersion][]metav1.APIResource{ + {Group: "TestGroup", Version: "v1"}: { + { + + Name: "testResources", + SingularName: "testResource", + Kind: "TestResource", + Namespaced: true, + Verbs: metav1.Verbs{"get"}, + }, + }, + }, + crdErr: nil, + crds: []v1.CustomResourceDefinition{}, + document: nil, + documentErr: fmt.Errorf("can't get document"), + wantError: true, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + testDiscovery := fakeDiscovery{} + for _, gvr := range test.groups { + gvr := gvr + testDiscovery.AddGroup(gvr.Group, gvr.Version, false) + } + testDiscovery.Document = test.document + testDiscovery.DocumentErr = test.documentErr + for gvr, resourceSlice := range test.resources { + for _, resource := range resourceSlice { + resource := resource + testDiscovery.AddResource(gvr.Group, gvr.Version, resource) + } + } + testDiscovery.GroupResourcesErr = test.discoveryErr + var crds *v1.CustomResourceDefinitionList + if test.crds != nil { + crds = &v1.CustomResourceDefinitionList{ + Items: test.crds, + } + } + fakeClient := fake.NewMockNonNamespacedClientInterface[*v1.CustomResourceDefinition, *v1.CustomResourceDefinitionList](ctrl) + fakeClient.EXPECT().List(gomock.Any()).Return(crds, test.crdErr).AnyTimes() + + schemas, err := ToSchemas(fakeClient, &testDiscovery) + if test.wantError { + assert.Error(t, err, "wanted error but didn't get one") + } else { + assert.NoError(t, err, "got an error but did not want one") + } + assert.Equal(t, test.desiredSchema, schemas, "did not get the desired schemas") + }) + } + +} + +func TestGVKToVersionedSchemaID(t *testing.T) { + tests := []struct { + name string + gvk schema.GroupVersionKind + want string + }{ + { + name: "basic gvk", + gvk: schema.GroupVersionKind{ + Group: "TestGroup", + Version: "v1", + Kind: "TestKind", + }, + want: "testgroup.v1.testkind", + }, + { + name: "core resource", + gvk: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "TestKind", + }, + want: "core.v1.testkind", + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.want, GVKToVersionedSchemaID(test.gvk)) + }) + } + +} + +func TestGVKToSchemaID(t *testing.T) { + tests := []struct { + name string + gvk schema.GroupVersionKind + want string + }{ + { + name: "basic gvk", + gvk: schema.GroupVersionKind{ + Group: "TestGroup", + Version: "v1", + Kind: "TestKind", + }, + want: "testgroup.testkind", + }, + { + name: "core resource", + gvk: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "TestKind", + }, + want: "testkind", + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.want, GVKToSchemaID(test.gvk)) + }) + } +} + +func TestGVRToPluralName(t *testing.T) { + tests := []struct { + name string + gvr schema.GroupVersionResource + want string + }{ + { + name: "basic gvk", + gvr: schema.GroupVersionResource{ + Group: "TestGroup", + Version: "v1", + Resource: "TestResources", + }, + want: "TestGroup.TestResources", + }, + { + name: "core resource", + gvr: schema.GroupVersionResource{ + Group: "", + Version: "v1", + Resource: "TestResources", + }, + want: "TestResources", + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.want, GVRToPluralName(test.gvr)) + }) + } +} + +func TestGetGVKForKind(t *testing.T) { + tests := []struct { + name string + kind *proto.Kind + wantGVK *schema.GroupVersionKind + }{ + { + name: "basic kind", + kind: &proto.Kind{ + BaseSchema: proto.BaseSchema{ + Extensions: map[string]any{ + gvkExtensionName: []any{ + "some other extension", + map[any]any{ + gvkExtensionGroup: "TestGroup", + gvkExtensionVersion: "v1", + gvkExtensionKind: "TestKind", + }, + }, + }, + }, + }, + wantGVK: &schema.GroupVersionKind{ + Group: "TestGroup", + Version: "v1", + Kind: "TestKind", + }, + }, + { + name: "kind missing gvkExtension", + kind: &proto.Kind{ + BaseSchema: proto.BaseSchema{ + Extensions: map[string]any{}, + }, + }, + wantGVK: nil, + }, + { + name: "kind missing gvk map", + kind: &proto.Kind{ + BaseSchema: proto.BaseSchema{ + Extensions: map[string]any{ + gvkExtensionName: []any{"some value"}, + }, + }, + }, + wantGVK: nil, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, test.wantGVK, GetGVKForKind(test.kind)) + }) + } + +} diff --git a/pkg/schema/converter/openapi.go b/pkg/schema/converter/openapi.go deleted file mode 100644 index 6a1d4563..00000000 --- a/pkg/schema/converter/openapi.go +++ /dev/null @@ -1,117 +0,0 @@ -package converter - -import ( - "github.com/rancher/apiserver/pkg/types" - "github.com/rancher/steve/pkg/attributes" - "github.com/rancher/wrangler/v2/pkg/data/convert" - "github.com/rancher/wrangler/v2/pkg/schemas" - "github.com/sirupsen/logrus" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/client-go/discovery" - "k8s.io/kube-openapi/pkg/util/proto" -) - -func modelToSchema(modelName string, k *proto.Kind) *types.APISchema { - s := types.APISchema{ - Schema: &schemas.Schema{ - ID: modelName, - ResourceFields: map[string]schemas.Field{}, - Attributes: map[string]interface{}{}, - Description: k.GetDescription(), - }, - } - - for fieldName, schemaField := range k.Fields { - s.ResourceFields[fieldName] = toField(schemaField) - } - - for _, fieldName := range k.RequiredFields { - if f, ok := s.ResourceFields[fieldName]; ok { - f.Required = true - s.ResourceFields[fieldName] = f - } - } - - if ms, ok := k.Extensions["x-kubernetes-group-version-kind"].([]interface{}); ok { - for _, mv := range ms { - if m, ok := mv.(map[interface{}]interface{}); ok { - gvk := schema.GroupVersionKind{ - Group: convert.ToString(m["group"]), - Version: convert.ToString(m["version"]), - Kind: convert.ToString(m["kind"]), - } - - s.ID = GVKToVersionedSchemaID(gvk) - attributes.SetGVK(&s, gvk) - } - } - } - - for k, v := range s.ResourceFields { - if types.ReservedFields[k] { - s.ResourceFields["_"+k] = v - delete(s.ResourceFields, k) - } - } - - return &s -} - -func AddOpenAPI(client discovery.DiscoveryInterface, schemas map[string]*types.APISchema) error { - openapi, err := client.OpenAPISchema() - if err != nil { - return err - } - - models, err := proto.NewOpenAPIData(openapi) - if err != nil { - return err - } - - for _, modelName := range models.ListModels() { - model := models.LookupModel(modelName) - if k, ok := model.(*proto.Kind); ok { - schema := modelToSchema(modelName, k) - schemas[schema.ID] = schema - } - } - - return nil -} - -func toField(schema proto.Schema) schemas.Field { - f := schemas.Field{ - Description: schema.GetDescription(), - Create: true, - Update: true, - } - switch v := schema.(type) { - case *proto.Array: - f.Type = "array[" + toField(v.SubType).Type + "]" - case *proto.Primitive: - if v.Type == "number" || v.Type == "integer" { - f.Type = "int" - } else { - f.Type = v.Type - } - case *proto.Map: - f.Type = "map[" + toField(v.SubType).Type + "]" - case *proto.Kind: - f.Type = v.Path.String() - case proto.Reference: - sub := v.SubSchema() - if p, ok := sub.(*proto.Primitive); ok { - f.Type = p.Type - } else { - f.Type = sub.GetPath().String() - } - case *proto.Arbitrary: - logrus.Debugf("arbitrary type: %v", schema) - f.Type = "json" - default: - logrus.Errorf("unknown type: %v", schema) - f.Type = "json" - } - - return f -} diff --git a/pkg/schema/converter/openapiv3.go b/pkg/schema/converter/openapiv3.go deleted file mode 100644 index ee7bc78d..00000000 --- a/pkg/schema/converter/openapiv3.go +++ /dev/null @@ -1,92 +0,0 @@ -package converter - -import ( - "github.com/rancher/apiserver/pkg/types" - "github.com/rancher/wrangler/v2/pkg/schemas" - v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" -) - -func modelV3ToSchema(name string, k *v1.JSONSchemaProps, schemasMap map[string]*types.APISchema) *types.APISchema { - s := types.APISchema{ - Schema: &schemas.Schema{ - ID: name, - ResourceFields: map[string]schemas.Field{}, - Attributes: map[string]interface{}{}, - Description: k.Description, - }, - } - - for fieldName, schemaField := range k.Properties { - s.ResourceFields[fieldName] = toResourceField(name+"."+fieldName, schemaField, schemasMap) - } - - for _, fieldName := range k.Required { - if f, ok := s.ResourceFields[fieldName]; ok { - f.Required = true - s.ResourceFields[fieldName] = f - } - } - - if existing, ok := schemasMap[s.ID]; ok && len(existing.Attributes) > 0 { - s.Attributes = existing.Attributes - } - schemasMap[s.ID] = &s - - for k, v := range s.ResourceFields { - if types.ReservedFields[k] { - s.ResourceFields["_"+k] = v - delete(s.ResourceFields, k) - } - } - - return &s -} - -func toResourceField(name string, schema v1.JSONSchemaProps, schemasMap map[string]*types.APISchema) schemas.Field { - f := schemas.Field{ - Description: schema.Description, - Nullable: true, - Create: true, - Update: true, - } - var itemSchema *v1.JSONSchemaProps - if schema.Items != nil { - if schema.Items.Schema != nil { - itemSchema = schema.Items.Schema - } else if len(schema.Items.JSONSchemas) > 0 { - itemSchema = &schema.Items.JSONSchemas[0] - } - } - - switch schema.Type { - case "array": - if itemSchema == nil { - f.Type = "array[json]" - } else if itemSchema.Type == "object" { - f.Type = "array[" + name + "]" - modelV3ToSchema(name, itemSchema, schemasMap) - } else { - f.Type = "array[" + itemSchema.Type + "]" - } - case "object": - if schema.AdditionalProperties != nil && schema.AdditionalProperties.Schema != nil && schema.AdditionalProperties.Schema.Type == "object" { - f.Type = "map[" + name + "]" - modelV3ToSchema(name, schema.AdditionalProperties.Schema, schemasMap) - } else if schema.AdditionalProperties != nil && schema.AdditionalProperties.Schema != nil { - f.Type = "map[" + schema.AdditionalProperties.Schema.Type + "]" - } else { - f.Type = name - modelV3ToSchema(name, &schema, schemasMap) - } - case "number": - f.Type = "int" - default: - f.Type = schema.Type - } - - if f.Type == "" { - f.Type = "json" - } - - return f -} diff --git a/pkg/schema/definitions/handler.go b/pkg/schema/definitions/handler.go new file mode 100644 index 00000000..094cb7ea --- /dev/null +++ b/pkg/schema/definitions/handler.go @@ -0,0 +1,197 @@ +package definitions + +import ( + "errors" + "fmt" + "net/http" + "sync" + "time" + + "github.com/rancher/apiserver/pkg/apierror" + "github.com/rancher/apiserver/pkg/types" + "github.com/rancher/steve/pkg/schema/converter" + "github.com/rancher/wrangler/v2/pkg/schemas/validation" + "github.com/sirupsen/logrus" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/discovery" + "k8s.io/kube-openapi/pkg/util/proto" +) + +var ( + internalServerErrorCode = validation.ErrorCode{ + Status: http.StatusInternalServerError, + Code: "InternalServerError", + } + notRefreshedErrorCode = validation.ErrorCode{ + Status: http.StatusServiceUnavailable, + Code: "SchemasNotRefreshed", + } +) + +// schemaDefinitionHandler is a byID handler for a specific schema, which provides field definitions for all schemas. +// Does not implement any method allowing a caller to list definitions for all schemas. +type schemaDefinitionHandler struct { + sync.RWMutex + + // lastRefresh is the last time that the handler retrieved models from kubernetes. + lastRefresh time.Time + // refreshStale is the duration between lastRefresh and the next refresh of models. + refreshStale time.Duration + // client is the discovery client used to get the groups/resources/fields from kubernetes. + client discovery.DiscoveryInterface + // models are the cached models from the last response from kubernetes. + models *proto.Models + // schemaToModel is a map of the schema name to the model for that schema. Can be used to load the + // top-level definition for a schema, which can then be processed by the schemaFieldVisitor. + schemaToModel map[string]string +} + +// byIDHandler is the Handler method for a request to get the schema definition for a specifc schema. Will use the +// cached models found during the last refresh as part of this process. +func (s *schemaDefinitionHandler) byIDHandler(request *types.APIRequest) (types.APIObject, error) { + // pseudo-access check, designed to make sure that users have access to the schema for the definition that they + // are accessing. + requestSchema := request.Schemas.LookupSchema(request.Name) + if requestSchema == nil { + return types.APIObject{}, apierror.NewAPIError(validation.NotFound, "no such schema") + } + + if s.needsRefresh() { + err := s.refresh() + if err != nil { + logrus.Errorf("error refreshing schemas %s", err.Error()) + return types.APIObject{}, apierror.NewAPIError(internalServerErrorCode, "error refreshing schemas") + } + } + + // lock only in read-mode so that we don't read while refresh writes. Only use a read-lock - using a write lock + // would make this endpoint only usable by one caller at a time + s.RLock() + defer s.RUnlock() + + if s.models == nil { + return types.APIObject{}, apierror.NewAPIError(notRefreshedErrorCode, "schema definitions not yet refreshed") + } + models := *s.models + modelName, ok := s.schemaToModel[requestSchema.ID] + if !ok { + return types.APIObject{}, apierror.NewAPIError(notRefreshedErrorCode, "no model found for schema, try again after refresh") + } + model := models.LookupModel(modelName) + protoKind, ok := model.(*proto.Kind) + if !ok { + errorMsg := fmt.Sprintf("model for %s was type %T, not a proto.Kind", modelName, model) + return types.APIObject{}, apierror.NewAPIError(internalServerErrorCode, errorMsg) + } + definitions := map[string]definition{} + visitor := schemaFieldVisitor{ + definitions: definitions, + models: models, + } + protoKind.Accept(&visitor) + + return types.APIObject{ + ID: request.Name, + Type: "schemaDefinition", + Object: schemaDefinition{ + DefinitionType: modelName, + Definitions: definitions, + }, + }, nil +} + +// needsRefresh readLocks and checks if the cache needs to be refreshed. +func (s *schemaDefinitionHandler) needsRefresh() bool { + s.RLock() + defer s.RUnlock() + if s.lastRefresh.IsZero() { + return true + } + return s.lastRefresh.Add(s.refreshStale).Before(time.Now()) +} + +// refresh writeLocks and updates the cache with new schemaDefinitions. Will result in a call to kubernetes to retrieve +// the openAPI schemas. +func (s *schemaDefinitionHandler) refresh() error { + s.Lock() + defer s.Unlock() + openapi, err := s.client.OpenAPISchema() + if err != nil { + return fmt.Errorf("unable to fetch openapi definition: %w", err) + } + models, err := proto.NewOpenAPIData(openapi) + if err != nil { + return fmt.Errorf("unable to parse openapi definition into models: %w", err) + } + s.models = &models + nameIndex, err := s.indexSchemaNames(models) + // indexSchemaNames may successfully refresh some definitions, but still return an error + // in these cases, store what we could find, but still return up an error + if nameIndex != nil { + s.schemaToModel = nameIndex + s.lastRefresh = time.Now() + } + if err != nil { + return fmt.Errorf("unable to index schema name to model name: %w", err) + } + return nil +} + +// indexSchemaNames returns a map of schemaID to the modelName for a given schema. Will use the preferred version of a +// resource if possible. May return a map and an error if it was able to index some schemas but not others. +func (s *schemaDefinitionHandler) indexSchemaNames(models proto.Models) (map[string]string, error) { + _, resourceLists, err := s.client.ServerGroupsAndResources() + // this may occasionally fail to discover certain groups, but we still can refresh the others in those cases + if _, ok := err.(*discovery.ErrGroupDiscoveryFailed); err != nil && !ok { + return nil, fmt.Errorf("unable to retrieve groups and resources: %w", err) + } + preferredResourceVersions := map[schema.GroupKind]string{} + for _, resourceList := range resourceLists { + if resourceList == nil { + continue + } + groupVersion, gvErr := schema.ParseGroupVersion(resourceList.GroupVersion) + // we may fail to parse the GV of one group, but can still parse out the others + if gvErr != nil { + err = errors.Join(err, fmt.Errorf("unable to parse group version %s: %w", resourceList.GroupVersion, gvErr)) + continue + } + for _, resource := range resourceList.APIResources { + gk := schema.GroupKind{ + Group: groupVersion.Group, + Kind: resource.Kind, + } + // per the resource docs, if the resource.Version is empty, the preferred version for + // this resource is the version of the APIResourceList it is in + if resource.Version == "" || resource.Version == groupVersion.Version { + preferredResourceVersions[gk] = groupVersion.Version + } + } + } + schemaToModel := map[string]string{} + for _, modelName := range models.ListModels() { + protoKind, ok := models.LookupModel(modelName).(*proto.Kind) + if !ok { + // no need to process models that aren't kinds + continue + } + gvk := converter.GetGVKForKind(protoKind) + if gvk == nil { + // not all kinds are for top-level resources, since these won't have a schema, + // we can safely continue + continue + } + gk := schema.GroupKind{ + Group: gvk.Group, + Kind: gvk.Kind, + } + prefVersion, ok := preferredResourceVersions[gk] + // if we don't have a known preferred version for this group or we are the preferred version + // add this as the model name for the schema + if !ok || prefVersion == gvk.Version { + schemaID := converter.GVKToSchemaID(*gvk) + schemaToModel[schemaID] = modelName + } + } + return schemaToModel, err +} diff --git a/pkg/schema/definitions/handler_test.go b/pkg/schema/definitions/handler_test.go new file mode 100644 index 00000000..72b8d7dd --- /dev/null +++ b/pkg/schema/definitions/handler_test.go @@ -0,0 +1,325 @@ +package definitions + +import ( + "fmt" + "testing" + "time" + + openapi_v2 "github.com/google/gnostic-models/openapiv2" + "github.com/rancher/apiserver/pkg/apierror" + "github.com/rancher/apiserver/pkg/types" + wschemas "github.com/rancher/wrangler/v2/pkg/schemas" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/version" + "k8s.io/client-go/discovery" + "k8s.io/client-go/openapi" + restclient "k8s.io/client-go/rest" +) + +var globalRoleObject = types.APIObject{ + ID: "management.cattle.io.globalrole", + Type: "schemaDefinition", + Object: schemaDefinition{ + DefinitionType: "io.cattle.management.v2.GlobalRole", + Definitions: map[string]definition{ + "io.cattle.management.v2.GlobalRole": { + ResourceFields: map[string]definitionField{ + "apiVersion": { + Type: "string", + Description: "The APIVersion of this resource", + }, + "kind": { + Type: "string", + Description: "The kind", + }, + "metadata": { + Type: "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta", + Description: "The metadata", + }, + "spec": { + Type: "io.cattle.management.v2.GlobalRole.spec", Description: "The spec for the project", + }, + }, + Type: "io.cattle.management.v2.GlobalRole", + Description: "A Global Role V2 provides Global Permissions in Rancher", + }, + "io.cattle.management.v2.GlobalRole.spec": { + ResourceFields: map[string]definitionField{ + "clusterName": { + Type: "string", + Description: "The name of the cluster", + Required: true, + }, + "displayName": { + Type: "string", + Description: "The UI readable name", + Required: true, + }, + "newField": { + Type: "string", + Description: "A new field not present in v1", + }, + "notRequired": { + Type: "boolean", + Description: "Some field that isn't required", + }, + }, + Type: "io.cattle.management.v2.GlobalRole.spec", + Description: "The spec for the project", + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta": { + ResourceFields: map[string]definitionField{ + "annotations": { + Type: "map", + SubType: "string", + Description: "annotations of the resource", + }, + "name": { + Type: "string", + SubType: "", + Description: "name of the resource", + }, + }, + Type: "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta", + Description: "Object Metadata", + }, + }, + }, +} + +func TestByID(t *testing.T) { + schemas := types.EmptyAPISchemas() + addBaseSchema := func(names ...string) { + for _, name := range names { + schemas.MustAddSchema(types.APISchema{ + Schema: &wschemas.Schema{ + ID: name, + CollectionMethods: []string{"get"}, + ResourceMethods: []string{"get"}, + }, + }) + } + } + + intPtr := func(input int) *int { + return &input + } + + addBaseSchema("management.cattle.io.globalrole", "management.cattle.io.missingfrommodel") + + tests := []struct { + name string + schemaName string + needsRefresh bool + openapiError error + serverGroupsResourcesErr error + useBadOpenApiDoc bool + unparseableGV bool + wantObject *types.APIObject + wantError bool + wantErrorCode *int + }{ + { + name: "global role definition", + schemaName: "management.cattle.io.globalrole", + needsRefresh: true, + wantObject: &globalRoleObject, + }, + { + name: "missing definition", + schemaName: "management.cattle.io.cluster", + needsRefresh: true, + wantError: true, + wantErrorCode: intPtr(404), + }, + { + name: "not refreshed", + schemaName: "management.cattle.io.globalrole", + needsRefresh: false, + wantError: true, + wantErrorCode: intPtr(503), + }, + { + name: "missing from model", + schemaName: "management.cattle.io.missingfrommodel", + needsRefresh: true, + wantError: true, + wantErrorCode: intPtr(503), + }, + { + name: "refresh error - openapi doc unavailable", + schemaName: "management.cattle.io.globalrole", + needsRefresh: true, + openapiError: fmt.Errorf("server unavailable"), + wantError: true, + wantErrorCode: intPtr(500), + }, + { + name: "refresh error - unable to parse openapi doc", + schemaName: "management.cattle.io.globalrole", + needsRefresh: true, + useBadOpenApiDoc: true, + wantError: true, + wantErrorCode: intPtr(500), + }, + { + name: "refresh error - unable to retrieve groups and resources", + schemaName: "management.cattle.io.globalrole", + needsRefresh: true, + serverGroupsResourcesErr: fmt.Errorf("server not available"), + wantError: true, + wantErrorCode: intPtr(500), + }, + { + name: "refresh error - unable to retrieve all groups and resources", + schemaName: "management.cattle.io.globalrole", + needsRefresh: true, + serverGroupsResourcesErr: &discovery.ErrGroupDiscoveryFailed{ + Groups: map[schema.GroupVersion]error{ + { + Group: "other.cattle.io", + Version: "v1", + }: fmt.Errorf("some group error"), + }, + }, + wantError: true, + wantErrorCode: intPtr(500), + }, + { + name: "refresh error - unparesable gv", + schemaName: "management.cattle.io.globalrole", + needsRefresh: true, + unparseableGV: true, + wantError: true, + wantErrorCode: intPtr(500), + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + client, err := buildDefaultDiscovery() + client.DocumentErr = test.openapiError + client.GroupResourcesErr = test.serverGroupsResourcesErr + if test.useBadOpenApiDoc { + schema := client.Document.Definitions.AdditionalProperties[0] + schema.Value.Type = &openapi_v2.TypeItem{ + Value: []string{"multiple", "entries"}, + } + } + if test.unparseableGV { + client.Resources = append(client.Resources, &metav1.APIResourceList{ + GroupVersion: "not/parse/able", + }) + } + require.Nil(t, err) + handler := schemaDefinitionHandler{ + client: client, + } + if !test.needsRefresh { + handler.lastRefresh = time.Now() + handler.refreshStale = time.Minute * 1 + } + request := types.APIRequest{ + Schemas: schemas, + Name: test.schemaName, + } + response, err := handler.byIDHandler(&request) + if test.wantError { + require.Error(t, err) + if test.wantErrorCode != nil { + require.True(t, apierror.IsAPIError(err)) + apiErr, _ := err.(*apierror.APIError) + require.Equal(t, *test.wantErrorCode, apiErr.Code.Status) + } + } else { + require.NoError(t, err) + require.Equal(t, *test.wantObject, response) + } + }) + } +} + +func buildDefaultDiscovery() (*fakeDiscovery, error) { + document, err := openapi_v2.ParseDocument([]byte(openapi_raw)) + if err != nil { + return nil, fmt.Errorf("unable to parse openapi document %w", err) + } + groups := []*metav1.APIGroup{ + { + Name: "management.cattle.io", + PreferredVersion: metav1.GroupVersionForDiscovery{ + Version: "v2", + }, + }, + } + resources := []*metav1.APIResourceList{ + { + GroupVersion: schema.GroupVersion{ + Group: "management.cattle.io", + Version: "v2", + }.String(), + APIResources: []metav1.APIResource{ + { + Group: "management.cattle.io", + Kind: "GlobalRole", + Version: "v2", + }, + }, + }, + { + GroupVersion: schema.GroupVersion{ + Group: "management.cattle.io", + Version: "v1", + }.String(), + APIResources: []metav1.APIResource{ + { + Group: "management.cattle.io", + Kind: "GlobalRole", + Version: "v2", + }, + }, + }, + nil, + } + return &fakeDiscovery{ + Groups: groups, + Resources: resources, + Document: document, + }, nil +} + +type fakeDiscovery struct { + Groups []*metav1.APIGroup + Resources []*metav1.APIResourceList + Document *openapi_v2.Document + GroupResourcesErr error + DocumentErr error +} + +// ServerGroupsAndResources is the only method we actually need for the test - just returns what is on the struct +func (f *fakeDiscovery) ServerGroupsAndResources() ([]*metav1.APIGroup, []*metav1.APIResourceList, error) { + return f.Groups, f.Resources, f.GroupResourcesErr +} + +// The rest of these methods are just here to conform to discovery.DiscoveryInterface +func (f *fakeDiscovery) RESTClient() restclient.Interface { return nil } +func (f *fakeDiscovery) ServerGroups() (*metav1.APIGroupList, error) { return nil, nil } +func (f *fakeDiscovery) ServerResourcesForGroupVersion(groupVersion string) (*metav1.APIResourceList, error) { + return nil, nil +} +func (f *fakeDiscovery) ServerPreferredResources() ([]*metav1.APIResourceList, error) { + return nil, nil +} +func (f *fakeDiscovery) ServerPreferredNamespacedResources() ([]*metav1.APIResourceList, error) { + return nil, nil +} +func (f *fakeDiscovery) ServerVersion() (*version.Info, error) { return nil, nil } +func (f *fakeDiscovery) OpenAPISchema() (*openapi_v2.Document, error) { + return f.Document, f.DocumentErr +} +func (f *fakeDiscovery) OpenAPIV3() openapi.Client { return nil } +func (f *fakeDiscovery) WithLegacy() discovery.DiscoveryInterface { return f } diff --git a/pkg/schema/definitions/openapi_test.go b/pkg/schema/definitions/openapi_test.go new file mode 100644 index 00000000..40487349 --- /dev/null +++ b/pkg/schema/definitions/openapi_test.go @@ -0,0 +1,99 @@ +package definitions + +const openapi_raw = ` +swagger: "2.0" +info: + title: "Test openapi spec" + version: "v1.0.0" +paths: + /apis/management.cattle.io/v3/globalroles: + get: + description: "get a global role" + responses: + 200: + description: "OK" +definitions: + io.cattle.management.v1.GlobalRole: + description: "A Global Role V1 provides Global Permissions in Rancher" + type: "object" + properties: + apiVersion: + description: "The APIVersion of this resource" + type: "string" + kind: + description: "The kind" + type: "string" + metadata: + description: "The metadata" + $ref: "#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta" + spec: + description: "The spec for the project" + type: "object" + required: + - "clusterName" + - "displayName" + properties: + clusterName: + description: "The name of the cluster" + type: "string" + displayName: + description: "The UI readable name" + type: "string" + notRequired: + description: "Some field that isn't required" + type: "boolean" + x-kubernetes-group-version-kind: + - group: "management.cattle.io" + version: "v1" + kind: "GlobalRole" + io.cattle.management.v2.GlobalRole: + description: "A Global Role V2 provides Global Permissions in Rancher" + type: "object" + properties: + apiVersion: + description: "The APIVersion of this resource" + type: "string" + kind: + description: "The kind" + type: "string" + metadata: + description: "The metadata" + $ref: "#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta" + spec: + description: "The spec for the project" + type: "object" + required: + - "clusterName" + - "displayName" + properties: + clusterName: + description: "The name of the cluster" + type: "string" + displayName: + description: "The UI readable name" + type: "string" + notRequired: + description: "Some field that isn't required" + type: "boolean" + newField: + description: "A new field not present in v1" + type: "string" + x-kubernetes-group-version-kind: + - group: "management.cattle.io" + version: "v2" + kind: "GlobalRole" + io.management.cattle.NotAKind: + type: "string" + description: "Some string which isn't a kind" + io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta: + description: "Object Metadata" + properties: + annotations: + description: "annotations of the resource" + type: "object" + additionalProperties: + type: "string" + name: + description: "name of the resource" + type: "string" +` diff --git a/pkg/schema/definitions/schema.go b/pkg/schema/definitions/schema.go new file mode 100644 index 00000000..b6ddf44c --- /dev/null +++ b/pkg/schema/definitions/schema.go @@ -0,0 +1,51 @@ +package definitions + +import ( + "time" + + "github.com/rancher/apiserver/pkg/types" + "github.com/rancher/wrangler/v2/pkg/schemas" + "k8s.io/client-go/discovery" +) + +const ( + gvkExtensionName = "x-kubernetes-group-version-kind" + gvkExtensionGroup = "group" + gvkExtensionVersion = "version" + gvkExtensionKind = "kind" + defaultDuration = time.Second * 5 +) + +// Register registers the schemaDefinition schema. +func Register(baseSchema *types.APISchemas, client discovery.DiscoveryInterface) { + handler := schemaDefinitionHandler{ + client: client, + refreshStale: defaultDuration, + } + baseSchema.MustAddSchema(types.APISchema{ + Schema: &schemas.Schema{ + ID: "schemaDefinition", + PluralName: "schemaDefinitions", + ResourceMethods: []string{"GET"}, + }, + ByIDHandler: handler.byIDHandler, + }) +} + +type schemaDefinition struct { + DefinitionType string `json:"definitionType"` + Definitions map[string]definition `json:"definitions"` +} + +type definition struct { + ResourceFields map[string]definitionField `json:"resourceFields"` + Type string `json:"type"` + Description string `json:"description"` +} + +type definitionField struct { + Type string `json:"type"` + SubType string `json:"subtype,omitempty"` + Description string `json:"description,omitempty"` + Required bool `json:"required,omitempty"` +} diff --git a/pkg/schema/definitions/visitor.go b/pkg/schema/definitions/visitor.go new file mode 100644 index 00000000..c112dbd0 --- /dev/null +++ b/pkg/schema/definitions/visitor.go @@ -0,0 +1,122 @@ +package definitions + +import ( + "k8s.io/kube-openapi/pkg/util/proto" +) + +// schemaFieldVisitor implements proto.SchemaVisitor and turns a given schema into a definitionField. +type schemaFieldVisitor struct { + field definitionField + definitions map[string]definition + models proto.Models +} + +// VisitArray turns an array into a definitionField (stored on the receiver). For arrays of complex types, will also +// visit the subtype. +func (s *schemaFieldVisitor) VisitArray(array *proto.Array) { + field := definitionField{ + Description: array.GetDescription(), + } + // this currently is not recursive and provides little information for nested types- while this isn't optimal, + // it was kept this way to provide backwards compat with previous endpoints. + array.SubType.Accept(s) + subField := s.field + field.Type = "array" + field.SubType = subField.Type + s.field = field +} + +// VisitMap turns a map into a definitionField (stored on the receiver). For maps of complex types, will also visit the +// subtype. +func (s *schemaFieldVisitor) VisitMap(protoMap *proto.Map) { + field := definitionField{ + Description: protoMap.GetDescription(), + } + // this currently is not recursive and provides little information for nested types- while this isn't optimal, + // it was kept this way to provide backwards compat with previous endpoints. + protoMap.SubType.Accept(s) + subField := s.field + field.Type = "map" + field.SubType = subField.Type + s.field = field +} + +// VisitPrimitive turns a primitive into a definitionField (stored on the receiver). +func (s *schemaFieldVisitor) VisitPrimitive(primitive *proto.Primitive) { + field := definitionField{ + Description: primitive.GetDescription(), + } + if primitive.Type == "number" || primitive.Type == "integer" { + field.Type = "int" + } else { + field.Type = primitive.Type + } + s.field = field +} + +// VisitKind turns a kind into a definitionField and a definition. Both are stored on the receiver. +func (s *schemaFieldVisitor) VisitKind(kind *proto.Kind) { + path := kind.Path.String() + field := definitionField{ + Description: kind.GetDescription(), + Type: path, + } + if _, ok := s.definitions[path]; ok { + // if we have already seen this kind, we don't want to re-evaluate the definition. Some kinds can be + // recursive through use of references, so this circuit-break is necessary to avoid infinite loops + s.field = field + return + } + schemaDefinition := definition{ + ResourceFields: map[string]definitionField{}, + Type: path, + Description: kind.GetDescription(), + } + // this definition may refer to itself, so we mark this as seen to not infinitely recurse + s.definitions[path] = definition{} + for fieldName, schemaField := range kind.Fields { + schemaField.Accept(s) + schemaDefinition.ResourceFields[fieldName] = s.field + } + for _, field := range kind.RequiredFields { + current, ok := schemaDefinition.ResourceFields[field] + if !ok { + // this does silently ignore inconsistent kinds that list + continue + } + current.Required = true + schemaDefinition.ResourceFields[field] = current + } + s.definitions[path] = schemaDefinition + // the visitor may have set the field multiple times while evaluating kind fields, so we only set the final + // kind-based field at the end + s.field = field +} + +// VisitReference turns a reference into a definitionField. Will also visit the referred type. +func (s *schemaFieldVisitor) VisitReference(ref proto.Reference) { + sub := ref.SubSchema() + if sub == nil { + // if we don't have a sub-schema defined, we can't extract much meaningful information + field := definitionField{ + Description: ref.GetDescription(), + Type: ref.Reference(), + } + s.field = field + return + } + sub.Accept(s) + field := s.field + field.Description = ref.GetDescription() + s.field = field +} + +// VisitArbitrary turns an abitrary (item with no type) into a definitionField (stored on the receiver). +func (s *schemaFieldVisitor) VisitArbitrary(arb *proto.Arbitrary) { + // In certain cases k8s seems to not provide a type for certain fields. We assume for the + // purposes of this visitor that all of these have a type of string. + s.field = definitionField{ + Description: arb.GetDescription(), + Type: "string", + } +} diff --git a/pkg/schema/definitions/visitor_test.go b/pkg/schema/definitions/visitor_test.go new file mode 100644 index 00000000..b2f14503 --- /dev/null +++ b/pkg/schema/definitions/visitor_test.go @@ -0,0 +1,259 @@ +package definitions + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/kube-openapi/pkg/util/proto" +) + +var ( + protoPrimitive = proto.Primitive{ + BaseSchema: proto.BaseSchema{ + Description: "primitive value", + }, + Type: "string", + } + protoPrimitiveInt = proto.Primitive{ + BaseSchema: proto.BaseSchema{ + Description: "primitive value - int", + }, + Type: "integer", + } + protoPrimitiveNumber = proto.Primitive{ + BaseSchema: proto.BaseSchema{ + Description: "primitive value - number", + }, + Type: "number", + } + protoArray = proto.Array{ + BaseSchema: proto.BaseSchema{ + Description: "testArray", + }, + SubType: &protoPrimitive, + } + protoMap = proto.Map{ + BaseSchema: proto.BaseSchema{ + Description: "testMap", + }, + SubType: &protoPrimitive, + } + protoKind = proto.Kind{ + BaseSchema: proto.BaseSchema{ + Description: "testKind", + Path: proto.NewPath("io.cattle.test"), + }, + Fields: map[string]proto.Schema{ + "protoArray": &protoArray, + "protoPrimitive": &protoPrimitive, + "protoMap": &protoMap, + }, + RequiredFields: []string{ + "protoArray", + "protoPrimitive", + "missing", + }, + } + protoRefNoSubSchema = testRef{ + BaseSchema: proto.BaseSchema{ + Description: "testRef - no subSchema", + }, + reference: "some-other-type", + } + protoRef = testRef{ + BaseSchema: proto.BaseSchema{ + Description: "testRef", + }, + reference: "testKind", + subSchema: &protoKind, + } + protoArbitrary = proto.Arbitrary{ + BaseSchema: proto.BaseSchema{ + Description: "testArbitrary", + }, + } +) + +// testRef implements proto.Reference to test VisitReference +type testRef struct { + proto.BaseSchema + reference string + subSchema proto.Schema +} + +func (t *testRef) Reference() string { + return t.reference +} + +func (t *testRef) SubSchema() proto.Schema { + return t.subSchema +} + +func (t *testRef) Accept(v proto.SchemaVisitor) { + v.VisitReference(t) +} + +func (t *testRef) GetName() string { + return fmt.Sprintf("Reference to %q", t.reference) +} + +func TestSchemaFieldVisitor(t *testing.T) { + protoKind.Fields["protoRef"] = &protoRef + tests := []struct { + name string + inputSchema proto.Schema + wantDefinitions map[string]definition + wantField definitionField + }{ + { + name: "array", + inputSchema: &protoArray, + wantDefinitions: map[string]definition{}, + wantField: definitionField{ + Type: "array", + Description: protoArray.Description, + SubType: protoPrimitive.Type, + }, + }, + { + name: "map", + inputSchema: &protoMap, + wantDefinitions: map[string]definition{}, + wantField: definitionField{ + Type: "map", + Description: protoMap.Description, + SubType: protoPrimitive.Type, + }, + }, + { + name: "string primitive", + inputSchema: &protoPrimitive, + wantDefinitions: map[string]definition{}, + wantField: definitionField{ + Type: protoPrimitive.Type, + Description: protoPrimitive.Description, + }, + }, + { + name: "integer primitive", + inputSchema: &protoPrimitiveInt, + wantDefinitions: map[string]definition{}, + wantField: definitionField{ + Type: "int", + Description: protoPrimitiveInt.Description, + }, + }, + { + name: "number primitive", + inputSchema: &protoPrimitiveNumber, + wantDefinitions: map[string]definition{}, + wantField: definitionField{ + Type: "int", + Description: protoPrimitiveNumber.Description, + }, + }, + { + name: "kind", + inputSchema: &protoKind, + wantDefinitions: map[string]definition{ + protoKind.Path.String(): { + ResourceFields: map[string]definitionField{ + "protoArray": { + Type: "array", + Description: protoArray.Description, + SubType: protoPrimitive.Type, + Required: true, + }, + "protoMap": { + Type: "map", + Description: protoMap.Description, + SubType: protoPrimitive.Type, + }, + "protoPrimitive": { + Type: protoPrimitive.Type, + Description: protoPrimitive.Description, + Required: true, + }, + "protoRef": { + Type: protoKind.Path.String(), + Description: protoRef.Description, + }, + }, + Type: protoKind.Path.String(), + Description: protoKind.Description, + }, + }, + wantField: definitionField{ + Description: protoKind.Description, + Type: protoKind.Path.String(), + }, + }, + { + name: "reference no subschema", + inputSchema: &protoRefNoSubSchema, + wantDefinitions: map[string]definition{}, + wantField: definitionField{ + Type: protoRefNoSubSchema.reference, + Description: protoRefNoSubSchema.Description, + }, + }, + { + name: "reference", + inputSchema: &protoRef, + wantDefinitions: map[string]definition{ + protoKind.Path.String(): { + ResourceFields: map[string]definitionField{ + "protoArray": { + Type: "array", + Description: protoArray.Description, + SubType: protoPrimitive.Type, + Required: true, + }, + "protoMap": { + Type: "map", + Description: protoMap.Description, + SubType: protoPrimitive.Type, + }, + "protoPrimitive": { + Type: protoPrimitive.Type, + Description: protoPrimitive.Description, + Required: true, + }, + "protoRef": { + Type: protoKind.Path.String(), + Description: protoRef.Description, + }, + }, + Type: protoKind.Path.String(), + Description: protoKind.Description, + }, + }, + wantField: definitionField{ + Type: protoKind.Path.String(), + Description: protoRef.Description, + }, + }, + { + name: "abitrary schema", + inputSchema: &protoArbitrary, + wantDefinitions: map[string]definition{}, + wantField: definitionField{ + Type: "string", + Description: protoArbitrary.Description, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + definitions := map[string]definition{} + visitor := schemaFieldVisitor{ + definitions: definitions, + } + test.inputSchema.Accept(&visitor) + require.Equal(t, test.wantField, visitor.field) + require.Equal(t, test.wantDefinitions, visitor.definitions) + }) + } +} diff --git a/pkg/schema/factory_test.go b/pkg/schema/factory_test.go index bc9ac419..20dc5c0a 100644 --- a/pkg/schema/factory_test.go +++ b/pkg/schema/factory_test.go @@ -2,9 +2,10 @@ package schema import ( "context" - "github.com/stretchr/testify/assert" "testing" + "github.com/stretchr/testify/assert" + "github.com/rancher/apiserver/pkg/types" "github.com/rancher/wrangler/v2/pkg/schemas" k8sSchema "k8s.io/apimachinery/pkg/runtime/schema" diff --git a/pkg/server/server.go b/pkg/server/server.go index 06f1c0e4..88ee2da4 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -18,6 +18,7 @@ import ( "github.com/rancher/steve/pkg/resources/common" "github.com/rancher/steve/pkg/resources/schemas" "github.com/rancher/steve/pkg/schema" + "github.com/rancher/steve/pkg/schema/definitions" "github.com/rancher/steve/pkg/server/handler" "github.com/rancher/steve/pkg/server/router" "github.com/rancher/steve/pkg/summarycache" @@ -141,6 +142,7 @@ func setup(ctx context.Context, server *Server) error { if err = resources.DefaultSchemas(ctx, server.BaseSchemas, ccache, server.ClientFactory, sf, server.Version); err != nil { return err } + definitions.Register(server.BaseSchemas, server.controllers.K8s.Discovery()) summaryCache := summarycache.New(sf, ccache) summaryCache.Start(ctx)