Skip to content

Commit 5268a03

Browse files
qvalentinmsvechlarowahldatho7561
authored
feat: auto-detect Kubernetes crd schema (#1050)
* feat: auto-detect Kubernetes schema automatically detect the Kubernetes schema based on the document's GroupVersionKind (GVK) and retrieve the matching schema from the CRD catalog. * fix: only auto-detect k8s crd for k8s files * feat: check if GVK in main kubeSchema * fix: test * fix: promises were made * fix: rename crd options and document them * fix eslint errors * fix tests * fix: do not use CRDs-catalog for builtin k8s ressources * fix: clarify @ts-ignore * fix: import type from ../jsonSchema * Handle case where custom schema provider is present The CRD schema resolving logic now runs even if a custom schema provider is present, allowing this to work properly in vscode-yaml. Signed-off-by: David Thompson <davthomp@redhat.com> * Add logic to handle OpenShift CRDs The CRD schemas for OpenShift are layed out slightly different from everything else in the CRD schema repo in order to provide different sets of schemas for different versions of OpenShift. Signed-off-by: David Thompson <davthomp@redhat.com> --------- Signed-off-by: David Thompson <davthomp@redhat.com> Co-authored-by: Marius Svechla <m.svechla@gmail.com> Co-authored-by: Roland Wahl <rwahl@protonmail.com> Co-authored-by: David Thompson <davthomp@redhat.com>
1 parent 769a8f7 commit 5268a03

File tree

9 files changed

+353
-8
lines changed

9 files changed

+353
-8
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ The following settings are supported:
4141
- `yaml.schemas`: Helps you associate schemas with files in a glob pattern
4242
- `yaml.schemaStore.enable`: When set to true the YAML language server will pull in all available schemas from [JSON Schema Store](https://www.schemastore.org)
4343
- `yaml.schemaStore.url`: URL of a schema store catalog to use when downloading schemas.
44+
- `yaml.kubernetesCRDStore.enable`: When set to true the YAML language server will parse Kubernetes CRDs automatically and download them from the [CRD store](https://github.com/datreeio/CRDs-catalog).
45+
- `yaml.kubernetesCRDStore.url`: URL of a crd store catalog to use when downloading schemas. Defaults to `https://raw.githubusercontent.com/datreeio/CRDs-catalog/main`.
4446
- `yaml.customTags`: Array of custom tags that the parser will validate against. It has two ways to be used. Either an item in the array is a custom tag such as "!Ref" and it will automatically map !Ref to scalar or you can specify the type of the object !Ref should be e.g. "!Ref sequence". The type of object can be either scalar (for strings and booleans), sequence (for arrays), map (for objects).
4547
- `yaml.maxItemsComputed`: The maximum number of outline symbols and folding regions computed (limited for performance reasons).
4648
- `[yaml].editor.tabSize`: the number of spaces to use when autocompleting. Takes priority over editor.tabSize.

src/languageserver/handlers/settingsHandlers.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,14 @@ export class SettingsHandler {
8484
this.yamlSettings.schemaStoreUrl = settings.yaml.schemaStore.url;
8585
}
8686
}
87+
88+
if (settings.yaml.kubernetesCRDStore) {
89+
this.yamlSettings.kubernetesCRDStoreEnabled = settings.yaml.kubernetesCRDStore.enable;
90+
if (settings.yaml.kubernetesCRDStore.url?.length !== 0) {
91+
this.yamlSettings.kubernetesCRDStoreUrl = settings.yaml.kubernetesCRDStore.url;
92+
}
93+
}
94+
8795
if (settings.files?.associations) {
8896
for (const [ext, languageId] of Object.entries(settings.files.associations)) {
8997
if (languageId === 'yaml') {
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { SingleYAMLDocument } from '../parser/yamlParser07';
2+
import { JSONDocument } from '../parser/jsonParser07';
3+
4+
import { ResolvedSchema } from 'vscode-json-languageservice/lib/umd/services/jsonSchemaService';
5+
import { JSONSchema } from '../jsonSchema';
6+
7+
/**
8+
* Retrieve schema by auto-detecting the Kubernetes GroupVersionKind (GVK) from the document.
9+
* If there is no definition for the GVK in the main kubernetes schema,
10+
* the schema is then retrieved from the CRD catalog.
11+
* Public for testing purpose, not part of the API.
12+
* @param doc
13+
* @param crdCatalogURI The URL of the CRD catalog to retrieve the schema from
14+
* @param kubernetesSchema The main kubernetes schema, if it includes a definition for the GVK it will be used
15+
*/
16+
export function autoDetectKubernetesSchemaFromDocument(
17+
doc: SingleYAMLDocument | JSONDocument,
18+
crdCatalogURI: string,
19+
kubernetesSchema: ResolvedSchema
20+
): string | undefined {
21+
const res = getGroupVersionKindFromDocument(doc);
22+
if (!res) {
23+
return undefined;
24+
}
25+
const { group, version, kind } = res;
26+
if (!group || !version || !kind) {
27+
return undefined;
28+
}
29+
30+
const k8sSchema: JSONSchema = kubernetesSchema.schema;
31+
const kubernetesBuildIns: string[] = (k8sSchema.oneOf || [])
32+
.map((s) => {
33+
if (typeof s === 'boolean') {
34+
return undefined;
35+
}
36+
return s._$ref || s.$ref;
37+
})
38+
.filter((ref) => ref)
39+
.map((ref) => ref.replace('_definitions.json#/definitions/', '').toLowerCase());
40+
const groupWithoutK8sIO = group.replace('.k8s.io', '');
41+
const k8sTypeName = `io.k8s.api.${groupWithoutK8sIO.toLowerCase()}.${version.toLowerCase()}.${kind.toLowerCase()}`;
42+
43+
if (kubernetesBuildIns.includes(k8sTypeName)) {
44+
return undefined;
45+
}
46+
47+
if (k8sTypeName.includes('openshift.io')) {
48+
return `${crdCatalogURI}/openshift/v4.15-strict/${kind.toLowerCase()}_${group.toLowerCase()}_${version.toLowerCase()}.json`;
49+
}
50+
51+
const schemaURL = `${crdCatalogURI}/${group.toLowerCase()}/${kind.toLowerCase()}_${version.toLowerCase()}.json`;
52+
return schemaURL;
53+
}
54+
55+
/**
56+
* Retrieve the group, version and kind from the document.
57+
* Public for testing purpose, not part of the API.
58+
* @param doc
59+
*/
60+
export function getGroupVersionKindFromDocument(
61+
doc: SingleYAMLDocument | JSONDocument
62+
): { group: string; version: string; kind: string } | undefined {
63+
if (doc instanceof SingleYAMLDocument) {
64+
try {
65+
const rootJSON = doc.root.internalNode.toJSON();
66+
if (!rootJSON) {
67+
return undefined;
68+
}
69+
70+
const groupVersion = rootJSON['apiVersion'];
71+
if (!groupVersion) {
72+
return undefined;
73+
}
74+
75+
const [group, version] = groupVersion.split('/');
76+
if (!group || !version) {
77+
return undefined;
78+
}
79+
80+
const kind = rootJSON['kind'];
81+
if (!kind) {
82+
return undefined;
83+
}
84+
85+
return { group, version, kind };
86+
} catch (error) {
87+
console.error('Error parsing YAML document:', error);
88+
return undefined;
89+
}
90+
}
91+
return undefined;
92+
}

src/languageservice/services/yamlSchemaService.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import { JSONSchema, JSONSchemaMap, JSONSchemaRef } from '../jsonSchema';
88
import { SchemaPriority, SchemaRequestService, WorkspaceContextService } from '../yamlLanguageService';
9+
import { SettingsState } from '../../yamlSettings';
910
import {
1011
UnresolvedSchema,
1112
ResolvedSchema,
@@ -31,6 +32,8 @@ import Ajv, { DefinedError, type AnySchemaObject, type ValidateFunction } from '
3132
import Ajv4 from 'ajv-draft-04';
3233
import Ajv2019 from 'ajv/dist/2019';
3334
import Ajv2020 from 'ajv/dist/2020';
35+
import { autoDetectKubernetesSchemaFromDocument } from './crdUtil';
36+
import { CRD_CATALOG_URL, KUBERNETES_SCHEMA_URL } from '../utils/schemaUrls';
3437

3538
const ajv4 = new Ajv4({ allErrors: true });
3639
const ajv7 = new Ajv({ allErrors: true });
@@ -120,19 +123,22 @@ export class YAMLSchemaService extends JSONSchemaService {
120123
private filePatternAssociations: JSONSchemaService.FilePatternAssociation[];
121124
private contextService: WorkspaceContextService;
122125
private requestService: SchemaRequestService;
126+
private yamlSettings: SettingsState;
123127
public schemaPriorityMapping: Map<string, Set<SchemaPriority>>;
124128

125129
private schemaUriToNameAndDescription = new Map<string, SchemaStoreSchema>();
126130

127131
constructor(
128132
requestService: SchemaRequestService,
129133
contextService?: WorkspaceContextService,
130-
promiseConstructor?: PromiseConstructor
134+
promiseConstructor?: PromiseConstructor,
135+
yamlSettings?: SettingsState
131136
) {
132137
super(requestService, contextService, promiseConstructor);
133138
this.customSchemaProvider = undefined;
134139
this.requestService = requestService;
135140
this.schemaPriorityMapping = new Map();
141+
this.yamlSettings = yamlSettings;
136142
}
137143

138144
registerCustomSchemaProvider(customSchemaProvider: CustomSchemaProvider): void {
@@ -408,16 +414,35 @@ export class YAMLSchemaService extends JSONSchemaService {
408414
};
409415

410416
// eslint-disable-next-line @typescript-eslint/no-explicit-any
411-
const resolveSchema = (): any => {
417+
const resolveSchema = async (): Promise<any> => {
412418
const seen: { [schemaId: string]: boolean } = Object.create(null);
413419
const schemas: string[] = [];
420+
let k8sAllSchema: ResolvedSchema = undefined;
414421

415422
for (const entry of this.filePatternAssociations) {
416423
if (entry.matchesPattern(resource)) {
417424
for (const schemaId of entry.getURIs()) {
418425
if (!seen[schemaId]) {
419-
schemas.push(schemaId);
420-
seen[schemaId] = true;
426+
if (this.yamlSettings?.kubernetesCRDStoreEnabled && schemaId === KUBERNETES_SCHEMA_URL) {
427+
if (!k8sAllSchema) {
428+
k8sAllSchema = await this.getResolvedSchema(KUBERNETES_SCHEMA_URL);
429+
}
430+
const kubeSchema = autoDetectKubernetesSchemaFromDocument(
431+
doc,
432+
this.yamlSettings.kubernetesCRDStoreUrl ?? CRD_CATALOG_URL,
433+
k8sAllSchema
434+
);
435+
if (kubeSchema) {
436+
schemas.push(kubeSchema);
437+
seen[schemaId] = true;
438+
} else {
439+
schemas.push(schemaId);
440+
seen[schemaId] = true;
441+
}
442+
} else {
443+
schemas.push(schemaId);
444+
seen[schemaId] = true;
445+
}
421446
}
422447
}
423448
}
@@ -435,6 +460,7 @@ export class YAMLSchemaService extends JSONSchemaService {
435460
if (modelineSchema) {
436461
return resolveSchemaForResource([modelineSchema]);
437462
}
463+
438464
if (this.customSchemaProvider) {
439465
return this.customSchemaProvider(resource)
440466
.then((schemaUri) => {
@@ -477,9 +503,8 @@ export class YAMLSchemaService extends JSONSchemaService {
477503
return resolveSchema();
478504
}
479505
);
480-
} else {
481-
return resolveSchema();
482506
}
507+
return resolveSchema();
483508
}
484509

485510
// Set the priority of a schema in the schema service

src/languageservice/utils/schemaUrls.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { isRelativePath, relativeToAbsolutePath } from './paths';
88
export const KUBERNETES_SCHEMA_URL =
99
'https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/v1.32.1-standalone-strict/all.json';
1010
export const JSON_SCHEMASTORE_URL = 'https://www.schemastore.org/api/json/catalog.json';
11+
export const CRD_CATALOG_URL = 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main';
1112

1213
export function checkSchemaURI(
1314
workspaceFolders: WorkspaceFolder[],

src/languageservice/yamlLanguageService.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ export function getLanguageService(params: {
197197
yamlSettings?: SettingsState;
198198
clientCapabilities?: ClientCapabilities;
199199
}): LanguageService {
200-
const schemaService = new YAMLSchemaService(params.schemaRequestService, params.workspaceContext);
200+
const schemaService = new YAMLSchemaService(params.schemaRequestService, params.workspaceContext, null, params.yamlSettings);
201201
const completer = new YamlCompletion(schemaService, params.clientCapabilities, yamlDocumentsCache, params.telemetry);
202202
const hover = new YAMLHover(schemaService, params.telemetry);
203203
const yamlDocumentSymbols = new YAMLDocumentSymbols(schemaService, params.telemetry);

src/yamlSettings.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { ISchemaAssociations } from './requestTypes';
44
import { URI } from 'vscode-uri';
55
import { JSONSchema } from './languageservice/jsonSchema';
66
import { TextDocument } from 'vscode-languageserver-textdocument';
7-
import { JSON_SCHEMASTORE_URL } from './languageservice/utils/schemaUrls';
7+
import { CRD_CATALOG_URL, JSON_SCHEMASTORE_URL } from './languageservice/utils/schemaUrls';
88
import { YamlVersion } from './languageservice/parser/yamlParser07';
99

1010
// Client settings interface to grab settings relevant for the language server
@@ -20,6 +20,10 @@ export interface Settings {
2020
url: string;
2121
enable: boolean;
2222
};
23+
kubernetesCRDStore: {
24+
url: string;
25+
enable: boolean;
26+
};
2327
disableDefaultProperties: boolean;
2428
disableAdditionalProperties: boolean;
2529
suggest: {
@@ -78,6 +82,8 @@ export class SettingsState {
7882
customTags = [];
7983
schemaStoreEnabled = true;
8084
schemaStoreUrl = JSON_SCHEMASTORE_URL;
85+
kubernetesCRDStoreEnabled = true;
86+
kubernetesCRDStoreUrl = CRD_CATALOG_URL;
8187
indentation: string | undefined = undefined;
8288
disableAdditionalProperties = false;
8389
disableDefaultProperties = false;

test/schema.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { LanguageService, SchemaPriority } from '../src';
1414
import { MarkupContent, Position } from 'vscode-languageserver-types';
1515
import { LineCounter } from 'yaml';
1616
import { getSchemaFromModeline } from '../src/languageservice/services/modelineUtil';
17+
import { getGroupVersionKindFromDocument } from '../src/languageservice/services/crdUtil';
1718

1819
const requestServiceMock = function (uri: string): Promise<string> {
1920
return Promise.reject<string>(`Resource ${uri} not found.`);
@@ -701,6 +702,70 @@ describe('JSON Schema', () => {
701702
});
702703
});
703704

705+
describe('Test getGroupVersionKindFromDocument', function () {
706+
it('builtin kubernetes resource group should not get resolved', async () => {
707+
checkReturnGroupVersionKind('apiVersion: v1\nkind: Pod', true, undefined, 'v1', 'Pod');
708+
});
709+
710+
it('builtin kubernetes resource with complex apiVersion should get resolved ', async () => {
711+
checkReturnGroupVersionKind(
712+
'apiVersion: admissionregistration.k8s.io/v1\nkind: MutatingWebhook',
713+
false,
714+
'admissionregistration.k8s.io',
715+
'v1',
716+
'MutatingWebhook'
717+
);
718+
});
719+
720+
it('custom argo application CRD should get resolved', async () => {
721+
checkReturnGroupVersionKind(
722+
'apiVersion: argoproj.io/v1alpha1\nkind: Application',
723+
false,
724+
'argoproj.io',
725+
'v1alpha1',
726+
'Application'
727+
);
728+
});
729+
730+
it('custom argo application CRD with whitespace should get resolved', async () => {
731+
checkReturnGroupVersionKind(
732+
'apiVersion: argoproj.io/v1alpha1\nkind: Application ',
733+
false,
734+
'argoproj.io',
735+
'v1alpha1',
736+
'Application'
737+
);
738+
});
739+
740+
it('custom argo application CRD with other fields should get resolved', async () => {
741+
checkReturnGroupVersionKind(
742+
'someOtherVal: test\napiVersion: argoproj.io/v1alpha1\nkind: Application\nmetadata:\n name: my-app',
743+
false,
744+
'argoproj.io',
745+
'v1alpha1',
746+
'Application'
747+
);
748+
});
749+
750+
function checkReturnGroupVersionKind(
751+
content: string,
752+
error: boolean,
753+
expectedGroup: string,
754+
expectedVersion: string,
755+
expectedKind: string
756+
): void {
757+
const yamlDoc = parser.parse(content);
758+
const res = getGroupVersionKindFromDocument(yamlDoc.documents[0]);
759+
if (error) {
760+
assert.strictEqual(res, undefined);
761+
} else {
762+
assert.strictEqual(res.group, expectedGroup);
763+
assert.strictEqual(res.version, expectedVersion);
764+
assert.strictEqual(res.kind, expectedKind);
765+
}
766+
}
767+
});
768+
704769
describe('Test getSchemaFromModeline', function () {
705770
it('simple case', async () => {
706771
checkReturnSchemaUrl('# yaml-language-server: $schema=expectedUrl', 'expectedUrl');

0 commit comments

Comments
 (0)