diff --git a/src/common/k8s-api/__tests__/helm-charts.api.test.ts b/src/common/k8s-api/__tests__/helm-charts.api.test.ts new file mode 100644 index 0000000000..43efd504b3 --- /dev/null +++ b/src/common/k8s-api/__tests__/helm-charts.api.test.ts @@ -0,0 +1,290 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { anyObject } from "jest-mock-extended"; +import { HelmChart } from "../endpoints/helm-charts.api"; + +describe("HelmChart tests", () => { + describe("HelmChart.create() tests", () => { + it("should throw on non-object input", () => { + expect(() => HelmChart.create("" as any)).toThrowError('"value" must be of type object'); + expect(() => HelmChart.create(1 as any)).toThrowError('"value" must be of type object'); + expect(() => HelmChart.create(false as any)).toThrowError('"value" must be of type object'); + expect(() => HelmChart.create([] as any)).toThrowError('"value" must be of type object'); + expect(() => HelmChart.create(Symbol() as any)).toThrowError('"value" must be of type object'); + }); + + it("should throw on missing fields", () => { + expect(() => HelmChart.create({} as any)).toThrowError('"apiVersion" is required'); + expect(() => HelmChart.create({ + apiVersion: "!", + } as any)).toThrowError('"name" is required'); + expect(() => HelmChart.create({ + apiVersion: "!", + name: "!", + } as any)).toThrowError('"version" is required'); + expect(() => HelmChart.create({ + apiVersion: "!", + name: "!", + version: "!", + } as any)).toThrowError('"repo" is required'); + expect(() => HelmChart.create({ + apiVersion: "!", + name: "!", + version: "!", + repo: "!", + } as any)).toThrowError('"created" is required'); + expect(() => HelmChart.create({ + apiVersion: "!", + name: "!", + version: "!", + repo: "!", + created: "!", + } as any)).toThrowError('"digest" is required'); + }); + + it("should throw on fields being wrong type", () => { + expect(() => HelmChart.create({ + apiVersion: 1, + name: "!", + version: "!", + repo: "!", + created: "!", + digest: "!", + } as any)).toThrowError('"apiVersion" must be a string'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: 1, + version: "!", + repo: "!", + created: "!", + digest: "!", + } as any)).toThrowError('"name" must be a string'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: "", + version: 1, + repo: "!", + created: "!", + digest: "!", + } as any)).toThrowError('"version" must be a string'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: 1, + created: "!", + digest: "!", + } as any)).toThrowError('"repo" must be a string'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + created: 1, + digest: "a", + } as any)).toThrowError('"created" must be a string'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + created: "!", + digest: 1, + } as any)).toThrowError('"digest" must be a string'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + digest: "1", + created: "!", + kubeVersion: 1, + } as any)).toThrowError('"kubeVersion" must be a string'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + digest: "1", + created: "!", + description: 1, + } as any)).toThrowError('"description" must be a string'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + digest: "1", + created: "!", + home: 1, + } as any)).toThrowError('"home" must be a string'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + digest: "1", + created: "!", + engine: 1, + } as any)).toThrowError('"engine" must be a string'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + digest: "1", + created: "!", + icon: 1, + } as any)).toThrowError('"icon" must be a string'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + digest: "1", + created: "!", + appVersion: 1, + } as any)).toThrowError('"appVersion" must be a string'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + digest: "1", + created: "!", + tillerVersion: 1, + } as any)).toThrowError('"tillerVersion" must be a string'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + digest: "1", + created: "!", + deprecated: 1, + } as any)).toThrowError('"deprecated" must be a boolean'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + digest: "1", + created: "!", + keywords: 1, + } as any)).toThrowError('"keywords" must be an array'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + digest: "1", + created: "!", + sources: 1, + } as any)).toThrowError('"sources" must be an array'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + digest: "1", + created: "!", + maintainers: 1, + } as any)).toThrowError('"maintainers" must be an array'); + }); + + it("should filter non-string keywords", () => { + const chart = HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + digest: "1", + created: "!", + keywords: [1, "a", false, {}, "b"] as any, + }); + + expect(chart.keywords).toStrictEqual(["a", "b"]); + }); + + it("should filter non-string sources", () => { + const chart = HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + digest: "1", + created: "!", + sources: [1, "a", false, {}, "b"] as any, + }); + + expect(chart.sources).toStrictEqual(["a", "b"]); + }); + + it("should filter invalid maintainers", () => { + const chart = HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + digest: "1", + created: "!", + maintainers: [{ + name: "a", + email: "b", + url: "c", + }] as any, + }); + + expect(chart.maintainers).toStrictEqual([{ + name: "a", + email: "b", + url: "c", + }]); + }); + + it("should warn on unknown fields", () => { + const { warn } = console; + const warnFn = console.warn = jest.fn(); + + HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + digest: "1", + created: "!", + maintainers: [{ + name: "a", + email: "b", + url: "c", + }] as any, + "asdjhajksdhadjks": 1, + } as any); + + expect(warnFn).toHaveBeenCalledWith("HelmChart data has unexpected fields", { + original: anyObject(), + unknownFields: ["asdjhajksdhadjks"] + }); + console.warn = warn; + }); + }); +}); diff --git a/src/common/k8s-api/endpoints/helm-charts.api.ts b/src/common/k8s-api/endpoints/helm-charts.api.ts index c7bc662b75..82d486ac81 100644 --- a/src/common/k8s-api/endpoints/helm-charts.api.ts +++ b/src/common/k8s-api/endpoints/helm-charts.api.ts @@ -22,8 +22,9 @@ import { compile } from "path-to-regexp"; import { apiBase } from "../index"; import { stringify } from "querystring"; -import { autoBind } from "../../utils"; import type { RequestInit } from "node-fetch"; +import { autoBind, bifurcateArray } from "../../utils"; +import Joi from "joi"; export type RepoHelmChartList = Record; export type HelmChartList = Record; @@ -47,7 +48,8 @@ export async function listCharts(): Promise { return Object .values(data) .reduce((allCharts, repoCharts) => allCharts.concat(Object.values(repoCharts)), []) - .map(([chart]) => HelmChart.create(chart)); + .map(([chart]) => HelmChart.create(chart, { onError: "log" })) + .filter(Boolean); } export interface GetChartDetailsOptions { @@ -66,7 +68,7 @@ export async function getChartDetails(repo: string, name: string, { version, req const path = endpoint({ repo, name }); const { readme, ...data } = await apiBase.get(`${path}?${stringify({ version })}`, undefined, reqInit); - const versions = data.versions.map(HelmChart.create); + const versions = data.versions.map(version => HelmChart.create(version, { onError: "log" })).filter(Boolean); return { readme, @@ -84,6 +86,179 @@ export async function getChartValues(repo: string, name: string, version: string return apiBase.get(`/v2/charts/${repo}/${name}/values?${stringify({ version })}`); } +export interface RawHelmChart { + apiVersion: string; + name: string; + version: string; + repo: string; + created: string; + digest: string; + kubeVersion?: string; + description?: string; + home?: string; + engine?: string; + icon?: string; + appVersion?: string; + type?: string; + tillerVersion?: string; + deprecated?: boolean; + keywords?: string[]; + sources?: string[]; + urls?: string[]; + maintainers?: HelmChartMaintainer[]; + dependencies?: RawHelmChartDependency[]; + annotations?: Record, +} + +const helmChartMaintainerValidator = Joi.object({ + name: Joi + .string() + .required(), + email: Joi + .string() + .required(), + url: Joi + .string() + .optional(), +}); + +const helmChartDependencyValidator = Joi.object({ + name: Joi + .string() + .required(), + repository: Joi + .string() + .required(), + condition: Joi + .string() + .optional(), + version: Joi + .string() + .required(), + tags: Joi + .array() + .items(Joi.string()) + .default(() => ([])), +}); + +const helmChartValidator = Joi.object({ + apiVersion: Joi + .string() + .required(), + name: Joi + .string() + .required(), + version: Joi + .string() + .required(), + repo: Joi + .string() + .required(), + created: Joi + .string() + .required(), + digest: Joi + .string() + .required(), + kubeVersion: Joi + .string() + .optional(), + description: Joi + .string() + .default(""), + home: Joi + .string() + .optional(), + engine: Joi + .string() + .optional(), + icon: Joi + .string() + .optional(), + appVersion: Joi + .string() + .optional(), + tillerVersion: Joi + .string() + .optional(), + type: Joi + .string() + .optional(), + deprecated: Joi + .boolean() + .default(false), + keywords: Joi + .array() + .items(Joi.string()) + .options({ + stripUnknown: { + arrays: true + }, + }) + .default(() => ([])), + sources: Joi + .array() + .items(Joi.string()) + .options({ + stripUnknown: { + arrays: true + }, + }) + .default(() => ([])), + urls: Joi + .array() + .items(Joi.string()) + .options({ + stripUnknown: { + arrays: true + }, + }) + .default(() => ([])), + maintainers: Joi + .array() + .items(helmChartMaintainerValidator) + .options({ + stripUnknown: { + arrays: true + }, + }) + .default(() => ([])), + dependencies: Joi + .array() + .items(helmChartDependencyValidator) + .options({ + stripUnknown: { + arrays: true + }, + }) + .default(() => ([])), + annotations: Joi + .object({}) + .pattern(/.*/, Joi.string()) + .default(() => ({})), +}); + +export interface HelmChartCreateOpts { + onError?: "throw" | "log"; +} + +export interface HelmChartMaintainer { + name: string; + email: string; + url?: string; +} + +export interface RawHelmChartDependency { + name: string; + repository: string; + condition?: string; + version: string; + tags?: string[]; +} + +export type HelmChartDependency = Required> + & Pick; + export interface HelmChart { apiVersion: string; name: string; @@ -91,74 +266,123 @@ export interface HelmChart { repo: string; kubeVersion?: string; created: string; - description?: string; + description: string; digest: string; - keywords?: string[]; + keywords: string[]; home?: string; - sources?: string[]; - maintainers?: { - name: string; - email: string; - url: string; - }[]; + sources: string[]; + urls: string[]; + annotations: Record; + dependencies: HelmChartDependency[]; + maintainers: HelmChartMaintainer[]; engine?: string; icon?: string; appVersion?: string; - deprecated?: boolean; + type?: string; + deprecated: boolean; tillerVersion?: string; } export class HelmChart { - constructor(data: HelmChart) { - Object.assign(this, data); + private constructor(value: HelmChart) { + this.apiVersion = value.apiVersion; + this.name = value.name; + this.version = value.version; + this.repo = value.repo; + this.kubeVersion = value.kubeVersion; + this.created = value.created; + this.description = value.description; + this.digest = value.digest; + this.keywords = value.keywords; + this.home = value.home; + this.sources = value.sources; + this.maintainers = value.maintainers; + this.engine = value.engine; + this.icon = value.icon; + this.apiVersion = value.apiVersion; + this.deprecated = value.deprecated; + this.tillerVersion = value.tillerVersion; + this.annotations = value.annotations; + this.urls = value.urls; + this.dependencies = value.dependencies; + this.type = value.type; + autoBind(this); } - static create(data: any) { - return new HelmChart(data); + static create(data: RawHelmChart, { onError = "throw" }: HelmChartCreateOpts = {}): HelmChart | undefined { + const result = helmChartValidator.validate(data, { + abortEarly: false, + }); + let { error } = result; + const { value } = result; + + if (!error) { + return new HelmChart(value); + } + + const [actualErrors, unknownDetails] = bifurcateArray(error.details, ({ type }) => type === "object.unknown"); + + if (unknownDetails.length > 0) { + console.warn("HelmChart data has unexpected fields", { original: data, unknownFields: unknownDetails.flatMap(d => d.path) }); + } + + if (actualErrors.length === 0) { + return new HelmChart(value); + } + + error = new Joi.ValidationError(actualErrors.map(er => er.message).join(". "), actualErrors, error._original); + + if (onError === "throw") { + throw error; + } + + console.warn("[HELM-CHART]: failed to validate data", data, error); + + return undefined; } - getId() { + getId(): string { return `${this.repo}:${this.apiVersion}/${this.name}@${this.getAppVersion()}+${this.digest}`; } - getName() { + getName(): string { return this.name; } - getFullName(splitter = "/") { - return [this.getRepository(), this.getName()].join(splitter); + getFullName(seperator = "/"): string { + return [this.getRepository(), this.getName()].join(seperator); } - getDescription() { + getDescription(): string { return this.description; } - getIcon() { + getIcon(): string | undefined { return this.icon; } - getHome() { + getHome(): string { return this.home; } - getMaintainers() { - return this.maintainers || []; + getMaintainers(): HelmChartMaintainer[] { + return this.maintainers; } - getVersion() { + getVersion(): string { return this.version; } - getRepository() { + getRepository(): string { return this.repo; } - getAppVersion() { - return this.appVersion || ""; + getAppVersion(): string | undefined { + return this.appVersion; } - getKeywords() { - return this.keywords || []; + getKeywords(): string[] { + return this.keywords; } }