kubernetes-fluent-client 1.5.1 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,383 @@
1
+ import { beforeEach, describe, expect, jest, test } from "@jest/globals";
2
+ import { generate } from "./generate";
3
+ import fs from "fs";
4
+
5
+ const sampleYaml = `
6
+ # non-crd should be ignored
7
+ apiVersion: v1
8
+ kind: ConfigMap
9
+ metadata:
10
+ name: test
11
+ namespace: default
12
+ data:
13
+ any: bleh
14
+ ---
15
+ apiVersion: apiextensions.k8s.io/v1
16
+ kind: CustomResourceDefinition
17
+ metadata:
18
+ name: movies.example.com
19
+ spec:
20
+ group: example.com
21
+ names:
22
+ kind: Movie
23
+ plural: movies
24
+ scope: Namespaced
25
+ versions:
26
+ - name: v1
27
+ schema:
28
+ openAPIV3Schema:
29
+ type: object
30
+ description: Movie nerd
31
+ properties:
32
+ spec:
33
+ properties:
34
+ title:
35
+ type: string
36
+ author:
37
+ type: string
38
+ type: object
39
+ ---
40
+ # duplicate entries should not break things
41
+ apiVersion: apiextensions.k8s.io/v1
42
+ kind: CustomResourceDefinition
43
+ metadata:
44
+ name: movies.example.com
45
+ spec:
46
+ group: example.com
47
+ names:
48
+ kind: Movie
49
+ plural: movies
50
+ scope: Namespaced
51
+ versions:
52
+ - name: v1
53
+ schema:
54
+ openAPIV3Schema:
55
+ type: object
56
+ description: Movie nerd
57
+ properties:
58
+ spec:
59
+ properties:
60
+ title:
61
+ type: string
62
+ author:
63
+ type: string
64
+ type: object
65
+ ---
66
+ # should support multiple versions
67
+ apiVersion: apiextensions.k8s.io/v1
68
+ kind: CustomResourceDefinition
69
+ metadata:
70
+ name: books.example.com
71
+ spec:
72
+ group: example.com
73
+ names:
74
+ kind: Book
75
+ plural: books
76
+ scope: Namespaced
77
+ versions:
78
+ - name: v1
79
+ schema:
80
+ openAPIV3Schema:
81
+ type: object
82
+ description: Book nerd
83
+ properties:
84
+ spec:
85
+ properties:
86
+ title:
87
+ type: string
88
+ author:
89
+ type: string
90
+ type: object
91
+ - name: v2
92
+ schema:
93
+ openAPIV3Schema:
94
+ type: object
95
+ description: Book nerd
96
+ properties:
97
+ spec:
98
+ properties:
99
+ author:
100
+ type: string
101
+ type: object
102
+ served: true
103
+ storage: true
104
+ `;
105
+
106
+ jest.mock("./fetch", () => ({
107
+ fetch: jest.fn(),
108
+ }));
109
+
110
+ jest.mock("./fluent", () => ({
111
+ K8s: jest.fn(),
112
+ }));
113
+
114
+ describe("CRD Generate", () => {
115
+ const originalReadFileSync = fs.readFileSync;
116
+
117
+ jest.spyOn(fs, "existsSync").mockReturnValue(true);
118
+ jest.spyOn(fs, "readFileSync").mockImplementation((...args) => {
119
+ // Super janky hack due ot source-map-support calling readFileSync internally
120
+ if (args[0].toString().includes("test-crd.yaml")) {
121
+ return sampleYaml;
122
+ }
123
+ return originalReadFileSync(...args);
124
+ });
125
+ const mkdirSyncSpy = jest.spyOn(fs, "mkdirSync").mockReturnValue(undefined);
126
+ const writeFileSyncSpy = jest.spyOn(fs, "writeFileSync").mockReturnValue(undefined);
127
+
128
+ beforeEach(() => {
129
+ jest.clearAllMocks();
130
+ });
131
+
132
+ test("converts CRD to TypeScript", async () => {
133
+ const options = { source: "test-crd.yaml", language: "ts", logFn: jest.fn() };
134
+
135
+ const actual = await generate(options);
136
+ const expectedMovie = [
137
+ "// This file is auto-generated by kubernetes-fluent-client, do not edit manually\n",
138
+ 'import { GenericKind, RegisterKind } from "kubernetes-fluent-client";\n',
139
+ "/**",
140
+ " * Movie nerd",
141
+ " */",
142
+ "export class Movie extends GenericKind {",
143
+ " spec?: Spec;",
144
+ "}",
145
+ "",
146
+ "export interface Spec {",
147
+ " author?: string;",
148
+ " title?: string;",
149
+ "}",
150
+ "",
151
+ "RegisterKind(Movie, {",
152
+ ' group: "example.com",',
153
+ ' version: "v1",',
154
+ ' kind: "Movie",',
155
+ "});",
156
+ ];
157
+ const expectedBookV1 = [
158
+ "// This file is auto-generated by kubernetes-fluent-client, do not edit manually\n",
159
+ 'import { GenericKind, RegisterKind } from "kubernetes-fluent-client";\n',
160
+ "/**",
161
+ " * Book nerd",
162
+ " */",
163
+ "export class Book extends GenericKind {",
164
+ " spec?: Spec;",
165
+ "}",
166
+ "",
167
+ "export interface Spec {",
168
+ " author?: string;",
169
+ " title?: string;",
170
+ "}",
171
+ "",
172
+ "RegisterKind(Book, {",
173
+ ' group: "example.com",',
174
+ ' version: "v1",',
175
+ ' kind: "Book",',
176
+ "});",
177
+ ];
178
+ const expectedBookV2 = expectedBookV1
179
+ .filter(line => !line.includes("title?"))
180
+ .map(line => line.replace("v1", "v2"));
181
+
182
+ expect(actual["movie-v1"]).toEqual(expectedMovie);
183
+ expect(actual["book-v1"]).toEqual(expectedBookV1);
184
+ expect(actual["book-v2"]).toEqual(expectedBookV2);
185
+ });
186
+
187
+ test("converts CRD to TypeScript with plain option", async () => {
188
+ const options = { source: "test-crd.yaml", language: "ts", plain: true, logFn: jest.fn() };
189
+
190
+ const actual = await generate(options);
191
+ const expectedMovie = [
192
+ "/**",
193
+ " * Movie nerd",
194
+ " */",
195
+ "export interface Movie {",
196
+ " spec?: Spec;",
197
+ "}",
198
+ "",
199
+ "export interface Spec {",
200
+ " author?: string;",
201
+ " title?: string;",
202
+ "}",
203
+ "",
204
+ ];
205
+ const expectedBookV1 = [
206
+ "/**",
207
+ " * Book nerd",
208
+ " */",
209
+ "export interface Book {",
210
+ " spec?: Spec;",
211
+ "}",
212
+ "",
213
+ "export interface Spec {",
214
+ " author?: string;",
215
+ " title?: string;",
216
+ "}",
217
+ "",
218
+ ];
219
+ const expectedBookV2 = expectedBookV1
220
+ .filter(line => !line.includes("title?"))
221
+ .map(line => line.replace("v1", "v2"));
222
+
223
+ expect(actual["movie-v1"]).toEqual(expectedMovie);
224
+ expect(actual["book-v1"]).toEqual(expectedBookV1);
225
+ expect(actual["book-v2"]).toEqual(expectedBookV2);
226
+ });
227
+
228
+ test("converts CRD to TypeScript with other options", async () => {
229
+ const options = {
230
+ source: "test-crd.yaml",
231
+ npmPackage: "test-package",
232
+ logFn: jest.fn(),
233
+ };
234
+
235
+ const actual = await generate(options);
236
+ const expectedMovie = [
237
+ "// This file is auto-generated by test-package, do not edit manually\n",
238
+ 'import { GenericKind, RegisterKind } from "test-package";\n',
239
+ "/**",
240
+ " * Movie nerd",
241
+ " */",
242
+ "export class Movie extends GenericKind {",
243
+ " spec?: Spec;",
244
+ "}",
245
+ "",
246
+ "export interface Spec {",
247
+ " author?: string;",
248
+ " title?: string;",
249
+ "}",
250
+ "",
251
+ "RegisterKind(Movie, {",
252
+ ' group: "example.com",',
253
+ ' version: "v1",',
254
+ ' kind: "Movie",',
255
+ "});",
256
+ ];
257
+ const expectedBookV1 = [
258
+ "// This file is auto-generated by test-package, do not edit manually\n",
259
+ 'import { GenericKind, RegisterKind } from "test-package";\n',
260
+ "/**",
261
+ " * Book nerd",
262
+ " */",
263
+ "export class Book extends GenericKind {",
264
+ " spec?: Spec;",
265
+ "}",
266
+ "",
267
+ "export interface Spec {",
268
+ " author?: string;",
269
+ " title?: string;",
270
+ "}",
271
+ "",
272
+ "RegisterKind(Book, {",
273
+ ' group: "example.com",',
274
+ ' version: "v1",',
275
+ ' kind: "Book",',
276
+ "});",
277
+ ];
278
+ const expectedBookV2 = expectedBookV1
279
+ .filter(line => !line.includes("title?"))
280
+ .map(line => line.replace("v1", "v2"));
281
+
282
+ expect(actual["movie-v1"]).toEqual(expectedMovie);
283
+ expect(actual["book-v1"]).toEqual(expectedBookV1);
284
+ expect(actual["book-v2"]).toEqual(expectedBookV2);
285
+ });
286
+
287
+ test("converts CRD to TypeScript and writes to the given directory", async () => {
288
+ const options = {
289
+ source: "test-crd.yaml",
290
+ directory: "test",
291
+ logFn: jest.fn(),
292
+ };
293
+
294
+ await generate(options);
295
+ const expectedMovie = [
296
+ "// This file is auto-generated by kubernetes-fluent-client, do not edit manually\n",
297
+ 'import { GenericKind, RegisterKind } from "kubernetes-fluent-client";\n',
298
+ "/**",
299
+ " * Movie nerd",
300
+ " */",
301
+ "export class Movie extends GenericKind {",
302
+ " spec?: Spec;",
303
+ "}",
304
+ "",
305
+ "export interface Spec {",
306
+ " author?: string;",
307
+ " title?: string;",
308
+ "}",
309
+ "",
310
+ "RegisterKind(Movie, {",
311
+ ' group: "example.com",',
312
+ ' version: "v1",',
313
+ ' kind: "Movie",',
314
+ "});",
315
+ ];
316
+ const expectedBookV1 = [
317
+ "// This file is auto-generated by kubernetes-fluent-client, do not edit manually\n",
318
+ 'import { GenericKind, RegisterKind } from "kubernetes-fluent-client";\n',
319
+ "/**",
320
+ " * Book nerd",
321
+ " */",
322
+ "export class Book extends GenericKind {",
323
+ " spec?: Spec;",
324
+ "}",
325
+ "",
326
+ "export interface Spec {",
327
+ " author?: string;",
328
+ " title?: string;",
329
+ "}",
330
+ "",
331
+ "RegisterKind(Book, {",
332
+ ' group: "example.com",',
333
+ ' version: "v1",',
334
+ ' kind: "Book",',
335
+ "});",
336
+ ];
337
+ const expectedBookV2 = expectedBookV1
338
+ .filter(line => !line.includes("title?"))
339
+ .map(line => line.replace("v1", "v2"));
340
+
341
+ expect(mkdirSyncSpy).toHaveBeenCalledWith("test", { recursive: true });
342
+ expect(writeFileSyncSpy).toHaveBeenCalledWith("test/movie-v1.ts", expectedMovie.join("\n"));
343
+ expect(writeFileSyncSpy).toHaveBeenCalledWith("test/book-v1.ts", expectedBookV1.join("\n"));
344
+ expect(writeFileSyncSpy).toHaveBeenCalledWith("test/book-v2.ts", expectedBookV2.join("\n"));
345
+ });
346
+
347
+ test("converts CRD to Go", async () => {
348
+ const options = { source: "test-crd.yaml", language: "go", logFn: jest.fn() };
349
+
350
+ const actual = await generate(options);
351
+ const expectedMovie = [
352
+ "// Movie nerd",
353
+ "type Movie struct {",
354
+ '\tSpec *Spec `json:"spec,omitempty"`',
355
+ "}",
356
+ "",
357
+ "type Spec struct {",
358
+ '\tAuthor *string `json:"author,omitempty"`',
359
+ '\tTitle *string `json:"title,omitempty"`',
360
+ "}",
361
+ "",
362
+ ];
363
+ const expectedBookV1 = [
364
+ "// Book nerd",
365
+ "type Book struct {",
366
+ '\tSpec *Spec `json:"spec,omitempty"`',
367
+ "}",
368
+ "",
369
+ "type Spec struct {",
370
+ '\tAuthor *string `json:"author,omitempty"`',
371
+ '\tTitle *string `json:"title,omitempty"`',
372
+ "}",
373
+ "",
374
+ ];
375
+ const expectedBookV2 = expectedBookV1
376
+ .filter(line => !line.includes("Title"))
377
+ .map(line => line.replace("v1", "v2"));
378
+
379
+ expect(actual["movie-v1"]).toEqual(expectedMovie);
380
+ expect(actual["book-v1"]).toEqual(expectedBookV1);
381
+ expect(actual["book-v2"]).toEqual(expectedBookV2);
382
+ });
383
+ });
@@ -0,0 +1,228 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2023-Present The Kubernetes Fluent Client Authors
3
+
4
+ import { loadAllYaml } from "@kubernetes/client-node";
5
+ import * as fs from "fs";
6
+ import * as path from "path";
7
+ import {
8
+ FetchingJSONSchemaStore,
9
+ InputData,
10
+ JSONSchemaInput,
11
+ TargetLanguage,
12
+ quicktype,
13
+ } from "quicktype-core";
14
+
15
+ import { fetch } from "./fetch";
16
+ import { K8s } from "./fluent";
17
+ import { CustomResourceDefinition } from "./upstream";
18
+ import { LogFn } from "./types";
19
+
20
+ export interface GenerateOptions {
21
+ /** The source URL, yaml file path or K8s CRD name */
22
+ source: string;
23
+ /** The output directory path */
24
+ directory?: string;
25
+ /** Disable kubernetes-fluent-client wrapping */
26
+ plain?: boolean;
27
+ /** The language to generate types in */
28
+ language?: string | TargetLanguage;
29
+ /** Override the NPM package to import when generating formatted Typescript */
30
+ npmPackage?: string;
31
+ /** Log function callback */
32
+ logFn: LogFn;
33
+ }
34
+
35
+ /**
36
+ * Converts a CustomResourceDefinition to TypeScript types
37
+ *
38
+ * @param crd The CustomResourceDefinition to convert
39
+ * @param opts The options to use when converting
40
+ * @returns A promise that resolves when the CustomResourceDefinition has been converted
41
+ */
42
+ async function convertCRDtoTS(
43
+ crd: CustomResourceDefinition,
44
+ opts: GenerateOptions,
45
+ ): Promise<Record<string, string[]>> {
46
+ // Get the name of the kind
47
+ const name = crd.spec.names.kind;
48
+
49
+ const results: Record<string, string[]> = {};
50
+
51
+ for (const match of crd.spec.versions) {
52
+ const version = match.name;
53
+
54
+ // Get the schema from the matched version
55
+ const schema = JSON.stringify(match?.schema?.openAPIV3Schema);
56
+
57
+ // Create a new JSONSchemaInput
58
+ const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore());
59
+
60
+ opts.logFn(`- Generating ${crd.spec.group}/${version} types for ${name}`);
61
+
62
+ // Add the schema to the input
63
+ await schemaInput.addSource({ name, schema });
64
+
65
+ // Create a new InputData object
66
+ const inputData = new InputData();
67
+ inputData.addInput(schemaInput);
68
+
69
+ // If the language is not specified, default to TypeScript
70
+ if (!opts.language) {
71
+ opts.language = "ts";
72
+ }
73
+
74
+ // Generate the types
75
+ const out = await quicktype({
76
+ inputData,
77
+ lang: opts.language,
78
+ rendererOptions: { "just-types": "true" },
79
+ });
80
+
81
+ let processedLines = out.lines;
82
+
83
+ // If using typescript, remove the line containing `[property: string]: any;`
84
+ if (opts.language === "ts" || opts.language === "typescript") {
85
+ processedLines = out.lines.filter(line => !line.includes("[property: string]: any;"));
86
+ }
87
+
88
+ // If the language is TypeScript and plain is not specified, wire up the fluent client
89
+ if (opts.language === "ts" && !opts.plain) {
90
+ if (!opts.npmPackage) {
91
+ opts.npmPackage = "kubernetes-fluent-client";
92
+ }
93
+
94
+ processedLines.unshift(
95
+ // Add warning that the file is auto-generated
96
+ `// This file is auto-generated by ${opts.npmPackage}, do not edit manually\n`,
97
+ // Add the imports before any other lines
98
+ `import { GenericKind, RegisterKind } from "${opts.npmPackage}";\n`,
99
+ );
100
+
101
+ // Replace the interface with a named class that extends GenericKind
102
+ const entryIdx = processedLines.findIndex(line =>
103
+ line.includes(`export interface ${name} {`),
104
+ );
105
+
106
+ // Replace the interface with a named class that extends GenericKind
107
+ processedLines[entryIdx] = `export class ${name} extends GenericKind {`;
108
+
109
+ // Add the RegisterKind call
110
+ processedLines.push(`RegisterKind(${name}, {`);
111
+ processedLines.push(` group: "${crd.spec.group}",`);
112
+ processedLines.push(` version: "${version}",`);
113
+ processedLines.push(` kind: "${name}",`);
114
+ processedLines.push(`});`);
115
+ }
116
+
117
+ const finalContents = processedLines.join("\n");
118
+ const fileName = `${name.toLowerCase()}-${version.toLowerCase()}`;
119
+
120
+ // If an output file is specified, write the output to the file
121
+ if (opts.directory) {
122
+ // Create the directory if it doesn't exist
123
+ fs.mkdirSync(opts.directory, { recursive: true });
124
+
125
+ // Write the file
126
+ const filePath = path.join(opts.directory, `${fileName}.${opts.language}`);
127
+ fs.writeFileSync(filePath, finalContents);
128
+ }
129
+
130
+ // Add the results to the array
131
+ results[fileName] = processedLines;
132
+ }
133
+
134
+ return results;
135
+ }
136
+
137
+ /**
138
+ * Reads a CustomResourceDefinition from a file, the cluster or the internet
139
+ *
140
+ * @param opts The options to use when reading
141
+ * @returns A promise that resolves when the CustomResourceDefinition has been read
142
+ */
143
+ async function readOrFetchCrd(opts: GenerateOptions): Promise<CustomResourceDefinition[]> {
144
+ const { source, logFn } = opts;
145
+ const filePath = path.join(process.cwd(), source);
146
+
147
+ // First try to read the source as a file
148
+ try {
149
+ if (fs.existsSync(filePath)) {
150
+ logFn(`Attempting to load ${source} as a local file`);
151
+ const payload = fs.readFileSync(filePath, "utf8");
152
+ return loadAllYaml(payload) as CustomResourceDefinition[];
153
+ }
154
+ } catch (e) {
155
+ // Ignore errors
156
+ }
157
+
158
+ // Next try to parse the source as a URL
159
+ try {
160
+ const url = new URL(source);
161
+
162
+ // If the source is a URL, fetch it
163
+ if (url.protocol === "http:" || url.protocol === "https:") {
164
+ logFn(`Attempting to load ${source} as a URL`);
165
+ const { ok, data } = await fetch<string>(source);
166
+
167
+ // If the request failed, throw an error
168
+ if (!ok) {
169
+ throw new Error(`Failed to fetch ${source}: ${data}`);
170
+ }
171
+
172
+ return loadAllYaml(data) as CustomResourceDefinition[];
173
+ }
174
+ } catch (e) {
175
+ // If invalid, ignore the error
176
+ if (e.code !== "ERR_INVALID_URL") {
177
+ throw new Error(e);
178
+ }
179
+ }
180
+
181
+ // Finally, if the source is not a file or URL, try to read it as a CustomResourceDefinition from the cluster
182
+ try {
183
+ logFn(`Attempting to read ${source} from the current Kubernetes context`);
184
+ return [await K8s(CustomResourceDefinition).Get(source)];
185
+ } catch (e) {
186
+ throw new Error(
187
+ `Failed to read ${source} as a file, url or K8s CRD: ${
188
+ e.data?.message || "Cluster not available"
189
+ }`,
190
+ );
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Generate TypeScript types from a K8s CRD
196
+ *
197
+ * @param opts The options to use when generating
198
+ * @returns A promise that resolves when the TypeScript types have been generated
199
+ */
200
+ export async function generate(opts: GenerateOptions) {
201
+ const crds = (await readOrFetchCrd(opts)).filter(crd => !!crd);
202
+ const results: Record<string, string[]> = {};
203
+
204
+ opts.logFn("");
205
+
206
+ for (const crd of crds) {
207
+ if (crd.kind !== "CustomResourceDefinition" || !crd.spec?.versions?.length) {
208
+ opts.logFn(`Skipping ${crd?.metadata?.name}, it does not appear to be a CRD`);
209
+ // Ignore empty and non-CRD objects
210
+ continue;
211
+ }
212
+
213
+ // Add the results to the record
214
+ const out = await convertCRDtoTS(crd, opts);
215
+ for (const key of Object.keys(out)) {
216
+ results[key] = out[key];
217
+ }
218
+ }
219
+
220
+ if (opts.directory) {
221
+ // Notify the user that the files have been generated
222
+ opts.logFn(
223
+ `\n✅ Generated ${Object.keys(results).length} files in the ${opts.directory} directory`,
224
+ );
225
+ }
226
+
227
+ return results;
228
+ }
package/src/index.ts CHANGED
@@ -19,6 +19,9 @@ export { K8s } from "./fluent";
19
19
  // Export helpers for working with K8s types
20
20
  export { RegisterKind, modelToGroupVersionKind } from "./kinds";
21
21
 
22
+ // Export the GenericKind interface for CRD registration
23
+ export { GenericKind } from "./types";
24
+
22
25
  export * from "./types";
23
26
 
24
27
  export * as K8sClientNode from "@kubernetes/client-node";