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

Request cluster's allowed resources on demand

- add unit tests for asyncThrottle

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2021-05-26 09:49:36 -04:00
parent 36dab02c34
commit 07e1f84707
27 changed files with 541 additions and 192 deletions

View File

@ -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",

View File

@ -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];

View File

@ -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<KubeResource, KubeApiResourceData> = {
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<KubeResource, string> = {
"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<string, KubeResource> = Object.fromEntries(
Object.entries(apiResourceRecord)
.map(([resource, { kind }]) => [kind, resource as KubeResource])
);

View File

@ -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);
});
});

View File

@ -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<Fn extends (...args: any[]) => Promise<any>>(fn: Fn, cooldownPeriod: number): Fn {
let p: Promise<any> | undefined = undefined;
let shouldCallAgain = false;
const res = async (...args: any[]): Promise<any> => {
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;
}

View File

@ -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<K, V> extends Map<K, V> {
static new<K, V>(entries?: readonly (readonly [K, V])[] | null): ExtendedMap<K, V> {
@ -67,6 +67,13 @@ export class ExtendedMap<K, V> extends Map<K, V> {
}
export class ExtendedObservableMap<K, V> extends ObservableMap<K, V> {
/**
* Create a new `ExtendedObservableMap<K, V>`. The arguments are the arguments of `ObservableMap<K, V>`.
*/
static new<K, V>(initialData?: IObservableMapInitialValues<K, V>, enhancer?: IEnhancer<V>, name?: string): ExtendedObservableMap<K, V> {
return new ExtendedObservableMap<K, V>(initialData, enhancer, name);
}
@action
getOrInsert(key: K, getVal: () => V): V {
if (this.has(key)) {

View File

@ -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);
}
}

View File

@ -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";

View File

@ -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<KubeApiResource, boolean> = 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<void> {
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<KubeConfig> {
@ -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<string, () => Promise<Map<string, boolean>>>();
private async getIsAllowedResourcesInNamespace(namespace: string): Promise<Map<string, boolean>> {
const groups = await this.getApiResourceMap();
const isAllowed = new Map<string, boolean>();
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<Map<string, boolean>> {
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));
}

View File

@ -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<string, boolean>();
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);
});
}

View File

@ -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<string>,
group: Group,
kind: string,
name: ResourceName,
namespaced: boolean,
shortNames: Set<string>,
singularName: string,
verbs: Set<string>,
version: Version,
}
type Group = string;
type Version = string;
type ResourceName = string;
/**
* Mapping between groupVersions and resource names and their information
*/
export type ApiResourceMap = Map<Group, Map<Version, Map<ResourceName, ApiResource>>>;
/**
* 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<ApiResourceMap> {
const api = kc.makeApiClient(ApisApi);
const { body: apiGroups } = await api.getAPIVersions();
const limit = pLimit(throttle);
const promises: Promise<V1APIResourceList>[] = [
// This is the legacy APIs
limit(() => got.get(`${kc.getCurrentCluster().server}/api/v1`).json<V1APIResourceList>()),
];
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<V1APIResourceList>()));
}
}
const apiResourceLists = await Promise.all(promises);
const res = new ExtendedMap<string, ExtendedMap<string, ExtendedMap<string, ApiResource>>>();
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;
}

View File

@ -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<ResourceName, boolean>();
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);
}

View File

@ -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<any>[] = [];

View File

@ -29,7 +29,6 @@ import { helmChartsURL, helmChartsRoute, releaseURL, releaseRoute } from "../../
@observer
export class Apps extends React.Component {
static get tabRoutes(): TabLayoutRoute[] {
return [
{
title: "Charts",

View File

@ -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 (
<TabLayout className="Config" tabs={Config.tabRoutes}/>
<TabLayout className="Config" tabs={Config.tabRoutes} />
);
}
}

View File

@ -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 (
<TabLayout className="Network" tabs={Network.tabRoutes}/>
<TabLayout className="Network" tabs={Network.tabRoutes} />
);
}
}

View File

@ -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 (
<TabLayout className="Storage" tabs={Storage.tabRoutes}/>
<TabLayout className="Storage" tabs={Storage.tabRoutes} />
);
}
}

View File

@ -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 (
<TabLayout className="UserManagement" tabs={UserManagement.tabRoutes}/>
<TabLayout className="UserManagement" tabs={UserManagement.tabRoutes} />
);
}
}

View File

@ -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",

View File

@ -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 (
<TabLayout className="Workloads" tabs={Workloads.tabRoutes}/>
<TabLayout className="Workloads" tabs={Workloads.tabRoutes} />
);
}
}

View File

@ -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 = () => <TabLayout tabs={tabRoutes}/>;
const pageComponent = () => <TabLayout tabs={tabRoutes} />;
return <Route key={`extension-tab-layout-route-${index}`} component={pageComponent} path={tabRoutes.map((tab) => tab.routePath)}/>;
return <Route key={`extension-tab-layout-route-${index}`} component={pageComponent} path={tabRoutes.map((tab) => tab.routePath)} />;
} else {
const page = ClusterPageRegistry.getInstance().getByPageTarget(menu.target);
if (page) {
return <Route key={`extension-tab-layout-route-${index}`} path={page.url} component={page.components.Page}/>;
return <Route key={`extension-tab-layout-route-${index}`} path={page.url} component={page.components.Page} />;
}
}
@ -163,7 +172,7 @@ export class App extends React.Component {
const menu = ClusterPageMenuRegistry.getInstance().getByPage(page);
if (!menu) {
return <Route key={`extension-route-${index}`} path={page.url} component={page.components.Page}/>;
return <Route key={`extension-route-${index}`} path={page.url} component={page.components.Page} />;
}
return null;
@ -171,6 +180,17 @@ export class App extends React.Component {
}
render() {
if (this.isLoading) {
return (
<div className={"flex column gaps box align-center justify-center"}>
<CubeSpinner />
<pre>
<p>Loading...</p>
</pre>
</div>
);
}
return (
<Router history={history}>
<ErrorBoundary>
@ -189,8 +209,8 @@ export class App extends React.Component {
<Route component={Apps} {...routes.appsRoute}/>
{this.renderExtensionTabLayoutRoutes()}
{this.renderExtensionRoutes()}
<Redirect exact from="/" to={this.startUrl}/>
<Route component={NotFound}/>
<Redirect exact from="/" to={this.startUrl} />
<Route component={NotFound} />
</Switch>
</MainLayout>
<Notifications/>

View File

@ -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;

View File

@ -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<Props> {
if (crdStore.isLoading) {
return (
<div className="flex justify-center">
<Spinner/>
<Spinner />
</div>
);
}
@ -153,7 +153,7 @@ export class Sidebar extends React.Component<Props> {
url={pageUrl}
isActive={isActive}
text={menuItem.title}
icon={<menuItem.components.Icon/>}
icon={<menuItem.components.Icon />}
>
{this.renderTreeFromTabRoutes(tabRoutes)}
</SidebarItem>
@ -264,7 +264,7 @@ export class Sidebar extends React.Component<Props> {
url={routes.crdURL()}
isActive={isActiveRoute(routes.crdRoute)}
isHidden={!isAllowedResource("customresourcedefinitions")}
icon={<Icon material="extension"/>}
icon={<Icon material="extension" />}
>
{this.renderTreeFromTabRoutes(CustomResources.tabRoutes)}
{this.renderCustomResources()}

View File

@ -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";

View File

@ -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<T extends KubeObject = any> extends ItemSt
}
protected async loadItems({ namespaces, api, reqInit }: KubeObjectStoreLoadingParams): Promise<T[]> {
if (this.context?.cluster.isAllowedResource(api.kind)) {
if (isAllowedResource(api.apiResource)) {
if (!api.isNamespaced) {
return api.list({ reqInit }, this.query);
}

View File

@ -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<KubeResource, string> = {
"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<string, KubeResource> = Object.fromEntries(
Object.entries(apiResourceRecord)
.map(([resource, { kind }]) => [kind, resource as KubeResource])
);

View File

@ -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"