diff --git a/src/languageservice/services/crdUtil.ts b/src/languageservice/services/k8sSchemaUtil.ts similarity index 50% rename from src/languageservice/services/crdUtil.ts rename to src/languageservice/services/k8sSchemaUtil.ts index 205cc0d2..f2941322 100644 --- a/src/languageservice/services/crdUtil.ts +++ b/src/languageservice/services/k8sSchemaUtil.ts @@ -1,49 +1,82 @@ -import { SingleYAMLDocument } from '../parser/yamlParser07'; import { JSONDocument } from '../parser/jsonDocument'; +import { SingleYAMLDocument } from '../parser/yamlParser07'; import { ResolvedSchema } from 'vscode-json-languageservice/lib/umd/services/jsonSchemaService'; import { JSONSchema } from '../jsonSchema'; +import { BASE_KUBERNETES_SCHEMA_URL } from '../utils/schemaUrls'; /** - * Retrieve schema by auto-detecting the Kubernetes GroupVersionKind (GVK) from the document. - * If there is no definition for the GVK in the main kubernetes schema, - * the schema is then retrieved from the CRD catalog. - * Public for testing purpose, not part of the API. - * @param doc - * @param crdCatalogURI The URL of the CRD catalog to retrieve the schema from - * @param kubernetesSchema The main kubernetes schema, if it includes a definition for the GVK it will be used + * Attempt to retrieve the schema for a given YAML document based on the Kubernetes GroupVersionKind (GVK). + * + * First, checks for a schema for a matching builtin resource, then it checks for a schema for a CRD. + * + * @param doc the yaml document being validated + * @param kubernetesSchema the resolved copy of the Kubernetes builtin + * @param crdCatalogURI the catalog uri to use to find schemas for custom resource definitions + * @returns a schema uri, or undefined if no specific schema can be identified */ -export function autoDetectKubernetesSchemaFromDocument( +export function autoDetectKubernetesSchema( doc: SingleYAMLDocument | JSONDocument, - crdCatalogURI: string, - kubernetesSchema: ResolvedSchema + kubernetesSchema: ResolvedSchema, + crdCatalogURI: string ): string | undefined { - const res = getGroupVersionKindFromDocument(doc); - if (!res) { + const gvk = getGroupVersionKindFromDocument(doc); + if (!gvk || !gvk.group || !gvk.version || !gvk.kind) { return undefined; } - const { group, version, kind } = res; - if (!group || !version || !kind) { - return undefined; + const builtinResource = autoDetectBuiltinResource(gvk, kubernetesSchema); + if (builtinResource) { + return builtinResource; } + const customResource = autoDetectCustomResource(gvk, crdCatalogURI); + if (customResource) { + return customResource; + } + return undefined; +} +function autoDetectBuiltinResource(gvk: GroupVersionKind, kubernetesSchema: ResolvedSchema): string | undefined { + const { group, version, kind } = gvk; + + const groupWithoutK8sIO = group.replace('.k8s.io', '').replace('rbac.authorization', 'rbac'); + const k8sTypeName = `io.k8s.api.${groupWithoutK8sIO.toLowerCase()}.${version.toLowerCase()}.${kind.toLowerCase()}`; const k8sSchema: JSONSchema = kubernetesSchema.schema; - const kubernetesBuildIns: string[] = (k8sSchema.oneOf || []) + const matchingBuiltin: string | undefined = (k8sSchema.oneOf || []) .map((s) => { if (typeof s === 'boolean') { return undefined; } return s._$ref || s.$ref; }) - .filter((ref) => ref) - .map((ref) => ref.replace('_definitions.json#/definitions/', '').toLowerCase()); - const groupWithoutK8sIO = group.replace('.k8s.io', '').replace('rbac.authorization', 'rbac'); - const k8sTypeName = `io.k8s.api.${groupWithoutK8sIO.toLowerCase()}.${version.toLowerCase()}.${kind.toLowerCase()}`; + .find((ref) => { + if (!ref) { + return false; + } + const lowercaseRef = ref.replace('_definitions.json#/definitions/', '').toLowerCase(); + return lowercaseRef === k8sTypeName; + }); - if (kubernetesBuildIns.includes(k8sTypeName)) { - return undefined; + if (matchingBuiltin) { + return BASE_KUBERNETES_SCHEMA_URL + matchingBuiltin; } + return undefined; +} + +/** + * Retrieve schema by auto-detecting the Kubernetes GroupVersionKind (GVK) from the document. + * If there is no definition for the GVK in the main kubernetes schema, + * the schema is then retrieved from the CRD catalog. + * Public for testing purpose, not part of the API. + * @param doc + * @param crdCatalogURI The URL of the CRD catalog to retrieve the schema from + */ +export function autoDetectCustomResource(gvk: GroupVersionKind, crdCatalogURI: string): string | undefined { + const { group, version, kind } = gvk; + + const groupWithoutK8sIO = group.replace('.k8s.io', '').replace('rbac.authorization', 'rbac'); + const k8sTypeName = `io.k8s.api.${groupWithoutK8sIO.toLowerCase()}.${version.toLowerCase()}.${kind.toLowerCase()}`; + if (k8sTypeName.includes('openshift.io')) { return `${crdCatalogURI}/openshift/v4.15-strict/${kind.toLowerCase()}_${group.toLowerCase()}_${version.toLowerCase()}.json`; } @@ -52,14 +85,18 @@ export function autoDetectKubernetesSchemaFromDocument( return schemaURL; } +type GroupVersionKind = { + group: string; + version: string; + kind: string; +}; + /** * Retrieve the group, version and kind from the document. * Public for testing purpose, not part of the API. * @param doc */ -export function getGroupVersionKindFromDocument( - doc: SingleYAMLDocument | JSONDocument -): { group: string; version: string; kind: string } | undefined { +export function getGroupVersionKindFromDocument(doc: SingleYAMLDocument | JSONDocument): GroupVersionKind | undefined { if (doc instanceof SingleYAMLDocument) { try { const rootJSON = doc.root.internalNode.toJSON(); diff --git a/src/languageservice/services/yamlSchemaService.ts b/src/languageservice/services/yamlSchemaService.ts index b412d475..b6534452 100644 --- a/src/languageservice/services/yamlSchemaService.ts +++ b/src/languageservice/services/yamlSchemaService.ts @@ -4,35 +4,35 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { JSONSchema, JSONSchemaRef, JSONSchemaMap, SchemaDialect } from '../jsonSchema'; -import { SchemaPriority, SchemaRequestService, WorkspaceContextService } from '../yamlLanguageService'; -import { SettingsState } from '../../yamlSettings'; import { - UnresolvedSchema, - ResolvedSchema, + ISchemaContributions, JSONSchemaService, + ResolvedSchema, SchemaDependencies, - ISchemaContributions, SchemaHandle, + UnresolvedSchema, } from 'vscode-json-languageservice/lib/umd/services/jsonSchemaService'; +import { SettingsState } from '../../yamlSettings'; +import { JSONSchema, JSONSchemaMap, JSONSchemaRef, SchemaDialect } from '../jsonSchema'; +import { SchemaPriority, SchemaRequestService, WorkspaceContextService } from '../yamlLanguageService'; -import { URI } from 'vscode-uri'; import * as l10n from '@vscode/l10n'; -import { SingleYAMLDocument } from '../parser/yamlParser07'; -import { JSONDocument } from '../parser/jsonDocument'; import * as path from 'path'; -import { getSchemaFromModeline } from './modelineUtil'; +import { URI } from 'vscode-uri'; import { JSONSchemaDescriptionExt } from '../../requestTypes'; +import { JSONDocument } from '../parser/jsonDocument'; +import { SingleYAMLDocument } from '../parser/yamlParser07'; import { SchemaVersions } from '../yamlTypes'; +import { getSchemaFromModeline } from './modelineUtil'; -import { parse } from 'yaml'; -import * as Json from 'jsonc-parser'; import Ajv, { DefinedError, type AnySchemaObject, type ValidateFunction } from 'ajv'; import Ajv4 from 'ajv-draft-04'; import Ajv2019 from 'ajv/dist/2019'; import Ajv2020 from 'ajv/dist/2020'; -import { autoDetectKubernetesSchemaFromDocument } from './crdUtil'; +import * as Json from 'jsonc-parser'; +import { parse } from 'yaml'; import { CRD_CATALOG_URL, KUBERNETES_SCHEMA_URL } from '../utils/schemaUrls'; +import { autoDetectKubernetesSchema } from './k8sSchemaUtil'; const ajv4 = new Ajv4({ allErrors: true }); const ajv7 = new Ajv({ allErrors: true }); @@ -992,10 +992,10 @@ export class YAMLSchemaService extends JSONSchemaService { if (!k8sAllSchema) { k8sAllSchema = await this.getResolvedSchema(KUBERNETES_SCHEMA_URL); } - const kubeSchema = autoDetectKubernetesSchemaFromDocument( + const kubeSchema = autoDetectKubernetesSchema( doc, - this.yamlSettings.kubernetesCRDStoreUrl ?? CRD_CATALOG_URL, - k8sAllSchema + k8sAllSchema, + this.yamlSettings.kubernetesCRDStoreUrl ?? CRD_CATALOG_URL ); if (kubeSchema) { schemas.push(kubeSchema); diff --git a/src/languageservice/utils/schemaUrls.ts b/src/languageservice/utils/schemaUrls.ts index 82c5aa11..07fafa1f 100644 --- a/src/languageservice/utils/schemaUrls.ts +++ b/src/languageservice/utils/schemaUrls.ts @@ -5,8 +5,9 @@ import { JSONSchema, JSONSchemaRef } from '../jsonSchema'; import { isBoolean } from './objects'; import { isRelativePath, relativeToAbsolutePath } from './paths'; -export const KUBERNETES_SCHEMA_URL = - 'https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/v1.32.1-standalone-strict/all.json'; +export const BASE_KUBERNETES_SCHEMA_URL = + 'https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/v1.32.1-standalone-strict/'; +export const KUBERNETES_SCHEMA_URL = BASE_KUBERNETES_SCHEMA_URL + 'all.json'; export const JSON_SCHEMASTORE_URL = 'https://www.schemastore.org/api/json/catalog.json'; export const CRD_CATALOG_URL = 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main'; diff --git a/test/schema.test.ts b/test/schema.test.ts index 6ac01612..cc6e9b67 100644 --- a/test/schema.test.ts +++ b/test/schema.test.ts @@ -23,7 +23,7 @@ import { SettingsState, TextDocumentTestManager } from '../src/yamlSettings'; import { Diagnostic, MarkupContent, Position } from 'vscode-languageserver-types'; import { LineCounter } from 'yaml'; import { getSchemaFromModeline } from '../src/languageservice/services/modelineUtil'; -import { getGroupVersionKindFromDocument } from '../src/languageservice/services/crdUtil'; +import { getGroupVersionKindFromDocument } from '../src/languageservice/services/k8sSchemaUtil'; const requestServiceMock = function (uri: string): Promise { return Promise.reject(`Resource ${uri} not found.`); diff --git a/test/schemaValidation.test.ts b/test/schemaValidation.test.ts index 34e23b5a..0e21bd90 100644 --- a/test/schemaValidation.test.ts +++ b/test/schemaValidation.test.ts @@ -1212,6 +1212,10 @@ obj: }); describe('Test with custom kubernetes schemas', function () { + after(() => { + languageSettingsSetup.languageSettings.schemas.pop(); + languageService.configure(languageSettingsSetup.languageSettings); + }); it('Test that properties that match multiple enums get validated properly', (done) => { languageService.configure(languageSettingsSetup.withKubernetes().languageSettings); yamlSettings.specificValidatorPaths = ['*.yml', '*.yaml']; @@ -1257,6 +1261,40 @@ obj: }) .then(done, done); }); + + it('Test that it validates against the correct schema based on the GroupVersionKind', (done) => { + languageService.configure( + languageSettingsSetup.withKubernetes().withSchemaFileMatch({ uri: KUBERNETES_SCHEMA_URL, fileMatch: ['*.yml', '*.yaml'] }) + .languageSettings + ); + yamlSettings.specificValidatorPaths = ['*.yml', '*.yaml']; + const content = `apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: foo +spec: + foo: bar + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: foo + minReplicas: 2 + maxReplicas: 3 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 80`; + const validator = parseSetup(content); + validator + .then(function (result) { + assert.equal(result.length, 1); + assert.equal(result[0].message, `Property foo is not allowed.`); + }) + .then(done, done); + }); }); // https://github.com/redhat-developer/yaml-language-server/issues/118 diff --git a/test/yamlSchemaService.test.ts b/test/yamlSchemaService.test.ts index f7277fda..44069db9 100644 --- a/test/yamlSchemaService.test.ts +++ b/test/yamlSchemaService.test.ts @@ -10,7 +10,7 @@ import * as url from 'url'; import * as SchemaService from '../src/languageservice/services/yamlSchemaService'; import { parse } from '../src/languageservice/parser/yamlParser07'; import { SettingsState } from '../src/yamlSettings'; -import { KUBERNETES_SCHEMA_URL } from '../src/languageservice/utils/schemaUrls'; +import { BASE_KUBERNETES_SCHEMA_URL, KUBERNETES_SCHEMA_URL } from '../src/languageservice/utils/schemaUrls'; const expect = chai.expect; chai.use(sinonChai); @@ -416,11 +416,17 @@ spec: const service = new SchemaService.YAMLSchemaService(requestServiceMock, undefined, undefined, settings); service.registerExternalSchema(KUBERNETES_SCHEMA_URL, ['*.yaml']); const resolvedSchema = await service.getSchemaForResource('test.yaml', yamlDock.documents[0]); - expect(resolvedSchema.schema.url).eqls(KUBERNETES_SCHEMA_URL); + expect(resolvedSchema.schema.url).eqls( + BASE_KUBERNETES_SCHEMA_URL + '_definitions.json#/definitions/io.k8s.api.admissionregistration.v1.MutatingWebhook' + ); expect(requestServiceMock).calledWithExactly(KUBERNETES_SCHEMA_URL); expect(requestServiceMock).calledWithExactly('file:///_definitions.json'); - expect(requestServiceMock).calledTwice; + expect(requestServiceMock).calledWithExactly( + BASE_KUBERNETES_SCHEMA_URL + '_definitions.json#/definitions/io.k8s.api.admissionregistration.v1.MutatingWebhook' + ); + expect(requestServiceMock).calledWithExactly(BASE_KUBERNETES_SCHEMA_URL + '_definitions.json'); + expect(requestServiceMock.callCount).equals(4); }); it('should not get schema from crd catalog if definition in kubernetes schema (multiple oneOf)', async () => { @@ -450,11 +456,17 @@ spec: const service = new SchemaService.YAMLSchemaService(requestServiceMock, undefined, undefined, settings); service.registerExternalSchema(KUBERNETES_SCHEMA_URL, ['*.yaml']); const resolvedSchema = await service.getSchemaForResource('test.yaml', yamlDock.documents[0]); - expect(resolvedSchema.schema.url).eqls(KUBERNETES_SCHEMA_URL); + expect(resolvedSchema.schema.url).eqls( + BASE_KUBERNETES_SCHEMA_URL + '_definitions.json#/definitions/io.k8s.api.apps.v1.Deployment' + ); expect(requestServiceMock).calledWithExactly(KUBERNETES_SCHEMA_URL); expect(requestServiceMock).calledWithExactly('file:///_definitions.json'); - expect(requestServiceMock).calledTwice; + expect(requestServiceMock).calledWithExactly( + BASE_KUBERNETES_SCHEMA_URL + '_definitions.json#/definitions/io.k8s.api.apps.v1.Deployment' + ); + expect(requestServiceMock).calledWithExactly(BASE_KUBERNETES_SCHEMA_URL + '_definitions.json'); + expect(requestServiceMock.callCount).equals(4); }); it('should not get schema from crd catalog for RBAC-related resources', async () => { @@ -481,11 +493,146 @@ spec: const service = new SchemaService.YAMLSchemaService(requestServiceMock, undefined, undefined, settings); service.registerExternalSchema(KUBERNETES_SCHEMA_URL, ['*.yaml']); const resolvedSchema = await service.getSchemaForResource('test.yaml', yamlDock.documents[0]); - expect(resolvedSchema.schema.url).eqls(KUBERNETES_SCHEMA_URL); + expect(resolvedSchema.schema.url).eqls( + BASE_KUBERNETES_SCHEMA_URL + '_definitions.json#/definitions/io.k8s.api.rbac.v1.RoleBinding' + ); expect(requestServiceMock).calledWithExactly(KUBERNETES_SCHEMA_URL); expect(requestServiceMock).calledWithExactly('file:///_definitions.json'); - expect(requestServiceMock).calledTwice; + expect(requestServiceMock).calledWithExactly( + BASE_KUBERNETES_SCHEMA_URL + '_definitions.json#/definitions/io.k8s.api.rbac.v1.RoleBinding' + ); + expect(requestServiceMock).calledWithExactly(BASE_KUBERNETES_SCHEMA_URL + '_definitions.json'); + expect(requestServiceMock.callCount).equals(4); + }); + + it('should use GVK to get correct schema', async () => { + const documentContent = ` +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: foo +spec: + foo: bar + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: foo + minReplicas: 2 + maxReplicas: 3 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 80`; + const content = `${documentContent}`; + const yamlDock = parse(content); + + const settings = new SettingsState(); + settings.schemaAssociations = { + kubernetes: ['*.yaml'], + }; + settings.kubernetesCRDStoreEnabled = true; + requestServiceMock = sandbox.fake((uri) => { + if (uri === KUBERNETES_SCHEMA_URL) { + return Promise.resolve(` +{ + "oneOf": [ + { + "$ref": "_definitions.json#/definitions/io.k8s.api.autoscaling.v1.HorizontalPodAutoscaler" + }, + { + "$ref": "_definitions.json#/definitions/io.k8s.api.autoscaling.v2.HorizontalPodAutoscaler" + } + ] +} +`); + } else { + return Promise.resolve(` +{ + "io.k8s.api.autoscaling.v1.HorizontalPodAutoscaler": { + "description": "configuration of a horizontal pod autoscaler.", + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string", + "enum": [ + "HorizontalPodAutoscaler" + ] + }, + "metadata": { + "$ref": "#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta", + "description": "Standard object metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata" + }, + "spec": { + "$ref": "#/definitions/io.k8s.api.autoscaling.v1.HorizontalPodAutoscalerSpec", + "description": "spec defines the behaviour of autoscaler. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status." + }, + "status": { + "$ref": "#/definitions/io.k8s.api.autoscaling.v1.HorizontalPodAutoscalerStatus", + "description": "status is the current information about the autoscaler." + } + }, + "type": "object", + "x-kubernetes-group-version-kind": [ + { + "group": "autoscaling", + "kind": "HorizontalPodAutoscaler", + "version": "v1" + } + ] + }, + "io.k8s.api.autoscaling.v2.HorizontalPodAutoscaler": { + "description": "HorizontalPodAutoscaler is the configuration for a horizontal pod autoscaler, which automatically manages the replica count of any resource implementing the scale subresource based on the metrics specified.", + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string", + "enum": [ + "HorizontalPodAutoscaler" + ] + }, + "metadata": { + "$ref": "#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta", + "description": "metadata is the standard object metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata" + }, + "spec": { + "$ref": "#/definitions/io.k8s.api.autoscaling.v2.HorizontalPodAutoscalerSpec", + "description": "spec is the specification for the behaviour of the autoscaler. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status." + }, + "status": { + "$ref": "#/definitions/io.k8s.api.autoscaling.v2.HorizontalPodAutoscalerStatus", + "description": "status is the current information about the autoscaler." + } + }, + "type": "object", + "x-kubernetes-group-version-kind": [ + { + "group": "autoscaling", + "kind": "HorizontalPodAutoscaler", + "version": "v2" + } + ] + } +}`); + } + }); + const service = new SchemaService.YAMLSchemaService(requestServiceMock, undefined, undefined, settings); + service.registerExternalSchema(KUBERNETES_SCHEMA_URL, ['*.yaml']); + const resolvedSchema = await service.getSchemaForResource('test.yaml', yamlDock.documents[0]); + expect(resolvedSchema.schema.url).eqls( + BASE_KUBERNETES_SCHEMA_URL + '_definitions.json#/definitions/io.k8s.api.autoscaling.v2.HorizontalPodAutoscaler' + ); }); }); });