diff --git a/go.mod b/go.mod index 4a76a7ec..fbaf18ad 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require ( github.com/urfave/cli v1.22.14 github.com/urfave/cli/v2 v2.25.7 golang.org/x/sync v0.3.0 + gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.27.4 k8s.io/apiextensions-apiserver v0.27.4 k8s.io/apimachinery v0.27.4 @@ -36,6 +37,7 @@ require ( k8s.io/client-go v12.0.0+incompatible k8s.io/klog v1.0.0 k8s.io/kube-aggregator v0.27.4 + k8s.io/kube-openapi v0.0.0-20230501164219-8b0f38b5fd1f ) require ( @@ -99,10 +101,8 @@ 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.27.4 // indirect k8s.io/klog/v2 v2.100.1 // indirect - k8s.io/kube-openapi v0.0.0-20230501164219-8b0f38b5fd1f // indirect k8s.io/utils v0.0.0-20230209194617-a36077c30491 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.1.2 // indirect sigs.k8s.io/cli-utils v0.27.0 // indirect diff --git a/pkg/schema/definitions/handler_test.go b/pkg/schema/definitions/handler_test.go new file mode 100644 index 00000000..e72307ab --- /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/openapiv2" + "github.com/rancher/apiserver/pkg/apierror" + "github.com/rancher/apiserver/pkg/types" + wschemas "github.com/rancher/wrangler/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/visitor_test.go b/pkg/schema/definitions/visitor_test.go new file mode 100644 index 00000000..4b3de480 --- /dev/null +++ b/pkg/schema/definitions/visitor_test.go @@ -0,0 +1,245 @@ +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, + } +) + +// 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, + }, + }, + } + + 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) + }) + } +}