diff --git a/eslint.config.mjs b/eslint.config.mjs index 84da2762..79c5f609 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -159,8 +159,6 @@ export default [ */ quotes: ['error', 'single', { allowTemplateLiterals: true }], - 'capitalized-comments': 'error', - // disabled import/namespace rule as the plug-in is not fully compatible using the compat mode 'import/namespace': 'off', 'import/no-duplicates': 'error', @@ -171,7 +169,8 @@ export default [ 'vitest/consistent-test-filename': 'off', 'vitest/no-hooks': 'off', 'vitest/require-top-level-describe': 'off', - 'import/no-unresolved': 'off' + 'import/no-unresolved': 'off', + 'sonarjs/no-nested-functions': 'off' }, }, diff --git a/packages/common/src/model/api-sender.ts b/packages/common/src/model/api-sender.ts new file mode 100644 index 00000000..e6cf3ccc --- /dev/null +++ b/packages/common/src/model/api-sender.ts @@ -0,0 +1,21 @@ +/********************************************************************** + * Copyright (C) 2022-2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +export type ApiSenderType = { + send: (channel: string, data?: unknown) => void; +}; diff --git a/packages/common/src/model/kubernetes-contexts-healths.ts b/packages/common/src/model/kubernetes-contexts-healths.ts new file mode 100644 index 00000000..78dcbf80 --- /dev/null +++ b/packages/common/src/model/kubernetes-contexts-healths.ts @@ -0,0 +1,31 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +export interface ContextHealth { + contextName: string; + // is the health of the cluster being checked? + checking: boolean; + // was the health check successful? + reachable: boolean; + // is one of the informers marked offline (disconnect after being connected, the cache still being populated) + offline: boolean; + // description in case of error (other than health check) + // currently detected errors: + // - user.exec.command not found + errorMessage?: string; +} diff --git a/packages/common/src/model/kubernetes-contexts-permissions.ts b/packages/common/src/model/kubernetes-contexts-permissions.ts new file mode 100644 index 00000000..192ab5ef --- /dev/null +++ b/packages/common/src/model/kubernetes-contexts-permissions.ts @@ -0,0 +1,35 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +export interface ContextPermission { + contextName: string; + // the resource name is a generic string type and not a string literal type, as we want to handle CRDs names + resourceName: string; + // permitted if allowed and not denied + // > When multiple authorization modules are configured, each is checked in sequence. + // > If any authorizer approves or denies a request, that decision is immediately returned + // > and no other authorizer is consulted. If all modules have no opinion on the request, + // > then the request is denied. An overall deny verdict means that the API server rejects + // > the request and responds with an HTTP 403 (Forbidden) status. + // (source: https://kubernetes.io/docs/reference/access-authn-authz/authorization/) + permitted: boolean; + // A free-form and optional text reason for the resource being allowed or denied. + // We cannot rely on having a reason for every request. + // For exemple on Kind cluster, a reason is given only when the access is allowed, no reason is done for denial. + reason?: string; +} diff --git a/packages/common/src/model/kubernetes-contexts-states.ts b/packages/common/src/model/kubernetes-contexts-states.ts new file mode 100644 index 00000000..af81a1ec --- /dev/null +++ b/packages/common/src/model/kubernetes-contexts-states.ts @@ -0,0 +1,59 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +export const NO_CURRENT_CONTEXT_ERROR = 'no current context'; + +// CheckingState indicates the state of the check for a context +export interface CheckingState { + state: 'waiting' | 'checking' | 'gaveup'; +} + +// A selection of resources, to indicate the 'general' status of a context +type selectedResources = ['pods', 'deployments']; + +// resources managed by podman desktop, excepted the primary ones +// This is where to add new resources when adding new informers +export const secondaryResources = [ + 'services', + 'ingresses', + 'routes', + 'configmaps', + 'secrets', + 'nodes', + 'persistentvolumeclaims', + 'events', + 'cronjobs', + 'jobs', +] as const; + +export type SecondaryResourceName = (typeof secondaryResources)[number]; +export type ResourceName = SelectedResourceName | SecondaryResourceName; + +export type SelectedResourceName = selectedResources[number]; + +export type SelectedResourcesCount = { + [resourceName in SelectedResourceName]: number; +}; + +// information sent: status and count of selected resources +export interface ContextGeneralState { + checking?: CheckingState; + error?: string; + reachable: boolean; + resources: SelectedResourcesCount; +} diff --git a/packages/common/src/model/kubernetes-resource-count.ts b/packages/common/src/model/kubernetes-resource-count.ts new file mode 100644 index 00000000..712b165c --- /dev/null +++ b/packages/common/src/model/kubernetes-resource-count.ts @@ -0,0 +1,23 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +export interface ResourceCount { + contextName: string; + resourceName: string; + count: number; +} diff --git a/packages/common/src/model/kubernetes-resources.ts b/packages/common/src/model/kubernetes-resources.ts new file mode 100644 index 00000000..b8da2b2c --- /dev/null +++ b/packages/common/src/model/kubernetes-resources.ts @@ -0,0 +1,24 @@ +/********************************************************************** + * Copyright (C) 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { KubernetesObject } from '@kubernetes/client-node'; + +export interface KubernetesContextResources { + contextName: string; + items: readonly KubernetesObject[]; +} diff --git a/packages/common/src/model/kubernetes-troubleshooting.ts b/packages/common/src/model/kubernetes-troubleshooting.ts new file mode 100644 index 00000000..4934a6ea --- /dev/null +++ b/packages/common/src/model/kubernetes-troubleshooting.ts @@ -0,0 +1,43 @@ +/********************************************************************** + * Copyright (C) 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +export interface KubernetesTroubleshootingInformation { + healthCheckers: KubernetesTroubleshootingHealthChecker[]; + permissionCheckers: KubernetesTroubleshootingPermissionChecker[]; + informers: KubernetesTroubleshootingInformer[]; +} + +export interface KubernetesTroubleshootingHealthChecker { + contextName: string; + checking: boolean; + reachable: boolean; +} + +export interface KubernetesTroubleshootingPermissionChecker { + contextName: string; + resourceName: string; + permitted: boolean; + reason?: string; +} + +export interface KubernetesTroubleshootingInformer { + contextName: string; + resourceName: string; + isOffline: boolean; + objectsCount?: number; +} diff --git a/packages/common/src/model/openshift-types.ts b/packages/common/src/model/openshift-types.ts new file mode 100644 index 00000000..ef16b4fe --- /dev/null +++ b/packages/common/src/model/openshift-types.ts @@ -0,0 +1,49 @@ +/********************************************************************** + * Copyright (C) 2022 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { V1ManagedFieldsEntry } from '@kubernetes/client-node'; + +export type V1Route = { + apiVersion?: string; + kind?: string; + metadata: { + name: string; + namespace: string; + annotations?: { [key: string]: string }; + labels?: { [key: string]: string }; + managedFields?: Array; + creationTimestamp?: Date; + }; + spec: { + host: string; + port?: { + targetPort: string; + }; + path?: string; + tls: { + insecureEdgeTerminationPolicy: string; + termination: string; + }; + to: { + kind: string; + name: string; + weight: number; + }; + wildcardPolicy: string; + }; +}; diff --git a/packages/extension/package.json b/packages/extension/package.json index b5924873..3b23e9c4 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -40,6 +40,7 @@ "vitest": "^3.1" }, "dependencies": { + "@kubernetes/client-node": "^1.3.0", "ansi_up": "^6.0.5", "inversify": "^7.5.1", "reflect-metadata": "^0.2.2" diff --git a/packages/extension/src/manager/context-health-checker.spec.ts b/packages/extension/src/manager/context-health-checker.spec.ts new file mode 100644 index 00000000..8eb2dabf --- /dev/null +++ b/packages/extension/src/manager/context-health-checker.spec.ts @@ -0,0 +1,249 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { Cluster, Context, User } from '@kubernetes/client-node'; +import { Health } from '@kubernetes/client-node'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { ContextHealthChecker } from './context-health-checker.js'; +import type { KubeConfigSingleContext } from '../types/kubeconfig-single-context.js'; + +vi.mock('@kubernetes/client-node'); + +const context1 = { + name: 'context1', + cluster: 'cluster1', + user: 'user1', +}; +const contexts = [context1] as Context[]; + +const clusters = [ + { + name: 'cluster1', + }, +] as Cluster[]; + +const users = [ + { + name: 'user1', + }, +] as User[]; + +const config = { + getKubeConfig: () => ({ + contexts, + clusters, + users, + currentContext: 'context1', + }), +} as KubeConfigSingleContext; + +beforeEach(() => { + vi.mocked(Health).mockClear(); +}); + +describe('readyz returns a value', async () => { + const onStateChangeCB = vi.fn(); + const onReachableCB = vi.fn(); + const readyzMock = vi.fn(); + let hc: ContextHealthChecker; + + beforeEach(async () => { + vi.mocked(Health).mockImplementation( + () => + ({ + readyz: readyzMock, + }) as unknown as Health, + ); + + hc = new ContextHealthChecker(config); + + hc.onStateChange(onStateChangeCB); + hc.onReachable(onReachableCB); + }); + + test('onStateChange is fired with result of readyz', async () => { + readyzMock.mockResolvedValue(true); + await hc.start(); + + expect(onStateChangeCB).toHaveBeenCalledWith({ + kubeConfig: config, + contextName: 'context1', + checking: true, + reachable: false, + }); + expect(onStateChangeCB).toHaveBeenCalledWith({ + kubeConfig: config, + contextName: 'context1', + checking: false, + reachable: true, + }); + + expect(hc.getState()).toEqual({ + kubeConfig: config, + contextName: 'context1', + checking: false, + reachable: true, + }); + + onStateChangeCB.mockReset(); + + readyzMock.mockResolvedValue(false); + await hc.start(); + expect(onStateChangeCB).toHaveBeenCalledWith({ + kubeConfig: config, + contextName: 'context1', + checking: true, + reachable: false, + }); + expect(onStateChangeCB).toHaveBeenCalledWith({ + kubeConfig: config, + contextName: 'context1', + checking: false, + reachable: false, + }); + + expect(hc.getState()).toEqual({ + kubeConfig: config, + contextName: 'context1', + checking: false, + reachable: false, + }); + }); + + test('onReachable is fired when readyz returns true', async () => { + readyzMock.mockResolvedValue(true); + await hc.start(); + + expect(onReachableCB).toHaveBeenCalledWith({ + kubeConfig: config, + contextName: 'context1', + checking: false, + reachable: true, + }); + + onReachableCB.mockReset(); + + readyzMock.mockResolvedValue(false); + await hc.start(); + expect(onReachableCB).not.toHaveBeenCalled(); + }); +}); + +test('onStateChange is not fired when readyz is rejected with an abort error', async () => { + const readyzMock = vi.fn(); + vi.mocked(Health).mockImplementation( + () => + ({ + readyz: readyzMock, + }) as unknown as Health, + ); + + const hc = new ContextHealthChecker(config); + const onStateChangeCB = vi.fn(); + hc.onStateChange(onStateChangeCB); + + const err = new Error('a message'); + err.name = 'AbortError'; + readyzMock.mockRejectedValue(err); + await hc.start(); + expect(onStateChangeCB).toHaveBeenCalledOnce(); + expect(onStateChangeCB).toHaveBeenCalledWith({ + kubeConfig: config, + contextName: 'context1', + checking: true, + reachable: false, + }); + expect(hc.getState()).toEqual({ + kubeConfig: config, + contextName: 'context1', + checking: true, + reachable: false, + }); +}); + +test('onReadiness is called with false when readyz is rejected with a generic error', async () => { + const readyzMock = vi.fn(); + vi.mocked(Health).mockImplementation( + () => + ({ + readyz: readyzMock, + }) as unknown as Health, + ); + + const hc = new ContextHealthChecker(config); + const onStateChangeCB = vi.fn(); + hc.onStateChange(onStateChangeCB); + + readyzMock.mockRejectedValue(new Error('a generic error')); + await hc.start(); + expect(onStateChangeCB).toHaveBeenCalledWith({ + kubeConfig: config, + contextName: 'context1', + checking: true, + reachable: false, + }); + expect(onStateChangeCB).toHaveBeenCalledWith({ + kubeConfig: config, + contextName: 'context1', + checking: false, + reachable: false, + }); + expect(hc.getState()).toEqual({ + kubeConfig: config, + contextName: 'context1', + checking: false, + reachable: false, + }); +}); + +test('onReadiness is called with false when readyz is rejected with a noent error', async () => { + const readyzMock = vi.fn(); + vi.mocked(Health).mockImplementation( + () => + ({ + readyz: readyzMock, + }) as unknown as Health, + ); + + const hc = new ContextHealthChecker(config); + const onStateChangeCB = vi.fn(); + hc.onStateChange(onStateChangeCB); + + const enoentErr: NodeJS.ErrnoException = new Error('enoent error'); + enoentErr.code = 'ENOENT'; + enoentErr.path = '/path/to/command'; + readyzMock.mockRejectedValue(enoentErr); + await hc.start(); + expect(onStateChangeCB).toHaveBeenCalledWith({ + kubeConfig: config, + contextName: 'context1', + checking: true, + reachable: false, + }); + const state = { + kubeConfig: config, + contextName: 'context1', + checking: false, + reachable: false, + errorMessage: `Command not found: /path/to/command. +Please verify this command is installed, and specify its full path in your kubeconfig file.`, + }; + expect(onStateChangeCB).toHaveBeenCalledWith(state); + expect(hc.getState()).toEqual(state); +}); diff --git a/packages/extension/src/manager/context-health-checker.ts b/packages/extension/src/manager/context-health-checker.ts new file mode 100644 index 00000000..a4d9b77e --- /dev/null +++ b/packages/extension/src/manager/context-health-checker.ts @@ -0,0 +1,131 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { Health } from '@kubernetes/client-node'; +import type { Disposable } from '@podman-desktop/api'; + +import type { Event } from '../types/emitter.js'; +import { Emitter } from '../types/emitter.js'; +import type { KubeConfigSingleContext } from '../types/kubeconfig-single-context.js'; + +export interface ContextHealthState { + kubeConfig: KubeConfigSingleContext; + contextName: string; + checking: boolean; + reachable: boolean; + errorMessage?: string; +} + +export interface ContextHealthCheckOptions { + // timeout in ms + timeout?: number; +} + +// HealthChecker checks the readiness of a Kubernetes context +// by requesting the readiness endpoint of its server +export class ContextHealthChecker implements Disposable { + #health: Health; + #abortController: AbortController; + + #onStateChange = new Emitter(); + onStateChange: Event = this.#onStateChange.event; + + #onReachable = new Emitter(); + onReachable: Event = this.#onReachable.event; + + #contextName: string; + #kubeConfig: KubeConfigSingleContext; + + #currentState: ContextHealthState; + + // builds an HealthChecker which will check the cluster of the current context of the given kubeConfig + constructor(kubeConfig: KubeConfigSingleContext) { + this.#kubeConfig = kubeConfig; + this.#abortController = new AbortController(); + this.#health = new Health(kubeConfig.getKubeConfig()); + this.#contextName = kubeConfig.getKubeConfig().currentContext; + this.#currentState = { + kubeConfig: this.#kubeConfig, + contextName: this.#contextName, + checking: false, + reachable: false, + }; + this.onStateChange((e: ContextHealthState) => { + if (e.reachable) { + this.#onReachable.fire(e); + } + }); + } + + // start checking the readiness + public async start(opts?: ContextHealthCheckOptions): Promise { + this.#currentState = { + kubeConfig: this.#kubeConfig, + contextName: this.#contextName, + checking: true, + reachable: false, + }; + this.#onStateChange.fire(this.#currentState); + try { + const result = await this.#health.readyz({ signal: this.#abortController.signal, timeout: opts?.timeout }); + this.#currentState = { + kubeConfig: this.#kubeConfig, + contextName: this.#contextName, + checking: false, + reachable: result, + }; + this.#onStateChange.fire(this.#currentState); + } catch (err: unknown) { + if (!this.isAbortError(err)) { + this.#currentState = { + kubeConfig: this.#kubeConfig, + contextName: this.#contextName, + checking: false, + reachable: false, + }; + if (this.isNoEntryException(err)) { + let desc = `Command not found: ${err.path}.\nPlease verify this command is installed, and specify its full path in your kubeconfig file.`; + const config = this.#kubeConfig.getKubeConfig(); + if (config.users[0]?.exec && config.users[0].exec.command === err.path && config.users[0].exec.installHint) { + desc += `\n${config.users[0].exec.installHint}`; + } + this.#currentState.errorMessage = desc; + } + this.#onStateChange.fire(this.#currentState); + } + } + } + + public dispose(): void { + this.#onStateChange.dispose(); + this.#onReachable.dispose(); + this.#abortController.abort(); + } + + public getState(): ContextHealthState { + return this.#currentState; + } + + private isAbortError(err: unknown): boolean { + return err instanceof Error && err.name === 'AbortError'; + } + + private isNoEntryException(err: unknown): err is NodeJS.ErrnoException { + return err instanceof Error && 'code' in err && err.code === 'ENOENT'; + } +} diff --git a/packages/extension/src/manager/context-permissions-checker.spec.ts b/packages/extension/src/manager/context-permissions-checker.spec.ts new file mode 100644 index 00000000..72383cfe --- /dev/null +++ b/packages/extension/src/manager/context-permissions-checker.spec.ts @@ -0,0 +1,318 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import util from 'node:util'; + +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import type { ContextPermissionResult, ContextResourcePermission } from './context-permissions-checker.js'; +import { ContextPermissionsChecker } from './context-permissions-checker.js'; +import type { KubeConfigSingleContext } from '../types/kubeconfig-single-context.js'; + +describe('ContextPermissionsChecker is built with a non recursive request', async () => { + let permissionsChecker: ContextPermissionsChecker; + const createSelfSubjectAccessReviewMock = vi.fn(); + const onPermissionResultCB = vi.fn(); + + let kubeConfig: KubeConfigSingleContext; + + const attrs = { + namespace: 'ns1', + group: '*', + resource: '*', + verb: 'watch', + }; + const resources = ['resource1', 'resource2']; + + beforeEach(async () => { + vi.clearAllMocks(); + kubeConfig = { + getKubeConfig: vi.fn().mockReturnValue({ + makeApiClient: vi.fn().mockReturnValue({ + createSelfSubjectAccessReview: createSelfSubjectAccessReviewMock, + }), + }), + } as unknown as KubeConfigSingleContext; + }); + + describe('permission is allowed', async () => { + beforeEach(async () => { + createSelfSubjectAccessReviewMock.mockResolvedValue({ + status: { + allowed: true, + reason: 'a reason', + }, + }); + permissionsChecker = new ContextPermissionsChecker(kubeConfig, 'ctx1', { + attrs, + resources, + }); + permissionsChecker.onPermissionResult(onPermissionResultCB); + await permissionsChecker.start(); + }); + + test('onPermissionResult is fired with permitted true', async () => { + expect(onPermissionResultCB).toHaveBeenCalledWith({ + kubeConfig: expect.anything(), + attrs, + resources, + permitted: true, + reason: 'a reason', + }); + }); + + test('getPermissions returns permitted true', async () => { + const permissions = permissionsChecker.getPermissions(); + const expected: ContextResourcePermission[] = []; + expected.push({ + contextName: 'ctx1', + resourceName: 'resource1', + attrs, + permitted: true, + reason: 'a reason', + }); + expected.push({ + contextName: 'ctx1', + resourceName: 'resource2', + attrs, + permitted: true, + reason: 'a reason', + }); + expect(permissions).toEqual(expected); + }); + }); + + describe('permission is allowed and denied is true', async () => { + beforeEach(async () => { + createSelfSubjectAccessReviewMock.mockResolvedValue({ + status: { + allowed: true, + denied: true, + reason: 'a reason', + }, + }); + permissionsChecker = new ContextPermissionsChecker(kubeConfig, 'ctx1', { + attrs, + resources, + }); + permissionsChecker.onPermissionResult(onPermissionResultCB); + await permissionsChecker.start(); + }); + + test('onPermissionResult is fired with permitted false', async () => { + expect(onPermissionResultCB).toHaveBeenCalledWith({ + kubeConfig: expect.anything(), + attrs, + resources, + permitted: false, + reason: 'a reason', + }); + }); + + test('getPermissions returns permitted false', async () => { + const permissions = permissionsChecker.getPermissions(); + const expected: ContextResourcePermission[] = []; + expected.push({ + contextName: 'ctx1', + resourceName: 'resource1', + attrs, + permitted: false, + reason: 'a reason', + }); + expected.push({ + contextName: 'ctx1', + resourceName: 'resource2', + attrs, + permitted: false, + reason: 'a reason', + }); + expect(permissions).toEqual(expected); + }); + }); + + describe('permission is not allowed', async () => { + beforeEach(async () => { + createSelfSubjectAccessReviewMock.mockResolvedValue({ + status: { + allowed: false, + reason: 'a reason', + }, + }); + permissionsChecker = new ContextPermissionsChecker(kubeConfig, 'ctx1', { + attrs, + resources, + }); + permissionsChecker.onPermissionResult(onPermissionResultCB); + await permissionsChecker.start(); + }); + + test('onPermissionResult is fired with permitted false', async () => { + expect(onPermissionResultCB).toHaveBeenCalledWith({ + kubeConfig: expect.anything(), + attrs, + resources, + permitted: false, + reason: 'a reason', + }); + }); + + test('getPermissions returns permitted false', async () => { + const permissions = permissionsChecker.getPermissions(); + const expected: ContextResourcePermission[] = []; + expected.push({ + contextName: 'ctx1', + resourceName: 'resource1', + attrs, + permitted: false, + reason: 'a reason', + }); + expected.push({ + contextName: 'ctx1', + resourceName: 'resource2', + attrs, + permitted: false, + reason: 'a reason', + }); + expect(permissions).toEqual(expected); + }); + }); +}); + +describe('ContextPermissionsChecker is built with a recursive request', async () => { + let permissionsChecker: ContextPermissionsChecker; + const createSelfSubjectAccessReviewMock = vi.fn(); + const onPermissionResultCB = vi.fn(); + + let kubeConfig: KubeConfigSingleContext; + + const attrs = { + namespace: 'ns1', + group: '*', + resource: '*', + verb: 'watch', + }; + const resources = ['resource1', 'resource2']; + + const attrsResource1 = { + namespace: 'ns1', + group: 'group1', + resource: 'resource1', + verb: 'watch', + }; + const resources1 = ['resource1']; + + const attrsResource2 = { + namespace: 'ns1', + group: 'group2', + resource: 'resource2', + verb: 'watch', + }; + const resources2 = ['resource2']; + + beforeEach(async () => { + vi.clearAllMocks(); + kubeConfig = { + getKubeConfig: vi.fn().mockReturnValue({ + makeApiClient: vi.fn().mockReturnValue({ + createSelfSubjectAccessReview: createSelfSubjectAccessReviewMock, + }), + }), + } as unknown as KubeConfigSingleContext; + }); + + describe('permission is denied globally, and allowed for resource1 only', async () => { + beforeEach(async () => { + createSelfSubjectAccessReviewMock.mockImplementation(param => { + if (util.isDeepStrictEqual(param.body.spec.resourceAttributes, attrs)) { + return { + status: { + allowed: false, + reason: 'a global reason', + }, + }; + } else if (util.isDeepStrictEqual(param.body.spec.resourceAttributes, attrsResource1)) { + return { + status: { + allowed: true, + reason: 'a reason 1', + }, + }; + } + return { + status: { + allowed: false, + reason: 'a reason 2', + }, + }; + }); + permissionsChecker = new ContextPermissionsChecker(kubeConfig, 'ctx1', { + attrs, + resources, + onDenyRequests: [ + { + attrs: attrsResource1, + resources: resources1, + }, + { + attrs: attrsResource2, + resources: resources2, + }, + ], + }); + permissionsChecker.onPermissionResult(onPermissionResultCB); + await permissionsChecker.start(); + }); + + test('onPermissionResult is fired with permitted true for resource1 and false for resource2', async () => { + expect(onPermissionResultCB).toHaveBeenCalledWith({ + kubeConfig: expect.anything(), + attrs: attrsResource1, + resources: resources1, + permitted: true, + reason: 'a reason 1', + } as ContextPermissionResult); + expect(onPermissionResultCB).toHaveBeenCalledWith({ + kubeConfig: expect.anything(), + attrs: attrsResource2, + resources: resources2, + permitted: false, + reason: 'a reason 2', + } as ContextPermissionResult); + }); + + test('getPermissions returns permitted true for resource1 and false for resource2', async () => { + const permissions = permissionsChecker.getPermissions(); + const expected: ContextResourcePermission[] = []; + expected.push({ + contextName: 'ctx1', + resourceName: 'resource1', + attrs: attrsResource1, + permitted: true, + reason: 'a reason 1', + }); + expected.push({ + contextName: 'ctx1', + resourceName: 'resource2', + attrs: attrsResource2, + permitted: false, + reason: 'a reason 2', + }); + expect(permissions).toEqual(expected); + }); + }); +}); diff --git a/packages/extension/src/manager/context-permissions-checker.ts b/packages/extension/src/manager/context-permissions-checker.ts new file mode 100644 index 00000000..c0b63d72 --- /dev/null +++ b/packages/extension/src/manager/context-permissions-checker.ts @@ -0,0 +1,150 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { + AuthorizationV1ApiCreateSelfSubjectAccessReviewRequest, + V1ResourceAttributes, + V1SubjectAccessReviewStatus, +} from '@kubernetes/client-node'; +import { AuthorizationV1Api } from '@kubernetes/client-node'; +import type { Disposable } from '@podman-desktop/api'; + +import type { Event } from '../types/emitter.js'; +import { Emitter } from '../types/emitter.js'; +import type { KubeConfigSingleContext } from '../types/kubeconfig-single-context.js'; + +export interface ContextPermissionsRequest { + // the request to send + attrs: V1ResourceAttributes; + // the resources covered by the request + resources: string[]; + // list of more fine-grained requests to send in case of denied request + onDenyRequests?: ContextPermissionsRequest[]; +} + +// Permission is the permission for a specific resource +export interface Permission { + attrs: V1ResourceAttributes; + // permitted if allowed and not denied + permitted: boolean; + reason?: string; +} + +// ContextResourcePermission is the permission for a specific resource in a specific context +export interface ContextResourcePermission extends Permission { + contextName: string; + resourceName: string; +} + +// ContextPermissionResult is the result of a request, which can cover several resources +export interface ContextPermissionResult extends Permission { + kubeConfig: KubeConfigSingleContext; + resources: string[]; +} + +export class ContextPermissionsChecker implements Disposable { + #contextName: string; + #kubeconfig: KubeConfigSingleContext; + #client: AuthorizationV1Api; + #request: ContextPermissionsRequest; + #subCheckers: ContextPermissionsChecker[] = []; + #results: ContextResourcePermission[]; + + #onPermissionResult = new Emitter(); + onPermissionResult: Event = this.#onPermissionResult.event; + + // builds a ContextPermissionsChecker which will check permissions on the current context of the given kubeConfig + // The request will be made with `attrs` and if allowed, permissions will be given for `resources` + // If the result is denied, `onDenyRequests` will be started + constructor(kubeconfig: KubeConfigSingleContext, contextName: string, request: ContextPermissionsRequest) { + this.#kubeconfig = kubeconfig; + this.#contextName = contextName; + this.#request = request; + this.#results = []; + this.#client = this.#kubeconfig.getKubeConfig().makeApiClient(AuthorizationV1Api); + } + + get contextName(): string { + return this.#contextName; + } + + public async start(): Promise { + const result = await this.makeRequest(this.#request.attrs); + if ((!result.allowed || result.denied) && this.#request.onDenyRequests?.length) { + // if not permitted and sub-requests are defined, let start them and don't send any result + for (const subreq of this.#request.onDenyRequests) { + const subchecker = new ContextPermissionsChecker(this.#kubeconfig, this.#contextName, subreq); + this.#subCheckers.push(subchecker); + subchecker.onPermissionResult((permissionResult: ContextPermissionResult) => { + this.saveAndFireResult(permissionResult); + }); + await subchecker.start(); + } + } else { + // send the result for resources concerned by the request + this.saveAndFireResult({ + kubeConfig: this.#kubeconfig, + resources: this.#request.resources, + attrs: this.#request.attrs, + permitted: result.allowed && !result.denied, + reason: result.reason, + }); + } + } + + private saveAndFireResult(result: ContextPermissionResult): void { + this.#onPermissionResult.fire(result); + for (const resourceName of result.resources) { + this.#results.push({ + contextName: this.#contextName, + resourceName, + attrs: result.attrs, + permitted: result.permitted, + reason: result.reason, + }); + } + } + + public getPermissions(): ContextResourcePermission[] { + return this.#results; + } + + public dispose(): void { + this.#onPermissionResult.dispose(); + for (const subchecker of this.#subCheckers) { + subchecker.dispose(); + } + } + + private async makeRequest(attrs: V1ResourceAttributes): Promise { + const param: AuthorizationV1ApiCreateSelfSubjectAccessReviewRequest = { + body: { + spec: { + resourceAttributes: attrs, + }, + }, + }; + const result = await this.#client.createSelfSubjectAccessReview(param); + if (!result.status) { + return { + allowed: false, + }; + } + return result.status; + } +} diff --git a/packages/extension/src/manager/contexts-dispatcher.spec.ts b/packages/extension/src/manager/contexts-dispatcher.spec.ts new file mode 100644 index 00000000..a23f54c7 --- /dev/null +++ b/packages/extension/src/manager/contexts-dispatcher.spec.ts @@ -0,0 +1,267 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { Cluster, Context, User } from '@kubernetes/client-node'; +import { KubeConfig } from '@kubernetes/client-node'; +import { beforeEach, expect, test, vi } from 'vitest'; + +import { ContextsDispatcher } from './contexts-dispatcher.js'; +import { KubeConfigSingleContext } from '../types/kubeconfig-single-context.js'; + +const contexts = [ + { + name: 'context1', + cluster: 'cluster1', + user: 'user1', + }, + { + name: 'context2', + cluster: 'cluster2', + user: 'user2', + }, +] as Context[]; + +const clusters = [ + { + name: 'cluster1', + }, + { + name: 'cluster2', + }, +] as Cluster[]; + +const users = [ + { + name: 'user1', + }, + { + name: 'user2', + }, +] as User[]; + +const kcWith2contexts = { + contexts, + clusters, + users, +} as unknown as KubeConfig; + +const onAddMock = vi.fn(); +const onUpdateMock = vi.fn(); +const onDeleteMock = vi.fn(); + +let dispatcher: ContextsDispatcher; + +beforeEach(() => { + vi.clearAllMocks(); + dispatcher = new ContextsDispatcher(); + dispatcher.onAdd(onAddMock); + dispatcher.onUpdate(onUpdateMock); + dispatcher.onDelete(onDeleteMock); +}); + +test('first call to update calls onAdd for each context', () => { + const kc = new KubeConfig(); + kc.loadFromOptions(kcWith2contexts); + dispatcher.update(kc); + expect(onUpdateMock).not.toHaveBeenCalled(); + expect(onDeleteMock).not.toHaveBeenCalled(); + expect(onAddMock).toHaveBeenCalledTimes(2); + const kc1 = new KubeConfig(); + kc1.loadFromOptions({ + contexts: [contexts[0]], + users: [users[0]], + clusters: [clusters[0]], + currentContext: 'context1', + }); + expect(onAddMock).toHaveBeenCalledWith({ + contextName: 'context1', + config: new KubeConfigSingleContext(kc1, contexts[0]!), + }); + const kc2 = new KubeConfig(); + kc2.loadFromOptions({ + contexts: [contexts[1]], + users: [users[1]], + clusters: [clusters[1]], + currentContext: 'context2', + }); + expect(onAddMock).toHaveBeenCalledWith({ + contextName: 'context2', + config: new KubeConfigSingleContext(kc2, contexts[1]!), + }); +}); + +test('call update again with same kubeconfig calls nothing', () => { + const kc = new KubeConfig(); + kc.loadFromOptions(kcWith2contexts); + dispatcher.update(kc); + + onAddMock.mockReset(); + onUpdateMock.mockReset(); + onDeleteMock.mockReset(); + + dispatcher.update(kc); + + expect(onUpdateMock).not.toHaveBeenCalled(); + expect(onDeleteMock).not.toHaveBeenCalled(); + expect(onAddMock).not.toHaveBeenCalled(); +}); + +test('call update with a missing context calls onDelete', () => { + const kc = new KubeConfig(); + kc.loadFromOptions(kcWith2contexts); + dispatcher.update(kc); + + onAddMock.mockReset(); + onUpdateMock.mockReset(); + onDeleteMock.mockReset(); + + kc.loadFromOptions({ + contexts: [contexts[0]], + users: [users[0]], + clusters: [clusters[0]], + }); + dispatcher.update(kc); + + expect(onUpdateMock).not.toHaveBeenCalled(); + expect(onAddMock).not.toHaveBeenCalled(); + expect(onDeleteMock).toHaveBeenCalledOnce(); + const kcDeleted = new KubeConfig(); + kcDeleted.loadFromOptions({ + contexts: [contexts[1]], + users: [users[1]], + clusters: [clusters[1]], + currentContext: 'context2', + }); + expect(onDeleteMock).toHaveBeenCalledWith({ + contextName: 'context2', + config: new KubeConfigSingleContext(kcDeleted, contexts[1]!), + }); +}); + +test('call update with a new context calls onAdd', () => { + const kc = new KubeConfig(); + kc.loadFromOptions(kcWith2contexts); + dispatcher.update(kc); + + onAddMock.mockReset(); + onUpdateMock.mockReset(); + onDeleteMock.mockReset(); + + const newContext = { + name: 'context3', + cluster: 'cluster3', + user: 'user3', + }; + const newUser = { + name: 'user3', + }; + const newCluster = { + name: 'cluster3', + }; + kc.loadFromOptions({ + contexts: [...contexts, newContext], + users: [...users, newUser], + clusters: [...clusters, newCluster], + }); + dispatcher.update(kc); + + expect(onUpdateMock).not.toHaveBeenCalled(); + expect(onDeleteMock).not.toHaveBeenCalled(); + expect(onAddMock).toHaveBeenCalledOnce(); + const kc3 = new KubeConfig(); + kc3.loadFromOptions({ + contexts: [newContext], + users: [newUser], + clusters: [newCluster], + currentContext: 'context3', + }); + expect(onAddMock).toHaveBeenCalledWith({ + contextName: 'context3', + config: new KubeConfigSingleContext(kc3, newContext), + }); +}); + +test('call update with a modifed context calls onUpdate', () => { + const kc = new KubeConfig(); + kc.loadFromOptions(kcWith2contexts); + dispatcher.update(kc); + + onAddMock.mockReset(); + onUpdateMock.mockReset(); + onDeleteMock.mockReset(); + + const updatedUser = { + name: 'user1', + certData: 'cert', + } as User; + kc.loadFromOptions({ + contexts, + users: [updatedUser, users[1]], + clusters, + }); + dispatcher.update(kc); + + expect(onAddMock).not.toHaveBeenCalled(); + expect(onDeleteMock).not.toHaveBeenCalled(); + expect(onUpdateMock).toHaveBeenCalledOnce(); + const kc1 = new KubeConfig(); + kc1.loadFromOptions({ + contexts: [contexts[0]], + users: [updatedUser], + clusters: [clusters[0]], + currentContext: 'context1', + }); + expect(onUpdateMock).toHaveBeenCalledWith({ + contextName: 'context1', + config: new KubeConfigSingleContext(kc1, contexts[0]!), + }); +}); + +test('current context changes', async () => { + const onCurrentChangeMock = vi.fn(); + const kcWith2contextsWithCurrent1 = { + ...kcWith2contexts, + currentContext: 'context1', + }; + const kcWith2contextsWithCurrent2 = { + ...kcWith2contexts, + currentContext: 'context2', + }; + + dispatcher.onCurrentChange(onCurrentChangeMock); + + const kc = new KubeConfig(); + kc.loadFromOptions(kcWith2contextsWithCurrent1); + dispatcher.update(kc); + + expect(onCurrentChangeMock).toHaveBeenCalledWith({ + previous: undefined, + current: 'context1', + currentConfig: expect.anything(), + }); + + onCurrentChangeMock.mockClear(); + kc.loadFromOptions(kcWith2contextsWithCurrent2); + dispatcher.update(kc); + + expect(onCurrentChangeMock).toHaveBeenCalledWith({ + previous: 'context1', + current: 'context2', + currentConfig: expect.anything(), + }); +}); diff --git a/packages/extension/src/manager/contexts-dispatcher.ts b/packages/extension/src/manager/contexts-dispatcher.ts new file mode 100644 index 00000000..3f24573a --- /dev/null +++ b/packages/extension/src/manager/contexts-dispatcher.ts @@ -0,0 +1,110 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { KubeConfig } from '@kubernetes/client-node'; + +import type { Event } from '../types/emitter.js'; +import { Emitter } from '../types/emitter.js'; +import { KubeConfigSingleContext } from '../types/kubeconfig-single-context.js'; + +export interface DispatcherEvent { + contextName: string; + config: KubeConfigSingleContext; +} + +export interface CurrentChangeEvent { + // the current context before the change + previous?: string; + // the current context now + current?: string; + // the config for the current context now + currentConfig?: KubeConfigSingleContext; +} + +/** + * ContextsDispatcher gets new Kubeconfig values with the `update` method + * and fires Add/Update/Delete events + * for contexts added/updated/deleted since previous update + * + * the KubeConfig values emitted by add/update contain + * a single context and its related information (user, cluster), + * and is set as the current context for the KubeConfig + */ +export class ContextsDispatcher { + #contexts = new Map(); + #currentContext: string | undefined; + + #onAdd = new Emitter(); + #onUpdate = new Emitter(); + #onDelete = new Emitter(); + #onCurrentChange = new Emitter(); + + onAdd: Event = this.#onAdd.event; + onUpdate: Event = this.#onUpdate.event; + onDelete: Event = this.#onDelete.event; + onCurrentChange: Event = this.#onCurrentChange.event; + + update(kubeconfig: KubeConfig): void { + const contextsDiff = new Set(this.#contexts.keys()); + for (const kubeContext of kubeconfig.getContexts()) { + contextsDiff.delete(kubeContext.name); + const kubeconfigSingleContext = new KubeConfigSingleContext(kubeconfig, kubeContext); + + if (!this.#contexts.has(kubeContext.name)) { + this.#onAdd.fire({ contextName: kubeContext.name, config: kubeconfigSingleContext }); + this.#contexts.set(kubeContext.name, kubeconfigSingleContext); + continue; + } + if (kubeconfigSingleContext.equals(this.#contexts.get(kubeContext.name))) { + // already exists and is the same, nothing to declare + continue; + } + + // context has changed + this.#onUpdate.fire({ contextName: kubeContext.name, config: kubeconfigSingleContext }); + this.#contexts.set(kubeContext.name, kubeconfigSingleContext); + } + + for (const nameOfRemainingContext of contextsDiff.keys()) { + const ctxToRemove = this.#contexts.get(nameOfRemainingContext); + if (!ctxToRemove) { + throw new Error(`config for ${nameOfRemainingContext} not found, should not happen`); + } + this.#onDelete.fire({ contextName: nameOfRemainingContext, config: ctxToRemove }); + this.#contexts.delete(nameOfRemainingContext); + } + + if (kubeconfig.currentContext !== this.#currentContext) { + const currentConfig = this.#contexts.get(kubeconfig.currentContext); + this.#onCurrentChange.fire({ + previous: this.#currentContext, + current: kubeconfig.currentContext, + currentConfig, + }); + this.#currentContext = kubeconfig.currentContext; + } + } + + getKubeConfigSingleContext(contextName: string): KubeConfigSingleContext { + const result = this.#contexts.get(contextName); + if (!result) { + throw new Error(`config not found for context ${contextName}`); + } + return result; + } +} diff --git a/packages/extension/src/manager/contexts-manager.spec.ts b/packages/extension/src/manager/contexts-manager.spec.ts new file mode 100644 index 00000000..b9587eed --- /dev/null +++ b/packages/extension/src/manager/contexts-manager.spec.ts @@ -0,0 +1,1132 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { Cluster, KubernetesObject, ObjectCache } from '@kubernetes/client-node'; +import { KubeConfig } from '@kubernetes/client-node'; +import type { Event } from '@podman-desktop/api'; +import { assert, beforeEach, describe, expect, test, vi } from 'vitest'; + +import type { ContextHealthState } from './context-health-checker.js'; +import { ContextHealthChecker } from './context-health-checker.js'; +import { + ContextPermissionsChecker, + type ContextPermissionsRequest, + type ContextResourcePermission, +} from './context-permissions-checker.js'; +import { ContextsManager } from './contexts-manager.js'; +import { KubeConfigSingleContext } from '../types/kubeconfig-single-context.js'; +import type { ResourceFactory } from '../resources/resource-factory.js'; +import { ResourceFactoryBase } from '../resources/resource-factory.js'; +import type { CacheUpdatedEvent, OfflineEvent, ResourceInformer } from '../types/resource-informer.js'; + +const onCacheUpdatedMock = vi.fn>(); +const onOfflineMock = vi.fn>(); +const startMock = vi.fn(); +const informerDisposeMock = vi.fn(); + +class TestContextsManager extends ContextsManager { + override getResourceFactories(): ResourceFactory[] { + return [ + new ResourceFactoryBase({ + resource: 'resource1', + }) + .setPermissions({ + isNamespaced: true, + permissionsRequests: [ + { + group: '*', + resource: '*', + verb: 'watch', + }, + ], + }) + .setInformer({ + createInformer: (_kubeconfig: KubeConfigSingleContext): ResourceInformer => { + return { + onCacheUpdated: onCacheUpdatedMock, + onOffline: onOfflineMock, + start: startMock, + dispose: informerDisposeMock, + } as unknown as ResourceInformer; + }, + }) + .setIsActive((resource: KubernetesObject): boolean => { + return 'activeField' in resource && resource.activeField === true; + }), + new ResourceFactoryBase({ + resource: 'resource2', + }).setPermissions({ + isNamespaced: true, + permissionsRequests: [ + { + group: '*', + resource: '*', + verb: 'watch', + }, + ], + }), + new ResourceFactoryBase({ + resource: 'resource3', + }).setPermissions({ + isNamespaced: false, + permissionsRequests: [ + { + group: '*', + resource: '*', + verb: 'watch', + }, + ], + }), + ]; + } + + public override async startMonitoring(config: KubeConfigSingleContext, contextName: string): Promise { + return super.startMonitoring(config, contextName); + } + + public override stopMonitoring(contextName: string): void { + return super.stopMonitoring(contextName); + } +} + +const context1 = { + name: 'context1', + cluster: 'cluster1', + user: 'user1', + namespace: 'ns1', +}; + +const kcWithContext1asDefault = { + contexts: [context1], + clusters: [ + { + name: 'cluster1', + }, + ], + users: [ + { + name: 'user1', + }, + ], + currentContext: 'context1', +}; + +const context2 = { + name: 'context2', + cluster: 'cluster2', + user: 'user2', + namespace: 'ns2', +}; +const kcWithContext2asDefault = { + contexts: [context2], + clusters: [ + { + name: 'cluster2', + }, + ], + users: [ + { + name: 'user2', + }, + ], + currentContext: 'context2', +}; + +vi.mock('./context-health-checker.js'); +vi.mock('./context-permissions-checker.js'); + +let kcWith2contexts: KubeConfig; + +beforeEach(() => { + vi.clearAllMocks(); + kcWith2contexts = { + contexts: [ + { + name: 'context1', + cluster: 'cluster1', + user: 'user1', + namespace: 'ns1', + }, + { + name: 'context2', + cluster: 'cluster2', + user: 'user2', + namespace: 'ns2', + }, + ], + clusters: [ + { + name: 'cluster1', + } as Cluster, + { + name: 'cluster2', + } as Cluster, + ], + users: [ + { + name: 'user1', + }, + { + name: 'user2', + }, + ], + currentContext: 'context1', + } as unknown as KubeConfig; + + vi.mocked(ContextHealthChecker).mockClear(); + vi.mocked(ContextPermissionsChecker).mockClear(); +}); + +describe('HealthChecker is built and start is called for each context the first time', async () => { + let kc: KubeConfig; + let manager: TestContextsManager; + const healthStartMock = vi.fn(); + const healthDisposeMock = vi.fn(); + const onStateChangeMock = vi.fn(); + const onreachableMock = vi.fn(); + const permissionsStartMock = vi.fn(); + + beforeEach(async () => { + kc = new KubeConfig(); + kc.loadFromOptions(kcWith2contexts); + + vi.mocked(ContextHealthChecker).mockImplementation( + () => + ({ + start: healthStartMock, + dispose: healthDisposeMock, + onStateChange: onStateChangeMock, + onReachable: onreachableMock, + }) as unknown as ContextHealthChecker, + ); + + vi.mocked(ContextPermissionsChecker).mockImplementation( + () => + ({ + start: permissionsStartMock, + onPermissionResult: vi.fn(), + isForContext: vi.fn(), + }) as unknown as ContextPermissionsChecker, + ); + manager = new TestContextsManager(); + }); + + test('when context is not reachable', async () => { + await manager.update(kc); + expect(ContextHealthChecker).toHaveBeenCalledTimes(1); // current context only + const kc1 = new KubeConfig(); + kc1.loadFromOptions(kcWithContext1asDefault); + expect(ContextHealthChecker).toHaveBeenCalledWith(new KubeConfigSingleContext(kc1, context1)); + const kc2 = new KubeConfig(); + kc2.loadFromOptions(kcWithContext2asDefault); + expect(ContextHealthChecker).toHaveBeenCalledWith(new KubeConfigSingleContext(kc2, context2)); + expect(healthStartMock).toHaveBeenCalledTimes(1); + + expect(healthDisposeMock).not.toHaveBeenCalled(); + + expect(ContextPermissionsChecker).not.toHaveBeenCalled(); + }); + + test('when context is reachable, persmissions checkers are created and started', async () => { + const kcSingle1 = new KubeConfigSingleContext(kc, context1); + const kcSingle2 = new KubeConfigSingleContext(kc, context2); + let call = 0; + onreachableMock.mockImplementation(f => { + call++; + f({ + kubeConfig: call === 1 ? kcSingle1 : kcSingle2, + contextName: call === 1 ? 'context1' : 'context2', + checking: false, + reachable: true, + } as ContextHealthState); + }); + await manager.update(kc); + + // Once for namespaced resources, once for non-namespaced resources (on current context only) + expect(ContextPermissionsChecker).toHaveBeenCalledTimes(2); + expect(ContextPermissionsChecker).toHaveBeenCalledWith(kcSingle1, 'context1', expect.anything()); + + expect(permissionsStartMock).toHaveBeenCalledTimes(2); + }); +}); + +describe('HealthChecker pass and PermissionsChecker resturns a value', async () => { + let kc: KubeConfig; + let manager: TestContextsManager; + const healthStartMock = vi.fn(); + const healthDisposeMock = vi.fn(); + const onStateChangeMock = vi.fn(); + const onreachableMock = vi.fn(); + const permissionsStartMock = vi.fn(); + const onPermissionResultMock = vi.fn(); + + beforeEach(async () => { + kc = new KubeConfig(); + kc.loadFromOptions(kcWith2contexts); + + vi.mocked(ContextHealthChecker).mockImplementation( + () => + ({ + start: healthStartMock, + dispose: healthDisposeMock, + onStateChange: onStateChangeMock, + onReachable: onreachableMock, + }) as unknown as ContextHealthChecker, + ); + + vi.mocked(ContextPermissionsChecker).mockImplementation( + () => + ({ + start: permissionsStartMock, + onPermissionResult: onPermissionResultMock, + }) as unknown as ContextPermissionsChecker, + ); + manager = new TestContextsManager(); + }); + + test('permissions are correctly dispatched', async () => { + const kcSingle1 = new KubeConfigSingleContext(kc, context1); + let permissionCall = 0; + onreachableMock.mockImplementation(f => { + f({ + kubeConfig: kcSingle1, + contextName: 'context1', + checking: false, + reachable: true, + } as ContextHealthState); + }); + onPermissionResultMock.mockImplementation(f => { + permissionCall++; + switch (permissionCall) { + case 1: + case 2: + f({ + kubeConfig: kcSingle1, + resources: ['resource1', 'resource2'], + permitted: true, + }); + break; + case 3: + case 4: + f({ + kubeConfig: kcSingle1, + resources: ['resource3'], + permitted: true, + }); + break; + } + }); + const permissions1: ContextResourcePermission[] = [ + { + contextName: 'context1', + resourceName: 'resource1', + attrs: {}, + permitted: true, + }, + { + contextName: 'context1', + resourceName: 'resource2', + attrs: {}, + permitted: true, + }, + ]; + const permissions2: ContextResourcePermission[] = [ + { + contextName: 'context1', + resourceName: 'resource3', + attrs: {}, + permitted: true, + }, + ]; + let getPermissionsCall = 0; + vi.mocked(ContextPermissionsChecker).mockImplementation( + () => + ({ + start: permissionsStartMock, + onPermissionResult: onPermissionResultMock, + isForContext: vi.fn(), + getPermissions: vi.fn().mockImplementation(() => { + getPermissionsCall++; + switch (getPermissionsCall) { + case 1: + return permissions1; + case 2: + return permissions2; + } + return []; + }), + }) as unknown as ContextPermissionsChecker, + ); + await manager.update(kc); + const permissions = manager.getPermissions(); + expect(permissions).toEqual([...permissions1, ...permissions2]); + }); + + test('informer is started for each resource', async () => { + const kcSingle1 = new KubeConfigSingleContext(kc, context1); + const kcSingle2 = new KubeConfigSingleContext(kc, context2); + let call = 0; + let permissionCall = 0; + onreachableMock.mockImplementation(f => { + call++; + f({ + kubeConfig: call === 1 ? kcSingle1 : kcSingle2, + contextName: call === 1 ? 'context1' : 'context2', + checking: false, + reachable: true, + } as ContextHealthState); + }); + onPermissionResultMock.mockImplementation(f => { + permissionCall++; + switch (permissionCall) { + case 1: + case 2: + f({ + kubeConfig: kcSingle1, + resources: ['resource1', 'resource2'], + permitted: true, + }); + break; + case 3: + case 4: + f({ + kubeConfig: kcSingle2, + resources: ['resource1', 'resource2'], + permitted: true, + }); + break; + case 5: + case 6: + f({ + kubeConfig: kcSingle1, + resources: ['resource3'], + permitted: true, + }); + break; + case 7: + case 8: + f({ + kubeConfig: kcSingle2, + resources: ['resource3'], + permitted: true, + }); + break; + } + }); + vi.mocked(ContextPermissionsChecker).mockImplementation( + () => + ({ + start: permissionsStartMock, + onPermissionResult: onPermissionResultMock, + isForContext: vi.fn(), + }) as unknown as ContextPermissionsChecker, + ); + await manager.update(kc); + expect(startMock).toHaveBeenCalledTimes(2); // on resource1 for each context (resource2 and resource3 do not have informer declared) + }); + + test('informer is started for permitted resources only', async () => { + const kcSingle1 = new KubeConfigSingleContext(kc, context1); + const kcSingle2 = new KubeConfigSingleContext(kc, context2); + let call = 0; + let permissionCall = 0; + onreachableMock.mockImplementation(f => { + call++; + f({ + kubeConfig: call === 1 ? kcSingle1 : kcSingle2, + contextName: call === 1 ? 'context1' : 'context2', + checking: false, + reachable: true, + } as ContextHealthState); + }); + onPermissionResultMock.mockImplementation(f => { + permissionCall++; + switch (permissionCall) { + case 1: + case 2: + f({ + kubeConfig: kcSingle1, + resources: ['resource1', 'resource2'], + permitted: true, + }); + break; + case 3: + case 4: + f({ + kubeConfig: kcSingle2, + resources: ['resource1', 'resource2'], + permitted: false, + }); + break; + case 5: + case 6: + f({ + kubeConfig: kcSingle1, + resources: ['resource3'], + permitted: true, + }); + break; + case 7: + case 8: + f({ + kubeConfig: kcSingle2, + resources: ['resource3'], + permitted: true, + }); + break; + } + }); + vi.mocked(ContextPermissionsChecker).mockImplementation( + () => + ({ + start: permissionsStartMock, + onPermissionResult: onPermissionResultMock, + isForContext: vi.fn(), + }) as unknown as ContextPermissionsChecker, + ); + await manager.update(kc); + expect(startMock).toHaveBeenCalledTimes(1); // on resource1 for context1 only (resource2 and resource3 do not have informer declared;, and resource1 is not permitted in context2) + }); + + describe('informer is started', async () => { + let kcSingle1: KubeConfigSingleContext; + let kcSingle2: KubeConfigSingleContext; + beforeEach(async () => { + kcSingle1 = new KubeConfigSingleContext(kc, context1); + kcSingle2 = new KubeConfigSingleContext(kc, context2); + let call = 0; + let permissionCall = 0; + onreachableMock.mockImplementation(f => { + call++; + f({ + kubeConfig: call === 1 ? kcSingle1 : kcSingle2, + contextName: call === 1 ? 'context1' : 'context2', + checking: false, + reachable: call === 1, + } as ContextHealthState); + }); + onPermissionResultMock.mockImplementation(f => { + permissionCall++; + switch (permissionCall) { + case 1: + case 2: + f({ + kubeConfig: kcSingle1, + resources: ['resource1', 'resource2'], + permitted: true, + }); + break; + case 3: + case 4: + f({ + kubeConfig: kcSingle2, + resources: ['resource1', 'resource2'], + permitted: true, + }); + break; + case 5: + case 6: + f({ + kubeConfig: kcSingle1, + resources: ['resource3'], + permitted: true, + }); + break; + case 7: + case 8: + f({ + kubeConfig: kcSingle2, + resources: ['resource3'], + permitted: true, + }); + break; + } + }); + }); + + test('cache updated with a change on resource count', async () => { + vi.mocked(ContextPermissionsChecker).mockImplementation( + () => + ({ + start: permissionsStartMock, + onPermissionResult: onPermissionResultMock, + contextName: 'ctx', + }) as unknown as ContextPermissionsChecker, + ); + onCacheUpdatedMock.mockImplementation(f => { + f({ + kubeconfig: kcSingle1, + resourceName: 'resource1', + countChanged: true, + } as CacheUpdatedEvent); + return { + dispose: (): void => {}, + }; + }); + const onResourceUpdatedCB = vi.fn(); + const onResourceCountUpdatedCB = vi.fn(); + manager.onResourceUpdated(onResourceUpdatedCB); + manager.onResourceCountUpdated(onResourceCountUpdatedCB); + await manager.update(kc); + // called twice: on resource1 for each context + expect(startMock).toHaveBeenCalledTimes(2); + expect(onResourceUpdatedCB).toHaveBeenCalledTimes(2); + expect(onResourceCountUpdatedCB).toHaveBeenCalledTimes(2); + }); + + test('cache updated without a change on resource count', async () => { + vi.mocked(ContextPermissionsChecker).mockImplementation( + () => + ({ + start: permissionsStartMock, + onPermissionResult: onPermissionResultMock, + contextName: 'ctx1', + }) as unknown as ContextPermissionsChecker, + ); + onCacheUpdatedMock.mockImplementation(f => { + f({ + kubeconfig: kcSingle1, + resourceName: 'resource1', + countChanged: false, + } as CacheUpdatedEvent); + return { + dispose: (): void => {}, + }; + }); + const onResourceUpdatedCB = vi.fn(); + const onResourceCountUpdatedCB = vi.fn(); + manager.onResourceUpdated(onResourceUpdatedCB); + manager.onResourceCountUpdated(onResourceCountUpdatedCB); + await manager.update(kc); + // called twice: on resource1 for each context + expect(startMock).toHaveBeenCalledTimes(2); + expect(onResourceUpdatedCB).toHaveBeenCalledTimes(2); + expect(onResourceCountUpdatedCB).not.toHaveBeenCalled(); + }); + + test('getResourcesCount', async () => { + vi.mocked(ContextPermissionsChecker).mockImplementation( + () => + ({ + start: permissionsStartMock, + onPermissionResult: onPermissionResultMock, + contextName: 'ctx1', + }) as unknown as ContextPermissionsChecker, + ); + const listMock = vi.fn(); + startMock.mockReturnValue({ + list: listMock, + get: vi.fn(), + } as ObjectCache); + listMock.mockReturnValue([{}, {}]); + await manager.update(kc); + const counts = manager.getResourcesCount(); + expect(counts).toEqual([ + { + contextName: 'context1', + resourceName: 'resource1', + count: 2, + }, + { + contextName: 'context2', + resourceName: 'resource1', + count: 2, + }, + ]); + }); + + test('getActiveResourcesCount', async () => { + vi.mocked(ContextPermissionsChecker).mockImplementation( + () => + ({ + start: permissionsStartMock, + onPermissionResult: onPermissionResultMock, + contextName: 'ctx1', + }) as unknown as ContextPermissionsChecker, + ); + const listMock = vi.fn(); + startMock.mockReturnValue({ + list: listMock, + get: vi.fn(), + } as ObjectCache); + listMock.mockReturnValue([ + { + activeField: true, + }, + { + activeField: false, + }, + ]); + await manager.update(kc); + const counts = manager.getActiveResourcesCount(); + expect(counts).toEqual([ + { + contextName: 'context1', + resourceName: 'resource1', + count: 1, + }, + { + contextName: 'context2', + resourceName: 'resource1', + count: 1, + }, + ]); + }); + + test('getResources', async () => { + vi.mocked(ContextPermissionsChecker).mockImplementation( + () => + ({ + start: permissionsStartMock, + onPermissionResult: onPermissionResultMock, + contextName: 'ctx1', + }) as unknown as ContextPermissionsChecker, + ); + const listMock = vi.fn(); + startMock.mockReturnValue({ + list: listMock, + get: vi.fn(), + } as ObjectCache); + listMock.mockReturnValueOnce([{ metadata: { name: 'obj1' } }]); + listMock.mockReturnValueOnce([{ metadata: { name: 'obj2' } }, { metadata: { name: 'obj3' } }]); + await manager.update(kc); + const resources = manager.getResources(['context1', 'context2'], 'resource1'); + expect(resources).toEqual([ + { + contextName: 'context1', + items: [{ metadata: { name: 'obj1' } }], + }, + { + contextName: 'context2', + items: [{ metadata: { name: 'obj2' } }, { metadata: { name: 'obj3' } }], + }, + ]); + }); + + test('one offline informer clears all caches', async () => { + vi.mocked(ContextPermissionsChecker).mockImplementation( + () => + ({ + start: permissionsStartMock, + onPermissionResult: onPermissionResultMock, + contextName: 'ctx1', + }) as unknown as ContextPermissionsChecker, + ); + const listMock = vi.fn(); + startMock.mockReturnValue({ + list: listMock, + get: vi.fn(), + } as ObjectCache); + listMock.mockReturnValueOnce([{ metadata: { name: 'obj1' } }]); + listMock.mockReturnValueOnce([{ metadata: { name: 'obj2' } }, { metadata: { name: 'obj3' } }]); + await manager.update(kc); + const resources = manager.getResources(['context1', 'context2'], 'resource1'); + // At this point, resources are in caches for both contexts + expect(resources).toEqual([ + { + contextName: 'context1', + items: [{ metadata: { name: 'obj1' } }], + }, + { + contextName: 'context2', + items: [{ metadata: { name: 'obj2' } }, { metadata: { name: 'obj3' } }], + }, + ]); + + expect(onOfflineMock).toHaveBeenCalledTimes(2); + const onOfflineCB = onOfflineMock.mock.calls[0]?.[0]; + assert(onOfflineCB); + + // Let's declare informer for resource1 in context1 offline + onOfflineCB({ + kubeconfig: kcSingle1, + resourceName: 'resource1', + offline: true, + reason: 'because', + }); + + listMock.mockReturnValueOnce([{ metadata: { name: 'obj2' } }, { metadata: { name: 'obj3' } }]); + const resourcesAfter = manager.getResources(['context1', 'context2'], 'resource1'); + + // Caches for context1 are removed + expect(resourcesAfter).toEqual([ + { + contextName: 'context2', + items: [{ metadata: { name: 'obj2' } }, { metadata: { name: 'obj3' } }], + }, + ]); + + // Let's declare informer for resource1 in context2 offline + onOfflineCB({ + kubeconfig: kcSingle2, + resourceName: 'resource1', + offline: true, + reason: 'because', + }); + + const resourcesAfter2 = manager.getResources(['context1', 'context2'], 'resource1'); + + // Caches for context1 are removed + expect(resourcesAfter2).toEqual([]); + }); + }); +}); + +test('nothing is done when called again and kubeconfig does not change', async () => { + const kc = new KubeConfig(); + kc.loadFromOptions(kcWith2contexts); + const manager = new TestContextsManager(); + + const startMock = vi.fn(); + const disposeMock = vi.fn(); + const onStateChangeMock = vi.fn(); + + vi.mocked(ContextHealthChecker).mockImplementation( + () => + ({ + start: startMock, + dispose: disposeMock, + onStateChange: onStateChangeMock, + onReachable: vi.fn(), + }) as unknown as ContextHealthChecker, + ); + + await manager.update(kc); + + vi.mocked(ContextHealthChecker).mockClear(); + vi.mocked(startMock).mockClear(); + + // check it is not called again if kubeconfig does not change + await manager.update(kc); + expect(ContextHealthChecker).not.toHaveBeenCalled(); + expect(startMock).not.toHaveBeenCalled(); + expect(disposeMock).not.toHaveBeenCalled(); +}); + +test('HealthChecker is built and start is called for each context being changed', async () => { + const kc = new KubeConfig(); + kc.loadFromOptions(kcWith2contexts); + const manager = new TestContextsManager(); + + const startMock = vi.fn(); + const disposeMock = vi.fn(); + const onStateChangeMock = vi.fn(); + + vi.mocked(ContextHealthChecker).mockImplementation( + () => + ({ + start: startMock, + dispose: disposeMock, + onStateChange: onStateChangeMock, + onReachable: vi.fn(), + }) as unknown as ContextHealthChecker, + ); + + await manager.update(kc); + + // check it is called again if kubeconfig changes + vi.mocked(ContextHealthChecker).mockClear(); + vi.mocked(startMock).mockClear(); + + kcWith2contexts.users[0]!.certFile = 'file'; + kc.loadFromOptions(kcWith2contexts); + await manager.update(kc); + expect(disposeMock).toHaveBeenCalledTimes(1); + expect(ContextHealthChecker).toHaveBeenCalledTimes(1); + expect(startMock).toHaveBeenCalledTimes(1); +}); + +test('HealthChecker, PermissionsChecker and informers are disposed for each context being removed', async () => { + const kc = new KubeConfig(); + kc.loadFromOptions(kcWith2contexts); + const manager = new TestContextsManager(); + + const healthStartMock = vi.fn(); + const healthDisposeMock = vi.fn(); + const onStateChangeMock = vi.fn(); + const permissionsStartMock = vi.fn(); + const permissionsDisposeMock = vi.fn(); + + vi.mocked(ContextHealthChecker).mockImplementation((kubeConfig: KubeConfigSingleContext) => { + const contextName = kubeConfig.getKubeConfig().currentContext; + return { + start: healthStartMock, + dispose: healthDisposeMock, + onStateChange: onStateChangeMock, + onReachable: vi.fn().mockImplementation(f => + f({ + kubeConfig: { + getNamespace: vi.fn().mockReturnValue(contextName), + } as unknown as KubeConfigSingleContext, + contextName, + checking: false, + reachable: true, + } as ContextHealthState), + ), + } as unknown as ContextHealthChecker; + }); + + vi.mocked(ContextPermissionsChecker).mockImplementation( + (_kubeconfig: KubeConfigSingleContext, contextName: string, _request: ContextPermissionsRequest) => { + return { + start: permissionsStartMock, + dispose: permissionsDisposeMock, + onPermissionResult: vi.fn().mockImplementation(f => { + f({ + permitted: true, + resources: ['resource1'], + kubeConfig: new KubeConfigSingleContext(kcWith2contexts, contextName === 'context1' ? context1 : context2), + }); + }), + contextName, + } as unknown as ContextPermissionsChecker; + }, + ); + + await manager.update(kc); + + // check when kubeconfig changes + vi.mocked(ContextHealthChecker).mockClear(); + vi.mocked(healthStartMock).mockClear(); + vi.mocked(ContextPermissionsChecker).mockClear(); + vi.mocked(permissionsStartMock).mockClear(); + vi.mocked(permissionsDisposeMock).mockClear(); + + // we remove context1 from kubeconfig + const kc1 = { + contexts: [kcWith2contexts.contexts[1]], + clusters: [kcWith2contexts.clusters[1]], + users: [kcWith2contexts.users[1]], + currentContext: undefined, + } as unknown as KubeConfig; + kc.loadFromOptions(kc1); + await manager.update(kc); + expect(healthDisposeMock).toHaveBeenCalledTimes(1); + expect(ContextHealthChecker).toHaveBeenCalledTimes(0); + expect(healthStartMock).toHaveBeenCalledTimes(0); + + expect(permissionsDisposeMock).toHaveBeenCalledTimes(2); // one for namespaced, one for non-namespaced + + expect(informerDisposeMock).toHaveBeenCalledTimes(1); // for resource1 on context1 +}); + +test('getHealthCheckersStates calls getState for each health checker', async () => { + const kc = new KubeConfig(); + kc.loadFromOptions(kcWith2contexts); + const manager = new TestContextsManager(); + + const startMock = vi.fn(); + const disposeMock = vi.fn(); + const onStateChangeMock = vi.fn(); + + vi.mocked(ContextHealthChecker).mockImplementation( + (kubeConfig: KubeConfigSingleContext) => + ({ + start: startMock, + dispose: disposeMock, + onStateChange: onStateChangeMock, + onReachable: vi.fn(), + getState: vi.fn().mockImplementation(() => { + return { + kubeConfig: new KubeConfigSingleContext( + kcWith2contexts, + kubeConfig.getKubeConfig().currentContext === 'context1' ? context1 : context2, + ), + contextName: kubeConfig.getKubeConfig().currentContext, + checking: kubeConfig.getKubeConfig().currentContext === 'context1' ? true : false, + reachable: false, + }; + }), + }) as unknown as ContextHealthChecker, + ); + + await manager.update(kc); + + const result = manager.getHealthCheckersStates(); + const expectedMap = new Map(); + expectedMap.set('context1', { + kubeConfig: new KubeConfigSingleContext(kcWith2contexts, context1), + contextName: 'context1', + checking: true, + reachable: false, + }); + expect(result).toEqual(expectedMap); +}); + +test('getPermissions calls getPermissions for each permissions checker', async () => { + const kc = new KubeConfig(); + kc.loadFromOptions(kcWith2contexts); + const manager = new TestContextsManager(); + + const startMock = vi.fn(); + const disposeMock = vi.fn(); + const onStateChangeMock = vi.fn(); + const onReachableMock = vi.fn(); + + vi.mocked(ContextHealthChecker).mockImplementation( + () => + ({ + start: startMock, + dispose: disposeMock, + onStateChange: onStateChangeMock, + onReachable: onReachableMock, + getState: vi.fn(), + }) as unknown as ContextHealthChecker, + ); + + const kcSingle1 = new KubeConfigSingleContext(kc, context1); + const kcSingle2 = new KubeConfigSingleContext(kc, context2); + let call = 0; + onReachableMock.mockImplementation(f => { + call++; + f({ + kubeConfig: call === 1 ? kcSingle1 : kcSingle2, + contextName: call === 1 ? 'context1' : 'context2', + checking: false, + reachable: true, + } as ContextHealthState); + }); + + const getPermissionsMock = vi.fn(); + vi.mocked(ContextPermissionsChecker).mockImplementation( + () => + ({ + start: vi.fn(), + getPermissions: getPermissionsMock, + onPermissionResult: vi.fn(), + isForContext: vi.fn(), + }) as unknown as ContextPermissionsChecker, + ); + + await manager.update(kc); + + manager.getPermissions(); + expect(getPermissionsMock).toHaveBeenCalledTimes(2); +}); + +test('dispose calls dispose for each health checker', async () => { + const kc = new KubeConfig(); + kc.loadFromOptions(kcWith2contexts); + const manager = new TestContextsManager(); + + const startMock = vi.fn(); + const disposeMock = vi.fn(); + const onStateChangeMock = vi.fn(); + + vi.mocked(ContextHealthChecker).mockImplementation( + () => + ({ + start: startMock, + dispose: disposeMock, + onStateChange: onStateChangeMock, + onReachable: vi.fn(), + }) as unknown as ContextHealthChecker, + ); + + await manager.update(kc); + + manager.dispose(); + expect(disposeMock).toHaveBeenCalledTimes(1); +}); + +test('dispose calls dispose for each permissions checker', async () => { + const kc = new KubeConfig(); + kc.loadFromOptions(kcWith2contexts); + const manager = new TestContextsManager(); + + const startMock = vi.fn(); + const disposeMock = vi.fn(); + const onStateChangeMock = vi.fn(); + const onReachableMock = vi.fn(); + + vi.mocked(ContextHealthChecker).mockImplementation( + () => + ({ + start: startMock, + dispose: disposeMock, + onStateChange: onStateChangeMock, + onReachable: onReachableMock, + }) as unknown as ContextHealthChecker, + ); + + const kcSingle1 = new KubeConfigSingleContext(kc, context1); + const kcSingle2 = new KubeConfigSingleContext(kc, context2); + let call = 0; + onReachableMock.mockImplementation(f => { + call++; + f({ + kubeConfig: call === 1 ? kcSingle1 : kcSingle2, + contextName: call === 1 ? 'context1' : 'context2', + checking: false, + reachable: true, + } as ContextHealthState); + }); + + const getPermissionsMock = vi.fn(); + const permissionsDisposeMock = vi.fn(); + + vi.mocked(ContextPermissionsChecker).mockImplementation( + () => + ({ + start: vi.fn(), + getPermissions: getPermissionsMock, + onPermissionResult: vi.fn(), + dispose: permissionsDisposeMock, + isForContext: vi.fn(), + }) as unknown as ContextPermissionsChecker, + ); + + await manager.update(kc); + + manager.dispose(); + expect(permissionsDisposeMock).toHaveBeenCalledTimes(2); +}); + +test('only current context is monitored', async () => { + const kc = new KubeConfig(); + kc.loadFromOptions(kcWith2contexts); + const manager = new TestContextsManager(); + vi.spyOn(manager, 'startMonitoring'); + vi.spyOn(manager, 'stopMonitoring'); + await manager.update(kc); + expect(manager.startMonitoring).toHaveBeenCalledWith(expect.anything(), 'context1'); + + // change current context from context1 to context2 + vi.mocked(manager.startMonitoring).mockClear(); + vi.mocked(manager.stopMonitoring).mockClear(); + const kcWith2contextsChangeCurrent = { + ...kcWith2contexts, + currentContext: 'context2', + }; + kc.loadFromOptions(kcWith2contextsChangeCurrent); + await manager.update(kc); + expect(manager.stopMonitoring).toHaveBeenCalledWith('context1'); + expect(manager.startMonitoring).toHaveBeenCalledWith(expect.anything(), 'context2'); + + // no more current context + vi.mocked(manager.startMonitoring).mockClear(); + vi.mocked(manager.stopMonitoring).mockClear(); + const kcWith2contextsNoCurrent = { + ...kcWith2contexts, + currentContext: undefined, + }; + kc.loadFromOptions(kcWith2contextsNoCurrent); + await manager.update(kc); + expect(manager.stopMonitoring).toHaveBeenCalledWith('context2'); + expect(manager.startMonitoring).not.toHaveBeenCalled(); +}); diff --git a/packages/extension/src/manager/contexts-manager.ts b/packages/extension/src/manager/contexts-manager.ts new file mode 100644 index 00000000..b21096db --- /dev/null +++ b/packages/extension/src/manager/contexts-manager.ts @@ -0,0 +1,394 @@ +/********************************************************************** + * Copyright (C) 2024 - 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { KubeConfig, KubernetesObject, ObjectCache } from '@kubernetes/client-node'; + +import type { ContextPermission } from '/@common/model/kubernetes-contexts-permissions.js'; +import type { ContextGeneralState, ResourceName } from '/@common/model/kubernetes-contexts-states.js'; +import type { ResourceCount } from '/@common/model/kubernetes-resource-count.js'; +import type { KubernetesContextResources } from '/@common/model/kubernetes-resources.js'; +import type { KubernetesTroubleshootingInformation } from '/@common/model/kubernetes-troubleshooting.js'; + +import type { Event } from '../types/emitter.js'; +import { Emitter } from '../types/emitter.js'; +import { ConfigmapsResourceFactory } from '../resources/configmaps-resource-factory.js'; +import type { ContextHealthState } from './context-health-checker.js'; +import { ContextHealthChecker } from './context-health-checker.js'; +import type { ContextPermissionResult } from './context-permissions-checker.js'; +import { ContextPermissionsChecker } from './context-permissions-checker.js'; +import { ContextResourceRegistry } from '../registry/context-resource-registry.js'; +import type { CurrentChangeEvent, DispatcherEvent } from './contexts-dispatcher.js'; +import { ContextsDispatcher } from './contexts-dispatcher.js'; +import { CronjobsResourceFactory } from '../resources/cronjobs-resource-factory.js'; +import { DeploymentsResourceFactory } from '../resources/deployments-resource-factory.js'; +import { EventsResourceFactory } from '../resources/events-resource-factory.js'; +import { IngressesResourceFactory } from '../resources/ingresses-resource-factory.js'; +import { JobsResourceFactory } from '../resources/jobs-resource-factory.js'; +import type { KubeConfigSingleContext } from '../types/kubeconfig-single-context.js'; +import { NodesResourceFactory } from '../resources/nodes-resource-factory.js'; +import { PodsResourceFactory } from '../resources/pods-resource-factory.js'; +import { PVCsResourceFactory } from '../resources/pvcs-resource-factory.js'; +import type { ResourceFactory } from '../resources/resource-factory.js'; +import { ResourceFactoryHandler } from './resource-factory-handler.js'; +import type { CacheUpdatedEvent, OfflineEvent, ResourceInformer } from '../types/resource-informer.js'; +import { RoutesResourceFactory } from '../resources/routes-resource-factory.js'; +import { SecretsResourceFactory } from '../resources/secrets-resource-factory.js'; +import { ServicesResourceFactory } from '../resources/services-resource-factory.js'; + +const HEALTH_CHECK_TIMEOUT_MS = 5_000; + +/** + * ContextsManager receives new KubeConfig updates + * and manages the monitoring for each context of the KubeConfig. + * + * ContextsManager fire events when a context is deleted, and to forward the states of the health checkers, permission checkers and informers. + * + * ContextsManager exposes the current state of the health checkers, permission checkers and informers. + */ +export class ContextsManager { + #resourceFactoryHandler: ResourceFactoryHandler; + #dispatcher: ContextsDispatcher; + #healthCheckers: Map; + #permissionsCheckers: ContextPermissionsChecker[]; + #informers: ContextResourceRegistry>; + #objectCaches: ContextResourceRegistry>; + + #onContextHealthStateChange = new Emitter(); + onContextHealthStateChange: Event = this.#onContextHealthStateChange.event; + + #onOfflineChange = new Emitter(); + onOfflineChange: Event = this.#onOfflineChange.event; + + #onContextPermissionResult = new Emitter(); + onContextPermissionResult: Event = this.#onContextPermissionResult.event; + + #onContextDelete = new Emitter(); + onContextDelete: Event = this.#onContextDelete.event; + + #onResourceUpdated = new Emitter<{ contextName: string; resourceName: string }>(); + onResourceUpdated: Event<{ contextName: string; resourceName: string }> = this.#onResourceUpdated.event; + + #onResourceCountUpdated = new Emitter<{ contextName: string; resourceName: string }>(); + onResourceCountUpdated: Event<{ contextName: string; resourceName: string }> = this.#onResourceCountUpdated.event; + + constructor() { + this.#resourceFactoryHandler = new ResourceFactoryHandler(); + for (const resourceFactory of this.getResourceFactories()) { + this.#resourceFactoryHandler.add(resourceFactory); + } + // Add more resources here + this.#healthCheckers = new Map(); + this.#permissionsCheckers = []; + this.#informers = new ContextResourceRegistry>(); + this.#objectCaches = new ContextResourceRegistry>(); + this.#dispatcher = new ContextsDispatcher(); + this.#dispatcher.onUpdate(this.onUpdate.bind(this)); + this.#dispatcher.onDelete(this.onDelete.bind(this)); + this.#dispatcher.onDelete((state: DispatcherEvent) => this.#onContextDelete.fire(state)); + this.#dispatcher.onCurrentChange(this.onCurrentChange.bind(this)); + } + + protected getResourceFactories(): ResourceFactory[] { + return [ + new ConfigmapsResourceFactory(), + new CronjobsResourceFactory(), + new JobsResourceFactory(), + new DeploymentsResourceFactory(), + new EventsResourceFactory(), + new IngressesResourceFactory(), + new NodesResourceFactory(), + new PodsResourceFactory(), + new PVCsResourceFactory(), + new RoutesResourceFactory(), + new SecretsResourceFactory(), + new ServicesResourceFactory(), + ]; + } + + async update(kubeconfig: KubeConfig): Promise { + this.#dispatcher.update(kubeconfig); + } + + private async onUpdate(event: DispatcherEvent): Promise { + if (this.isMonitored(event.contextName)) { + // we don't try to update the checkers, we recreate them + return this.startMonitoring(event.config, event.contextName); + } + } + + private onDelete(state: DispatcherEvent): void { + if (this.isMonitored(state.contextName)) { + this.stopMonitoring(state.contextName); + } + } + + private async onCurrentChange(state: CurrentChangeEvent): Promise { + if (state.previous && this.isMonitored(state.previous)) { + this.stopMonitoring(state.previous); + } + if (state.current && state.currentConfig) { + await this.startMonitoring(state.currentConfig, state.current); + } + } + + private onStateChange(state: ContextHealthState): void { + this.#onContextHealthStateChange.fire(state); + } + + private onPermissionResult(event: ContextPermissionResult): void { + this.#onContextPermissionResult.fire(event); + } + + /* getHealthCheckersStates returns the current state of the health checkers */ + getHealthCheckersStates(): Map { + const result = new Map(); + for (const [contextName, hc] of this.#healthCheckers.entries()) { + result.set(contextName, hc.getState()); + } + return result; + } + + /* getPermissions returns the current permissions */ + getPermissions(): ContextPermission[] { + return this.#permissionsCheckers.flatMap(permissionsChecker => permissionsChecker.getPermissions()); + } + + getResourcesCount(): ResourceCount[] { + return this.#objectCaches.getAll().map(informer => ({ + contextName: informer.contextName, + resourceName: informer.resourceName, + count: informer.value.list().length, + })); + } + + // getActiveResourcesCount returns the count of filtered resources for each context/resource + // when isActive is declared for a resource, and filtered with isActive + getActiveResourcesCount(): ResourceCount[] { + return this.#objectCaches + .getAll() + .map(informer => { + const isActive = this.#resourceFactoryHandler.getResourceFactoryByResourceName(informer.resourceName)?.isActive; + return isActive + ? { + contextName: informer.contextName, + resourceName: informer.resourceName, + count: informer.value.list().filter(isActive).length, + } + : undefined; + }) + .filter(f => !!f); + } + + getResources(contextNames: string[], resourceName: string): KubernetesContextResources[] { + return this.#objectCaches.getForContextsAndResource(contextNames, resourceName).map(({ contextName, value }) => { + return { + contextName, + items: value.list(), + }; + }); + } + + getContextsGeneralState(): Map { + return new Map(); + } + + getCurrentContextGeneralState(): ContextGeneralState { + return { + reachable: false, + resources: { + pods: 0, + deployments: 0, + }, + }; + } + + registerGetCurrentContextResources(_resourceName: ResourceName): KubernetesObject[] { + return []; + } + + unregisterGetCurrentContextResources(_resourceName: ResourceName): KubernetesObject[] { + return []; + } + + /* dispose all disposable resources created by the instance */ + dispose(): void { + this.disposeAllHealthChecks(); + this.disposeAllPermissionsCheckers(); + this.disposeAllInformers(); + this.#onContextHealthStateChange.dispose(); + this.#onContextDelete.dispose(); + } + + async refreshContextState(contextName: string): Promise { + try { + const config = this.#dispatcher.getKubeConfigSingleContext(contextName); + await this.startMonitoring(config, contextName); + } catch (e: unknown) { + console.warn(`unable to refresh context ${contextName}`, String(e)); + } + } + + // disposeAllHealthChecks disposes all health checks and removes them from registry + private disposeAllHealthChecks(): void { + for (const [contextName, healthChecker] of this.#healthCheckers.entries()) { + healthChecker.dispose(); + this.#healthCheckers.delete(contextName); + } + } + + // disposeAllPermissionsCheckers disposes all permissions checkers and removes them from registry + private disposeAllPermissionsCheckers(): void { + for (const permissionChecker of this.#permissionsCheckers) { + permissionChecker.dispose(); + } + this.#permissionsCheckers = []; + } + + // disposeAllInformers disposes all informers and removes them from registry + private disposeAllInformers(): void { + for (const informer of this.#informers.getAll()) { + informer.value.dispose(); + } + } + + getTroubleshootingInformation(): KubernetesTroubleshootingInformation { + return { + healthCheckers: Array.from(this.#healthCheckers.values()) + .map(healthChecker => healthChecker.getState()) + .map(state => ({ + contextName: state.contextName, + checking: state.checking, + reachable: state.reachable, + })), + permissionCheckers: this.#permissionsCheckers.flatMap(permissionChecker => permissionChecker.getPermissions()), + informers: this.#informers.getAll().map(informer => ({ + contextName: informer.contextName, + resourceName: informer.resourceName, + isOffline: informer.value.isOffline(), + objectsCount: this.#objectCaches.get(informer.contextName, informer.resourceName)?.list().length, + })), + }; + } + + private isMonitored(contextName: string): boolean { + return this.#healthCheckers.has(contextName); + } + + protected async startMonitoring(config: KubeConfigSingleContext, contextName: string): Promise { + this.stopMonitoring(contextName); + + // register and start health checker + const newHealthChecker = new ContextHealthChecker(config); + this.#healthCheckers.set(contextName, newHealthChecker); + newHealthChecker.onStateChange(this.onStateChange.bind(this)); + + newHealthChecker.onReachable(async (state: ContextHealthState) => { + // register and start permissions checker + const previousPermissionsCheckers = this.#permissionsCheckers.filter( + permissionChecker => permissionChecker.contextName === state.contextName, + ); + for (const checker of previousPermissionsCheckers) { + checker.dispose(); + } + + const namespace = state.kubeConfig.getNamespace(); + const permissionRequests = this.#resourceFactoryHandler.getPermissionsRequests(namespace); + for (const permissionRequest of permissionRequests) { + const newPermissionChecker = new ContextPermissionsChecker( + state.kubeConfig, + state.contextName, + permissionRequest, + ); + this.#permissionsCheckers.push(newPermissionChecker); + newPermissionChecker.onPermissionResult(this.onPermissionResult.bind(this)); + + newPermissionChecker.onPermissionResult((event: ContextPermissionResult) => { + if (!event.permitted) { + // if the user does not have watch permission, do not try to start informers on these resources + return; + } + for (const resource of event.resources) { + const contextName = event.kubeConfig.getKubeConfig().currentContext; + const factory = this.#resourceFactoryHandler.getResourceFactoryByResourceName(resource); + if (!factory) { + throw new Error( + `a permission for resource ${resource} has been received but no factory is handling it, this should not happen`, + ); + } + if (!factory.informer) { + // no informer for this factory, skipping + // (we may want to check permissions on some resource, without having to start an informer) + continue; + } + const informer = factory.informer.createInformer(event.kubeConfig); + this.#informers.set(contextName, resource, informer); + informer.onCacheUpdated((e: CacheUpdatedEvent) => { + this.#onResourceUpdated.fire({ + contextName: e.kubeconfig.getKubeConfig().currentContext, + resourceName: e.resourceName, + }); + if (e.countChanged) { + this.#onResourceCountUpdated.fire({ + contextName: e.kubeconfig.getKubeConfig().currentContext, + resourceName: e.resourceName, + }); + } + }); + informer.onOffline((e: OfflineEvent) => { + this.#onOfflineChange.fire(); + this.#objectCaches.removeForContext(e.kubeconfig.getKubeConfig().currentContext); + }); + const cache = informer.start(); + this.#objectCaches.set(contextName, resource, cache); + } + }); + await newPermissionChecker.start(); + } + }); + await newHealthChecker.start({ timeout: HEALTH_CHECK_TIMEOUT_MS }); + } + + protected stopMonitoring(contextName: string): void { + const healthChecker = this.#healthCheckers.get(contextName); + healthChecker?.dispose(); + this.#healthCheckers.delete(contextName); + const permissionsCheckers = this.#permissionsCheckers.filter( + permissionChecker => permissionChecker.contextName === contextName, + ); + for (const checker of permissionsCheckers) { + checker.dispose(); + } + this.#permissionsCheckers = this.#permissionsCheckers.filter( + permissionChecker => permissionChecker.contextName !== contextName, + ); + + const contextInformers = this.#informers.getForContext(contextName); + for (const informer of contextInformers) { + informer.dispose(); + } + this.#informers.removeForContext(contextName); + this.#objectCaches.removeForContext(contextName); + } + + // returns true if at least one informer for the context is 'offline' + // meaning that it has lost connection with the cluster (after being connected) + isContextOffline(contextName: string): boolean { + const informers = this.#informers.getForContext(contextName); + return informers.some(informer => informer.isOffline()); + } +} diff --git a/packages/extension/src/manager/contexts-states-dispatcher.spec.ts b/packages/extension/src/manager/contexts-states-dispatcher.spec.ts new file mode 100644 index 00000000..801f9a56 --- /dev/null +++ b/packages/extension/src/manager/contexts-states-dispatcher.spec.ts @@ -0,0 +1,308 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { expect, test, vi } from 'vitest'; + +import type { IDisposable } from '../types/disposable.js'; +import type { ContextPermission } from '/@common/model/kubernetes-contexts-permissions.js'; + +import type { ApiSenderType } from '/@common/model/api-sender.js'; +import type { ContextHealthState } from './context-health-checker.js'; +import type { ContextPermissionResult } from './context-permissions-checker.js'; +import type { DispatcherEvent } from './contexts-dispatcher.js'; +import type { ContextsManager } from './contexts-manager.js'; +import { ContextsStatesDispatcher } from './contexts-states-dispatcher.js'; +import type { KubeConfigSingleContext } from '../types/kubeconfig-single-context.js'; + +test('ContextsStatesDispatcher should call updateHealthStates when onContextHealthStateChange event is fired', () => { + const manager: ContextsManager = { + onContextHealthStateChange: vi.fn(), + onOfflineChange: vi.fn(), + onContextPermissionResult: vi.fn(), + onContextDelete: vi.fn(), + getHealthCheckersStates: vi.fn(), + getPermissions: vi.fn(), + onResourceCountUpdated: vi.fn(), + onResourceUpdated: vi.fn(), + isContextOffline: vi.fn(), + } as unknown as ContextsManager; + const apiSender: ApiSenderType = { + send: vi.fn(), + } as unknown as ApiSenderType; + const dispatcher = new ContextsStatesDispatcher(manager, apiSender); + const updateHealthStatesSpy = vi.spyOn(dispatcher, 'updateHealthStates'); + const updatePermissionsSpy = vi.spyOn(dispatcher, 'updatePermissions'); + dispatcher.init(); + expect(updateHealthStatesSpy).not.toHaveBeenCalled(); + expect(updatePermissionsSpy).not.toHaveBeenCalled(); + + vi.mocked(manager.onContextHealthStateChange).mockImplementation(f => f({} as ContextHealthState) as IDisposable); + vi.mocked(manager.getHealthCheckersStates).mockReturnValue(new Map()); + dispatcher.init(); + expect(updateHealthStatesSpy).toHaveBeenCalled(); + expect(updatePermissionsSpy).not.toHaveBeenCalled(); +}); + +test('ContextsStatesDispatcher should call updateHealthStates, updateResourcesCount and updateActiveResourcesCount when onOfflineChange event is fired', () => { + const manager: ContextsManager = { + onContextHealthStateChange: vi.fn(), + onOfflineChange: vi.fn(), + onContextPermissionResult: vi.fn(), + onContextDelete: vi.fn(), + getHealthCheckersStates: vi.fn(), + getPermissions: vi.fn(), + onResourceCountUpdated: vi.fn(), + onResourceUpdated: vi.fn(), + isContextOffline: vi.fn(), + } as unknown as ContextsManager; + const apiSender: ApiSenderType = { + send: vi.fn(), + } as unknown as ApiSenderType; + const dispatcher = new ContextsStatesDispatcher(manager, apiSender); + const updateHealthStatesSpy = vi.spyOn(dispatcher, 'updateHealthStates'); + const updateResourcesCountSpy = vi.spyOn(dispatcher, 'updateResourcesCount'); + const updateActiveResourcesCountSpy = vi.spyOn(dispatcher, 'updateActiveResourcesCount'); + dispatcher.init(); + expect(updateHealthStatesSpy).not.toHaveBeenCalled(); + expect(updateResourcesCountSpy).not.toHaveBeenCalled(); + expect(updateActiveResourcesCountSpy).not.toHaveBeenCalled(); + + vi.mocked(manager.onOfflineChange).mockImplementation(f => f() as IDisposable); + vi.mocked(manager.getHealthCheckersStates).mockReturnValue(new Map()); + dispatcher.init(); + expect(updateHealthStatesSpy).toHaveBeenCalled(); + expect(updateResourcesCountSpy).toHaveBeenCalled(); + expect(updateActiveResourcesCountSpy).toHaveBeenCalled(); +}); + +test('ContextsStatesDispatcher should call updatePermissions when onContextPermissionResult event is fired', () => { + const manager: ContextsManager = { + onContextHealthStateChange: vi.fn(), + onOfflineChange: vi.fn(), + onContextPermissionResult: vi.fn(), + onContextDelete: vi.fn(), + getHealthCheckersStates: vi.fn(), + getPermissions: vi.fn(), + onResourceCountUpdated: vi.fn(), + onResourceUpdated: vi.fn(), + isContextOffline: vi.fn(), + } as unknown as ContextsManager; + const apiSender: ApiSenderType = { + send: vi.fn(), + } as unknown as ApiSenderType; + vi.mocked(manager.getPermissions).mockReturnValue([]); + const dispatcher = new ContextsStatesDispatcher(manager, apiSender); + const updateHealthStatesSpy = vi.spyOn(dispatcher, 'updateHealthStates'); + const updatePermissionsSpy = vi.spyOn(dispatcher, 'updatePermissions'); + dispatcher.init(); + expect(updateHealthStatesSpy).not.toHaveBeenCalled(); + expect(updatePermissionsSpy).not.toHaveBeenCalled(); + + vi.mocked(manager.onContextPermissionResult).mockImplementation(f => f({} as ContextPermissionResult) as IDisposable); + dispatcher.init(); + expect(updateHealthStatesSpy).not.toHaveBeenCalled(); + expect(updatePermissionsSpy).toHaveBeenCalled(); +}); + +test('ContextsStatesDispatcher should call updateHealthStates and updatePermissions when onContextDelete event is fired', () => { + const manager: ContextsManager = { + onContextHealthStateChange: vi.fn(), + onOfflineChange: vi.fn(), + onContextPermissionResult: vi.fn(), + onContextDelete: vi.fn(), + getHealthCheckersStates: vi.fn(), + getPermissions: vi.fn(), + onResourceCountUpdated: vi.fn(), + onResourceUpdated: vi.fn(), + isContextOffline: vi.fn(), + } as unknown as ContextsManager; + const apiSender: ApiSenderType = { + send: vi.fn(), + } as unknown as ApiSenderType; + vi.mocked(manager.getPermissions).mockReturnValue([]); + const dispatcher = new ContextsStatesDispatcher(manager, apiSender); + const updateHealthStatesSpy = vi.spyOn(dispatcher, 'updateHealthStates'); + const updatePermissionsSpy = vi.spyOn(dispatcher, 'updatePermissions'); + vi.mocked(manager.getHealthCheckersStates).mockReturnValue(new Map()); + dispatcher.init(); + expect(updateHealthStatesSpy).not.toHaveBeenCalled(); + expect(updatePermissionsSpy).not.toHaveBeenCalled(); + + vi.mocked(manager.onContextDelete).mockImplementation(f => f({} as DispatcherEvent) as IDisposable); + dispatcher.init(); + expect(updateHealthStatesSpy).toHaveBeenCalled(); + expect(updatePermissionsSpy).toHaveBeenCalled(); +}); + +test('ContextsStatesDispatcher should call updateResource and updateActiveResourcesCount when onResourceUpdated event is fired', () => { + const manager: ContextsManager = { + onContextHealthStateChange: vi.fn(), + onOfflineChange: vi.fn(), + onContextPermissionResult: vi.fn(), + onContextDelete: vi.fn(), + getHealthCheckersStates: vi.fn(), + getPermissions: vi.fn(), + onResourceCountUpdated: vi.fn(), + onResourceUpdated: vi.fn(), + isContextOffline: vi.fn(), + } as unknown as ContextsManager; + const apiSender: ApiSenderType = { + send: vi.fn(), + } as unknown as ApiSenderType; + vi.mocked(manager.getPermissions).mockReturnValue([]); + const dispatcher = new ContextsStatesDispatcher(manager, apiSender); + const updateResourceSpy = vi.spyOn(dispatcher, 'updateResource'); + const updateActiveResourcesCountSpy = vi.spyOn(dispatcher, 'updateActiveResourcesCount'); + vi.mocked(manager.getHealthCheckersStates).mockReturnValue(new Map()); + dispatcher.init(); + expect(updateResourceSpy).not.toHaveBeenCalled(); + expect(updateActiveResourcesCountSpy).not.toHaveBeenCalled(); + + vi.mocked(manager.onResourceUpdated).mockImplementation( + f => f({} as { contextName: string; resourceName: string }) as IDisposable, + ); + dispatcher.init(); + expect(updateResourceSpy).toHaveBeenCalled(); + expect(updateActiveResourcesCountSpy).toHaveBeenCalled(); +}); + +test('getContextsHealths should return the values of the map returned by manager.getHealthCheckersStates without kubeConfig', () => { + const manager: ContextsManager = { + onContextHealthStateChange: vi.fn(), + onOfflineChange: vi.fn(), + onContextPermissionResult: vi.fn(), + onContextDelete: vi.fn(), + getHealthCheckersStates: vi.fn(), + getPermissions: vi.fn(), + isContextOffline: vi.fn(), + } as unknown as ContextsManager; + const apiSender: ApiSenderType = { + send: vi.fn(), + } as unknown as ApiSenderType; + const dispatcher = new ContextsStatesDispatcher(manager, apiSender); + const context1State = { + contextName: 'context1', + checking: true, + reachable: false, + }; + const context2State = { + contextName: 'context2', + checking: false, + reachable: true, + }; + const context3State = { + contextName: 'context3', + checking: false, + reachable: false, + errorMessage: 'an error', + }; + const value = new Map([ + ['context1', { ...context1State, kubeConfig: {} as unknown as KubeConfigSingleContext }], + ['context2', { ...context2State, kubeConfig: {} as unknown as KubeConfigSingleContext }], + ['context3', { ...context3State, kubeConfig: {} as unknown as KubeConfigSingleContext }], + ]); + vi.mocked(manager.getHealthCheckersStates).mockReturnValue(value); + const result = dispatcher.getContextsHealths(); + expect(result).toEqual([context1State, context2State, context3State]); +}); + +test('updateHealthStates should call apiSender.send with kubernetes-contexts-healths', () => { + const manager: ContextsManager = { + onContextHealthStateChange: vi.fn(), + onContextPermissionResult: vi.fn(), + onContextDelete: vi.fn(), + getHealthCheckersStates: vi.fn(), + getPermissions: vi.fn(), + } as unknown as ContextsManager; + const apiSender: ApiSenderType = { + send: vi.fn(), + } as unknown as ApiSenderType; + const dispatcher = new ContextsStatesDispatcher(manager, apiSender); + vi.spyOn(dispatcher, 'getContextsHealths').mockReturnValue([]); + dispatcher.updateHealthStates(); + expect(apiSender.send).toHaveBeenCalledWith('kubernetes-contexts-healths'); +}); + +test('getContextsPermissions should return the values as an array', () => { + const manager: ContextsManager = { + getPermissions: vi.fn(), + } as unknown as ContextsManager; + const apiSender: ApiSenderType = { + send: vi.fn(), + } as unknown as ApiSenderType; + const dispatcher = new ContextsStatesDispatcher(manager, apiSender); + const value: ContextPermission[] = [ + { + contextName: 'context1', + resourceName: 'resource1', + permitted: true, + reason: 'ok', + }, + { + contextName: 'context1', + resourceName: 'resource2', + permitted: false, + reason: 'nok', + }, + { + contextName: 'context2', + resourceName: 'resource1', + permitted: false, + reason: 'nok', + }, + { + contextName: 'context2', + resourceName: 'resource2', + permitted: true, + reason: 'ok', + }, + ]; + vi.mocked(manager.getPermissions).mockReturnValue(value); + const result = dispatcher.getContextsPermissions(); + expect(result).toEqual(value); +}); + +test('updatePermissions should call apiSender.send with kubernetes-contexts-permissions', () => { + const manager: ContextsManager = {} as ContextsManager; + const apiSender: ApiSenderType = { + send: vi.fn(), + } as unknown as ApiSenderType; + const dispatcher = new ContextsStatesDispatcher(manager, apiSender); + dispatcher.updatePermissions(); + expect(vi.mocked(apiSender.send)).toHaveBeenCalledWith('kubernetes-contexts-permissions'); +}); + +test('updateResourcesCount should call apiSender.send with kubernetes-resources-count', () => { + const manager: ContextsManager = {} as ContextsManager; + const apiSender: ApiSenderType = { + send: vi.fn(), + } as unknown as ApiSenderType; + const dispatcher = new ContextsStatesDispatcher(manager, apiSender); + dispatcher.updateResourcesCount(); + expect(vi.mocked(apiSender.send)).toHaveBeenCalledWith('kubernetes-resources-count'); +}); + +test('updateResource should call apiSender.send with kubernetes-`resource-name`', () => { + const manager: ContextsManager = {} as ContextsManager; + const apiSender: ApiSenderType = { + send: vi.fn(), + } as unknown as ApiSenderType; + const dispatcher = new ContextsStatesDispatcher(manager, apiSender); + dispatcher.updateResource('resource1'); + expect(vi.mocked(apiSender.send)).toHaveBeenCalledWith('kubernetes-update-resource1'); +}); diff --git a/packages/extension/src/manager/contexts-states-dispatcher.ts b/packages/extension/src/manager/contexts-states-dispatcher.ts new file mode 100644 index 00000000..21753b8e --- /dev/null +++ b/packages/extension/src/manager/contexts-states-dispatcher.ts @@ -0,0 +1,109 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { ContextHealth } from '/@common/model/kubernetes-contexts-healths.js'; +import type { ContextPermission } from '/@common/model/kubernetes-contexts-permissions.js'; +import type { ResourceCount } from '/@common/model/kubernetes-resource-count.js'; +import type { KubernetesContextResources } from '/@common/model/kubernetes-resources.js'; +import type { KubernetesTroubleshootingInformation } from '/@common/model/kubernetes-troubleshooting.js'; + +import type { ApiSenderType } from '/@common/model/api-sender.js'; +import type { ContextHealthState } from './context-health-checker.js'; +import type { ContextPermissionResult } from './context-permissions-checker.js'; +import type { DispatcherEvent } from './contexts-dispatcher.js'; +import type { ContextsManager } from './contexts-manager.js'; + +export class ContextsStatesDispatcher { + constructor( + private manager: ContextsManager, + private apiSender: ApiSenderType, + ) {} + + init(): void { + this.manager.onContextHealthStateChange((_state: ContextHealthState) => this.updateHealthStates()); + this.manager.onOfflineChange(() => { + this.updateHealthStates(); + this.updateResourcesCount(); + this.updateActiveResourcesCount(); + }); + this.manager.onContextPermissionResult((_permissions: ContextPermissionResult) => this.updatePermissions()); + this.manager.onContextDelete((_state: DispatcherEvent) => { + this.updateHealthStates(); + this.updatePermissions(); + }); + this.manager.onResourceCountUpdated(() => this.updateResourcesCount()); + this.manager.onResourceUpdated(event => { + this.updateResource(event.resourceName); + this.updateActiveResourcesCount(); + }); + } + + updateHealthStates(): void { + this.apiSender.send('kubernetes-contexts-healths'); + } + + getContextsHealths(): ContextHealth[] { + const value: ContextHealth[] = []; + for (const [contextName, health] of this.manager.getHealthCheckersStates()) { + value.push({ + contextName, + checking: health.checking, + reachable: health.reachable, + offline: this.manager.isContextOffline(contextName), + errorMessage: health.errorMessage, + }); + } + return value; + } + + updatePermissions(): void { + this.apiSender.send('kubernetes-contexts-permissions'); + } + + getContextsPermissions(): ContextPermission[] { + return this.manager.getPermissions(); + } + + updateResourcesCount(): void { + this.apiSender.send(`kubernetes-resources-count`); + } + + updateActiveResourcesCount(): void { + this.apiSender.send(`kubernetes-active-resources-count`); + } + + getResourcesCount(): ResourceCount[] { + return this.manager.getResourcesCount(); + } + + getActiveResourcesCount(): ResourceCount[] { + return this.manager.getActiveResourcesCount(); + } + + updateResource(resourceName: string): void { + this.apiSender.send(`kubernetes-update-${resourceName}`); + } + + getResources(contextNames: string[], resourceName: string): KubernetesContextResources[] { + return this.manager.getResources(contextNames, resourceName); + } + + getTroubleshootingInformation(): KubernetesTroubleshootingInformation { + return this.manager.getTroubleshootingInformation(); + } +} diff --git a/packages/extension/src/manager/resource-factory-handler.ts b/packages/extension/src/manager/resource-factory-handler.ts new file mode 100644 index 00000000..383255d8 --- /dev/null +++ b/packages/extension/src/manager/resource-factory-handler.ts @@ -0,0 +1,93 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import util from 'node:util'; + +import type { ContextPermissionsRequest } from './context-permissions-checker.js'; +import type { ResourceFactory } from '../resources/resource-factory.js'; +import { isResourceFactoryWithPermissions } from '../resources/resource-factory.js'; + +export class ResourceFactoryHandler { + #resourceFactories: ResourceFactory[] = []; + + add(factory: ResourceFactory): void { + if (this.getResourceFactoryByResourceName(factory.resource)) { + throw new Error(`a factory for resource ${factory.resource} has already been added`); + } + this.#resourceFactories.push(factory); + } + + getPermissionsRequests(namespace: string): ContextPermissionsRequest[] { + return [ + ...this.getNamespacedOrNotPermissionsRequests(this.#resourceFactories, namespace), + ...this.getNamespacedOrNotPermissionsRequests(this.#resourceFactories), + ]; + } + + getResourceFactoryByResourceName(resource: string): ResourceFactory | undefined { + return this.#resourceFactories.find(f => f.resource === resource); + } + + private getNamespacedOrNotPermissionsRequests( + factories: ResourceFactory[], + namespace?: string, + ): ContextPermissionsRequest[] { + const isNamespaced = !!namespace; + const filteredResourceFactories = factories + .filter(isResourceFactoryWithPermissions) + .filter(f => f.permissions.isNamespaced === isNamespaced); + if (!filteredResourceFactories[0]) { + return []; + } + const firstFilteredResourceFactory = filteredResourceFactories[0]; + if (!firstFilteredResourceFactory.permissions.permissionsRequests[0]) { + return []; + } + const children: ResourceFactory[] = []; + const newRequest: ContextPermissionsRequest = { + attrs: { + namespace: isNamespaced ? namespace : undefined, + ...firstFilteredResourceFactory.permissions.permissionsRequests[0], + }, + resources: [firstFilteredResourceFactory.resource], + }; + const child = firstFilteredResourceFactory.copyWithSlicedPermissions(); + children.push(child); + const remainings: ResourceFactory[] = []; + for (const filteredResourceFactory of filteredResourceFactories.slice(1)) { + if ( + util.isDeepStrictEqual( + filteredResourceFactory.permissions.permissionsRequests[0], + firstFilteredResourceFactory.permissions.permissionsRequests[0], + ) + ) { + newRequest.resources.push(filteredResourceFactory.resource); + const child = filteredResourceFactory.copyWithSlicedPermissions(); + children.push(child); + } else { + remainings.push(filteredResourceFactory); + } + } + const childrenRequests = this.getNamespacedOrNotPermissionsRequests(children, namespace); + if (childrenRequests.length) { + newRequest.onDenyRequests = childrenRequests; + } + + return [newRequest, ...this.getNamespacedOrNotPermissionsRequests(remainings, namespace)]; + } +} diff --git a/packages/extension/src/registry/context-resource-registry.spec.ts b/packages/extension/src/registry/context-resource-registry.spec.ts new file mode 100644 index 00000000..4288d865 --- /dev/null +++ b/packages/extension/src/registry/context-resource-registry.spec.ts @@ -0,0 +1,78 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { beforeEach, expect, test } from 'vitest'; + +import { ContextResourceRegistry } from './context-resource-registry.js'; + +let registry: ContextResourceRegistry; + +beforeEach(() => { + registry = new ContextResourceRegistry(); +}); + +test('ContextResourceRegistry', () => { + registry.set('context1', 'resource1', 'value1'); + expect(registry.get('context1', 'resource1')).toEqual('value1'); + + registry.set('context1', 'resource2', 'value2'); + expect(registry.getAll()).toEqual([ + { + contextName: 'context1', + resourceName: 'resource1', + value: 'value1', + }, + { + contextName: 'context1', + resourceName: 'resource2', + value: 'value2', + }, + ]); +}); + +test('getForContextsAndResource', () => { + registry.set('context1', 'resource1', 'value1'); + registry.set('context1', 'resource2', 'value2'); + registry.set('context2', 'resource1', 'value3'); + + const resultNoContext = registry.getForContextsAndResource([], 'resource1'); + expect(resultNoContext).toEqual([]); + + const resultOneContext = registry.getForContextsAndResource(['context1'], 'resource1'); + expect(resultOneContext).toEqual([ + { + contextName: 'context1', + resourceName: 'resource1', + value: 'value1', + }, + ]); + + const resultAllContexts = registry.getForContextsAndResource(['context1', 'context2'], 'resource1'); + expect(resultAllContexts).toEqual([ + { + contextName: 'context1', + resourceName: 'resource1', + value: 'value1', + }, + { + contextName: 'context2', + resourceName: 'resource1', + value: 'value3', + }, + ]); +}); diff --git a/packages/extension/src/registry/context-resource-registry.ts b/packages/extension/src/registry/context-resource-registry.ts new file mode 100644 index 00000000..6cbe1ff6 --- /dev/null +++ b/packages/extension/src/registry/context-resource-registry.ts @@ -0,0 +1,75 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +export interface Details { + contextName: string; + resourceName: string; + value: T; +} + +// ContextResourceRegistry stores objects of type T for contexts and resources +export class ContextResourceRegistry { + #registry: Map> = new Map(); + + set(context: string, resource: string, object: T): void { + if (!this.#registry.get(context)) { + this.#registry.set(context, new Map()); + } + this.#registry.get(context)?.set(resource, object); + } + + get(context: string, resource: string): T | undefined { + return this.#registry.get(context)?.get(resource); + } + + getAll(): Details[] { + return Array.from(this.#registry.entries()).flatMap(([contextName, resources]) => { + return Array.from(resources.entries()).map(([resourceName, value]) => ({ + contextName, + resourceName, + value, + })); + }); + } + + getForContextsAndResource(contextNames: string[], resourceName: string): Details[] { + const result: Details[] = []; + for (const [contextName, contextResources] of this.#registry.entries()) { + if (!contextNames.includes(contextName)) { + continue; + } + const value = contextResources.get(resourceName); + if (value) { + result.push({ contextName, value, resourceName }); + } + } + return result; + } + + getForContext(contextName: string): T[] { + const forContext = this.#registry.get(contextName); + if (!forContext) { + return []; + } + return Array.from(forContext.values()); + } + + removeForContext(contextName: string): void { + this.#registry.delete(contextName); + } +} diff --git a/packages/extension/src/resources/configmaps-resource-factory.ts b/packages/extension/src/resources/configmaps-resource-factory.ts new file mode 100644 index 00000000..7d7d797d --- /dev/null +++ b/packages/extension/src/resources/configmaps-resource-factory.ts @@ -0,0 +1,59 @@ +/********************************************************************** + * Copyright (C) 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { V1ConfigMap, V1ConfigMapList } from '@kubernetes/client-node'; +import { CoreV1Api } from '@kubernetes/client-node'; + +import type { KubeConfigSingleContext } from '../types/kubeconfig-single-context.js'; +import type { ResourceFactory } from './resource-factory.js'; +import { ResourceFactoryBase } from './resource-factory.js'; +import { ResourceInformer } from '../types/resource-informer.js'; + +export class ConfigmapsResourceFactory extends ResourceFactoryBase implements ResourceFactory { + constructor() { + super({ + resource: 'configmaps', + }); + + this.setPermissions({ + isNamespaced: true, + permissionsRequests: [ + { + group: '*', + resource: '*', + verb: 'watch', + }, + { + verb: 'watch', + resource: 'configmaps', + }, + ], + }); + this.setInformer({ + createInformer: this.createInformer, + }); + } + + createInformer(kubeconfig: KubeConfigSingleContext): ResourceInformer { + const namespace = kubeconfig.getNamespace(); + const apiClient = kubeconfig.getKubeConfig().makeApiClient(CoreV1Api); + const listFn = (): Promise => apiClient.listNamespacedConfigMap({ namespace }); + const path = `/api/v1/namespaces/${namespace}/configmaps`; + return new ResourceInformer({ kubeconfig, path, listFn, kind: 'ConfigMap', plural: 'configmaps' }); + } +} diff --git a/packages/extension/src/resources/cronjobs-resource-factory.ts b/packages/extension/src/resources/cronjobs-resource-factory.ts new file mode 100644 index 00000000..e1351bb6 --- /dev/null +++ b/packages/extension/src/resources/cronjobs-resource-factory.ts @@ -0,0 +1,60 @@ +/********************************************************************** + * Copyright (C) 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { V1CronJob, V1CronJobList } from '@kubernetes/client-node'; +import { BatchV1Api } from '@kubernetes/client-node'; + +import type { KubeConfigSingleContext } from '../types/kubeconfig-single-context.js'; +import type { ResourceFactory } from './resource-factory.js'; +import { ResourceFactoryBase } from './resource-factory.js'; +import { ResourceInformer } from '../types/resource-informer.js'; + +export class CronjobsResourceFactory extends ResourceFactoryBase implements ResourceFactory { + constructor() { + super({ + resource: 'cronjobs', + }); + + this.setPermissions({ + isNamespaced: true, + permissionsRequests: [ + { + group: '*', + resource: '*', + verb: 'watch', + }, + { + group: 'batch', + verb: 'watch', + resource: 'cronjobs', + }, + ], + }); + this.setInformer({ + createInformer: this.createInformer, + }); + } + + createInformer(kubeconfig: KubeConfigSingleContext): ResourceInformer { + const namespace = kubeconfig.getNamespace(); + const apiClient = kubeconfig.getKubeConfig().makeApiClient(BatchV1Api); + const listFn = (): Promise => apiClient.listNamespacedCronJob({ namespace }); + const path = `/apis/batch/v1/namespaces/${namespace}/cronjobs`; + return new ResourceInformer({ kubeconfig, path, listFn, kind: 'CronJob', plural: 'cronjobs' }); + } +} diff --git a/packages/extension/src/resources/deployments-resource-factory.spec.ts b/packages/extension/src/resources/deployments-resource-factory.spec.ts new file mode 100644 index 00000000..4cc2b19a --- /dev/null +++ b/packages/extension/src/resources/deployments-resource-factory.spec.ts @@ -0,0 +1,58 @@ +/********************************************************************** + * Copyright (C) 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { V1Deployment } from '@kubernetes/client-node'; +import { expect, test } from 'vitest'; + +import { DeploymentsResourceFactory } from './deployments-resource-factory.js'; + +test('deployment with replica=0 is not active', () => { + const factory = new DeploymentsResourceFactory(); + if (!factory.isActive) { + throw new Error('isActive should not be undefined'); + } + expect( + factory.isActive({ + spec: { + replicas: 0, + }, + } as V1Deployment), + ).toBeFalsy(); +}); + +test('deployment with replica=1 is active', () => { + const factory = new DeploymentsResourceFactory(); + if (!factory.isActive) { + throw new Error('isActive should not be undefined'); + } + expect( + factory.isActive({ + spec: { + replicas: 1, + }, + } as V1Deployment), + ).toBeTruthy(); +}); + +test('deployment with replica undefined is not active', () => { + const factory = new DeploymentsResourceFactory(); + if (!factory.isActive) { + throw new Error('isActive should not be undefined'); + } + expect(factory.isActive({} as V1Deployment)).toBeFalsy(); +}); diff --git a/packages/extension/src/resources/deployments-resource-factory.ts b/packages/extension/src/resources/deployments-resource-factory.ts new file mode 100644 index 00000000..fd6cedc5 --- /dev/null +++ b/packages/extension/src/resources/deployments-resource-factory.ts @@ -0,0 +1,65 @@ +/********************************************************************** + * Copyright (C) 2024, 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { V1Deployment, V1DeploymentList } from '@kubernetes/client-node'; +import { AppsV1Api } from '@kubernetes/client-node'; + +import type { KubeConfigSingleContext } from '../types/kubeconfig-single-context.js'; +import type { ResourceFactory } from './resource-factory.js'; +import { ResourceFactoryBase } from './resource-factory.js'; +import { ResourceInformer } from '../types/resource-informer.js'; + +export class DeploymentsResourceFactory extends ResourceFactoryBase implements ResourceFactory { + constructor() { + super({ + resource: 'deployments', + }); + + this.setPermissions({ + isNamespaced: true, + permissionsRequests: [ + { + group: '*', + resource: '*', + verb: 'watch', + }, + { + verb: 'watch', + group: 'apps', + resource: 'deployments', + }, + ], + }); + this.setInformer({ + createInformer: this.createInformer, + }); + this.setIsActive(this.isDeploymentActive); + } + + createInformer(kubeconfig: KubeConfigSingleContext): ResourceInformer { + const namespace = kubeconfig.getNamespace(); + const apiClient = kubeconfig.getKubeConfig().makeApiClient(AppsV1Api); + const listFn = (): Promise => apiClient.listNamespacedDeployment({ namespace }); + const path = `/apis/apps/v1/namespaces/${namespace}/deployments`; + return new ResourceInformer({ kubeconfig, path, listFn, kind: 'Deployment', plural: 'deployments' }); + } + + isDeploymentActive(deployment: V1Deployment): boolean { + return (deployment.spec?.replicas ?? 0) > 0; + } +} diff --git a/packages/extension/src/resources/events-resource-factory.ts b/packages/extension/src/resources/events-resource-factory.ts new file mode 100644 index 00000000..f6a03783 --- /dev/null +++ b/packages/extension/src/resources/events-resource-factory.ts @@ -0,0 +1,59 @@ +/********************************************************************** + * Copyright (C) 2024, 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { CoreV1Event, CoreV1EventList } from '@kubernetes/client-node'; +import { CoreV1Api } from '@kubernetes/client-node'; + +import type { KubeConfigSingleContext } from '../types/kubeconfig-single-context.js'; +import type { ResourceFactory } from './resource-factory.js'; +import { ResourceFactoryBase } from './resource-factory.js'; +import { ResourceInformer } from '../types/resource-informer.js'; + +export class EventsResourceFactory extends ResourceFactoryBase implements ResourceFactory { + constructor() { + super({ + resource: 'events', + }); + + this.setPermissions({ + isNamespaced: true, + permissionsRequests: [ + { + group: '*', + resource: '*', + verb: 'watch', + }, + { + verb: 'watch', + resource: 'events', + }, + ], + }); + this.setInformer({ + createInformer: this.createInformer, + }); + } + + createInformer(kubeconfig: KubeConfigSingleContext): ResourceInformer { + const namespace = kubeconfig.getNamespace(); + const apiClient = kubeconfig.getKubeConfig().makeApiClient(CoreV1Api); + const listFn = (): Promise => apiClient.listNamespacedEvent({ namespace }); + const path = `/api/v1/namespaces/${namespace}/events`; + return new ResourceInformer({ kubeconfig, path, listFn, kind: 'Event', plural: 'events' }); + } +} diff --git a/packages/extension/src/resources/ingresses-resource-factory.ts b/packages/extension/src/resources/ingresses-resource-factory.ts new file mode 100644 index 00000000..bc100f87 --- /dev/null +++ b/packages/extension/src/resources/ingresses-resource-factory.ts @@ -0,0 +1,60 @@ +/********************************************************************** + * Copyright (C) 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { V1Ingress, V1IngressList } from '@kubernetes/client-node'; +import { NetworkingV1Api } from '@kubernetes/client-node'; + +import type { KubeConfigSingleContext } from '../types/kubeconfig-single-context.js'; +import type { ResourceFactory } from './resource-factory.js'; +import { ResourceFactoryBase } from './resource-factory.js'; +import { ResourceInformer } from '../types/resource-informer.js'; + +export class IngressesResourceFactory extends ResourceFactoryBase implements ResourceFactory { + constructor() { + super({ + resource: 'ingresses', + }); + + this.setPermissions({ + isNamespaced: true, + permissionsRequests: [ + { + group: '*', + resource: '*', + verb: 'watch', + }, + { + verb: 'watch', + group: 'networking.k8s.io', + resource: 'ingresses', + }, + ], + }); + this.setInformer({ + createInformer: this.createInformer, + }); + } + + createInformer(kubeconfig: KubeConfigSingleContext): ResourceInformer { + const namespace = kubeconfig.getNamespace(); + const apiClient = kubeconfig.getKubeConfig().makeApiClient(NetworkingV1Api); + const listFn = (): Promise => apiClient.listNamespacedIngress({ namespace }); + const path = `/apis/networking.k8s.io/v1/namespaces/${namespace}/ingresses`; + return new ResourceInformer({ kubeconfig, path, listFn, kind: 'Ingress', plural: 'ingresses' }); + } +} diff --git a/packages/extension/src/resources/jobs-resource-factory.ts b/packages/extension/src/resources/jobs-resource-factory.ts new file mode 100644 index 00000000..610f2676 --- /dev/null +++ b/packages/extension/src/resources/jobs-resource-factory.ts @@ -0,0 +1,60 @@ +/********************************************************************** + * Copyright (C) 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { V1Job, V1JobList } from '@kubernetes/client-node'; +import { BatchV1Api } from '@kubernetes/client-node'; + +import type { KubeConfigSingleContext } from '../types/kubeconfig-single-context.js'; +import type { ResourceFactory } from './resource-factory.js'; +import { ResourceFactoryBase } from './resource-factory.js'; +import { ResourceInformer } from '../types/resource-informer.js'; + +export class JobsResourceFactory extends ResourceFactoryBase implements ResourceFactory { + constructor() { + super({ + resource: 'jobs', + }); + + this.setPermissions({ + isNamespaced: true, + permissionsRequests: [ + { + group: '*', + resource: '*', + verb: 'watch', + }, + { + group: 'batch', + verb: 'watch', + resource: 'jobs', + }, + ], + }); + this.setInformer({ + createInformer: this.createInformer, + }); + } + + createInformer(kubeconfig: KubeConfigSingleContext): ResourceInformer { + const namespace = kubeconfig.getNamespace(); + const apiClient = kubeconfig.getKubeConfig().makeApiClient(BatchV1Api); + const listFn = (): Promise => apiClient.listNamespacedJob({ namespace }); + const path = `/apis/batch/v1/namespaces/${namespace}/jobs`; + return new ResourceInformer({ kubeconfig, path, listFn, kind: 'Job', plural: 'jobs' }); + } +} diff --git a/packages/extension/src/resources/nodes-resource-factory.spec.ts b/packages/extension/src/resources/nodes-resource-factory.spec.ts new file mode 100644 index 00000000..3ba01c8c --- /dev/null +++ b/packages/extension/src/resources/nodes-resource-factory.spec.ts @@ -0,0 +1,74 @@ +/********************************************************************** + * Copyright (C) 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { V1Node } from '@kubernetes/client-node'; +import { expect, test } from 'vitest'; + +import { NodesResourceFactory } from './nodes-resource-factory.js'; + +test('node with no status is not active', () => { + const factory = new NodesResourceFactory(); + if (!factory.isActive) { + throw new Error('isActive should not be undefined'); + } + expect( + factory.isActive({ + spec: { + replicas: 0, + }, + } as V1Node), + ).toBeFalsy(); +}); + +test('node with "Ready" condition set to true is active', () => { + const factory = new NodesResourceFactory(); + if (!factory.isActive) { + throw new Error('isActive should not be undefined'); + } + expect( + factory.isActive({ + status: { + conditions: [ + { + type: 'Ready', + status: 'True', + }, + ], + }, + } as V1Node), + ).toBeTruthy(); +}); + +test('node with "Ready" condition set to false is not active', () => { + const factory = new NodesResourceFactory(); + if (!factory.isActive) { + throw new Error('isActive should not be undefined'); + } + expect( + factory.isActive({ + status: { + conditions: [ + { + type: 'Ready', + status: 'False', + }, + ], + }, + } as V1Node), + ).toBeFalsy(); +}); diff --git a/packages/extension/src/resources/nodes-resource-factory.ts b/packages/extension/src/resources/nodes-resource-factory.ts new file mode 100644 index 00000000..1bc0a2ef --- /dev/null +++ b/packages/extension/src/resources/nodes-resource-factory.ts @@ -0,0 +1,65 @@ +/********************************************************************** + * Copyright (C) 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { V1Node, V1NodeList } from '@kubernetes/client-node'; +import { CoreV1Api } from '@kubernetes/client-node'; + +import type { KubeConfigSingleContext } from '../types/kubeconfig-single-context.js'; +import type { ResourceFactory } from './resource-factory.js'; +import { ResourceFactoryBase } from './resource-factory.js'; +import { ResourceInformer } from '../types/resource-informer.js'; + +export class NodesResourceFactory extends ResourceFactoryBase implements ResourceFactory { + constructor() { + super({ + resource: 'nodes', + }); + + this.setPermissions({ + isNamespaced: false, + permissionsRequests: [ + { + group: '*', + resource: '*', + verb: 'watch', + }, + { + verb: 'watch', + resource: 'nodes', + }, + ], + }); + this.setInformer({ + createInformer: this.createInformer, + }); + this.setIsActive(this.isNodeActive); + } + + createInformer(kubeconfig: KubeConfigSingleContext): ResourceInformer { + const apiClient = kubeconfig.getKubeConfig().makeApiClient(CoreV1Api); + const listFn = (): Promise => apiClient.listNode(); + const path = `/api/v1/nodes`; + return new ResourceInformer({ kubeconfig, path, listFn, kind: 'Node', plural: 'nodes' }); + } + + isNodeActive(node: V1Node): boolean { + return ( + node.status?.conditions?.some(condition => condition.type === 'Ready' && condition.status === 'True') ?? false + ); + } +} diff --git a/packages/extension/src/resources/pods-resource-factory.ts b/packages/extension/src/resources/pods-resource-factory.ts new file mode 100644 index 00000000..802b4368 --- /dev/null +++ b/packages/extension/src/resources/pods-resource-factory.ts @@ -0,0 +1,59 @@ +/********************************************************************** + * Copyright (C) 2024, 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { V1Pod, V1PodList } from '@kubernetes/client-node'; +import { CoreV1Api } from '@kubernetes/client-node'; + +import type { KubeConfigSingleContext } from '../types/kubeconfig-single-context.js'; +import type { ResourceFactory } from './resource-factory.js'; +import { ResourceFactoryBase } from './resource-factory.js'; +import { ResourceInformer } from '../types/resource-informer.js'; + +export class PodsResourceFactory extends ResourceFactoryBase implements ResourceFactory { + constructor() { + super({ + resource: 'pods', + }); + + this.setPermissions({ + isNamespaced: true, + permissionsRequests: [ + { + group: '*', + resource: '*', + verb: 'watch', + }, + { + verb: 'watch', + resource: 'pods', + }, + ], + }); + this.setInformer({ + createInformer: this.createInformer, + }); + } + + createInformer(kubeconfig: KubeConfigSingleContext): ResourceInformer { + const namespace = kubeconfig.getNamespace(); + const apiClient = kubeconfig.getKubeConfig().makeApiClient(CoreV1Api); + const listFn = (): Promise => apiClient.listNamespacedPod({ namespace }); + const path = `/api/v1/namespaces/${namespace}/pods`; + return new ResourceInformer({ kubeconfig, path, listFn, kind: 'Pod', plural: 'pods' }); + } +} diff --git a/packages/extension/src/resources/pvcs-resource-factory.ts b/packages/extension/src/resources/pvcs-resource-factory.ts new file mode 100644 index 00000000..8a4addfb --- /dev/null +++ b/packages/extension/src/resources/pvcs-resource-factory.ts @@ -0,0 +1,66 @@ +/********************************************************************** + * Copyright (C) 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { V1PersistentVolumeClaim, V1PersistentVolumeClaimList } from '@kubernetes/client-node'; +import { CoreV1Api } from '@kubernetes/client-node'; + +import type { KubeConfigSingleContext } from '../types/kubeconfig-single-context.js'; +import type { ResourceFactory } from './resource-factory.js'; +import { ResourceFactoryBase } from './resource-factory.js'; +import { ResourceInformer } from '../types/resource-informer.js'; + +export class PVCsResourceFactory extends ResourceFactoryBase implements ResourceFactory { + constructor() { + super({ + resource: 'persistentvolumeclaims', + }); + + this.setPermissions({ + isNamespaced: true, + permissionsRequests: [ + { + group: '*', + resource: '*', + verb: 'watch', + }, + { + verb: 'watch', + resource: 'persistentvolumeclaims', + }, + ], + }); + this.setInformer({ + createInformer: this.createInformer, + }); + } + + createInformer(kubeconfig: KubeConfigSingleContext): ResourceInformer { + const namespace = kubeconfig.getNamespace(); + const apiClient = kubeconfig.getKubeConfig().makeApiClient(CoreV1Api); + const listFn = (): Promise => + apiClient.listNamespacedPersistentVolumeClaim({ namespace }); + const path = `/api/v1/namespaces/${namespace}/persistentvolumeclaims`; + return new ResourceInformer({ + kubeconfig, + path, + listFn, + kind: 'PersistentVolumeClaim', + plural: 'persistentvolumeclaims', + }); + } +} diff --git a/packages/extension/src/resources/resource-factory-handler.spec.ts b/packages/extension/src/resources/resource-factory-handler.spec.ts new file mode 100644 index 00000000..a7aee8de --- /dev/null +++ b/packages/extension/src/resources/resource-factory-handler.spec.ts @@ -0,0 +1,467 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { expect, test } from 'vitest'; + +import { DeploymentsResourceFactory } from './deployments-resource-factory.js'; +import { PodsResourceFactory } from './pods-resource-factory.js'; +import { ResourceFactoryBase } from './resource-factory.js'; +import { ResourceFactoryHandler } from '../manager/resource-factory-handler.js'; + +test('with 1 level and same request', () => { + const factoryHandler = new ResourceFactoryHandler(); + + factoryHandler.add( + new ResourceFactoryBase({ + resource: 'resource1', + }).setPermissions({ + isNamespaced: true, + permissionsRequests: [ + { + group: '*', + resource: '*', + verb: 'watch', + }, + ], + }), + ); + + factoryHandler.add( + new ResourceFactoryBase({ + resource: 'resource2', + }).setPermissions({ + isNamespaced: true, + permissionsRequests: [ + { + group: '*', + resource: '*', + verb: 'watch', + }, + ], + }), + ); + + const requests = factoryHandler.getPermissionsRequests('ns'); + expect(requests).toEqual([ + { + attrs: { + namespace: 'ns', + group: '*', + resource: '*', + verb: 'watch', + }, + resources: ['resource1', 'resource2'], + }, + ]); +}); + +test('with 1 level and different requests', () => { + const factoryHandler = new ResourceFactoryHandler(); + + factoryHandler.add( + new ResourceFactoryBase({ + resource: 'resource1', + }).setPermissions({ + isNamespaced: true, + permissionsRequests: [ + { + group: 'group1', + resource: '*', + verb: 'watch', + }, + ], + }), + ); + + factoryHandler.add( + new ResourceFactoryBase({ + resource: 'resource2', + }).setPermissions({ + isNamespaced: true, + permissionsRequests: [ + { + group: 'group2', + resource: '*', + verb: 'watch', + }, + ], + }), + ); + + const requests = factoryHandler.getPermissionsRequests('ns'); + expect(requests).toEqual([ + { + attrs: { + namespace: 'ns', + group: 'group1', + resource: '*', + verb: 'watch', + }, + resources: ['resource1'], + }, + { + attrs: { + namespace: 'ns', + group: 'group2', + resource: '*', + verb: 'watch', + }, + resources: ['resource2'], + }, + ]); +}); + +test('with 2 levels and same request at first level', () => { + const factoryHandler = new ResourceFactoryHandler(); + + factoryHandler.add( + new ResourceFactoryBase({ + resource: 'resource1', + }).setPermissions({ + isNamespaced: true, + permissionsRequests: [ + { + group: '*', + resource: '*', + verb: 'watch', + }, + { + verb: 'watch', + resource: 'resource1', + }, + ], + }), + ); + + factoryHandler.add( + new ResourceFactoryBase({ + resource: 'resource2', + }).setPermissions({ + isNamespaced: true, + permissionsRequests: [ + { + group: '*', + resource: '*', + verb: 'watch', + }, + { + verb: 'watch', + group: 'group2', + resource: 'resource2', + }, + ], + }), + ); + + const requests = factoryHandler.getPermissionsRequests('ns'); + expect(requests).toEqual([ + { + attrs: { + namespace: 'ns', + group: '*', + resource: '*', + verb: 'watch', + }, + resources: ['resource1', 'resource2'], + onDenyRequests: [ + { + attrs: { + namespace: 'ns', + verb: 'watch', + resource: 'resource1', + }, + resources: ['resource1'], + }, + { + attrs: { + namespace: 'ns', + verb: 'watch', + group: 'group2', + resource: 'resource2', + }, + resources: ['resource2'], + }, + ], + }, + ]); +}); + +test('with 1 level and same request, non namespaced', () => { + const factoryHandler = new ResourceFactoryHandler(); + + factoryHandler.add( + new ResourceFactoryBase({ + resource: 'resource1', + }).setPermissions({ + isNamespaced: false, + permissionsRequests: [ + { + group: '*', + resource: '*', + verb: 'watch', + }, + ], + }), + ); + + factoryHandler.add( + new ResourceFactoryBase({ + resource: 'resource2', + }).setPermissions({ + isNamespaced: false, + permissionsRequests: [ + { + group: '*', + resource: '*', + verb: 'watch', + }, + ], + }), + ); + + const requests = factoryHandler.getPermissionsRequests('ns'); + expect(requests).toEqual([ + { + attrs: { + group: '*', + resource: '*', + verb: 'watch', + }, + resources: ['resource1', 'resource2'], + }, + ]); +}); + +test('with 1 level and different requests, non namespaced', () => { + const factoryHandler = new ResourceFactoryHandler(); + + factoryHandler.add( + new ResourceFactoryBase({ + resource: 'resource1', + }).setPermissions({ + isNamespaced: false, + permissionsRequests: [ + { + group: 'group1', + resource: '*', + verb: 'watch', + }, + ], + }), + ); + + factoryHandler.add( + new ResourceFactoryBase({ + resource: 'resource2', + }).setPermissions({ + isNamespaced: false, + permissionsRequests: [ + { + group: 'group2', + resource: '*', + verb: 'watch', + }, + ], + }), + ); + + const requests = factoryHandler.getPermissionsRequests('ns'); + expect(requests).toEqual([ + { + attrs: { + group: 'group1', + resource: '*', + verb: 'watch', + }, + resources: ['resource1'], + }, + { + attrs: { + group: 'group2', + resource: '*', + verb: 'watch', + }, + resources: ['resource2'], + }, + ]); +}); + +test('with 2 levels and same request at first level, non namespaced', () => { + const factoryHandler = new ResourceFactoryHandler(); + + factoryHandler.add( + new ResourceFactoryBase({ + resource: 'resource1', + }).setPermissions({ + isNamespaced: false, + permissionsRequests: [ + { + group: '*', + resource: '*', + verb: 'watch', + }, + { + verb: 'watch', + resource: 'resource1', + }, + ], + }), + ); + + factoryHandler.add( + new ResourceFactoryBase({ + resource: 'resource2', + }).setPermissions({ + isNamespaced: false, + permissionsRequests: [ + { + group: '*', + resource: '*', + verb: 'watch', + }, + { + verb: 'watch', + group: 'group2', + resource: 'resource2', + }, + ], + }), + ); + + const requests = factoryHandler.getPermissionsRequests('ns'); + expect(requests).toEqual([ + { + attrs: { + group: '*', + resource: '*', + verb: 'watch', + }, + resources: ['resource1', 'resource2'], + onDenyRequests: [ + { + attrs: { + verb: 'watch', + resource: 'resource1', + }, + resources: ['resource1'], + }, + { + attrs: { + verb: 'watch', + group: 'group2', + resource: 'resource2', + }, + resources: ['resource2'], + }, + ], + }, + ]); +}); + +test('with 1 level and same request, both namespaced ant not namespaced', () => { + const factoryHandler = new ResourceFactoryHandler(); + + factoryHandler.add( + new ResourceFactoryBase({ + resource: 'resource1', + }).setPermissions({ + isNamespaced: true, + permissionsRequests: [ + { + group: '*', + resource: '*', + verb: 'watch', + }, + ], + }), + ); + + factoryHandler.add( + new ResourceFactoryBase({ + resource: 'resource2', + }).setPermissions({ + isNamespaced: false, + permissionsRequests: [ + { + group: '*', + resource: '*', + verb: 'watch', + }, + ], + }), + ); + + const requests = factoryHandler.getPermissionsRequests('ns'); + expect(requests).toEqual([ + { + attrs: { + namespace: 'ns', + group: '*', + resource: '*', + verb: 'watch', + }, + resources: ['resource1'], + }, + { + attrs: { + group: '*', + resource: '*', + verb: 'watch', + }, + resources: ['resource2'], + }, + ]); +}); + +test('real pods and deployments', () => { + const factoryHandler = new ResourceFactoryHandler(); + + factoryHandler.add(new PodsResourceFactory()); + factoryHandler.add(new DeploymentsResourceFactory()); + const requests = factoryHandler.getPermissionsRequests('ns'); + expect(requests).toEqual([ + { + attrs: { + namespace: 'ns', + group: '*', + resource: '*', + verb: 'watch', + }, + resources: ['pods', 'deployments'], + onDenyRequests: [ + { + attrs: { + namespace: 'ns', + verb: 'watch', + resource: 'pods', + }, + resources: ['pods'], + }, + { + attrs: { + namespace: 'ns', + verb: 'watch', + group: 'apps', + resource: 'deployments', + }, + resources: ['deployments'], + }, + ], + }, + ]); +}); diff --git a/packages/extension/src/resources/resource-factory.spec.ts b/packages/extension/src/resources/resource-factory.spec.ts new file mode 100644 index 00000000..86759ab1 --- /dev/null +++ b/packages/extension/src/resources/resource-factory.spec.ts @@ -0,0 +1,90 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import type { V1Pod } from '@kubernetes/client-node'; +import { expect, test } from 'vitest'; + +import type { KubeConfigSingleContext } from '../types/kubeconfig-single-context.js'; +import { isResourceFactoryWithPermissions, ResourceFactoryBase } from './resource-factory.js'; +import type { ResourceInformer } from '../types/resource-informer.js'; + +test('ResourceFactoryBase set permissions', () => { + const factory = new ResourceFactoryBase({ resource: 'resource1' }); + + const permissionsRequests = [ + { + group: '*', + resource: '*', + verb: 'watch', + }, + { + verb: 'watch', + resource: 'resource1', + }, + ]; + factory.setPermissions({ + isNamespaced: true, + permissionsRequests, + }); + expect(factory.permissions?.isNamespaced).toBeTruthy(); + expect(factory.permissions?.permissionsRequests).toEqual(permissionsRequests); + expect(factory.informer).toBeUndefined(); + + expect(isResourceFactoryWithPermissions(factory)).toBeTruthy(); +}); + +test('copyWithSlicedPermissions', () => { + const factory = new ResourceFactoryBase({ resource: 'resource1' }); + + const permissionsRequests = [ + { + group: '*', + resource: '*', + verb: 'watch', + }, + { + verb: 'watch', + resource: 'resource1', + }, + ]; + factory.setPermissions({ + isNamespaced: true, + permissionsRequests, + }); + + const copy = factory.copyWithSlicedPermissions(); + expect(copy.permissions?.isNamespaced).toBeTruthy(); + expect(copy.permissions?.permissionsRequests).toEqual([ + { + verb: 'watch', + resource: 'resource1', + }, + ]); +}); + +test('ResourceFactoryBase set informer', () => { + const factory = new ResourceFactoryBase({ resource: 'resource1' }); + const createInformer = (_kubeconfig: KubeConfigSingleContext): ResourceInformer => { + return {} as ResourceInformer; + }; + factory.setInformer({ + createInformer, + }); + + expect(factory.informer?.createInformer).toEqual(createInformer); + expect(isResourceFactoryWithPermissions(factory)).toBeFalsy(); +}); diff --git a/packages/extension/src/resources/resource-factory.ts b/packages/extension/src/resources/resource-factory.ts new file mode 100644 index 00000000..baed3024 --- /dev/null +++ b/packages/extension/src/resources/resource-factory.ts @@ -0,0 +1,108 @@ +/********************************************************************** + * Copyright (C) 2024 - 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import type { KubernetesObject, V1ResourceAttributes } from '@kubernetes/client-node'; + +import type { KubeConfigSingleContext } from '../types/kubeconfig-single-context.js'; +import type { ResourceInformer } from '../types/resource-informer.js'; + +export interface ResourcePermissionsFactory { + get permissionsRequests(): V1ResourceAttributes[]; + get isNamespaced(): boolean; +} + +export interface ResourceInformerFactory { + createInformer(kubeconfig: KubeConfigSingleContext): ResourceInformer; +} + +export class ResourceFactoryBase { + #resource: string; + #permissions: ResourcePermissionsFactory | undefined; + #informer: ResourceInformerFactory | undefined; + #isActive: undefined | ((resource: KubernetesObject) => boolean); + + constructor(options: { resource: string }) { + this.#resource = options.resource; + } + + setPermissions(options: { permissionsRequests: V1ResourceAttributes[]; isNamespaced: boolean }): ResourceFactoryBase { + this.#permissions = { + permissionsRequests: options.permissionsRequests, + isNamespaced: options.isNamespaced, + }; + return this; + } + + setInformer(options: { + createInformer: (kubeconfig: KubeConfigSingleContext) => ResourceInformer; + }): ResourceFactoryBase { + this.#informer = { + createInformer: options.createInformer, + }; + return this; + } + + setIsActive(isActive: (resource: KubernetesObject) => boolean): ResourceFactoryBase { + this.#isActive = isActive; + return this; + } + + get resource(): string { + return this.#resource; + } + + get permissions(): ResourcePermissionsFactory | undefined { + return this.#permissions; + } + + get informer(): ResourceInformerFactory | undefined { + return this.#informer; + } + + get isActive(): undefined | ((resource: KubernetesObject) => boolean) { + return this.#isActive; + } + + copyWithSlicedPermissions(): ResourceFactory { + if (!this.#permissions) { + throw new Error('permission must be defined before calling copyWithSlicedPermissions'); + } + return new ResourceFactoryBase({ + resource: this.#resource, + }).setPermissions({ + permissionsRequests: this.#permissions.permissionsRequests.slice(1), + isNamespaced: this.#permissions.isNamespaced, + }); + } +} + +export interface ResourceFactory { + get resource(): string; + permissions?: ResourcePermissionsFactory; + informer?: ResourceInformerFactory; + // isActive returns true if `resource` is considered active + isActive?: (resource: KubernetesObject) => boolean; + copyWithSlicedPermissions(): ResourceFactory; +} + +export function isResourceFactoryWithPermissions(object: ResourceFactory): object is ResourceFactoryWithPermissions { + return !!object.permissions; +} + +interface ResourceFactoryWithPermissions extends ResourceFactory { + permissions: ResourcePermissionsFactory; +} diff --git a/packages/extension/src/resources/routes-resource-factory.ts b/packages/extension/src/resources/routes-resource-factory.ts new file mode 100644 index 00000000..310eb080 --- /dev/null +++ b/packages/extension/src/resources/routes-resource-factory.ts @@ -0,0 +1,68 @@ +/********************************************************************** + * Copyright (C) 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { KubernetesListObject } from '@kubernetes/client-node'; +import { CustomObjectsApi } from '@kubernetes/client-node'; + +import type { V1Route } from '/@common/model/openshift-types.js'; + +import type { KubeConfigSingleContext } from '../types/kubeconfig-single-context.js'; +import type { ResourceFactory } from './resource-factory.js'; +import { ResourceFactoryBase } from './resource-factory.js'; +import { ResourceInformer } from '../types/resource-informer.js'; + +export class RoutesResourceFactory extends ResourceFactoryBase implements ResourceFactory { + constructor() { + super({ + resource: 'routes', + }); + + this.setPermissions({ + isNamespaced: true, + permissionsRequests: [ + { + group: '*', + resource: '*', + verb: 'watch', + }, + { + verb: 'watch', + group: 'route.openshift.io', + resource: 'routes', + }, + ], + }); + this.setInformer({ + createInformer: this.createInformer, + }); + } + + createInformer(kubeconfig: KubeConfigSingleContext): ResourceInformer { + const namespace = kubeconfig.getNamespace(); + const apiClient = kubeconfig.getKubeConfig().makeApiClient(CustomObjectsApi); + const listFn = (): Promise> => + apiClient.listNamespacedCustomObject({ + group: 'route.openshift.io', + version: 'v1', + namespace, + plural: 'routes', + }); + const path = `/apis/route.openshift.io/v1/namespaces/${namespace}/routes`; + return new ResourceInformer({ kubeconfig, path, listFn, kind: 'Route', plural: 'routes' }); + } +} diff --git a/packages/extension/src/resources/secrets-resource-factory.ts b/packages/extension/src/resources/secrets-resource-factory.ts new file mode 100644 index 00000000..dd5ffe80 --- /dev/null +++ b/packages/extension/src/resources/secrets-resource-factory.ts @@ -0,0 +1,59 @@ +/********************************************************************** + * Copyright (C) 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { V1Secret, V1SecretList } from '@kubernetes/client-node'; +import { CoreV1Api } from '@kubernetes/client-node'; + +import type { KubeConfigSingleContext } from '../types/kubeconfig-single-context.js'; +import type { ResourceFactory } from './resource-factory.js'; +import { ResourceFactoryBase } from './resource-factory.js'; +import { ResourceInformer } from '../types/resource-informer.js'; + +export class SecretsResourceFactory extends ResourceFactoryBase implements ResourceFactory { + constructor() { + super({ + resource: 'secrets', + }); + + this.setPermissions({ + isNamespaced: true, + permissionsRequests: [ + { + group: '*', + resource: '*', + verb: 'watch', + }, + { + verb: 'watch', + resource: 'secrets', + }, + ], + }); + this.setInformer({ + createInformer: this.createInformer, + }); + } + + createInformer(kubeconfig: KubeConfigSingleContext): ResourceInformer { + const namespace = kubeconfig.getNamespace(); + const apiClient = kubeconfig.getKubeConfig().makeApiClient(CoreV1Api); + const listFn = (): Promise => apiClient.listNamespacedSecret({ namespace }); + const path = `/api/v1/namespaces/${namespace}/secrets`; + return new ResourceInformer({ kubeconfig, path, listFn, kind: 'Secret', plural: 'secrets' }); + } +} diff --git a/packages/extension/src/resources/services-resource-factory.ts b/packages/extension/src/resources/services-resource-factory.ts new file mode 100644 index 00000000..080a0481 --- /dev/null +++ b/packages/extension/src/resources/services-resource-factory.ts @@ -0,0 +1,59 @@ +/********************************************************************** + * Copyright (C) 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { V1Service, V1ServiceList } from '@kubernetes/client-node'; +import { CoreV1Api } from '@kubernetes/client-node'; + +import type { KubeConfigSingleContext } from '../types/kubeconfig-single-context.js'; +import type { ResourceFactory } from './resource-factory.js'; +import { ResourceFactoryBase } from './resource-factory.js'; +import { ResourceInformer } from '../types/resource-informer.js'; + +export class ServicesResourceFactory extends ResourceFactoryBase implements ResourceFactory { + constructor() { + super({ + resource: 'services', + }); + + this.setPermissions({ + isNamespaced: true, + permissionsRequests: [ + { + group: '*', + resource: '*', + verb: 'watch', + }, + { + verb: 'watch', + resource: 'services', + }, + ], + }); + this.setInformer({ + createInformer: this.createInformer, + }); + } + + createInformer(kubeconfig: KubeConfigSingleContext): ResourceInformer { + const namespace = kubeconfig.getNamespace(); + const apiClient = kubeconfig.getKubeConfig().makeApiClient(CoreV1Api); + const listFn = (): Promise => apiClient.listNamespacedService({ namespace }); + const path = `/api/v1/namespaces/${namespace}/services`; + return new ResourceInformer({ kubeconfig, path, listFn, kind: 'Service', plural: 'services' }); + } +} diff --git a/packages/extension/src/types/disposable.ts b/packages/extension/src/types/disposable.ts new file mode 100644 index 00000000..668211ce --- /dev/null +++ b/packages/extension/src/types/disposable.ts @@ -0,0 +1,58 @@ +/********************************************************************** + * Copyright (C) 2022 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +export interface IDisposable { + dispose(): void; +} + +export class Disposable implements IDisposable { + private disposable: undefined | (() => void); + + static from(...disposables: { dispose(): unknown }[]): Disposable { + return new Disposable(() => { + if (disposables) { + for (const disposable of disposables) { + if (disposable && typeof disposable.dispose === 'function') { + disposable.dispose(); + } + } + } + }); + } + + constructor(func: () => void) { + this.disposable = func; + } + /** + * Dispose this object. + */ + dispose(): void { + if (this.disposable) { + this.disposable(); + this.disposable = undefined; + } + } + + static create(func: () => void): Disposable { + return new Disposable(func); + } + + static noop(): Disposable { + return Disposable.from(); + } +} diff --git a/packages/extension/src/types/emitter.ts b/packages/extension/src/types/emitter.ts new file mode 100644 index 00000000..48b49a41 --- /dev/null +++ b/packages/extension/src/types/emitter.ts @@ -0,0 +1,285 @@ +/********************************************************************** + * Copyright (C) 2023-2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +/* eslint-disable @typescript-eslint/no-unsafe-function-type */ + +import type { IDisposable } from './disposable.js'; + +export type DisposableGroup = { push(disposable: IDisposable): void } | { add(disposable: IDisposable): void }; + +/** + * Represents a typed event. + */ +/** + * + * @param listener The listener function will be call when the event happens. + * @param thisArgs The 'this' which will be used when calling the event listener. + * @param disposables An array to which a {{IDisposable}} will be added. + * @return a disposable to remove the listener again. + */ +export type Event = (listener: (e: T) => unknown, thisArgs?: unknown, disposables?: DisposableGroup) => IDisposable; + +type Callback = (...args: unknown[]) => unknown; +class CallbackList implements Iterable { + private _callbacks: Function[] | undefined; + private _contexts: unknown[] | undefined; + + get length(): number { + return this._callbacks?.length ?? 0; + } + + public add(callback: Function, context: unknown = undefined, bucket?: IDisposable[]): void { + if (!this._callbacks) { + this._callbacks = []; + this._contexts = []; + } + this._callbacks.push(callback); + this._contexts?.push(context); + + if (Array.isArray(bucket)) { + bucket.push({ dispose: () => this.remove(callback, context) }); + } + } + + public remove(callback: Function, context: unknown = undefined): void { + if (!this._callbacks) { + return; + } + + let foundCallbackWithDifferentContext = false; + for (let i = 0; i < this._callbacks.length; i++) { + if (this._callbacks[i] === callback) { + if (this._contexts?.[i] === context) { + // callback & context match => remove it + this._callbacks.splice(i, 1); + this._contexts?.splice(i, 1); + return; + } else { + foundCallbackWithDifferentContext = true; + } + } + } + + if (foundCallbackWithDifferentContext) { + throw new Error('When adding a listener with a context, you should remove it with the same context'); + } + } + + // prettier-ignore + public [Symbol.iterator](): IterableIterator { + if (!this._callbacks) { + return [][Symbol.iterator](); + } + const callbacks = this._callbacks.slice(0); + const contexts = this._contexts?.slice(0); + return callbacks + .map( + (callback, i) => + (...args: unknown[]) => + callback.apply(contexts?.[i], args), + )[Symbol.iterator](); + } + + public invoke(...args: unknown[]): unknown[] { + const ret: unknown[] = []; + for (const callback of this) { + try { + ret.push(callback(...args)); + } catch (e) { + console.error(e); + } + } + return ret; + } + + public isEmpty(): boolean { + return !this._callbacks || this._callbacks.length === 0; + } + + public dispose(): void { + this._callbacks = undefined; + this._contexts = undefined; + } +} + +export interface EmitterOptions { + onFirstListenerAdd?: Function; + onLastListenerRemove?: Function; +} + +export class Emitter { + private static LEAK_WARNING_THRESHHOLD = 175; + + // eslint-disable-next-line @typescript-eslint/no-empty-function + private static _noop = function (): void {}; + + private _event: Event | undefined; + protected _callbacks: CallbackList | undefined; + private _disposed = false; + + private _leakingStacks: Map | undefined; + private _leakWarnCountdown = 0; + + constructor(private _options?: EmitterOptions) {} + + canPushDisposable(candidate?: DisposableGroup): candidate is { push(disposable: IDisposable): void } { + return Boolean(candidate && (candidate as { push(): void }).push); + } + canAddDisposable(candidate?: DisposableGroup): candidate is { add(disposable: IDisposable): void } { + return Boolean(candidate && (candidate as { add(): void }).add); + } + + private getMaxListeners(event: Event | undefined): number { + return event && 'maxListeners' in event && typeof event.maxListeners === 'number' ? event.maxListeners : 0; + } + + /** + * For the public to allow to subscribe + * to events from this Emitter + */ + get event(): Event { + this._event ??= Object.assign( + (listener: (e: T) => unknown, thisArgs?: unknown, disposables?: DisposableGroup) => { + this._callbacks ??= new CallbackList(); + if (this._options?.onFirstListenerAdd && this._callbacks.isEmpty()) { + this._options.onFirstListenerAdd(this); + } + this._callbacks.add(listener, thisArgs); + const removeMaxListenersCheck = this.checkMaxListeners(this.getMaxListeners(this._event)); + + const result: IDisposable = { + dispose: () => { + if (removeMaxListenersCheck) { + removeMaxListenersCheck(); + } + result.dispose = Emitter._noop; + if (!this._disposed) { + this._callbacks?.remove(listener, thisArgs); + result.dispose = Emitter._noop; + if (this._options?.onLastListenerRemove && this._callbacks?.isEmpty()) { + this._options.onLastListenerRemove(this); + } + } + }, + }; + if (this.canPushDisposable(disposables)) { + disposables.push(result); + } else if (this.canAddDisposable(disposables)) { + disposables.add(result); + } + + return result; + }, + { + maxListeners: Emitter.LEAK_WARNING_THRESHHOLD, + }, + ); + return this._event; + } + + protected checkMaxListeners(maxListeners: number): (() => void) | undefined { + if (maxListeners === 0 || !this._callbacks) { + return undefined; + } + const listenerCount = this._callbacks.length; + if (listenerCount <= maxListeners) { + return undefined; + } + + const popStack = this.pushLeakingStack(); + + this._leakWarnCountdown -= 1; + if (this._leakWarnCountdown <= 0) { + // only warn on first exceed and then every time the limit + // is exceeded by 50% again + this._leakWarnCountdown = maxListeners * 0.5; + + let topStack = ''; + let topCount = 0; + this._leakingStacks?.forEach((stackCount, stack) => { + if (!topStack || topCount < stackCount) { + topStack = stack; + topCount = stackCount; + } + }); + + // eslint-disable-next-line max-len + console.warn( + `Possible Emitter memory leak detected. ${listenerCount} listeners added. Use event.maxListeners to increase the limit (${maxListeners}). MOST frequent listener (${topCount}):`, + ); + console.warn(topStack); + } + + return popStack; + } + + protected pushLeakingStack(): () => void { + this._leakingStacks ??= new Map(); + const stack = new Error().stack?.split('\n').slice(3).join('\n'); + if (stack) { + const count = this._leakingStacks.get(stack) ?? 0; + this._leakingStacks.set(stack, count + 1); + return () => this.popLeakingStack(stack); + } + return () => Emitter._noop; + } + + protected popLeakingStack(stack: string): void { + if (!this._leakingStacks) { + return; + } + const count = this._leakingStacks.get(stack) ?? 0; + this._leakingStacks.set(stack, count - 1); + } + + /** + * To be kept private to fire an event to + * subscribers + */ + fire(event: T): void { + if (this._callbacks) { + this._callbacks.invoke(event); + } + } + + /** + * Process each listener one by one. + * Return `false` to stop iterating over the listeners, `true` to continue. + */ + async sequence(processor: (listener: (e: T) => unknown) => Promise): Promise { + if (this._callbacks) { + for (const listener of this._callbacks) { + if (!(await processor(listener))) { + break; + } + } + } + } + + dispose(): void { + if (this._leakingStacks) { + this._leakingStacks.clear(); + this._leakingStacks = undefined; + } + if (this._callbacks) { + this._callbacks.dispose(); + this._callbacks = undefined; + } + this._disposed = true; + } +} diff --git a/packages/extension/src/types/kubeconfig-single-context.spec.ts b/packages/extension/src/types/kubeconfig-single-context.spec.ts new file mode 100644 index 00000000..a02a561a --- /dev/null +++ b/packages/extension/src/types/kubeconfig-single-context.spec.ts @@ -0,0 +1,131 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { Cluster, Context, User } from '@kubernetes/client-node'; +import { KubeConfig } from '@kubernetes/client-node'; +import { expect, test } from 'vitest'; + +import { KubeConfigSingleContext } from './kubeconfig-single-context.js'; + +const contexts = [ + { + name: 'context1', + cluster: 'cluster1', + user: 'user1', + namespace: 'ns1', + }, + { + name: 'context2', + cluster: 'cluster2', + user: 'user2', + namespace: 'ns2', + }, +] as Context[]; + +const clusters = [ + { + name: 'cluster1', + }, + { + name: 'cluster2', + }, +] as Cluster[]; + +const users = [ + { + name: 'user1', + }, + { + name: 'user2', + }, +] as User[]; + +const kcWith2contexts = { + contexts, + clusters, + users, +} as unknown as KubeConfig; + +test('KubeConfigSingleContext', () => { + const kc = new KubeConfig(); + kc.loadFromOptions(kcWith2contexts); + const single = new KubeConfigSingleContext(kc, contexts[0]!); + const expected = { + contexts: [contexts[0]], + users: [users[0]], + clusters: [clusters[0]], + currentContext: 'context1', + } as KubeConfig; + expect(single.getKubeConfig()).toEqual(expect.objectContaining(expected)); + + const kcExpected = new KubeConfig(); + kcExpected.loadFromOptions(expected); + const expectedSingle = new KubeConfigSingleContext(expected, contexts[0]!); + expect(single.equals(expectedSingle)).toBeTruthy(); + + const otherSingle = new KubeConfigSingleContext(kc, contexts[1]!); + expect(single.equals(otherSingle)).toBeFalsy(); + + expect(single.equals(undefined)).toBeFalsy(); +}); + +test('getNamespace', () => { + const contexts = [ + { + name: 'context1', + cluster: 'cluster1', + user: 'user1', + }, + { + name: 'context2', + cluster: 'cluster2', + user: 'user2', + namespace: 'ns2', + }, + ] as Context[]; + + const clusters = [ + { + name: 'cluster1', + }, + { + name: 'cluster2', + }, + ] as Cluster[]; + + const users = [ + { + name: 'user1', + }, + { + name: 'user2', + }, + ] as User[]; + + const kcWith2contexts = { + contexts, + clusters, + users, + } as unknown as KubeConfig; + + const single1 = new KubeConfigSingleContext(kcWith2contexts, contexts[0]!); + expect(single1.getNamespace()).toEqual('default'); + + const single2 = new KubeConfigSingleContext(kcWith2contexts, contexts[1]!); + expect(single2.getNamespace()).toEqual('ns2'); +}); diff --git a/packages/extension/src/types/kubeconfig-single-context.ts b/packages/extension/src/types/kubeconfig-single-context.ts new file mode 100644 index 00000000..6cd8209c --- /dev/null +++ b/packages/extension/src/types/kubeconfig-single-context.ts @@ -0,0 +1,59 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { Context } from '@kubernetes/client-node'; +import { KubeConfig } from '@kubernetes/client-node'; + +/** + * KubeConfigSingleContext represents a KubeConfig with a single Context + * and its related information (user, cluster) + * which is set as the current context for the KubeConfig. + * The namespace of the current context is set as `default` if it is not defined + */ +export class KubeConfigSingleContext { + #value: KubeConfig; + #representation: string; + #context: Context; + + constructor(kubeconfig: KubeConfig, kubeContext: Context) { + this.#context = { ...kubeContext, namespace: kubeContext.namespace ?? 'default' }; + this.#value = new KubeConfig(); + this.#value.loadFromOptions({ + contexts: [this.#context], + clusters: kubeconfig.clusters.filter(c => c.name === this.#context.cluster), + users: kubeconfig.users.filter(u => u.name === this.#context.user), + currentContext: this.#context.name, + }); + this.#representation = JSON.stringify(this.#value); + } + + getKubeConfig(): KubeConfig { + return this.#value; + } + + getNamespace(): string { + return this.#context.namespace ?? 'default'; + } + + equals(other: KubeConfigSingleContext | undefined): boolean { + if (!other) { + return false; + } + return other.#representation === this.#representation; + } +} diff --git a/packages/extension/src/types/resource-informer.spec.ts b/packages/extension/src/types/resource-informer.spec.ts new file mode 100644 index 00000000..2a4dfdde --- /dev/null +++ b/packages/extension/src/types/resource-informer.spec.ts @@ -0,0 +1,352 @@ +/********************************************************************** + * Copyright (C) 2024, 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { + Cluster, + Context, + KubernetesObject, + ListPromise, + ListWatch, + User, + V1ObjectMeta, +} from '@kubernetes/client-node'; +import { ApiException, DELETE, ERROR, KubeConfig, UPDATE } from '@kubernetes/client-node'; +import { expect, test, vi } from 'vitest'; + +import { KubeConfigSingleContext } from './kubeconfig-single-context.js'; +import { ResourceInformer } from './resource-informer.js'; + +interface MyResource { + apiVersion?: string; + kind?: string; + metadata?: V1ObjectMeta; +} + +class TestResourceInformer extends ResourceInformer { + override getListWatch(path: string, listFn: ListPromise): ListWatch { + return super.getListWatch(path, listFn); + } +} + +const contexts = [ + { + name: 'context1', + cluster: 'cluster1', + user: 'user1', + namespace: 'ns1', + }, + { + name: 'context2', + cluster: 'cluster2', + user: 'user2', + }, +] as Context[]; + +const clusters = [ + { + name: 'cluster1', + }, + { + name: 'cluster2', + }, +] as Cluster[]; + +const users = [ + { + name: 'user1', + }, + { + name: 'user2', + }, +] as User[]; + +const kcWith2contexts = { + contexts, + clusters, + users, +} as unknown as KubeConfig; + +test('ResourceInformer should eventually return the list of resources', async () => { + const kc = new KubeConfig(); + kc.loadFromOptions(kcWith2contexts); + const listFn = vi.fn(); + const kubeconfig = new KubeConfigSingleContext(kc, contexts[0]!); + const items = [{ metadata: { name: 'res1', namespace: 'ns1' } }, { metadata: { name: 'res2', namespace: 'ns1' } }]; + listFn.mockResolvedValue({ apiVersion: 'v8', items: items }); + const informer = new ResourceInformer({ + kubeconfig, + path: '/a/path', + listFn, + kind: 'MyResource', + plural: 'myresources', + }); + const result = informer.start(); + await vi.waitFor(() => { + const list = result.list(); + expect(list).toEqual(items.map(i => ({ apiVersion: 'v8', kind: 'MyResource', ...i }))); + }); +}); + +test('ResourceInformer should fire onCacheUpdated event with countChanged to true when informer is started an resources exist', async () => { + const kc = new KubeConfig(); + kc.loadFromOptions(kcWith2contexts); + const listFn = vi.fn(); + const kubeconfig = new KubeConfigSingleContext(kc, contexts[0]!); + const items = [{ metadata: { name: 'res1', namespace: 'ns1' } }, { metadata: { name: 'res2', namespace: 'ns1' } }]; + listFn.mockResolvedValue({ items: items }); + const informer = new ResourceInformer({ + kubeconfig, + path: '/a/path', + listFn, + kind: 'MyResource', + plural: 'myresources', + }); + const onCacheUpdatedCB = vi.fn(); + informer.onCacheUpdated(onCacheUpdatedCB); + informer.start(); + await vi.waitFor(() => { + expect(onCacheUpdatedCB).toHaveBeenCalledWith({ kubeconfig, resourceName: 'myresources', countChanged: true }); + }); +}); + +test('ResourceInformer should fire onCacheUpdated event with countChanged to true when resources are deleted', async () => { + const kc = new KubeConfig(); + kc.loadFromOptions(kcWith2contexts); + const listFn = vi.fn(); + const kubeconfig = new KubeConfigSingleContext(kc, contexts[0]!); + const items = [ + { metadata: { name: 'res1', namespace: 'ns1' } }, + { metadata: { name: 'res2', namespace: 'ns1' } }, + ] as MyResource[]; + listFn.mockResolvedValue({ items: items }); + const informer = new TestResourceInformer({ + kubeconfig, + path: '/a/path', + listFn, + kind: 'MyResource', + plural: 'myresources', + }); + const getListWatchOnMock = vi.fn(); + vi.spyOn(informer, 'getListWatch').mockReturnValue({ + on: getListWatchOnMock, + start: vi.fn().mockResolvedValue({}), + } as unknown as ListWatch); + getListWatchOnMock.mockImplementation((event: string, f: (obj: MyResource) => void) => { + if (event === DELETE) { + f(items[0]!); + } + }); + const onCacheUpdatedCB = vi.fn(); + informer.onCacheUpdated(onCacheUpdatedCB); + informer.start(); + await vi.waitFor(() => { + expect(onCacheUpdatedCB).toHaveBeenCalledWith({ kubeconfig, resourceName: 'myresources', countChanged: true }); + }); +}); + +test('ResourceInformer should fire onCacheUpdated event with countChanged to false when resources are updated', async () => { + const kc = new KubeConfig(); + kc.loadFromOptions(kcWith2contexts); + const listFn = vi.fn(); + const kubeconfig = new KubeConfigSingleContext(kc, contexts[0]!); + const items = [ + { metadata: { name: 'res1', namespace: 'ns1' } }, + { metadata: { name: 'res2', namespace: 'ns1' } }, + ] as MyResource[]; + listFn.mockResolvedValue({ items: items }); + const informer = new TestResourceInformer({ + kubeconfig, + path: '/a/path', + listFn, + kind: 'MyResource', + plural: 'myresources', + }); + const getListWatchOnMock = vi.fn(); + vi.spyOn(informer, 'getListWatch').mockReturnValue({ + on: getListWatchOnMock, + start: vi.fn().mockResolvedValue({}), + } as unknown as ListWatch); + getListWatchOnMock.mockImplementation((event: string, f: (obj: MyResource) => void) => { + if (event === UPDATE) { + f({ metadata: { ...items[0]!.metadata, resourceVersion: '2' } }); + } + }); + const onCacheUpdatedCB = vi.fn(); + informer.onCacheUpdated(onCacheUpdatedCB); + informer.start(); + await vi.waitFor(() => { + expect(onCacheUpdatedCB).toHaveBeenCalledWith({ kubeconfig, resourceName: 'myresources', countChanged: false }); + }); +}); + +test('ResourceInformer should fire onOffline event is informer fails', async () => { + const kc = new KubeConfig(); + kc.loadFromOptions(kcWith2contexts); + const listFn = vi.fn(); + const kubeconfig = new KubeConfigSingleContext(kc, contexts[0]!); + const informer = new TestResourceInformer({ + kubeconfig, + path: '/a/path', + listFn, + kind: 'MyResource', + plural: 'myresources', + }); + const onCB = vi.fn(); + vi.spyOn(informer, 'getListWatch').mockReturnValue({ + on: onCB, + start: vi.fn().mockResolvedValue({}), + } as unknown as ListWatch); + const onOfflineCB = vi.fn(); + onCB.mockImplementation((e: string, f) => { + if (e === ERROR) { + f(new ApiException(500, 'an error', {}, {})); + } + }); + informer.onOffline(onOfflineCB); + informer.start(); + expect(onOfflineCB).toHaveBeenCalledWith({ + kubeconfig, + offline: true, + reason: `Error: HTTP-Code: 500 +Message: an error +Body: {} +Headers: {}`, + resourceName: 'myresources', + }); +}); + +test('ResourceInformer should not fire onOffline event is informer fails with a 404 error', async () => { + const kc = new KubeConfig(); + kc.loadFromOptions(kcWith2contexts); + const listFn = vi.fn(); + const kubeconfig = new KubeConfigSingleContext(kc, contexts[0]!); + const informer = new TestResourceInformer({ + kubeconfig, + path: '/a/path', + listFn, + kind: 'MyResource', + plural: 'myresources', + }); + const onCB = vi.fn(); + vi.spyOn(informer, 'getListWatch').mockReturnValue({ + on: onCB, + start: vi.fn().mockResolvedValue({}), + } as unknown as ListWatch); + const onOfflineCB = vi.fn(); + onCB.mockImplementation((e: string, f) => { + if (e === ERROR) { + f(new ApiException(404, 'an error', {}, {})); + } + }); + informer.onOffline(onOfflineCB); + informer.start(); + expect(onOfflineCB).not.toHaveBeenCalled(); +}); + +test('reconnect should do nothing if there is no error', async () => { + const kc = new KubeConfig(); + kc.loadFromOptions(kcWith2contexts); + const listFn = vi.fn(); + const kubeconfig = new KubeConfigSingleContext(kc, contexts[0]!); + const informer = new TestResourceInformer({ + kubeconfig, + path: '/a/path', + listFn, + kind: 'MyResource', + plural: 'myresources', + }); + const onCB = vi.fn(); + const startMock = vi.fn().mockResolvedValue({}); + vi.spyOn(informer, 'getListWatch').mockReturnValue({ + on: onCB, + start: startMock, + } as unknown as ListWatch); + const onOfflineCB = vi.fn(); + onCB.mockImplementation((e: string, _f) => { + if (e === ERROR) { + // do nothing + } + }); + informer.onOffline(onOfflineCB); + informer.start(); + expect(startMock).toHaveBeenCalledOnce(); + startMock.mockClear(); + informer.reconnect(); + expect(startMock).not.toHaveBeenCalled(); +}); + +test('reconnect should call start again if there is an error', async () => { + const kc = new KubeConfig(); + kc.loadFromOptions(kcWith2contexts); + const listFn = vi.fn(); + const kubeconfig = new KubeConfigSingleContext(kc, contexts[0]!); + const informer = new TestResourceInformer({ + kubeconfig, + path: '/a/path', + listFn, + kind: 'MyResource', + plural: 'myresources', + }); + const onCB = vi.fn(); + const startMock = vi.fn().mockResolvedValue({}); + vi.spyOn(informer, 'getListWatch').mockReturnValue({ + on: onCB, + start: startMock, + } as unknown as ListWatch); + const onOfflineCB = vi.fn(); + onCB.mockImplementation((e: string, f) => { + if (e === ERROR) { + f('an error'); + } + }); + informer.onOffline(onOfflineCB); + informer.start(); + expect(startMock).toHaveBeenCalledOnce(); + startMock.mockClear(); + informer.reconnect(); + expect(startMock).toHaveBeenCalled(); +}); + +test('informer is stopped when disposed', async () => { + const kc = new KubeConfig(); + kc.loadFromOptions(kcWith2contexts); + const listFn = vi.fn(); + const kubeconfig = new KubeConfigSingleContext(kc, contexts[0]!); + const informer = new TestResourceInformer({ + kubeconfig, + path: '/a/path', + listFn, + kind: 'MyResource', + plural: 'myresources', + }); + const onCB = vi.fn(); + const startMock = vi.fn().mockResolvedValue({}); + const stopMock = vi.fn().mockResolvedValue({}); + vi.spyOn(informer, 'getListWatch').mockReturnValue({ + on: onCB, + start: startMock, + stop: stopMock, + } as unknown as ListWatch); + const onOfflineCB = vi.fn(); + informer.onOffline(onOfflineCB); + informer.start(); + expect(startMock).toHaveBeenCalledOnce(); + startMock.mockClear(); + informer.dispose(); + expect(stopMock).toHaveBeenCalled(); +}); diff --git a/packages/extension/src/types/resource-informer.ts b/packages/extension/src/types/resource-informer.ts new file mode 100644 index 00000000..f42b209d --- /dev/null +++ b/packages/extension/src/types/resource-informer.ts @@ -0,0 +1,181 @@ +/********************************************************************** + * Copyright (C) 2024, 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { + Informer, + KubernetesListObject, + KubernetesObject, + ListPromise, + ObjectCache, +} from '@kubernetes/client-node'; +import { ADD, ApiException, DELETE, ERROR, ListWatch, UPDATE, Watch } from '@kubernetes/client-node'; +import type { Disposable } from '@podman-desktop/api'; + +import type { Event } from './emitter.js'; +import { Emitter } from './emitter.js'; +import type { KubeConfigSingleContext } from './kubeconfig-single-context.js'; + +interface BaseEvent { + kubeconfig: KubeConfigSingleContext; + resourceName: string; +} + +export interface CacheUpdatedEvent extends BaseEvent { + countChanged: boolean; +} + +export interface OfflineEvent extends BaseEvent { + offline: boolean; + reason?: string; +} + +export interface ResourceInformerOptions { + kubeconfig: KubeConfigSingleContext; + // the endpoint in the Kubernetes api server to list the resources + path: string; + // the function to list the resources + listFn: ListPromise; + // the kind of the resource (Pod, ...), appearing in the `kind` field of manifests for this resource + kind: string; + // the name of the resource for the 'REST API' (pods, ...), appearing in the path above + plural: string; +} + +export class ResourceInformer implements Disposable { + #kubeConfig: KubeConfigSingleContext; + #path: string; + #listFn: ListPromise; + #pluralName: string; + #kindName: string; + #informer: Informer | undefined; + #offline: boolean = false; + + #onCacheUpdated = new Emitter(); + onCacheUpdated: Event = this.#onCacheUpdated.event; + + #onOffline = new Emitter(); + onOffline: Event = this.#onOffline.event; + + constructor(options: ResourceInformerOptions) { + this.#kubeConfig = options.kubeconfig; + this.#path = options.path; + this.#listFn = options.listFn; + this.#pluralName = options.plural; + this.#kindName = options.kind; + } + + // start the informer and returns a cache to the data + // The cache will be active all the time, even if an error happens + // and the informer becomes offline + start(): ObjectCache { + // internalInformer extends both Informer and ObjectCache + const typedList = async (): Promise> => { + const list = await this.#listFn(); + return { + ...list, + items: list.items.map(item => ({ + kind: this.#kindName, + apiVersion: list.apiVersion, + ...item, + })), + }; + }; + const internalInformer = this.getListWatch(this.#path, typedList); + this.#informer = internalInformer; + + this.#informer.on(UPDATE, (_obj: T) => { + this.#onCacheUpdated.fire({ + kubeconfig: this.#kubeConfig, + resourceName: this.#pluralName, + countChanged: false, + }); + }); + this.#informer.on(ADD, (_obj: T) => { + this.#onCacheUpdated.fire({ + kubeconfig: this.#kubeConfig, + resourceName: this.#pluralName, + countChanged: true, + }); + }); + this.#informer.on(DELETE, (_obj: T) => { + this.#onCacheUpdated.fire({ + kubeconfig: this.#kubeConfig, + resourceName: this.#pluralName, + countChanged: true, + }); + }); + // This is issued when there is an error + this.#informer.on(ERROR, (error: unknown) => { + if (error instanceof ApiException && error.code === 404) { + // starting from kubernetes-client v1.1, informer is correctly started even if resource does not exist in API + // and the 404 error is received here + return; + } + this.#offline = true; + this.#onOffline.fire({ + kubeconfig: this.#kubeConfig, + resourceName: this.#pluralName, + offline: true, + reason: String(error), + }); + }); + this.#informer.start().catch((err: unknown) => { + console.error( + `error starting the informer for resource ${this.#pluralName} on context ${this.#kubeConfig.getKubeConfig().currentContext}: ${String(err)}`, + ); + }); + return internalInformer; + } + + // reconnect tries to start the informer again if it is marked as offline + // (after an error happens) + reconnect(): void { + if (!!this.#informer && this.#offline) { + this.#offline = false; + this.#onOffline.fire({ + kubeconfig: this.#kubeConfig, + resourceName: this.#pluralName, + offline: false, + }); + this.#informer.start().catch((err: unknown) => { + console.error( + `error starting the informer for resource ${this.#pluralName} on context ${this.#kubeConfig.getKubeConfig().currentContext}: ${String(err)}`, + ); + }); + } + } + + protected getListWatch(path: string, listFn: ListPromise): ListWatch { + const watch = new Watch(this.#kubeConfig.getKubeConfig()); + return new ListWatch(path, watch, listFn, false); + } + + dispose(): void { + this.#onCacheUpdated.dispose(); + this.#onOffline.dispose(); + this.#informer?.stop().catch((err: unknown) => { + console.error( + `error stopping the informer for resource ${this.#pluralName} on context ${this.#kubeConfig.getKubeConfig().currentContext}: ${String(err)}`, + ); + }); + } + + isOffline(): boolean { + return this.#offline; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c24cd65..63b182d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,9 @@ importers: packages/extension: dependencies: + '@kubernetes/client-node': + specifier: ^1.3.0 + version: 1.3.0(encoding@0.1.13) ansi_up: specifier: ^6.0.5 version: 6.0.6 @@ -642,6 +645,21 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@jsep-plugin/assignment@1.3.0': + resolution: {integrity: sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==} + engines: {node: '>= 10.16.0'} + peerDependencies: + jsep: ^0.4.0||^1.0.0 + + '@jsep-plugin/regex@1.0.4': + resolution: {integrity: sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==} + engines: {node: '>= 10.16.0'} + peerDependencies: + jsep: ^0.4.0||^1.0.0 + + '@kubernetes/client-node@1.3.0': + resolution: {integrity: sha512-IE0yrIpOT97YS5fg2QpzmPzm8Wmcdf4ueWMn+FiJSI3jgTTQT1u+LUhoYpdfhdHAVxdrNsaBg2C0UXSnOgMoCQ==} + '@microsoft/fetch-event-source@2.0.1': resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==} @@ -961,6 +979,9 @@ packages: '@types/humanize-duration@3.27.4': resolution: {integrity: sha512-yaf7kan2Sq0goxpbcwTQ+8E9RP6HutFBPv74T/IA/ojcHKhuKVlk2YFYyHhWZeLvZPzzLE3aatuQB4h0iqyyUA==} + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -973,12 +994,21 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node-fetch@2.6.12': + resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==} + + '@types/node@22.15.33': + resolution: {integrity: sha512-wzoocdnnpSxZ+6CjW4ADCK1jVmd1S/J3ArNWfn8FDDQtRm8dkDg7TA+mvek2wNrfCgwuZxqEOiB9B1XCJ6+dbw==} + '@types/node@24.0.4': resolution: {integrity: sha512-ulyqAkrhnuNq9pB76DRBTkcS6YsmDALy6Ua63V8OhrOBgbcYt6IOdzpw5P1+dyRIyMerzLkeYWBeOXPpA9GMAA==} '@types/semver@7.5.8': resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} + '@types/stream-buffers@3.0.7': + resolution: {integrity: sha512-azOCy05sXVXrO+qklf0c/B07H/oHaIuDDAiHPVwlk3A9Ek+ksHyTeMajLZl3r76FxpPpxem//4Te61G1iW3Giw==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -1360,6 +1390,9 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + autolinker@3.16.2: resolution: {integrity: sha512-JiYl7j2Z19F9NdTmirENSUUIIL/9MytEWtmzhfmsKPCp9E+G35Y0UNCMoM9tFigxT59qSc8Ml2dlZXOCVTYwuA==} @@ -1378,9 +1411,42 @@ packages: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} + b4a@1.6.7: + resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + bare-events@2.5.4: + resolution: {integrity: sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==} + + bare-fs@4.1.5: + resolution: {integrity: sha512-1zccWBMypln0jEE05LzZt+V/8y8AQsQQqxtklqaIyg5nu6OAYFhZxPXinJTSG+kU5qyNmeLgcn9AW7eHiCHVLA==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-os@3.6.1: + resolution: {integrity: sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==} + engines: {bare: '>=1.14.0'} + + bare-path@3.0.0: + resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} + + bare-stream@2.6.5: + resolution: {integrity: sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==} + peerDependencies: + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + bare-events: + optional: true + brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} @@ -1482,6 +1548,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -1576,6 +1646,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1617,6 +1691,9 @@ packages: encoding@0.1.13: resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.18.2: resolution: {integrity: sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==} engines: {node: '>=10.13.0'} @@ -1883,6 +1960,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -1935,6 +2015,10 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + form-data@4.0.3: + resolution: {integrity: sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==} + engines: {node: '>= 6'} + fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} @@ -2048,6 +2132,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hpagent@1.2.0: + resolution: {integrity: sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==} + engines: {node: '>=14'} + html-encoding-sniffer@4.0.0: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} @@ -2237,6 +2325,11 @@ packages: resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} engines: {node: '>=16'} + isomorphic-ws@5.0.0: + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: '*' + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -2260,6 +2353,9 @@ packages: resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} hasBin: true + jose@6.0.11: + resolution: {integrity: sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2282,6 +2378,10 @@ packages: canvas: optional: true + jsep@1.4.0: + resolution: {integrity: sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==} + engines: {node: '>= 10.16.0'} + jsesc@3.0.2: resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} engines: {node: '>=6'} @@ -2305,6 +2405,11 @@ packages: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true + jsonpath-plus@10.3.0: + resolution: {integrity: sha512-8TNmfeTCk2Le33A3vRRwtuworG/L5RrgMvdjhKZxvyShO+mBu2fP50OWUjRLNtvw344DdDarFh9buFAZs5ujeA==} + engines: {node: '>=18.0.0'} + hasBin: true + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -2458,6 +2563,14 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -2543,6 +2656,15 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + node-gyp@11.2.0: resolution: {integrity: sha512-T0S1zqskVUSxcsSTkAsLc7xCycrRYmtDHadDinzocrThjyQCn5kMlEBSj6H4qDbgsIOSLmmlRIeb0lZXj+UArA==} engines: {node: ^18.17.0 || >=20.5.0} @@ -2563,6 +2685,9 @@ packages: nwsapi@2.2.18: resolution: {integrity: sha512-p1TRH/edngVEHVbwqWnxUViEmq5znDvyB+Sik5cmuLpGOIfDf/39zLiq3swPF8Vakqn+gvNiOQAZu8djYlQILA==} + oauth4webapi@3.5.3: + resolution: {integrity: sha512-2bnHosmBLAQpXNBLOvaJMyMkr4Yya5ohE5Q9jqyxiN+aa7GFCzvDN1RRRMrp0NkfqRR2MTaQNkcSUCCjILD9oQ==} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -2587,6 +2712,12 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + openid-client@6.6.1: + resolution: {integrity: sha512-GmqoICGMI3IyFFjhvXxad8of4QWk2D0tm4vdJkldGm9nw7J3p1f7LPLWgGeFuKuw8HjDVe8Dd8QLGBe0NFvSSg==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2754,6 +2885,9 @@ packages: resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} engines: {node: '>=10'} + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -2835,6 +2969,9 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfc4648@1.5.4: + resolution: {integrity: sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg==} + rimraf@5.0.10: resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} hasBin: true @@ -2992,6 +3129,13 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + stream-buffers@3.0.3: + resolution: {integrity: sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw==} + engines: {node: '>= 0.10.0'} + + streamx@2.22.1: + resolution: {integrity: sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -3129,6 +3273,12 @@ packages: resolution: {integrity: sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==} engines: {node: '>=6'} + tar-fs@3.0.10: + resolution: {integrity: sha512-C1SwlQGNLe/jPNqapK8epDsXME7CAJR5RL3GcE6KWx1d9OUByzoHVcbu1VPI8tevg9H8Alae0AApHHFGzrD5zA==} + + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + tar@7.4.3: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} @@ -3137,6 +3287,9 @@ packages: resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} engines: {node: '>=18'} + text-decoder@1.2.3: + resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -3178,6 +3331,9 @@ packages: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@5.0.0: resolution: {integrity: sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==} engines: {node: '>=18'} @@ -3256,6 +3412,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.8.0: resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==} @@ -3367,6 +3526,9 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -3383,6 +3545,9 @@ packages: resolution: {integrity: sha512-mDGf9diDad/giZ/Sm9Xi2YcyzaFpbdLpJPr+E9fSkyQ7KpQD4SdFcugkRQYzhmfI4KeV4Qpnn2sKPdo+kmsgRQ==} engines: {node: '>=18'} + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -3426,6 +3591,9 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.18.1: resolution: {integrity: sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==} engines: {node: '>=10.0.0'} @@ -3438,6 +3606,18 @@ packages: utf-8-validate: optional: true + ws@8.18.2: + resolution: {integrity: sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -3781,6 +3961,39 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@jsep-plugin/assignment@1.3.0(jsep@1.4.0)': + dependencies: + jsep: 1.4.0 + + '@jsep-plugin/regex@1.0.4(jsep@1.4.0)': + dependencies: + jsep: 1.4.0 + + '@kubernetes/client-node@1.3.0(encoding@0.1.13)': + dependencies: + '@types/js-yaml': 4.0.9 + '@types/node': 22.15.33 + '@types/node-fetch': 2.6.12 + '@types/stream-buffers': 3.0.7 + form-data: 4.0.3 + hpagent: 1.2.0 + isomorphic-ws: 5.0.0(ws@8.18.2) + js-yaml: 4.1.0 + jsonpath-plus: 10.3.0 + node-fetch: 2.7.0(encoding@0.1.13) + openid-client: 6.6.1 + rfc4648: 1.5.4 + socks-proxy-agent: 8.0.5 + stream-buffers: 3.0.3 + tar-fs: 3.0.10 + ws: 8.18.2 + transitivePeerDependencies: + - bare-buffer + - bufferutil + - encoding + - supports-color + - utf-8-validate + '@microsoft/fetch-event-source@2.0.1': {} '@napi-rs/wasm-runtime@0.2.11': @@ -4065,6 +4278,8 @@ snapshots: '@types/humanize-duration@3.27.4': {} + '@types/js-yaml@4.0.9': {} + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -4074,12 +4289,25 @@ snapshots: '@types/ms@2.1.0': optional: true + '@types/node-fetch@2.6.12': + dependencies: + '@types/node': 24.0.4 + form-data: 4.0.3 + + '@types/node@22.15.33': + dependencies: + undici-types: 6.21.0 + '@types/node@24.0.4': dependencies: undici-types: 7.8.0 '@types/semver@7.5.8': {} + '@types/stream-buffers@3.0.7': + dependencies: + '@types/node': 24.0.4 + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.33': @@ -4534,6 +4762,8 @@ snapshots: async-function@1.0.0: {} + asynckit@0.4.0: {} + autolinker@3.16.2: dependencies: tslib: 2.8.1 @@ -4554,8 +4784,35 @@ snapshots: axobject-query@4.1.0: {} + b4a@1.6.7: {} + balanced-match@1.0.2: {} + bare-events@2.5.4: + optional: true + + bare-fs@4.1.5: + dependencies: + bare-events: 2.5.4 + bare-path: 3.0.0 + bare-stream: 2.6.5(bare-events@2.5.4) + optional: true + + bare-os@3.6.1: + optional: true + + bare-path@3.0.0: + dependencies: + bare-os: 3.6.1 + optional: true + + bare-stream@2.6.5(bare-events@2.5.4): + dependencies: + streamx: 2.22.1 + optionalDependencies: + bare-events: 2.5.4 + optional: true + brace-expansion@1.1.11: dependencies: balanced-match: 1.0.2 @@ -4666,6 +4923,10 @@ snapshots: color-name@1.1.4: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + concat-map@0.0.1: {} concurrently@9.2.0: @@ -4758,6 +5019,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + delayed-stream@1.0.0: {} + dequal@2.0.3: {} detect-libc@2.0.4: {} @@ -4793,6 +5056,10 @@ snapshots: iconv-lite: 0.6.3 optional: true + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + enhanced-resolve@5.18.2: dependencies: graceful-fs: 4.2.11 @@ -5256,6 +5523,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -5307,6 +5576,14 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data@4.0.3: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + fraction.js@4.3.7: {} fs-minipass@3.0.3: @@ -5426,6 +5703,8 @@ snapshots: dependencies: function-bind: 1.1.2 + hpagent@1.2.0: {} + html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 @@ -5616,6 +5895,10 @@ snapshots: isexe@3.1.1: {} + isomorphic-ws@5.0.0(ws@8.18.2): + dependencies: + ws: 8.18.2 + istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -5645,6 +5928,8 @@ snapshots: jiti@2.4.2: {} + jose@6.0.11: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -5682,6 +5967,8 @@ snapshots: - supports-color - utf-8-validate + jsep@1.4.0: {} + jsesc@3.0.2: {} jsesc@3.1.0: {} @@ -5696,6 +5983,12 @@ snapshots: dependencies: minimist: 1.2.8 + jsonpath-plus@10.3.0: + dependencies: + '@jsep-plugin/assignment': 1.3.0(jsep@1.4.0) + '@jsep-plugin/regex': 1.0.4(jsep@1.4.0) + jsep: 1.4.0 + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -5829,6 +6122,12 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + min-indent@1.0.1: {} minimatch@3.1.2: @@ -5900,6 +6199,12 @@ snapshots: negotiator@1.0.0: {} + node-fetch@2.7.0(encoding@0.1.13): + dependencies: + whatwg-url: 5.0.0 + optionalDependencies: + encoding: 0.1.13 + node-gyp@11.2.0: dependencies: env-paths: 2.2.1 @@ -5925,6 +6230,8 @@ snapshots: nwsapi@2.2.18: {} + oauth4webapi@3.5.3: {} + object-inspect@1.13.4: {} object-keys@1.1.1: {} @@ -5958,6 +6265,15 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + openid-client@6.6.1: + dependencies: + jose: 6.0.11 + oauth4webapi: 3.5.3 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -6095,6 +6411,11 @@ snapshots: err-code: 2.0.3 retry: 0.12.0 + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} queue-microtask@1.2.3: {} @@ -6170,6 +6491,8 @@ snapshots: reusify@1.1.0: {} + rfc4648@1.5.4: {} + rimraf@5.0.10: dependencies: glob: 10.4.5 @@ -6355,6 +6678,15 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + stream-buffers@3.0.3: {} + + streamx@2.22.1: + dependencies: + fast-fifo: 1.3.2 + text-decoder: 1.2.3 + optionalDependencies: + bare-events: 2.5.4 + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -6488,6 +6820,22 @@ snapshots: tapable@2.2.2: {} + tar-fs@3.0.10: + dependencies: + pump: 3.0.3 + tar-stream: 3.1.7 + optionalDependencies: + bare-fs: 4.1.5 + bare-path: 3.0.0 + transitivePeerDependencies: + - bare-buffer + + tar-stream@3.1.7: + dependencies: + b4a: 1.6.7 + fast-fifo: 1.3.2 + streamx: 2.22.1 + tar@7.4.3: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -6503,6 +6851,10 @@ snapshots: glob: 10.4.5 minimatch: 9.0.5 + text-decoder@1.2.3: + dependencies: + b4a: 1.6.7 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -6537,6 +6889,8 @@ snapshots: dependencies: tldts: 6.1.83 + tr46@0.0.3: {} + tr46@5.0.0: dependencies: punycode: 2.3.1 @@ -6630,6 +6984,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + undici-types@6.21.0: {} + undici-types@7.8.0: {} unique-filename@4.0.0: @@ -6760,6 +7116,8 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + webidl-conversions@3.0.1: {} + webidl-conversions@7.0.0: {} whatwg-encoding@3.1.1: @@ -6773,6 +7131,11 @@ snapshots: tr46: 5.0.0 webidl-conversions: 7.0.0 + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -6841,8 +7204,12 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.0 + wrappy@1.0.2: {} + ws@8.18.1: {} + ws@8.18.2: {} + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {}