From 07e1f84707eb5b908b78704a259041ed539ea8db Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Wed, 26 May 2021 09:49:36 -0400 Subject: [PATCH] Request cluster's allowed resources on demand - add unit tests for asyncThrottle Signed-off-by: Sebastian Malton --- package.json | 1 + src/common/ipc/cluster.ipc.ts | 2 + src/common/rbac.ts | 50 +++++-- .../utils/__tests__/async-throttle.test.ts | 60 +++++++++ src/common/utils/async-throttle.ts | 44 ++++++ src/common/utils/extended-map.ts | 9 +- src/common/utils/observable-timer.ts | 41 ++++++ src/extensions/renderer-api/k8s-api.ts | 2 +- src/main/cluster.ts | 125 +++++++----------- src/main/initializers/ipc.ts | 32 ++++- src/main/utils/api-resources.ts | 96 ++++++++++++++ src/renderer/api/allowed-resources.ts | 108 +++++++++++++++ src/renderer/api/kube-watch-api.ts | 5 - src/renderer/components/+apps/apps.tsx | 1 - src/renderer/components/+config/config.tsx | 4 +- src/renderer/components/+network/network.tsx | 4 +- src/renderer/components/+storage/storage.tsx | 4 +- .../+user-management/user-management.tsx | 7 +- .../+workloads-overview/overview-statuses.tsx | 4 +- .../components/+workloads/workloads.tsx | 7 +- src/renderer/components/app.tsx | 36 +++-- .../kube-object/kube-object-list-layout.tsx | 2 +- src/renderer/components/layout/sidebar.tsx | 8 +- .../workloads-overview-detail-registry.tsx | 2 +- src/renderer/kube-object.store.ts | 3 +- src/renderer/utils/rbac.ts | 60 --------- yarn.lock | 16 ++- 27 files changed, 541 insertions(+), 192 deletions(-) create mode 100644 src/common/utils/__tests__/async-throttle.test.ts create mode 100644 src/common/utils/async-throttle.ts create mode 100644 src/common/utils/observable-timer.ts create mode 100644 src/main/utils/api-resources.ts create mode 100644 src/renderer/api/allowed-resources.ts delete mode 100644 src/renderer/utils/rbac.ts diff --git a/package.json b/package.json index 81e557e186..0f6cf6eb19 100644 --- a/package.json +++ b/package.json @@ -199,6 +199,7 @@ "filehound": "^1.17.4", "filenamify": "^4.1.0", "fs-extra": "^9.0.1", + "got": "^11.8.2", "grapheme-splitter": "^1.0.4", "handlebars": "^4.7.7", "http-proxy": "^1.18.1", diff --git a/src/common/ipc/cluster.ipc.ts b/src/common/ipc/cluster.ipc.ts index 986e979692..af9542fbb7 100644 --- a/src/common/ipc/cluster.ipc.ts +++ b/src/common/ipc/cluster.ipc.ts @@ -24,6 +24,8 @@ * during a refresh and no `accessibleNamespaces` have been set. */ export const ClusterListNamespaceForbiddenChannel = "cluster:list-namespace-forbidden"; +export const ClusterGetResourcesChannel = "cluster:resources"; +export const ClusterResourceIsAllowedChannel = "cluster:resource:is-allowed"; export type ListNamespaceForbiddenArgs = [clusterId: string]; diff --git a/src/common/rbac.ts b/src/common/rbac.ts index 55b8ca414c..4a2ea69a73 100644 --- a/src/common/rbac.ts +++ b/src/common/rbac.ts @@ -19,7 +19,6 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { getHostedCluster } from "./cluster-store"; export type KubeResource = "namespaces" | "nodes" | "events" | "resourcequotas" | "services" | "limitranges" | @@ -74,17 +73,40 @@ export const apiResourceRecord: Record = { export const apiResources: KubeApiResource[] = Object.entries(apiResourceRecord) .map(([apiName, data]) => ({ apiName: apiName as KubeResource, ...data })); -export function isAllowedResource(resources: KubeResource | KubeResource[]) { - if (!Array.isArray(resources)) { - resources = [resources]; - } - const { allowedResources = [] } = getHostedCluster() || {}; +export const ResourceNames: Record = { + "namespaces": "Namespaces", + "nodes": "Nodes", + "events": "Events", + "resourcequotas": "Resource Quotas", + "services": "Services", + "secrets": "Secrets", + "configmaps": "Config Maps", + "ingresses": "Ingresses", + "networkpolicies": "Network Policies", + "persistentvolumeclaims": "Persistent Volume Claims", + "persistentvolumes": "Persistent Volumes", + "storageclasses": "Storage Classes", + "pods": "Pods", + "daemonsets": "Daemon Sets", + "deployments": "Deployments", + "statefulsets": "Stateful Sets", + "replicasets": "Replica Sets", + "jobs": "Jobs", + "cronjobs": "Cron Jobs", + "endpoints": "Endpoints", + "customresourcedefinitions": "Custom Resource Definitions", + "horizontalpodautoscalers": "Horizontal Pod Autoscalers", + "podsecuritypolicies": "Pod Security Policies", + "poddisruptionbudgets": "Pod Disruption Budgets", + "limitranges": "Limit Ranges", + "roles": "Roles", + "rolebindings": "Role Bindings", + "clusterrolebindings": "Cluster Role Bindings", + "clusterroles": "Cluster Roles", + "serviceaccounts": "Service Accounts" +}; - for (const resource of resources) { - if (!allowedResources.includes(resource)) { - return false; - } - } - - return true; -} +export const ResourceKindMap: Record = Object.fromEntries( + Object.entries(apiResourceRecord) + .map(([resource, { kind }]) => [kind, resource as KubeResource]) +); diff --git a/src/common/utils/__tests__/async-throttle.test.ts b/src/common/utils/__tests__/async-throttle.test.ts new file mode 100644 index 0000000000..52979cd1c4 --- /dev/null +++ b/src/common/utils/__tests__/async-throttle.test.ts @@ -0,0 +1,60 @@ +/** + * 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 { asyncThrottle } from "../async-throttle"; +import { delay } from "../delay"; + +describe("asyncThrottle", () => { + it("should not call wrapped function between calls less than cooldownPeriod apart", async () => { + let i = 0; + const fn = asyncThrottle(async () => { + return ++i; + }, 100); + + expect(await fn()).toBe(1); + expect(await fn()).toBe(1); + expect(await fn()).toBe(1); + expect(await fn()).toBe(1); + expect(await fn()).toBe(1); + }); + + it("should only call wrapped function once if it takes longer than cooldownPeriod to settle", async () => { + let i = 0; + const fn = asyncThrottle(async () => { + await delay(150); + + return ++i; + }, 100); + + const f0 = fn(); + + await delay(110); + + expect(await f0).toBe(1); + + const [f1, f2, f3, f4] = [fn(), fn(), fn(), fn()]; + + expect(await f1).toBe(2); + expect(await f2).toBe(2); + expect(await f3).toBe(2); + expect(await f4).toBe(2); + }); +}); diff --git a/src/common/utils/async-throttle.ts b/src/common/utils/async-throttle.ts new file mode 100644 index 0000000000..4b0c90e897 --- /dev/null +++ b/src/common/utils/async-throttle.ts @@ -0,0 +1,44 @@ +/** + * 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. + */ + +export function asyncThrottle Promise>(fn: Fn, cooldownPeriod: number): Fn { + let p: Promise | undefined = undefined; + let shouldCallAgain = false; + + const res = async (...args: any[]): Promise => { + if (!p) { + setTimeout(() => shouldCallAgain = true, cooldownPeriod); + + return p ??= fn(...args); + } + + if (!shouldCallAgain) { + return p; + } + + shouldCallAgain = false; + setTimeout(() => shouldCallAgain = true, cooldownPeriod); + + return p = p.then(() => fn(...args)); + }; + + return res as Fn; +} diff --git a/src/common/utils/extended-map.ts b/src/common/utils/extended-map.ts index c759fa3459..3014a4333a 100644 --- a/src/common/utils/extended-map.ts +++ b/src/common/utils/extended-map.ts @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { action, ObservableMap } from "mobx"; +import { action, IEnhancer, IObservableMapInitialValues, ObservableMap } from "mobx"; export class ExtendedMap extends Map { static new(entries?: readonly (readonly [K, V])[] | null): ExtendedMap { @@ -67,6 +67,13 @@ export class ExtendedMap extends Map { } export class ExtendedObservableMap extends ObservableMap { + /** + * Create a new `ExtendedObservableMap`. The arguments are the arguments of `ObservableMap`. + */ + static new(initialData?: IObservableMapInitialValues, enhancer?: IEnhancer, name?: string): ExtendedObservableMap { + return new ExtendedObservableMap(initialData, enhancer, name); + } + @action getOrInsert(key: K, getVal: () => V): V { if (this.has(key)) { diff --git a/src/common/utils/observable-timer.ts b/src/common/utils/observable-timer.ts new file mode 100644 index 0000000000..8fb61336ca --- /dev/null +++ b/src/common/utils/observable-timer.ts @@ -0,0 +1,41 @@ +/** + * 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 { computed, observable, runInAction } from "mobx"; + +export class ObservableTimer { + protected counter = observable.box(0); + protected timeout: NodeJS.Timeout; + + constructor(tickPeriod: number) { + this.timeout = setInterval(() => runInAction(() => { + this.counter.set(this.counter.get() + 1); + }), tickPeriod); + } + + @computed get tickCount() { + return this.counter.get(); + } + + dispose() { + clearInterval(this.timeout); + } +} diff --git a/src/extensions/renderer-api/k8s-api.ts b/src/extensions/renderer-api/k8s-api.ts index c579aafbb6..925e2f22fc 100644 --- a/src/extensions/renderer-api/k8s-api.ts +++ b/src/extensions/renderer-api/k8s-api.ts @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -export { isAllowedResource } from "../../common/rbac"; +export { isAllowedResource, isAllowedResources, isAnyAllowedResources } from "../../renderer/api/allowed-resources"; export { ResourceStack } from "../../common/k8s/resource-stack"; export { apiManager } from "../../renderer/api/api-manager"; export { KubeObjectStore } from "../../renderer/kube-object.store"; diff --git a/src/main/cluster.ts b/src/main/cluster.ts index df0da0a552..b6e2c13d4c 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -28,12 +28,13 @@ import { AuthorizationV1Api, CoreV1Api, HttpError, KubeConfig, V1ResourceAttribu import { Kubectl } from "./kubectl"; import { KubeconfigManager } from "./kubeconfig-manager"; import { loadConfigFromFile, loadConfigFromFileSync, validateKubeConfig } from "../common/kube-helpers"; -import { apiResourceRecord, apiResources, KubeApiResource, KubeResource } from "../common/rbac"; import logger from "./logger"; import { VersionDetector } from "./cluster-detectors/version-detector"; import { detectorRegistry } from "./cluster-detectors/detector-registry"; -import plimit from "p-limit"; -import { toJS } from "../common/utils"; +import { ExtendedObservableMap, toJS } from "../common/utils"; +import { getClusterResources } from "./utils/api-resources"; +import { asyncThrottle } from "../common/utils/async-throttle"; +import pLimit from "p-limit"; export enum ClusterStatus { AccessGranted = 2, @@ -74,9 +75,7 @@ export interface ClusterState { accessible: boolean; ready: boolean; failureReason: string; - isAdmin: boolean; allowedNamespaces: string[] - allowedResources: string[] isGlobalWatchEnabled: boolean; } @@ -103,7 +102,6 @@ export class Cluster implements ClusterModel, ClusterState { protected kubeconfigManager: KubeconfigManager; protected eventDisposers: Function[] = []; protected activated = false; - private resourceAccessStatuses: Map = new Map(); get whenReady() { return when(() => this.ready); @@ -167,12 +165,6 @@ export class Cluster implements ClusterModel, ClusterState { * @observable */ @observable failureReason: string; - /** - * Does user have admin like access - * - * @observable - */ - @observable isAdmin = false; /** * Global watch-api accessibility , e.g. "/api/v1/services?watch=1" @@ -198,13 +190,6 @@ export class Cluster implements ClusterModel, ClusterState { * @observable */ @observable allowedNamespaces: string[] = []; - /** - * List of allowed resources - * - * @observable - * @internal - */ - @observable allowedResources: string[] = []; /** * List of accessible namespaces provided by user in the Cluster Settings * @@ -403,7 +388,6 @@ export class Cluster implements ClusterModel, ClusterState { this.ready = false; this.activated = false; this.allowedNamespaces = []; - this.resourceAccessStatuses.clear(); this.pushState(); } @@ -442,10 +426,9 @@ export class Cluster implements ClusterModel, ClusterState { * @internal */ private async refreshAccessibility(): Promise { - this.isAdmin = await this.isClusterAdmin(); this.isGlobalWatchEnabled = await this.canUseWatchApi({ resource: "*" }); - await this.refreshAllowedResources(); + this.allowedNamespaces = await this.getAllowedNamespaces(); this.ready = true; } @@ -467,7 +450,6 @@ export class Cluster implements ClusterModel, ClusterState { @action async refreshAllowedResources() { this.allowedNamespaces = await this.getAllowedNamespaces(); - this.allowedResources = await this.getAllowedResources(); } async getKubeconfig(): Promise { @@ -533,6 +515,46 @@ export class Cluster implements ClusterModel, ClusterState { } } + public getApiResourceMap = asyncThrottle(async () => { + return getClusterResources(await this.getProxyKubeconfig()); + }, 60 * 1000); // 1min + + private isAllowedCheckers = new ExtendedObservableMap Promise>>(); + + private async getIsAllowedResourcesInNamespace(namespace: string): Promise> { + const groups = await this.getApiResourceMap(); + const isAllowed = new Map(); + + for (const group of groups.values()) { + for (const versions of group.values()) { + for (const resource of versions.keys()) { + isAllowed.set(resource, await this.canI({ + name: resource, + namespace, + verb: "list", + })); + } + } + } + + return isAllowed; + } + + async getIsAllowedResources(namespace: string): Promise> { + return this.isAllowedCheckers.getOrInsert( + namespace, + () => asyncThrottle( + () => this.getIsAllowedResourcesInNamespace(namespace), + 60 * 1000, + ) + )(); + } + + /** + * This prevents too many `Cluster.canI` calls from happening at once + */ + private canIApiLimit = pLimit(10); + /** * @internal * @param resourceAttributes resource attributes @@ -541,11 +563,11 @@ export class Cluster implements ClusterModel, ClusterState { const authApi = (await this.getProxyKubeconfig()).makeApiClient(AuthorizationV1Api); try { - const accessReview = await authApi.createSelfSubjectAccessReview({ + const accessReview = await this.canIApiLimit(() => authApi.createSelfSubjectAccessReview({ apiVersion: "authorization.k8s.io/v1", kind: "SelfSubjectAccessReview", spec: { resourceAttributes } - }); + })); return accessReview.body.status.allowed; } catch (error) { @@ -602,9 +624,7 @@ export class Cluster implements ClusterModel, ClusterState { disconnected: this.disconnected, accessible: this.accessible, failureReason: this.failureReason, - isAdmin: this.isAdmin, allowedNamespaces: this.allowedNamespaces, - allowedResources: this.allowedResources, isGlobalWatchEnabled: this.isGlobalWatchEnabled, }; @@ -650,7 +670,7 @@ export class Cluster implements ClusterModel, ClusterState { const api = (await this.getProxyKubeconfig()).makeApiClient(CoreV1Api); try { - const { body: { items }} = await api.listNamespace(); + const { body: { items } } = await api.listNamespace(); const namespaces = items.map(ns => ns.metadata.name); this.getAllowedNamespacesErrorCount = 0; // reset on success @@ -677,55 +697,6 @@ export class Cluster implements ClusterModel, ClusterState { } } - protected async getAllowedResources() { - try { - if (!this.allowedNamespaces.length) { - return []; - } - const resources = apiResources.filter((resource) => this.resourceAccessStatuses.get(resource) === undefined); - const apiLimit = plimit(5); // 5 concurrent api requests - const requests = []; - - for (const apiResource of resources) { - requests.push(apiLimit(async () => { - for (const namespace of this.allowedNamespaces.slice(0, 10)) { - if (!this.resourceAccessStatuses.get(apiResource)) { - const result = await this.canI({ - resource: apiResource.apiName, - group: apiResource.group, - verb: "list", - namespace - }); - - this.resourceAccessStatuses.set(apiResource, result); - } - } - })); - } - await Promise.all(requests); - - return apiResources - .filter((resource) => this.resourceAccessStatuses.get(resource)) - .map(apiResource => apiResource.apiName); - } catch (error) { - return []; - } - } - - isAllowedResource(kind: string): boolean { - if ((kind as KubeResource) in apiResourceRecord) { - return this.allowedResources.includes(kind); - } - - const apiResource = apiResources.find(resource => resource.kind === kind); - - if (apiResource) { - return this.allowedResources.includes(apiResource.apiName); - } - - return true; // allowed by default for other resources - } - isMetricHidden(resource: ClusterMetricsResourceType): boolean { return Boolean(this.preferences.hiddenMetrics?.includes(resource)); } diff --git a/src/main/initializers/ipc.ts b/src/main/initializers/ipc.ts index e3bb4f5ff7..1fe05646d4 100644 --- a/src/main/initializers/ipc.ts +++ b/src/main/initializers/ipc.ts @@ -19,13 +19,13 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type { IpcMainInvokeEvent } from "electron"; +import { ipcMain, IpcMainInvokeEvent } from "electron"; import type { KubernetesCluster } from "../../common/catalog-entities"; import { clusterFrameMap } from "../../common/cluster-frames"; import { clusterActivateHandler, clusterSetFrameIdHandler, clusterVisibilityHandler, clusterRefreshHandler, clusterDisconnectHandler, clusterKubectlApplyAllHandler, clusterKubectlDeleteAllHandler } from "../../common/cluster-ipc"; import { ClusterId, ClusterStore } from "../../common/cluster-store"; import { appEventBus } from "../../common/event-bus"; -import { ipcMainHandle } from "../../common/ipc"; +import { ClusterGetResourcesChannel, ClusterResourceIsAllowedChannel, ipcMainHandle } from "../../common/ipc"; import { catalogEntityRegistry } from "../catalog"; import { ResourceApplier } from "../resource-applier"; @@ -110,4 +110,32 @@ export function initIpcMainHandlers() { throw `${clusterId} is not a valid cluster id`; } }); + + ipcMain.handle(ClusterGetResourcesChannel, async (event, clusterId: ClusterId) => { + // This needs to be `ipcMain.handle` because `utils.toJS` throws on `class T extends Map` + // mobx refuses to change that: https://github.com/mobxjs/mobx/pull/2980 + return ClusterStore.getInstance() + .getById(clusterId) + ?.getApiResourceMap(); + }); + + ipcMainHandle(ClusterResourceIsAllowedChannel, async (event, clusterId: ClusterId, namespaces: string[]): Promise<[string, boolean][]> => { + const cluster = ClusterStore.getInstance().getById(clusterId); + + if (!cluster) { + return []; + } + + const isAllowed = new Map(); + + await Promise.all( + namespaces.map(async namespace => { + for (const [resource, canList] of await cluster.getIsAllowedResources(namespace)) { + isAllowed.set(resource, Boolean(isAllowed.get(resource)) || canList); + } + }) + ); + + return Array.from(isAllowed); + }); } diff --git a/src/main/utils/api-resources.ts b/src/main/utils/api-resources.ts new file mode 100644 index 0000000000..b910bafd22 --- /dev/null +++ b/src/main/utils/api-resources.ts @@ -0,0 +1,96 @@ +/** + * 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 { ApisApi, KubeConfig, V1APIResourceList } from "@kubernetes/client-node"; +import got from "got"; +import pLimit from "p-limit"; +import { ExtendedMap } from "../../common/utils"; + +export interface ApiResource { + categories: Set, + group: Group, + kind: string, + name: ResourceName, + namespaced: boolean, + shortNames: Set, + singularName: string, + verbs: Set, + version: Version, +} + +type Group = string; +type Version = string; +type ResourceName = string; + +/** + * Mapping between groupVersions and resource names and their information + */ +export type ApiResourceMap = Map>>; + +/** + * Get the list of all resources kubernetes knows about from the current cluster of `kc`. + * @param kc The config of the cluster to get all resources of + * @param throttle The max number of inflight connections at a time + * @default throttle = 10 + * @returns A mapping of groups to a mapping of versions to mappings between the resource names and information about the resources + */ +export async function getClusterResources(kc: KubeConfig, throttle = 10): Promise { + const api = kc.makeApiClient(ApisApi); + const { body: apiGroups } = await api.getAPIVersions(); + const limit = pLimit(throttle); + const promises: Promise[] = [ + // This is the legacy APIs + limit(() => got.get(`${kc.getCurrentCluster().server}/api/v1`).json()), + ]; + + for (const apiGroup of apiGroups.groups) { + for (const { groupVersion } of apiGroup.versions) { + // This call returns a `V1APIResourceList` for the specific group version + promises.push(limit(() => got.get(`${kc.getCurrentCluster().server}/apis/${groupVersion}`).json())); + } + } + + const apiResourceLists = await Promise.all(promises); + const res = new ExtendedMap>>(); + + for (const apiResourceList of apiResourceLists) { + const [group, version] = apiResourceList.groupVersion.split("/"); + const versions = res.getOrInsert(group, ExtendedMap.new); + const resources = versions.getOrInsert(version, ExtendedMap.new); + + for (const resource of apiResourceList.resources) { + resources.strictSet(resource.name, { + categories: new Set(resource.categories ?? []), + kind: resource.kind, + name: resource.name, + namespaced: resource.namespaced, + shortNames: new Set(resource.shortNames), + singularName: resource.singularName, + verbs: new Set(resource.verbs), + // group and version are optional fields in the Kubernetes spec, and should be derived from the parent `V1APIResourceList` + group: resource.group || group, + version: resource.version || version, + }); + } + } + + return res; +} diff --git a/src/renderer/api/allowed-resources.ts b/src/renderer/api/allowed-resources.ts new file mode 100644 index 0000000000..70e2475f72 --- /dev/null +++ b/src/renderer/api/allowed-resources.ts @@ -0,0 +1,108 @@ +/** + * 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 { ObservableMap, reaction } from "mobx"; +import type { ClusterId } from "../../common/cluster-store"; +import { ClusterResourceIsAllowedChannel, ClusterGetResourcesChannel, requestMain } from "../../common/ipc"; +import { Disposer, Singleton } from "../utils"; +import type { ApiResourceMap } from "../../main/utils/api-resources"; +import { ObservableTimer } from "../../common/utils/observable-timer"; +import { Notifications } from "../components/notifications"; + +type NamespaceName = string; +type ResourceName = string; + +export class AllowedResources extends Singleton { + protected allowedResourceMap = new ObservableMap(); + public resources: ApiResourceMap; + protected timer = new ObservableTimer(60 * 1000); + disposer: Disposer; + + constructor(protected clusterId: ClusterId, protected getNamespaces: () => NamespaceName[]) { + super(); + } + + async init() { + try { + this.resources = await requestMain(ClusterGetResourcesChannel, this.clusterId); + } catch (error) { + console.error("[ALLOWED-RESOURCES]: failed to initialize resources", error); + Notifications.error("Failed to initialize resources"); + } + + this.refresh(this.getNamespaces()); + + this.disposer = reaction( + () => [this.timer.tickCount, this.getNamespaces()] as const, + ([, namespaces]) => this.refresh(namespaces), + ); + } + + private async refresh(namespaces: NamespaceName[]) { + try { + this.allowedResourceMap.replace(await requestMain(ClusterResourceIsAllowedChannel, this.clusterId, namespaces)); + } catch (error) { + console.error("[ALLOWED-RESOURCES]: failed to refresh", error, { namespaces }); + Notifications.error("Failed to refresh allowed resources"); + } + } + + /** + * Get the permissive list permissions of `name` over `namespaces` + * @param name The name of the resource + * @param namespaces The list of namespaces to check (should be `NamepaceSelectFilter` selected ones) + * @returns `true` if the resource exists; is cluster scoped and can be listed, or is namespaced and can be listed in at least one of the namespaces + */ + isAllowed(name: ResourceName): boolean { + return this.allowedResourceMap.get(name) ?? false; + } +} + +/** + * Get list permissions for a single resource + * @param name The name of the resource to check if it is allowed to be listed + * @returns `true` if the resource exists on the cluster and the cluster has list permissions for that resource + */ +export function isAllowedResource(name: ResourceName) { + return AllowedResources.getInstance().isAllowed(name); +} + +/** + * Get list permissions for several resources + * @param names Several names of resources + * @returns `true` iff `∀ name ∈ names : isAllowedResource(name)` + */ +export function isAllowedResources(...names: ResourceName[]) { + return names.map(isAllowedResource).every(Boolean); +} + +/** + * Get permissive list permissions over several resources + * @param names Several names of resources + * @returns `true` iff `!∀ name ∈ names : !isAllowedResource(name)` + */ +export function isAnyAllowedResources(...names: ResourceName[]) { + if (names.length === 0) { + return true; + } + + return names.map(isAllowedResource).some(Boolean); +} diff --git a/src/renderer/api/kube-watch-api.ts b/src/renderer/api/kube-watch-api.ts index 1a0f7e0b17..b474701dfd 100644 --- a/src/renderer/api/kube-watch-api.ts +++ b/src/renderer/api/kube-watch-api.ts @@ -28,7 +28,6 @@ import type { ClusterContext } from "../components/context"; import plimit from "p-limit"; import { comparer, observable, reaction, makeObservable } from "mobx"; import { autoBind, Disposer, noop } from "../utils"; -import type { KubeApi } from "./kube-api"; import type { KubeJsonApiData } from "./kube-json-api"; import { isDebugging, isProduction } from "../../common/vars"; @@ -58,10 +57,6 @@ export class KubeWatchApi { autoBind(this); } - isAllowedApi(api: KubeApi): boolean { - return Boolean(this.context?.cluster.isAllowedResource(api.kind)); - } - preloadStores(stores: KubeObjectStore[], opts: { namespaces?: string[], loadOnce?: boolean } = {}) { const limitRequests = plimit(1); // load stores one by one to allow quick skipping when fast clicking btw pages const preloading: Promise[] = []; diff --git a/src/renderer/components/+apps/apps.tsx b/src/renderer/components/+apps/apps.tsx index b12faa7ee7..8fa066e21a 100644 --- a/src/renderer/components/+apps/apps.tsx +++ b/src/renderer/components/+apps/apps.tsx @@ -29,7 +29,6 @@ import { helmChartsURL, helmChartsRoute, releaseURL, releaseRoute } from "../../ @observer export class Apps extends React.Component { static get tabRoutes(): TabLayoutRoute[] { - return [ { title: "Charts", diff --git a/src/renderer/components/+config/config.tsx b/src/renderer/components/+config/config.tsx index c04f5eeb37..e602deaad5 100644 --- a/src/renderer/components/+config/config.tsx +++ b/src/renderer/components/+config/config.tsx @@ -27,7 +27,7 @@ import { Secrets } from "../+config-secrets"; import { ResourceQuotas } from "../+config-resource-quotas"; import { PodDisruptionBudgets } from "../+config-pod-disruption-budgets"; import { HorizontalPodAutoscalers } from "../+config-autoscalers"; -import { isAllowedResource } from "../../../common/rbac"; +import { isAllowedResource } from "../../api/allowed-resources"; import { LimitRanges } from "../+config-limit-ranges"; import * as routes from "../../../common/routes"; @@ -95,7 +95,7 @@ export class Config extends React.Component { render() { return ( - + ); } } diff --git a/src/renderer/components/+network/network.tsx b/src/renderer/components/+network/network.tsx index ae4a7596fa..6a387b334d 100644 --- a/src/renderer/components/+network/network.tsx +++ b/src/renderer/components/+network/network.tsx @@ -28,7 +28,7 @@ import { Services } from "../+network-services"; import { Endpoints } from "../+network-endpoints"; import { Ingresses } from "../+network-ingresses"; import { NetworkPolicies } from "../+network-policies"; -import { isAllowedResource } from "../../../common/rbac"; +import { isAllowedResource } from "../../api/allowed-resources"; import * as routes from "../../../common/routes"; @observer @@ -77,7 +77,7 @@ export class Network extends React.Component { render() { return ( - + ); } } diff --git a/src/renderer/components/+storage/storage.tsx b/src/renderer/components/+storage/storage.tsx index 3d4dd55ce3..ed5db09a18 100644 --- a/src/renderer/components/+storage/storage.tsx +++ b/src/renderer/components/+storage/storage.tsx @@ -27,7 +27,7 @@ import { TabLayout, TabLayoutRoute } from "../layout/tab-layout"; import { PersistentVolumes } from "../+storage-volumes"; import { StorageClasses } from "../+storage-classes"; import { PersistentVolumeClaims } from "../+storage-volume-claims"; -import { isAllowedResource } from "../../../common/rbac"; +import { isAllowedResource } from "../../api/allowed-resources"; import * as routes from "../../../common/routes"; @observer @@ -67,7 +67,7 @@ export class Storage extends React.Component { render() { return ( - + ); } } diff --git a/src/renderer/components/+user-management/user-management.tsx b/src/renderer/components/+user-management/user-management.tsx index 3c7e042cd5..fdb377907c 100644 --- a/src/renderer/components/+user-management/user-management.tsx +++ b/src/renderer/components/+user-management/user-management.tsx @@ -22,10 +22,11 @@ import "./user-management.scss"; import React from "react"; +import { computed } from "mobx"; import { observer } from "mobx-react"; import { TabLayout, TabLayoutRoute } from "../layout/tab-layout"; import { PodSecurityPolicies } from "../+pod-security-policies"; -import { isAllowedResource } from "../../../common/rbac"; +import { isAllowedResource } from "../../api/allowed-resources"; import * as routes from "../../../common/routes"; import { ClusterRoleBindings } from "./+cluster-role-bindings"; import { ServiceAccounts } from "./+service-accounts"; @@ -35,7 +36,7 @@ import { ClusterRoles } from "./+cluster-roles"; @observer export class UserManagement extends React.Component { - static get tabRoutes() { + @computed static get tabRoutes() { const tabRoutes: TabLayoutRoute[] = []; if (isAllowedResource("serviceaccounts")) { @@ -97,7 +98,7 @@ export class UserManagement extends React.Component { render() { return ( - + ); } } diff --git a/src/renderer/components/+workloads-overview/overview-statuses.tsx b/src/renderer/components/+workloads-overview/overview-statuses.tsx index ae67d96667..503c50e19a 100644 --- a/src/renderer/components/+workloads-overview/overview-statuses.tsx +++ b/src/renderer/components/+workloads-overview/overview-statuses.tsx @@ -28,10 +28,10 @@ import { Link } from "react-router-dom"; import { workloadStores } from "../+workloads"; import { namespaceStore } from "../+namespaces/namespace.store"; import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter"; -import { isAllowedResource, KubeResource } from "../../../common/rbac"; -import { ResourceNames } from "../../utils/rbac"; +import { isAllowedResource } from "../../api/allowed-resources"; import { boundMethod } from "../../utils"; import { workloadURL } from "../../../common/routes"; +import { KubeResource, ResourceNames } from "../../../common/rbac"; const resources: KubeResource[] = [ "pods", diff --git a/src/renderer/components/+workloads/workloads.tsx b/src/renderer/components/+workloads/workloads.tsx index 5f8724b928..c5f7a4949b 100644 --- a/src/renderer/components/+workloads/workloads.tsx +++ b/src/renderer/components/+workloads/workloads.tsx @@ -22,6 +22,7 @@ import "./workloads.scss"; import React from "react"; +import { computed } from "mobx"; import { observer } from "mobx-react"; import { TabLayout, TabLayoutRoute } from "../layout/tab-layout"; import { WorkloadsOverview } from "../+workloads-overview/overview"; @@ -31,13 +32,13 @@ import { DaemonSets } from "../+workloads-daemonsets"; import { StatefulSets } from "../+workloads-statefulsets"; import { Jobs } from "../+workloads-jobs"; import { CronJobs } from "../+workloads-cronjobs"; -import { isAllowedResource } from "../../../common/rbac"; +import { isAllowedResource } from "../../api/allowed-resources"; import { ReplicaSets } from "../+workloads-replicasets"; import * as routes from "../../../common/routes"; @observer export class Workloads extends React.Component { - static get tabRoutes(): TabLayoutRoute[] { + @computed static get tabRoutes(): TabLayoutRoute[] { const tabs: TabLayoutRoute[] = [ { title: "Overview", @@ -115,7 +116,7 @@ export class Workloads extends React.Component { render() { return ( - + ); } } diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index 1e1c14fd4c..0e7521f1b2 100755 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -31,7 +31,6 @@ import { Events } from "./+events/events"; import { DeploymentScaleDialog } from "./+workloads-deployments/deployment-scale-dialog"; import { CronJobTriggerDialog } from "./+workloads-cronjobs/cronjob-trigger-dialog"; import { CustomResources } from "./+custom-resources/custom-resources"; -import { isAllowedResource } from "../../common/rbac"; import { getHostedCluster, getHostedClusterId } from "../../common/cluster-store"; import logger from "../../main/logger"; import { webFrame } from "electron"; @@ -69,9 +68,13 @@ import { Nodes } from "./+nodes"; import { Workloads } from "./+workloads"; import { Config } from "./+config"; import { Storage } from "./+storage"; +import { AllowedResources, isAllowedResources } from "../api/allowed-resources"; +import { CubeSpinner } from "./spinner"; @observer export class App extends React.Component { + @observable isLoading = true; + constructor(props: {}) { super(props); makeObservable(this); @@ -86,6 +89,7 @@ export class App extends React.Component { await requestMain(clusterSetFrameIdHandler, clusterId); await getHostedCluster().whenReady; // cluster.activate() is done at this point + await AllowedResources.createInstance(clusterId, () => clusterContext.contextNamespaces).init(); ExtensionLoader.getInstance().loadOnClusterRenderer(); setTimeout(() => { appEventBus.emit({ @@ -112,9 +116,14 @@ export class App extends React.Component { preload: true, }) ]); + + setTimeout(() => { + // This is here so that the rest of react can respond to AllowedResources loading + this.isLoading = false; + }, 2000); } - @observable startUrl = isAllowedResource(["events", "nodes", "pods"]) ? routes.clusterURL() : routes.workloadsURL(); + @observable startUrl = isAllowedResources("events", "nodes", "pods") ? routes.clusterURL() : routes.workloadsURL(); getTabLayoutRoutes(menuItem: ClusterPageMenuRegistration) { const routes: TabLayoutRoute[] = []; @@ -143,14 +152,14 @@ export class App extends React.Component { const tabRoutes = this.getTabLayoutRoutes(menu); if (tabRoutes.length > 0) { - const pageComponent = () => ; + const pageComponent = () => ; - return tab.routePath)}/>; + return tab.routePath)} />; } else { const page = ClusterPageRegistry.getInstance().getByPageTarget(menu.target); if (page) { - return ; + return ; } } @@ -163,7 +172,7 @@ export class App extends React.Component { const menu = ClusterPageMenuRegistry.getInstance().getByPage(page); if (!menu) { - return ; + return ; } return null; @@ -171,6 +180,17 @@ export class App extends React.Component { } render() { + if (this.isLoading) { + return ( +
+ +
+            

Loading...

+
+
+ ); + } + return ( @@ -189,8 +209,8 @@ export class App extends React.Component { {this.renderExtensionTabLayoutRoutes()} {this.renderExtensionRoutes()} - - + + diff --git a/src/renderer/components/kube-object/kube-object-list-layout.tsx b/src/renderer/components/kube-object/kube-object-list-layout.tsx index 39b0ee5019..8c486bbef4 100644 --- a/src/renderer/components/kube-object/kube-object-list-layout.tsx +++ b/src/renderer/components/kube-object/kube-object-list-layout.tsx @@ -31,7 +31,7 @@ import { kubeSelectedUrlParam, showDetails } from "./kube-object-details"; import { kubeWatchApi } from "../../api/kube-watch-api"; import { clusterContext } from "../context"; import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter"; -import { ResourceKindMap, ResourceNames } from "../../utils/rbac"; +import { ResourceKindMap, ResourceNames } from "../../../common/rbac"; export interface KubeObjectListLayoutProps extends ItemListLayoutProps { store: KubeObjectStore; diff --git a/src/renderer/components/layout/sidebar.tsx b/src/renderer/components/layout/sidebar.tsx index 2aa3f37488..1b51302960 100644 --- a/src/renderer/components/layout/sidebar.tsx +++ b/src/renderer/components/layout/sidebar.tsx @@ -33,7 +33,7 @@ import { Network } from "../+network"; import { crdStore } from "../+custom-resources/crd.store"; import { CustomResources } from "../+custom-resources/custom-resources"; import { isActiveRoute } from "../../navigation"; -import { isAllowedResource } from "../../../common/rbac"; +import { isAllowedResource } from "../../api/allowed-resources"; import { Spinner } from "../spinner"; import { ClusterPageMenuRegistration, ClusterPageMenuRegistry, ClusterPageRegistry, getExtensionPageUrl } from "../../../extensions/registries"; import { SidebarItem } from "./sidebar-item"; @@ -57,7 +57,7 @@ export class Sidebar extends React.Component { if (crdStore.isLoading) { return (
- +
); } @@ -153,7 +153,7 @@ export class Sidebar extends React.Component { url={pageUrl} isActive={isActive} text={menuItem.title} - icon={} + icon={} > {this.renderTreeFromTabRoutes(tabRoutes)} @@ -264,7 +264,7 @@ export class Sidebar extends React.Component { url={routes.crdURL()} isActive={isActiveRoute(routes.crdRoute)} isHidden={!isAllowedResource("customresourcedefinitions")} - icon={} + icon={} > {this.renderTreeFromTabRoutes(CustomResources.tabRoutes)} {this.renderCustomResources()} diff --git a/src/renderer/initializers/workloads-overview-detail-registry.tsx b/src/renderer/initializers/workloads-overview-detail-registry.tsx index b374bb11af..6dcd5a1076 100644 --- a/src/renderer/initializers/workloads-overview-detail-registry.tsx +++ b/src/renderer/initializers/workloads-overview-detail-registry.tsx @@ -20,8 +20,8 @@ */ import React from "react"; -import { isAllowedResource } from "../../common/rbac"; import { WorkloadsOverviewDetailRegistry } from "../../extensions/registries"; +import { isAllowedResource } from "../api/allowed-resources"; import { Events } from "../components/+events"; import { OverviewStatuses } from "../components/+workloads-overview/overview-statuses"; diff --git a/src/renderer/kube-object.store.ts b/src/renderer/kube-object.store.ts index 0c9f8688a2..f2dc8cd0d4 100644 --- a/src/renderer/kube-object.store.ts +++ b/src/renderer/kube-object.store.ts @@ -30,6 +30,7 @@ import { apiManager } from "./api/api-manager"; import { ensureObjectSelfLink, IKubeApiQueryParams, KubeApi, parseKubeApi } from "./api/kube-api"; import type { KubeJsonApiData } from "./api/kube-json-api"; import { Notifications } from "./components/notifications"; +import { isAllowedResource } from "./api/allowed-resources"; export interface KubeObjectStoreLoadingParams { namespaces: string[]; @@ -138,7 +139,7 @@ export abstract class KubeObjectStore extends ItemSt } protected async loadItems({ namespaces, api, reqInit }: KubeObjectStoreLoadingParams): Promise { - if (this.context?.cluster.isAllowedResource(api.kind)) { + if (isAllowedResource(api.apiResource)) { if (!api.isNamespaced) { return api.list({ reqInit }, this.query); } diff --git a/src/renderer/utils/rbac.ts b/src/renderer/utils/rbac.ts deleted file mode 100644 index da167541fc..0000000000 --- a/src/renderer/utils/rbac.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * 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 { apiResourceRecord, KubeResource } from "../../common/rbac"; - -export const ResourceNames: Record = { - "namespaces": "Namespaces", - "nodes": "Nodes", - "events": "Events", - "resourcequotas": "Resource Quotas", - "services": "Services", - "secrets": "Secrets", - "configmaps": "Config Maps", - "ingresses": "Ingresses", - "networkpolicies": "Network Policies", - "persistentvolumeclaims": "Persistent Volume Claims", - "persistentvolumes": "Persistent Volumes", - "storageclasses": "Storage Classes", - "pods": "Pods", - "daemonsets": "Daemon Sets", - "deployments": "Deployments", - "statefulsets": "Stateful Sets", - "replicasets": "Replica Sets", - "jobs": "Jobs", - "cronjobs": "Cron Jobs", - "endpoints": "Endpoints", - "customresourcedefinitions": "Custom Resource Definitions", - "horizontalpodautoscalers": "Horizontal Pod Autoscalers", - "podsecuritypolicies": "Pod Security Policies", - "poddisruptionbudgets": "Pod Disruption Budgets", - "limitranges": "Limit Ranges", - "roles": "Roles", - "rolebindings": "Role Bindings", - "clusterrolebindings": "Cluster Role Bindings", - "clusterroles": "Cluster Roles", - "serviceaccounts": "Service Accounts" -}; - -export const ResourceKindMap: Record = Object.fromEntries( - Object.entries(apiResourceRecord) - .map(([resource, { kind }]) => [kind, resource as KubeResource]) -); diff --git a/yarn.lock b/yarn.lock index 37eab9dab8..ef88038466 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6735,7 +6735,7 @@ globule@^1.0.0: lodash "~4.17.10" minimatch "~3.0.2" -got@^11.8.0: +got@^11.8.0, got@^11.8.2: version "11.8.2" resolved "https://registry.yarnpkg.com/got/-/got-11.8.2.tgz#7abb3959ea28c31f3576f1576c1effce23f33599" integrity sha512-D0QywKgIe30ODs+fm8wMZiAcZjypcCodPNuMz5H9Mny7RJ+IjJ10BdmGW7OM7fHXP+O7r6ZwapQ/YQmMSvB0UQ== @@ -13847,7 +13847,7 @@ tar@^4.4.10, tar@^4.4.12, tar@^4.4.13: safe-buffer "^5.1.2" yallist "^3.0.3" -tar@^6.0.2, tar@^6.0.5: +tar@^6.0.2: version "6.1.0" resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.0.tgz#d1724e9bcc04b977b18d5c573b333a2207229a83" integrity sha512-DUCttfhsnLCjwoDoFcI+B2iJgYa93vBnDUATYEeRx6sntCTdN01VnqsIuTlALXla/LWooNg0yEGeB+Y8WdFxGA== @@ -13859,6 +13859,18 @@ tar@^6.0.2, tar@^6.0.5: mkdirp "^1.0.3" yallist "^4.0.0" +tar@^6.0.5: + version "6.0.5" + resolved "https://registry.yarnpkg.com/tar/-/tar-6.0.5.tgz#bde815086e10b39f1dcd298e89d596e1535e200f" + integrity sha512-0b4HOimQHj9nXNEAA7zWwMM91Zhhba3pspja6sQbgTpynOJf+bkjBnfybNYzbpLbnwXnbyB4LOREvlyXLkCHSg== + dependencies: + chownr "^2.0.0" + fs-minipass "^2.0.0" + minipass "^3.0.0" + minizlib "^2.1.1" + mkdirp "^1.0.3" + yallist "^4.0.0" + tcp-port-used@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/tcp-port-used/-/tcp-port-used-1.0.1.tgz#46061078e2d38c73979a2c2c12b5a674e6689d70"