1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Refactor helm-chart.api and improve kube validation and error handling (#2265)

This commit is contained in:
Sebastian Malton 2021-04-21 08:19:36 -04:00 committed by GitHub
parent de7048770b
commit 3682be2f01
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 600 additions and 252 deletions

View File

@ -1,13 +1,89 @@
/** /**
* Narrows `val` to include the property `key` (if true is returned) * Narrows `val` to include the property `key` (if true is returned)
* @param val The object to be tested * @param val The object to be tested
* @param key The key to test if it is present on the object * @param key The key to test if it is present on the object (must be a literal for tsc to do any meaningful typing)
*/ */
export function hasOwnProperty<V extends object, K extends PropertyKey>(val: V, key: K): val is (V & { [key in K]: unknown }) { export function hasOwnProperty<S extends object, K extends PropertyKey>(val: S, key: K): val is (S & { [key in K]: unknown }) {
// this call syntax is for when `val` was created by `Object.create(null)` // this call syntax is for when `val` was created by `Object.create(null)`
return Object.prototype.hasOwnProperty.call(val, key); return Object.prototype.hasOwnProperty.call(val, key);
} }
export function hasOwnProperties<V extends object, K extends PropertyKey>(val: V, ...keys: K[]): val is (V & { [key in K]: unknown}) { /**
* Narrows `val` to a static type that includes fields of names in `keys`
* @param val the value that we are trying to type narrow
* @param keys the key names (must be literals for tsc to do any meaningful typing)
*/
export function hasOwnProperties<S extends object, K extends PropertyKey>(val: S, ...keys: K[]): val is (S & { [key in K]: unknown }) {
return keys.every(key => hasOwnProperty(val, key)); return keys.every(key => hasOwnProperty(val, key));
} }
/**
* Narrows `val` to include the property `key` with type `V`
* @param val the value that we are trying to type narrow
* @param key The key to test if it is present on the object (must be a literal for tsc to do any meaningful typing)
* @param isValid a function to check if the field is valid
*/
export function hasTypedProperty<S extends object, K extends PropertyKey, V>(val: S, key: K, isValid: (value: unknown) => value is V): val is (S & { [key in K]: V }) {
return hasOwnProperty(val, key) && isValid(val[key]);
}
/**
* Narrows `val` to include the property `key` with type `V | undefined` or doesn't contain it
* @param val the value that we are trying to type narrow
* @param key The key to test if it is present on the object (must be a literal for tsc to do any meaningful typing)
* @param isValid a function to check if the field (when present) is valid
*/
export function hasOptionalProperty<S extends object, K extends PropertyKey, V>(val: S, key: K, isValid: (value: unknown) => value is V): val is (S & { [key in K]?: V }) {
if (hasOwnProperty(val, key)) {
return typeof val[key] === "undefined" || isValid(val[key]);
}
return true;
}
/**
* isRecord checks if `val` matches the signature `Record<T, V>` or `{ [label in T]: V }`
* @param val The value to be checked
* @param isKey a function for checking if the key is of the correct type
* @param isValue a function for checking if a value is of the correct type
*/
export function isRecord<T extends PropertyKey, V>(val: unknown, isKey: (key: unknown) => key is T, isValue: (value: unknown) => value is V): val is Record<T, V> {
return isObject(val) && Object.entries(val).every(([key, value]) => isKey(key) && isValue(value));
}
/**
* isTypedArray checks if `val` is an array and all of its entries are of type `T`
* @param val The value to be checked
* @param isEntry a function for checking if an entry is the correct type
*/
export function isTypedArray<T>(val: unknown, isEntry: (entry: unknown) => entry is T): val is T[] {
return Array.isArray(val) && val.every(isEntry);
}
/**
* checks if val is of type string
* @param val the value to be checked
*/
export function isString(val: unknown): val is string {
return typeof val === "string";
}
/**
* checks if val is of type object and isn't null
* @param val the value to be checked
*/
export function isObject(val: unknown): val is object {
return typeof val === "object" && val !== null;
}
/**
* Creates a new predicate function (with the same predicate) from `fn`. Such
* that it can be called with just the value to be tested.
*
* This is useful for when using `hasOptionalProperty` and `hasTypedProperty`
* @param fn A typescript user predicate function to be bound
* @param boundArgs the set of arguments to be passed to `fn` in the new function
*/
export function bindPredicate<FnArgs extends any[], T>(fn: (arg1: unknown, ...args: FnArgs) => arg1 is T, ...boundArgs: FnArgs): (arg1: unknown) => arg1 is T {
return (arg1: unknown): arg1 is T => fn(arg1, ...boundArgs);
}

View File

@ -0,0 +1,228 @@
import { KubeObject } from "../kube-object";
describe("KubeObject", () => {
describe("isJsonApiData", () => {
{
type TestCase = [any];
const tests: TestCase[] = [
[false],
[true],
[null],
[undefined],
[""],
[1],
[(): unknown => void 0],
[Symbol("hello")],
[{}],
];
it.each(tests)("should reject invalid value: %p", (input) => {
expect(KubeObject.isJsonApiData(input)).toBe(false);
});
}
{
type TestCase = [string, any];
const tests: TestCase[] = [
["kind", { apiVersion: "", metadata: {uid: "", name: "", resourceVersion: "", selfLink: ""} }],
["apiVersion", { kind: "", metadata: {uid: "", name: "", resourceVersion: "", selfLink: ""} }],
["metadata", { kind: "", apiVersion: "" }],
["metadata.uid", { kind: "", apiVersion: "", metadata: { name: "", resourceVersion: "", selfLink: ""} }],
["metadata.name", { kind: "", apiVersion: "", metadata: { uid: "", resourceVersion: "", selfLink: "" } }],
["metadata.resourceVersion", { kind: "", apiVersion: "", metadata: { uid: "", name: "", selfLink: "" } }],
];
it.each(tests)("should reject with missing: %s", (missingField, input) => {
expect(KubeObject.isJsonApiData(input)).toBe(false);
});
}
{
type TestCase = [string, any];
const tests: TestCase[] = [
["kind", { kind: 1, apiVersion: "", metadata: {} }],
["apiVersion", { apiVersion: 1, kind: "", metadata: {} }],
["metadata", { kind: "", apiVersion: "", metadata: "" }],
["metadata.uid", { kind: "", apiVersion: "", metadata: { uid: 1 } }],
["metadata.name", { kind: "", apiVersion: "", metadata: { uid: "", name: 1 } }],
["metadata.resourceVersion", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: 1 } }],
["metadata.selfLink", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: 1 } }],
["metadata.namespace", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", namespace: 1 } }],
["metadata.creationTimestamp", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", creationTimestamp: 1 } }],
["metadata.continue", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", continue: 1 } }],
["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: 1 } }],
["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: [1] } }],
["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: {} } }],
["metadata.labels", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", labels: 1 } }],
["metadata.labels", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", labels: { food: 1 } } }],
["metadata.annotations", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: 1 } }],
["metadata.annotations", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: { food: 1 } } }],
];
it.each(tests)("should reject with wrong type for field: %s", (missingField, input) => {
expect(KubeObject.isJsonApiData(input)).toBe(false);
});
}
it("should accept valid KubeJsonApiData (ignoring other fields)", () => {
const valid = { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: { food: "" } } };
expect(KubeObject.isJsonApiData(valid)).toBe(true);
});
});
describe("isPartialJsonApiData", () => {
{
type TestCase = [any];
const tests: TestCase[] = [
[false],
[true],
[null],
[undefined],
[""],
[1],
[(): unknown => void 0],
[Symbol("hello")],
];
it.each(tests)("should reject invalid value: %p", (input) => {
expect(KubeObject.isPartialJsonApiData(input)).toBe(false);
});
}
it("should accept {}", () => {
expect(KubeObject.isPartialJsonApiData({})).toBe(true);
});
{
type TestCase = [string, any];
const tests: TestCase[] = [
["kind", { apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "" } }],
["apiVersion", { kind: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "" } }],
["metadata", { kind: "", apiVersion: "" }],
];
it.each(tests)("should not reject with missing top level field: %s", (missingField, input) => {
expect(KubeObject.isPartialJsonApiData(input)).toBe(true);
});
}
{
type TestCase = [string, any];
const tests: TestCase[] = [
["metadata.uid", { kind: "", apiVersion: "", metadata: { name: "", resourceVersion: "", selfLink: ""} }],
["metadata.name", { kind: "", apiVersion: "", metadata: { uid: "", resourceVersion: "", selfLink: "" } }],
["metadata.resourceVersion", { kind: "", apiVersion: "", metadata: { uid: "", name: "", selfLink: "" } }],
];
it.each(tests)("should reject with missing non-top level field: %s", (missingField, input) => {
expect(KubeObject.isPartialJsonApiData(input)).toBe(false);
});
}
{
type TestCase = [string, any];
const tests: TestCase[] = [
["kind", { kind: 1, apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "" } }],
["apiVersion", { apiVersion: 1, kind: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "" } }],
["metadata", { kind: "", apiVersion: "", metadata: "" }],
["metadata.uid", { kind: "", apiVersion: "", metadata: { uid: 1, name: "", resourceVersion: "", selfLink: "" } }],
["metadata.name", { kind: "", apiVersion: "", metadata: { uid: "", name: 1, resourceVersion: "", selfLink: "" } }],
["metadata.resourceVersion", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: 1, selfLink: "" } }],
["metadata.selfLink", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: 1 } }],
["metadata.namespace", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", namespace: 1 } }],
["metadata.creationTimestamp", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", creationTimestamp: 1 } }],
["metadata.continue", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", continue: 1 } }],
["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: 1 } }],
["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: [1] } }],
["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: {} } }],
["metadata.labels", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", labels: 1 } }],
["metadata.labels", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", labels: { food: 1 } } }],
["metadata.annotations", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: 1 } }],
["metadata.annotations", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: { food: 1 } } }],
];
it.each(tests)("should reject with wrong type for field: %s", (missingField, input) => {
expect(KubeObject.isPartialJsonApiData(input)).toBe(false);
});
}
it("should accept valid Partial<KubeJsonApiData> (ignoring other fields)", () => {
const valid = { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: { food: "" } } };
expect(KubeObject.isPartialJsonApiData(valid)).toBe(true);
});
});
describe("isJsonApiDataList", () => {
function isAny(val: unknown): val is any {
return !Boolean(void val);
}
function isNotAny(val: unknown): val is any {
return Boolean(void val);
}
function isBoolean(val: unknown): val is Boolean {
return typeof val === "boolean";
}
{
type TestCase = [any];
const tests: TestCase[] = [
[false],
[true],
[null],
[undefined],
[""],
[1],
[(): unknown => void 0],
[Symbol("hello")],
[{}],
];
it.each(tests)("should reject invalid value: %p", (input) => {
expect(KubeObject.isJsonApiDataList(input, isAny)).toBe(false);
});
}
{
type TestCase = [string, any];
const tests: TestCase[] = [
["kind", { apiVersion: "", items: [], metadata: { resourceVersion: "", selfLink: "" } }],
["apiVersion", { kind: "", items: [], metadata: { resourceVersion: "", selfLink: "" } }],
["metadata", { kind: "", items: [], apiVersion: "" }],
["metadata.resourceVersion", { kind: "", items: [], apiVersion: "", metadata: { selfLink: "" } }],
];
it.each(tests)("should reject with missing: %s", (missingField, input) => {
expect(KubeObject.isJsonApiDataList(input, isAny)).toBe(false);
});
}
{
type TestCase = [string, any];
const tests: TestCase[] = [
["kind", { kind: 1, items: [], apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }],
["apiVersion", { kind: "", items: [], apiVersion: 1, metadata: { resourceVersion: "", selfLink: "" } }],
["metadata", { kind: "", items: [], apiVersion: "", metadata: 1 }],
["metadata.resourceVersion", { kind: "", items: [], apiVersion: "", metadata: { resourceVersion: 1, selfLink: "" } }],
["metadata.selfLink", { kind: "", items: [], apiVersion: "", metadata: { resourceVersion: "", selfLink: 1 } }],
["items", { kind: "", items: 1, apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }],
["items", { kind: "", items: "", apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }],
["items", { kind: "", items: {}, apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }],
["items[0]", { kind: "", items: [""], apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }],
];
it.each(tests)("should reject with wrong type for field: %s", (missingField, input) => {
expect(KubeObject.isJsonApiDataList(input, isNotAny)).toBe(false);
});
}
it("should accept valid KubeJsonApiDataList (ignoring other fields)", () => {
const valid = { kind: "", items: [false], apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } };
expect(KubeObject.isJsonApiDataList(valid, isBoolean)).toBe(true);
});
});
});

View File

@ -16,39 +16,51 @@ const endpoint = compile(`/v2/charts/:repo?/:name?`) as (params?: {
name?: string; name?: string;
}) => string; }) => string;
export const helmChartsApi = { /**
list() { * Get a list of all helm charts from all saved helm repos
return apiBase */
.get<HelmChartList>(endpoint()) export async function listCharts(): Promise<HelmChart[]> {
.then(data => { const data = await apiBase.get<HelmChartList>(endpoint());
return Object return Object
.values(data) .values(data)
.reduce((allCharts, repoCharts) => allCharts.concat(Object.values(repoCharts)), []) .reduce((allCharts, repoCharts) => allCharts.concat(Object.values(repoCharts)), [])
.map(([chart]) => HelmChart.create(chart)); .map(([chart]) => HelmChart.create(chart));
}); }
},
get(repo: string, name: string, readmeVersion?: string) { export interface GetChartDetailsOptions {
version?: string;
reqInit?: RequestInit;
}
/**
* Get the readme and all versions of a chart
* @param repo The repo to get from
* @param name The name of the chart to request the data of
* @param options.version The version of the chart's readme to get, default latest
* @param options.reqInit A way for passing in an abort controller or other browser request options
*/
export async function getChartDetails(repo: string, name: string, { version, reqInit }: GetChartDetailsOptions = {}): Promise<IHelmChartDetails> {
const path = endpoint({ repo, name }); const path = endpoint({ repo, name });
return apiBase const { readme, ...data } = await apiBase.get<IHelmChartDetails>(`${path}?${stringify({ version })}`, undefined, reqInit);
.get<IHelmChartDetails>(`${path}?${stringify({ version: readmeVersion })}`)
.then(data => {
const versions = data.versions.map(HelmChart.create); const versions = data.versions.map(HelmChart.create);
const readme = data.readme;
return { return {
readme, readme,
versions, versions,
}; };
}); }
},
getValues(repo: string, name: string, version: string) { /**
return apiBase * Get chart values related to a specific repos' version of a chart
.get<string>(`/v2/charts/${repo}/${name}/values?${stringify({ version })}`); * @param repo The repo to get from
} * @param name The name of the chart to request the data of
}; * @param version The version to get the values from
*/
export async function getChartValues(repo: string, name: string, version: string): Promise<string> {
return apiBase.get<string>(`/v2/charts/${repo}/${name}/values?${stringify({ version })}`);
}
@autobind() @autobind()
export class HelmChart { export class HelmChart {

View File

@ -2,8 +2,8 @@
import { stringify } from "querystring"; import { stringify } from "querystring";
import { EventEmitter } from "../../common/event-emitter"; import { EventEmitter } from "../../common/event-emitter";
import { cancelableFetch } from "../utils/cancelableFetch";
import { randomBytes } from "crypto"; import { randomBytes } from "crypto";
export interface JsonApiData { export interface JsonApiData {
} }
@ -72,13 +72,11 @@ export class JsonApi<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
reqUrl += (reqUrl.includes("?") ? "&" : "?") + queryString; reqUrl += (reqUrl.includes("?") ? "&" : "?") + queryString;
} }
const infoLog: JsonApiLog = { this.writeLog({
method: reqInit.method.toUpperCase(), method: reqInit.method.toUpperCase(),
reqUrl: reqPath, reqUrl: reqPath,
reqInit, reqInit,
}; });
this.writeLog({ ...infoLog });
return fetch(reqUrl, reqInit); return fetch(reqUrl, reqInit);
} }
@ -99,7 +97,7 @@ export class JsonApi<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
return this.request<T>(path, params, { ...reqInit, method: "delete" }); return this.request<T>(path, params, { ...reqInit, method: "delete" });
} }
protected request<D>(path: string, params?: P, init: RequestInit = {}) { protected async request<D>(path: string, params?: P, init: RequestInit = {}) {
let reqUrl = this.config.apiBase + path; let reqUrl = this.config.apiBase + path;
const reqInit: RequestInit = { ...this.reqInit, ...init }; const reqInit: RequestInit = { ...this.reqInit, ...init };
const { data, query } = params || {} as P; const { data, query } = params || {} as P;
@ -119,15 +117,15 @@ export class JsonApi<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
reqInit, reqInit,
}; };
return cancelableFetch(reqUrl, reqInit).then(res => { const res = await fetch(reqUrl, reqInit);
return this.parseResponse<D>(res, infoLog); return this.parseResponse<D>(res, infoLog);
});
} }
protected parseResponse<D>(res: Response, log: JsonApiLog): Promise<D> { protected async parseResponse<D>(res: Response, log: JsonApiLog): Promise<D> {
const { status } = res; const { status } = res;
return res.text().then(text => { const text = await res.text();
let data; let data;
try { try {
@ -141,26 +139,31 @@ export class JsonApi<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
this.writeLog({ ...log, data }); this.writeLog({ ...log, data });
return data; return data;
} else if (log.method === "GET" && res.status === 403) { }
this.writeLog({ ...log, data });
} else { if (log.method === "GET" && res.status === 403) {
this.writeLog({ ...log, error: data });
throw data;
}
const error = new JsonApiErrorParsed(data, this.parseError(data, res)); const error = new JsonApiErrorParsed(data, this.parseError(data, res));
this.onError.emit(error, res); this.onError.emit(error, res);
this.writeLog({ ...log, error }); this.writeLog({ ...log, error });
throw error; throw error;
} }
});
}
protected parseError(error: JsonApiError | string, res: Response): string[] { protected parseError(error: JsonApiError | string, res: Response): string[] {
if (typeof error === "string") { if (typeof error === "string") {
return [error]; return [error];
} }
else if (Array.isArray(error.errors)) {
if (Array.isArray(error.errors)) {
return error.errors.map(error => error.title); return error.errors.map(error => error.title);
} }
else if (error.message) {
if (error.message) {
return [error.message]; return [error.message];
} }

View File

@ -7,11 +7,12 @@ import logger from "../../main/logger";
import { apiManager } from "./api-manager"; import { apiManager } from "./api-manager";
import { apiKube } from "./index"; import { apiKube } from "./index";
import { createKubeApiURL, parseKubeApi } from "./kube-api-parse"; import { createKubeApiURL, parseKubeApi } from "./kube-api-parse";
import { KubeJsonApi, KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api";
import { IKubeObjectConstructor, KubeObject, KubeStatus } from "./kube-object"; import { IKubeObjectConstructor, KubeObject, KubeStatus } from "./kube-object";
import byline from "byline"; import byline from "byline";
import { IKubeWatchEvent } from "./kube-watch-api"; import { IKubeWatchEvent } from "./kube-watch-api";
import { ReadableWebToNodeStream } from "../utils/readableStream"; import { ReadableWebToNodeStream } from "../utils/readableStream";
import { KubeJsonApi, KubeJsonApiData } from "./kube-json-api";
import { noop } from "../utils";
export interface IKubeApiOptions<T extends KubeObject> { export interface IKubeApiOptions<T extends KubeObject> {
/** /**
@ -34,6 +35,11 @@ export interface IKubeApiOptions<T extends KubeObject> {
checkPreferredVersion?: boolean; checkPreferredVersion?: boolean;
} }
export interface KubeApiListOptions {
namespace?: string;
reqInit?: RequestInit;
}
export interface IKubeApiQueryParams { export interface IKubeApiQueryParams {
watch?: boolean | number; watch?: boolean | number;
resourceVersion?: string; resourceVersion?: string;
@ -243,7 +249,7 @@ export class KubeApi<T extends KubeObject = any> {
return this.resourceVersions.get(namespace); return this.resourceVersions.get(namespace);
} }
async refreshResourceVersion(params?: { namespace: string }) { async refreshResourceVersion(params?: KubeApiListOptions) {
return this.list(params, { limit: 1 }); return this.list(params, { limit: 1 });
} }
@ -271,20 +277,12 @@ export class KubeApi<T extends KubeObject = any> {
return query; return query;
} }
protected parseResponse(data: KubeJsonApiData | KubeJsonApiData[] | KubeJsonApiDataList, namespace?: string): any { protected parseResponse(data: unknown, namespace?: string): T | T[] | null {
if (!data) return; if (!data) return;
const KubeObjectConstructor = this.objectConstructor; const KubeObjectConstructor = this.objectConstructor;
if (KubeObject.isJsonApiData(data)) { // process items list response, check before single item since there is overlap
const object = new KubeObjectConstructor(data); if (KubeObject.isJsonApiDataList(data, KubeObject.isPartialJsonApiData)) {
ensureObjectSelfLink(this, object);
return object;
}
// process items list response
if (KubeObject.isJsonApiDataList(data)) {
const { apiVersion, items, metadata } = data; const { apiVersion, items, metadata } = data;
this.setResourceVersion(namespace, metadata.resourceVersion); this.setResourceVersion(namespace, metadata.resourceVersion);
@ -303,36 +301,60 @@ export class KubeApi<T extends KubeObject = any> {
}); });
} }
// process a single item
if (KubeObject.isJsonApiData(data)) {
const object = new KubeObjectConstructor(data);
ensureObjectSelfLink(this, object);
return object;
}
// custom apis might return array for list response, e.g. users, groups, etc. // custom apis might return array for list response, e.g. users, groups, etc.
if (Array.isArray(data)) { if (Array.isArray(data)) {
return data.map(data => new KubeObjectConstructor(data)); return data.map(data => new KubeObjectConstructor(data));
} }
return data; return null;
} }
async list({ namespace = "" } = {}, query?: IKubeApiQueryParams): Promise<T[]> { async list({ namespace = "", reqInit }: KubeApiListOptions = {}, query?: IKubeApiQueryParams): Promise<T[] | null> {
await this.checkPreferredVersion(); await this.checkPreferredVersion();
return this.request const url = this.getUrl({ namespace });
.get(this.getUrl({ namespace }), { query }) const res = await this.request.get(url, { query }, reqInit);
.then(data => this.parseResponse(data, namespace)); const parsed = this.parseResponse(res, namespace);
if (Array.isArray(parsed)) {
return parsed;
} }
async get({ name = "", namespace = "default" } = {}, query?: IKubeApiQueryParams): Promise<T> { if (!parsed) {
await this.checkPreferredVersion(); return null;
return this.request
.get(this.getUrl({ namespace, name }), { query })
.then(this.parseResponse);
} }
async create({ name = "", namespace = "default" } = {}, data?: Partial<T>): Promise<T> { throw new Error(`GET multiple request to ${url} returned not an array: ${JSON.stringify(parsed)}`);
}
async get({ name = "", namespace = "default" } = {}, query?: IKubeApiQueryParams): Promise<T | null> {
await this.checkPreferredVersion(); await this.checkPreferredVersion();
const url = this.getUrl({ namespace, name });
const res = await this.request.get(url, { query });
const parsed = this.parseResponse(res);
if (Array.isArray(parsed)) {
throw new Error(`GET single request to ${url} returned an array: ${JSON.stringify(parsed)}`);
}
return parsed;
}
async create({ name = "", namespace = "default" } = {}, data?: Partial<T>): Promise<T | null> {
await this.checkPreferredVersion();
const apiUrl = this.getUrl({ namespace }); const apiUrl = this.getUrl({ namespace });
const res = await this.request.post(apiUrl, {
return this.request
.post(apiUrl, {
data: merge({ data: merge({
kind: this.kind, kind: this.kind,
apiVersion: this.apiVersionWithGroup, apiVersion: this.apiVersionWithGroup,
@ -341,17 +363,28 @@ export class KubeApi<T extends KubeObject = any> {
namespace namespace
} }
}, data) }, data)
}) });
.then(this.parseResponse); const parsed = this.parseResponse(res);
if (Array.isArray(parsed)) {
throw new Error(`POST request to ${apiUrl} returned an array: ${JSON.stringify(parsed)}`);
} }
async update({ name = "", namespace = "default" } = {}, data?: Partial<T>): Promise<T> { return parsed;
}
async update({ name = "", namespace = "default" } = {}, data?: Partial<T>): Promise<T | null> {
await this.checkPreferredVersion(); await this.checkPreferredVersion();
const apiUrl = this.getUrl({ namespace, name }); const apiUrl = this.getUrl({ namespace, name });
return this.request const res = await this.request.put(apiUrl, { data });
.put(apiUrl, { data }) const parsed = this.parseResponse(res);
.then(this.parseResponse);
if (Array.isArray(parsed)) {
throw new Error(`PUT request to ${apiUrl} returned an array: ${JSON.stringify(parsed)}`);
}
return parsed;
} }
async delete({ name = "", namespace = "default" }) { async delete({ name = "", namespace = "default" }) {
@ -370,28 +403,23 @@ export class KubeApi<T extends KubeObject = any> {
} }
watch(opts: KubeApiWatchOptions = { namespace: "" }): () => void { watch(opts: KubeApiWatchOptions = { namespace: "" }): () => void {
if (!opts.abortController) {
opts.abortController = new AbortController();
}
let errorReceived = false; let errorReceived = false;
let timedRetry: NodeJS.Timeout; let timedRetry: NodeJS.Timeout;
const { abortController, namespace, callback } = opts; const { abortController: { abort, signal } = new AbortController(), namespace, callback = noop } = opts;
abortController.signal.addEventListener("abort", () => { signal.addEventListener("abort", () => {
clearTimeout(timedRetry); clearTimeout(timedRetry);
}); });
const watchUrl = this.getWatchUrl(namespace); const watchUrl = this.getWatchUrl(namespace);
const responsePromise = this.request.getResponse(watchUrl, null, { const responsePromise = this.request.getResponse(watchUrl, null, { signal });
signal: abortController.signal
});
responsePromise.then((response) => { responsePromise
if (!response.ok && !abortController.signal.aborted) { .then(response => {
callback?.(null, response); if (!response.ok) {
return callback(null, response);
return;
} }
const nodeStream = new ReadableWebToNodeStream(response.body); const nodeStream = new ReadableWebToNodeStream(response.body);
["end", "close", "error"].forEach((eventName) => { ["end", "close", "error"].forEach((eventName) => {
@ -400,48 +428,35 @@ export class KubeApi<T extends KubeObject = any> {
clearTimeout(timedRetry); clearTimeout(timedRetry);
timedRetry = setTimeout(() => { // we did not get any kubernetes errors so let's retry timedRetry = setTimeout(() => { // we did not get any kubernetes errors so let's retry
if (abortController.signal.aborted) return;
this.watch({...opts, namespace, callback}); this.watch({...opts, namespace, callback});
}, 1000); }, 1000);
}); });
}); });
const stream = byline(nodeStream); byline(nodeStream).on("data", (line) => {
stream.on("data", (line) => {
try { try {
const event: IKubeWatchEvent = JSON.parse(line); const event: IKubeWatchEvent = JSON.parse(line);
if (event.type === "ERROR" && event.object.kind === "Status") { if (event.type === "ERROR" && event.object.kind === "Status") {
errorReceived = true; errorReceived = true;
callback(null, new KubeStatus(event.object as any));
return; return callback(null, new KubeStatus(event.object as any));
} }
this.modifyWatchEvent(event); this.modifyWatchEvent(event);
if (callback) {
callback(event, null); callback(event, null);
}
} catch (ignore) { } catch (ignore) {
// ignore parse errors // ignore parse errors
} }
}); });
}, (error) => { })
.catch(error => {
if (error instanceof DOMException) return; // AbortController rejects, we can ignore it if (error instanceof DOMException) return; // AbortController rejects, we can ignore it
callback?.(null, error); callback(null, error);
}).catch((error) => {
callback?.(null, error);
}); });
const disposer = () => { return abort;
abortController.abort();
};
return disposer;
} }
protected modifyWatchEvent(event: IKubeWatchEvent) { protected modifyWatchEvent(event: IKubeWatchEvent) {

View File

@ -1,19 +1,18 @@
import { JsonApi, JsonApiData, JsonApiError } from "./json-api"; import { JsonApi, JsonApiData, JsonApiError } from "./json-api";
export interface KubeJsonApiListMetadata {
resourceVersion: string;
selfLink?: string;
}
export interface KubeJsonApiDataList<T = KubeJsonApiData> { export interface KubeJsonApiDataList<T = KubeJsonApiData> {
kind: string; kind: string;
apiVersion: string; apiVersion: string;
items: T[]; items: T[];
metadata: { metadata: KubeJsonApiListMetadata;
resourceVersion: string;
selfLink: string;
};
} }
export interface KubeJsonApiData extends JsonApiData { export interface KubeJsonApiMetadata {
kind: string;
apiVersion: string;
metadata: {
uid: string; uid: string;
name: string; name: string;
namespace?: string; namespace?: string;
@ -28,7 +27,12 @@ export interface KubeJsonApiData extends JsonApiData {
annotations?: { annotations?: {
[annotation: string]: string; [annotation: string]: string;
}; };
}; }
export interface KubeJsonApiData extends JsonApiData {
kind: string;
apiVersion: string;
metadata: KubeJsonApiMetadata;
} }
export interface KubeJsonApiError extends JsonApiError { export interface KubeJsonApiError extends JsonApiError {

View File

@ -1,12 +1,13 @@
// Base class for all kubernetes objects // Base class for all kubernetes objects
import moment from "moment"; import moment from "moment";
import { KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api"; import { KubeJsonApiData, KubeJsonApiDataList, KubeJsonApiListMetadata, KubeJsonApiMetadata } from "./kube-json-api";
import { autobind, formatDuration } from "../utils"; import { autobind, formatDuration } from "../utils";
import { ItemObject } from "../item.store"; import { ItemObject } from "../item.store";
import { apiKube } from "./index"; import { apiKube } from "./index";
import { JsonApiParams } from "./json-api"; import { JsonApiParams } from "./json-api";
import { resourceApplierApi } from "./endpoints/resource-applier.api"; import { resourceApplierApi } from "./endpoints/resource-applier.api";
import { hasOptionalProperty, hasTypedProperty, isObject, isString, bindPredicate, isTypedArray, isRecord } from "../../common/utils/type-narrowing";
export type IKubeObjectConstructor<T extends KubeObject = any> = (new (data: KubeJsonApiData | any) => T) & { export type IKubeObjectConstructor<T extends KubeObject = any> = (new (data: KubeJsonApiData | any) => T) & {
kind?: string; kind?: string;
@ -78,15 +79,59 @@ export class KubeObject implements ItemObject {
return !item.metadata.name.startsWith("system:"); return !item.metadata.name.startsWith("system:");
} }
static isJsonApiData(object: any): object is KubeJsonApiData { static isJsonApiData(object: unknown): object is KubeJsonApiData {
return !object.items && object.metadata; return (
isObject(object)
&& hasTypedProperty(object, "kind", isString)
&& hasTypedProperty(object, "apiVersion", isString)
&& hasTypedProperty(object, "metadata", KubeObject.isKubeJsonApiMetadata)
);
} }
static isJsonApiDataList(object: any): object is KubeJsonApiDataList { static isKubeJsonApiListMetadata(object: unknown): object is KubeJsonApiListMetadata {
return object.items && object.metadata; return (
isObject(object)
&& hasTypedProperty(object, "resourceVersion", isString)
&& hasOptionalProperty(object, "selfLink", isString)
);
} }
static stringifyLabels(labels: { [name: string]: string }): string[] { static isKubeJsonApiMetadata(object: unknown): object is KubeJsonApiMetadata {
return (
isObject(object)
&& hasTypedProperty(object, "uid", isString)
&& hasTypedProperty(object, "name", isString)
&& hasTypedProperty(object, "resourceVersion", isString)
&& hasOptionalProperty(object, "selfLink", isString)
&& hasOptionalProperty(object, "namespace", isString)
&& hasOptionalProperty(object, "creationTimestamp", isString)
&& hasOptionalProperty(object, "continue", isString)
&& hasOptionalProperty(object, "finalizers", bindPredicate(isTypedArray, isString))
&& hasOptionalProperty(object, "labels", bindPredicate(isRecord, isString, isString))
&& hasOptionalProperty(object, "annotations", bindPredicate(isRecord, isString, isString))
);
}
static isPartialJsonApiData(object: unknown): object is Partial<KubeJsonApiData> {
return (
isObject(object)
&& hasOptionalProperty(object, "kind", isString)
&& hasOptionalProperty(object, "apiVersion", isString)
&& hasOptionalProperty(object, "metadata", KubeObject.isKubeJsonApiMetadata)
);
}
static isJsonApiDataList<T>(object: unknown, verifyItem:(val: unknown) => val is T): object is KubeJsonApiDataList<T> {
return (
isObject(object)
&& hasTypedProperty(object, "kind", isString)
&& hasTypedProperty(object, "apiVersion", isString)
&& hasTypedProperty(object, "metadata", KubeObject.isKubeJsonApiListMetadata)
&& hasTypedProperty(object, "items", bindPredicate(isTypedArray, verifyItem))
);
}
static stringifyLabels(labels?: { [name: string]: string }): string[] {
if (!labels) return []; if (!labels) return [];
return Object.entries(labels).map(([name, value]) => `${name}=${value}`); return Object.entries(labels).map(([name, value]) => `${name}=${value}`);

View File

@ -1,14 +1,13 @@
import "./helm-chart-details.scss"; import "./helm-chart-details.scss";
import React, { Component } from "react"; import React, { Component } from "react";
import { HelmChart, helmChartsApi } from "../../api/endpoints/helm-charts.api"; import { getChartDetails, HelmChart } from "../../api/endpoints/helm-charts.api";
import { observable, autorun } from "mobx"; import { observable, autorun } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Drawer, DrawerItem } from "../drawer"; import { Drawer, DrawerItem } from "../drawer";
import { autobind, stopPropagation } from "../../utils"; import { autobind, stopPropagation } from "../../utils";
import { MarkdownViewer } from "../markdown-viewer"; import { MarkdownViewer } from "../markdown-viewer";
import { Spinner } from "../spinner"; import { Spinner } from "../spinner";
import { CancelablePromise } from "../../utils/cancelableFetch";
import { Button } from "../button"; import { Button } from "../button";
import { Select, SelectOption } from "../select"; import { Select, SelectOption } from "../select";
import { createInstallChartTab } from "../dock/install-chart.store"; import { createInstallChartTab } from "../dock/install-chart.store";
@ -26,35 +25,37 @@ export class HelmChartDetails extends Component<Props> {
@observable readme: string = null; @observable readme: string = null;
@observable error: string = null; @observable error: string = null;
private chartPromise: CancelablePromise<{ readme: string; versions: HelmChart[] }>; private abortController?: AbortController;
componentWillUnmount() { componentWillUnmount() {
this.chartPromise?.cancel(); this.abortController?.abort();
} }
chartUpdater = autorun(() => { chartUpdater = autorun(() => {
this.selectedChart = null; this.selectedChart = null;
const { chart: { name, repo, version } } = this.props; const { chart: { name, repo, version } } = this.props;
helmChartsApi.get(repo, name, version).then(result => { getChartDetails(repo, name, { version })
.then(result => {
this.readme = result.readme; this.readme = result.readme;
this.chartVersions = result.versions; this.chartVersions = result.versions;
this.selectedChart = result.versions[0]; this.selectedChart = result.versions[0];
}, })
error => { .catch(error => {
this.error = error; this.error = error;
}); });
}); });
@autobind() @autobind()
async onVersionChange({ value: version }: SelectOption) { async onVersionChange({ value: version }: SelectOption<string>) {
this.selectedChart = this.chartVersions.find(chart => chart.version === version); this.selectedChart = this.chartVersions.find(chart => chart.version === version);
this.readme = null; this.readme = null;
try { try {
this.chartPromise?.cancel(); this.abortController?.abort();
this.abortController = new AbortController();
const { chart: { name, repo } } = this.props; const { chart: { name, repo } } = this.props;
const { readme } = await (this.chartPromise = helmChartsApi.get(repo, name, version)); const { readme } = await getChartDetails(repo, name, { version, reqInit: { signal: this.abortController.signal }});
this.readme = readme; this.readme = readme;
} catch (error) { } catch (error) {

View File

@ -1,7 +1,7 @@
import semver from "semver"; import semver from "semver";
import { observable } from "mobx"; import { observable } from "mobx";
import { autobind } from "../../utils"; import { autobind } from "../../utils";
import { HelmChart, helmChartsApi } from "../../api/endpoints/helm-charts.api"; import { getChartDetails, HelmChart, listCharts } from "../../api/endpoints/helm-charts.api";
import { ItemStore } from "../../item.store"; import { ItemStore } from "../../item.store";
import flatten from "lodash/flatten"; import flatten from "lodash/flatten";
@ -16,7 +16,7 @@ export class HelmChartStore extends ItemStore<HelmChart> {
async loadAll() { async loadAll() {
try { try {
const res = await this.loadItems(() => helmChartsApi.list()); const res = await this.loadItems(() => listCharts());
this.failedLoading = false; this.failedLoading = false;
@ -45,13 +45,13 @@ export class HelmChartStore extends ItemStore<HelmChart> {
return versions; return versions;
} }
const loadVersions = (repo: string) => { const loadVersions = async (repo: string) => {
return helmChartsApi.get(repo, chartName).then(({ versions }) => { const { versions } = await getChartDetails(repo, chartName);
return versions.map(chart => ({ return versions.map(chart => ({
repo, repo,
version: chart.getVersion() version: chart.getVersion()
})); }));
});
}; };
if (!this.isLoaded) { if (!this.isLoaded) {

View File

@ -1,7 +1,7 @@
import { action, autorun } from "mobx"; import { action, autorun } from "mobx";
import { dockStore, IDockTab, TabId, TabKind } from "./dock.store"; import { dockStore, IDockTab, TabId, TabKind } from "./dock.store";
import { DockTabStore } from "./dock-tab.store"; import { DockTabStore } from "./dock-tab.store";
import { HelmChart, helmChartsApi } from "../../api/endpoints/helm-charts.api"; import { getChartDetails, getChartValues, HelmChart } from "../../api/endpoints/helm-charts.api";
import { IReleaseUpdateDetails } from "../../api/endpoints/helm-releases.api"; import { IReleaseUpdateDetails } from "../../api/endpoints/helm-releases.api";
import { Notifications } from "../notifications"; import { Notifications } from "../notifications";
@ -54,7 +54,7 @@ export class InstallChartStore extends DockTabStore<IChartInstallData> {
const { repo, name, version } = this.getData(tabId); const { repo, name, version } = this.getData(tabId);
this.versions.clearData(tabId); // reset this.versions.clearData(tabId); // reset
const charts = await helmChartsApi.get(repo, name, version); const charts = await getChartDetails(repo, name, { version });
const versions = charts.versions.map(chartVersion => chartVersion.version); const versions = charts.versions.map(chartVersion => chartVersion.version);
this.versions.setData(tabId, versions); this.versions.setData(tabId, versions);
@ -64,7 +64,7 @@ export class InstallChartStore extends DockTabStore<IChartInstallData> {
async loadValues(tabId: TabId, attempt = 0): Promise<void> { async loadValues(tabId: TabId, attempt = 0): Promise<void> {
const data = this.getData(tabId); const data = this.getData(tabId);
const { repo, name, version } = data; const { repo, name, version } = data;
const values = await helmChartsApi.getValues(repo, name, version); const values = await getChartValues(repo, name, version);
if (values) { if (values) {
this.setData(tabId, { ...data, values }); this.setData(tabId, { ...data, values });

View File

@ -1,36 +0,0 @@
// Allow to cancel request for window.fetch()
export interface CancelablePromise<T> extends Promise<T> {
then<TResult1 = T, TResult2 = never>(onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null): CancelablePromise<TResult1 | TResult2>;
catch<TResult = never>(onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | undefined | null): CancelablePromise<T | TResult>;
finally(onfinally?: (() => void) | undefined | null): CancelablePromise<T>;
cancel(): void;
}
interface WrappingFunction {
<T>(result: Promise<T>): CancelablePromise<T>;
<T>(result: T): T;
}
export function cancelableFetch(reqInfo: RequestInfo, reqInit: RequestInit = {}) {
const abortController = new AbortController();
const signal = abortController.signal;
const cancel = abortController.abort.bind(abortController);
const wrapResult: WrappingFunction = function (result: any) {
if (result instanceof Promise) {
const promise: CancelablePromise<any> = result as any;
promise.then = function (onfulfilled, onrejected) {
const data = Object.getPrototypeOf(this).then.call(this, onfulfilled, onrejected);
return wrapResult(data);
};
promise.cancel = cancel;
}
return result;
};
const req = fetch(reqInfo, { ...reqInit, signal });
return wrapResult(req);
}