diff --git a/.changeset/long-pumpkins-walk.md b/.changeset/long-pumpkins-walk.md new file mode 100644 index 0000000000..0ccad4d678 --- /dev/null +++ b/.changeset/long-pumpkins-walk.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes-backend': patch +--- + +Added a new `customResources` field to the ClusterDetails interface, in order to specify (override) custom resources per cluster diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md index dc541c5c50..04a04304f9 100644 --- a/docs/features/kubernetes/configuration.md +++ b/docs/features/kubernetes/configuration.md @@ -31,6 +31,10 @@ kubernetes: dashboardUrl: http://127.0.0.1:64713 # url copied from running the command: minikube service kubernetes-dashboard -n kubernetes-dashboard dashboardApp: standard caData: ${K8S_CONFIG_CA_DATA} + customResources: + - group: 'argoproj.io' + apiVersion: 'v1alpha1' + plural: 'rollouts' - url: http://127.0.0.2:9999 name: aws-cluster-1 authProvider: 'aws' @@ -254,6 +258,11 @@ See also https://cloud.google.com/kubernetes-engine/docs/how-to/api-server-authentication#environments-without-gcloud for complete docs about GKE without `gcloud`. +##### `clusters.\*.customResources` (optional) + +Configures which [custom resources][3] to look for when returning an entity's +Kubernetes resources belonging to the cluster. Same specification as [`customResources`](#customresources-optional) + #### `gke` This cluster locator is designed to work with Kubernetes clusters running in @@ -327,9 +336,13 @@ it is also possible to implement a ### `customResources` (optional) -Configures which [custom resources][3] to look for when returning an entity's +Configures which [custom resources][3] to look for by default when returning an entity's Kubernetes resources. +**Notes:** + +- The optional `kubernetes.customResources` property is overrode by `customResources` at the [clusters level](#clusterscustomresources-optional). + Defaults to empty array. Example: ```yaml diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index 15b1cef853..03ecefa79d 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -35,6 +35,7 @@ export interface ClusterDetails { authProvider: string; // (undocumented) caData?: string | undefined; + customResources?: CustomResourceMatcher[]; dashboardApp?: string; dashboardParameters?: JsonObject; dashboardUrl?: string; diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts index 176498839d..04256ed3dc 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts @@ -15,7 +15,11 @@ */ import { getVoidLogger } from '@backstage/backend-common'; -import { ClusterDetails, ObjectFetchParams } from '../types/types'; +import { + ClusterDetails, + CustomResource, + ObjectFetchParams, +} from '../types/types'; import { KubernetesFanOutHandler } from './KubernetesFanOutHandler'; import { PodStatus } from '@kubernetes/client-node/dist/top'; @@ -56,6 +60,108 @@ const mockMetrics = (mock: jest.Mock) => { ); }; +const entity = { + apiVersion: 'backstage.io/v1beta1', + kind: 'Component', + metadata: { + name: 'test-component', + annotations: { + 'backstage.io/kubernetes-labels-selector': + 'backstage.io/test-label=test-component', + }, + }, + spec: { + type: 'service', + lifecycle: 'production', + owner: 'joe', + }, +}; + +const cluster1 = { + name: 'test-cluster', + authProvider: 'serviceAccount', + customResources: [ + { + group: 'some-other-crd.example.com', + apiVersion: 'v1alpha1', + plural: 'some-crd-only-on-this-cluster', + }, + ], +}; + +const cluster2 = { + name: 'cluster-two', + authProvider: 'serviceAccount', + customResources: [ + { + group: 'crd-two.example.com', + apiVersion: 'v1alpha1', + plural: 'crd-two-plural', + }, + ], +}; + +function resourcesByCluster(clusterName: string) { + return [ + { + resources: [ + { + metadata: { + name: `my-pods-test-component-${clusterName}`, + namespace: `ns-test-component-${clusterName}`, + }, + }, + ], + type: 'pods', + }, + { + resources: [ + { + metadata: { + name: `my-configmaps-test-component-${clusterName}`, + namespace: `ns-test-component-${clusterName}`, + }, + }, + ], + type: 'configmaps', + }, + { + resources: [ + { + metadata: { + name: `my-services-test-component-${clusterName}`, + namespace: `ns-test-component-${clusterName}`, + }, + }, + ], + type: 'services', + }, + ]; +} + +function mockFetchAndGetKubernetesFanOutHandler( + customResources: CustomResource[], +) { + mockFetch(fetchObjectsForService); + mockMetrics(fetchPodMetricsByNamespace); + + return getKubernetesFanOutHandler(customResources); +} + +function getKubernetesFanOutHandler(customResources: CustomResource[]) { + return new KubernetesFanOutHandler({ + logger: getVoidLogger(), + fetcher: { + fetchObjectsForService, + fetchPodMetricsByNamespace, + }, + serviceLocator: { + getClustersByEntity, + }, + customResources: customResources, + }); +} + function generatePodStatus( _clusterName: string, _namespace: string, @@ -160,7 +266,7 @@ function generateMockResourcesAndErrors( }; } -describe('handleGetKubernetesObjectsForService', () => { +describe('getKubernetesObjectsByEntity', () => { beforeEach(() => { jest.resetAllMocks(); }); @@ -177,38 +283,10 @@ describe('handleGetKubernetesObjectsForService', () => { }), ); - mockFetch(fetchObjectsForService); - mockMetrics(fetchPodMetricsByNamespace); - - const sut = new KubernetesFanOutHandler({ - logger: getVoidLogger(), - fetcher: { - fetchObjectsForService, - fetchPodMetricsByNamespace, - }, - serviceLocator: { - getClustersByEntity, - }, - customResources: [], - }); + const sut = mockFetchAndGetKubernetesFanOutHandler([]); const result = await sut.getKubernetesObjectsByEntity({ - entity: { - apiVersion: 'backstage.io/v1beta1', - kind: 'Component', - metadata: { - name: 'test-component', - annotations: { - 'backstage.io/kubernetes-labels-selector': - 'backstage.io/test-label=test-component', - }, - }, - spec: { - type: 'service', - lifecycle: 'production', - owner: 'joe', - }, - }, + entity, auth: {}, }); @@ -227,46 +305,91 @@ describe('handleGetKubernetesObjectsForService', () => { }, errors: [], podMetrics: [POD_METRICS_FIXTURE], - resources: [ - { - resources: [ - { - metadata: { - name: 'my-pods-test-component-test-cluster', - namespace: 'ns-test-component-test-cluster', - }, - }, - ], - type: 'pods', - }, - { - resources: [ - { - metadata: { - name: 'my-configmaps-test-component-test-cluster', - namespace: 'ns-test-component-test-cluster', - }, - }, - ], - type: 'configmaps', - }, - { - resources: [ - { - metadata: { - name: 'my-services-test-component-test-cluster', - namespace: 'ns-test-component-test-cluster', - }, - }, - ], - type: 'services', - }, - ], + resources: resourcesByCluster('test-cluster'), }, ], }); }); + it('retrieve objects for one cluster using customResources per cluster', async () => { + getClustersByEntity.mockImplementation(() => + Promise.resolve({ + clusters: [cluster1], + }), + ); + + const sut = mockFetchAndGetKubernetesFanOutHandler([]); + + await sut.getKubernetesObjectsByEntity({ + entity, + auth: {}, + }); + + expect(fetchObjectsForService.mock.calls.length).toBe(1); + expect(fetchObjectsForService.mock.calls[0][0].customResources.length).toBe( + 1, + ); + }); + + it('retrieve objects for two cluster using customResources per cluster', async () => { + getClustersByEntity.mockImplementation(() => + Promise.resolve({ + clusters: [cluster1, cluster2], + }), + ); + + const sut = mockFetchAndGetKubernetesFanOutHandler([]); + + await sut.getKubernetesObjectsByEntity({ + entity, + auth: {}, + }); + + expect(fetchObjectsForService.mock.calls.length).toBe(2); + expect( + fetchObjectsForService.mock.calls[0][0].customResources[0].group, + ).toBe('some-other-crd.example.com'); + expect( + fetchObjectsForService.mock.calls[1][0].customResources[0].group, + ).toBe('crd-two.example.com'); + }); + + it('retrieve objects for two cluster using customResources globally and per cluster', async () => { + getClustersByEntity.mockImplementation(() => + Promise.resolve({ + clusters: [ + { + name: 'test-cluster', + authProvider: 'serviceAccount', + }, + cluster2, + ], + }), + ); + + const sut = mockFetchAndGetKubernetesFanOutHandler([ + { + objectType: 'customresources', + group: 'some-group', + apiVersion: 'v2', + plural: 'things', + }, + ]); + + await sut.getKubernetesObjectsByEntity({ + entity, + auth: {}, + }); + + expect(fetchObjectsForService.mock.calls.length).toBe(2); + expect( + fetchObjectsForService.mock.calls[0][0].customResources[0].group, + ).toBe('some-group'); + expect( + fetchObjectsForService.mock.calls[1][0].customResources[0].group, + ).toBe('crd-two.example.com'); + }); + it('dont call top for the same namespace twice', async () => { getClustersByEntity.mockImplementation(() => Promise.resolve({ @@ -312,35 +435,10 @@ describe('handleGetKubernetesObjectsForService', () => { mockMetrics(fetchPodMetricsByNamespace); - const sut = new KubernetesFanOutHandler({ - logger: getVoidLogger(), - fetcher: { - fetchObjectsForService, - fetchPodMetricsByNamespace, - }, - serviceLocator: { - getClustersByEntity, - }, - customResources: [], - }); + const sut = getKubernetesFanOutHandler([]); const result = await sut.getKubernetesObjectsByEntity({ - entity: { - apiVersion: 'backstage.io/v1beta1', - kind: 'Component', - metadata: { - name: 'test-component', - annotations: { - 'backstage.io/kubernetes-labels-selector': - 'backstage.io/test-label=test-component', - }, - }, - spec: { - type: 'service', - lifecycle: 'production', - owner: 'joe', - }, - }, + entity, auth: {}, }); @@ -405,38 +503,10 @@ describe('handleGetKubernetesObjectsForService', () => { }), ); - mockFetch(fetchObjectsForService); - mockMetrics(fetchPodMetricsByNamespace); - - const sut = new KubernetesFanOutHandler({ - logger: getVoidLogger(), - fetcher: { - fetchObjectsForService, - fetchPodMetricsByNamespace, - }, - serviceLocator: { - getClustersByEntity, - }, - customResources: [], - }); + const sut = mockFetchAndGetKubernetesFanOutHandler([]); const result = await sut.getKubernetesObjectsByEntity({ - entity: { - apiVersion: 'backstage.io/v1beta1', - kind: 'Component', - metadata: { - name: 'test-component', - annotations: { - 'backstage.io/kubernetes-labels-selector': - 'backstage.io/test-label=test-component', - }, - }, - spec: { - type: 'service', - lifecycle: 'production', - owner: 'joe', - }, - }, + entity, auth: { google: 'google_token_123', }, @@ -453,41 +523,7 @@ describe('handleGetKubernetesObjectsForService', () => { }, errors: [], podMetrics: [POD_METRICS_FIXTURE], - resources: [ - { - resources: [ - { - metadata: { - name: 'my-pods-test-component-test-cluster', - namespace: 'ns-test-component-test-cluster', - }, - }, - ], - type: 'pods', - }, - { - resources: [ - { - metadata: { - name: 'my-configmaps-test-component-test-cluster', - namespace: 'ns-test-component-test-cluster', - }, - }, - ], - type: 'configmaps', - }, - { - resources: [ - { - metadata: { - name: 'my-services-test-component-test-cluster', - namespace: 'ns-test-component-test-cluster', - }, - }, - ], - type: 'services', - }, - ], + resources: resourcesByCluster('test-cluster'), }, { cluster: { @@ -495,41 +531,7 @@ describe('handleGetKubernetesObjectsForService', () => { }, errors: [], podMetrics: [POD_METRICS_FIXTURE], - resources: [ - { - resources: [ - { - metadata: { - name: 'my-pods-test-component-other-cluster', - namespace: 'ns-test-component-other-cluster', - }, - }, - ], - type: 'pods', - }, - { - resources: [ - { - metadata: { - name: 'my-configmaps-test-component-other-cluster', - namespace: 'ns-test-component-other-cluster', - }, - }, - ], - type: 'configmaps', - }, - { - resources: [ - { - metadata: { - name: 'my-services-test-component-other-cluster', - namespace: 'ns-test-component-other-cluster', - }, - }, - ], - type: 'services', - }, - ], + resources: resourcesByCluster('other-cluster'), }, ], }); @@ -554,38 +556,10 @@ describe('handleGetKubernetesObjectsForService', () => { }), ); - mockFetch(fetchObjectsForService); - mockMetrics(fetchPodMetricsByNamespace); - - const sut = new KubernetesFanOutHandler({ - logger: getVoidLogger(), - fetcher: { - fetchObjectsForService, - fetchPodMetricsByNamespace, - }, - serviceLocator: { - getClustersByEntity, - }, - customResources: [], - }); + const sut = mockFetchAndGetKubernetesFanOutHandler([]); const result = await sut.getKubernetesObjectsByEntity({ - entity: { - apiVersion: 'backstage.io/v1beta1', - kind: 'Component', - metadata: { - name: 'test-component', - annotations: { - 'backstage.io/kubernetes-labels-selector': - 'backstage.io/test-label=test-component', - }, - }, - spec: { - type: 'service', - lifecycle: 'production', - owner: 'joe', - }, - }, + entity, auth: { google: 'google_token_123', }, @@ -601,41 +575,7 @@ describe('handleGetKubernetesObjectsForService', () => { }, errors: [], podMetrics: [POD_METRICS_FIXTURE], - resources: [ - { - resources: [ - { - metadata: { - name: 'my-pods-test-component-test-cluster', - namespace: 'ns-test-component-test-cluster', - }, - }, - ], - type: 'pods', - }, - { - resources: [ - { - metadata: { - name: 'my-configmaps-test-component-test-cluster', - namespace: 'ns-test-component-test-cluster', - }, - }, - ], - type: 'configmaps', - }, - { - resources: [ - { - metadata: { - name: 'my-services-test-component-test-cluster', - namespace: 'ns-test-component-test-cluster', - }, - }, - ], - type: 'services', - }, - ], + resources: resourcesByCluster('test-cluster'), }, { cluster: { @@ -643,41 +583,7 @@ describe('handleGetKubernetesObjectsForService', () => { }, errors: [], podMetrics: [POD_METRICS_FIXTURE], - resources: [ - { - resources: [ - { - metadata: { - name: 'my-pods-test-component-other-cluster', - namespace: 'ns-test-component-other-cluster', - }, - }, - ], - type: 'pods', - }, - { - resources: [ - { - metadata: { - name: 'my-configmaps-test-component-other-cluster', - namespace: 'ns-test-component-other-cluster', - }, - }, - ], - type: 'configmaps', - }, - { - resources: [ - { - metadata: { - name: 'my-services-test-component-other-cluster', - namespace: 'ns-test-component-other-cluster', - }, - }, - ], - type: 'services', - }, - ], + resources: resourcesByCluster('other-cluster'), }, ], }); @@ -706,38 +612,10 @@ describe('handleGetKubernetesObjectsForService', () => { }), ); - mockFetch(fetchObjectsForService); - mockMetrics(fetchPodMetricsByNamespace); - - const sut = new KubernetesFanOutHandler({ - logger: getVoidLogger(), - fetcher: { - fetchObjectsForService, - fetchPodMetricsByNamespace, - }, - serviceLocator: { - getClustersByEntity, - }, - customResources: [], - }); + const sut = mockFetchAndGetKubernetesFanOutHandler([]); const result = await sut.getKubernetesObjectsByEntity({ - entity: { - apiVersion: 'backstage.io/v1beta1', - kind: 'Component', - metadata: { - name: 'test-component', - annotations: { - 'backstage.io/kubernetes-labels-selector': - 'backstage.io/test-label=test-component', - }, - }, - spec: { - type: 'service', - lifecycle: 'production', - owner: 'joe', - }, - }, + entity, auth: { google: 'google_token_123', }, @@ -753,41 +631,7 @@ describe('handleGetKubernetesObjectsForService', () => { }, errors: [], podMetrics: [POD_METRICS_FIXTURE], - resources: [ - { - resources: [ - { - metadata: { - name: 'my-pods-test-component-test-cluster', - namespace: 'ns-test-component-test-cluster', - }, - }, - ], - type: 'pods', - }, - { - resources: [ - { - metadata: { - name: 'my-configmaps-test-component-test-cluster', - namespace: 'ns-test-component-test-cluster', - }, - }, - ], - type: 'configmaps', - }, - { - resources: [ - { - metadata: { - name: 'my-services-test-component-test-cluster', - namespace: 'ns-test-component-test-cluster', - }, - }, - ], - type: 'services', - }, - ], + resources: resourcesByCluster('test-cluster'), }, { cluster: { @@ -795,41 +639,7 @@ describe('handleGetKubernetesObjectsForService', () => { }, errors: [], podMetrics: [POD_METRICS_FIXTURE], - resources: [ - { - resources: [ - { - metadata: { - name: 'my-pods-test-component-other-cluster', - namespace: 'ns-test-component-other-cluster', - }, - }, - ], - type: 'pods', - }, - { - resources: [ - { - metadata: { - name: 'my-configmaps-test-component-other-cluster', - namespace: 'ns-test-component-other-cluster', - }, - }, - ], - type: 'configmaps', - }, - { - resources: [ - { - metadata: { - name: 'my-services-test-component-other-cluster', - namespace: 'ns-test-component-other-cluster', - }, - }, - ], - type: 'services', - }, - ], + resources: resourcesByCluster('other-cluster'), }, { cluster: { @@ -856,3 +666,79 @@ describe('handleGetKubernetesObjectsForService', () => { }); }); }); + +describe('getCustomResourcesByEntity', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('retrieve objects for one cluster using customResources per cluster', async () => { + getClustersByEntity.mockImplementation(() => + Promise.resolve({ + clusters: [cluster1], + }), + ); + + const sut = mockFetchAndGetKubernetesFanOutHandler([]); + + await sut.getCustomResourcesByEntity({ + entity, + auth: {}, + customResources: [ + { + group: 'parameter-crd.example.com', + apiVersion: 'v1alpha1', + plural: 'parameter-crd', + }, + ], + }); + + expect(fetchObjectsForService.mock.calls.length).toBe(1); + expect( + fetchObjectsForService.mock.calls[0][0].customResources[0].plural, + ).toBe('parameter-crd'); + }); + + it('retrieve objects for two cluster using customResources globally and per cluster', async () => { + getClustersByEntity.mockImplementation(() => + Promise.resolve({ + clusters: [ + { + name: 'test-cluster', + authProvider: 'serviceAccount', + }, + cluster2, + ], + }), + ); + + const sut = mockFetchAndGetKubernetesFanOutHandler([ + { + objectType: 'customresources', + group: 'some-group', + apiVersion: 'v2', + plural: 'things', + }, + ]); + + await sut.getCustomResourcesByEntity({ + entity, + auth: {}, + customResources: [ + { + group: 'parameter-crd.example.com', + apiVersion: 'v1alpha1', + plural: 'parameter-crd', + }, + ], + }); + + expect(fetchObjectsForService.mock.calls.length).toBe(2); + expect( + fetchObjectsForService.mock.calls[0][0].customResources[0].group, + ).toBe('parameter-crd.example.com'); + expect( + fetchObjectsForService.mock.calls[1][0].customResources[0].group, + ).toBe('parameter-crd.example.com'); + }); +}); diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts index 67748e1506..3b96dc783f 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts @@ -211,19 +211,14 @@ export class KubernetesFanOutHandler { entity, auth, }: KubernetesObjectsByEntity): Promise { - return this.fanOutRequests( - entity, - auth, - this.objectTypesToFetch, - this.customResources, - ); + return this.fanOutRequests(entity, auth, this.objectTypesToFetch); } private async fanOutRequests( entity: Entity, auth: KubernetesRequestAuth, objectTypesToFetch: Set, - customResources: CustomResourceMatcher[], + customResources?: CustomResourceMatcher[], ) { const entityName = entity.metadata?.annotations?.['backstage.io/kubernetes-id'] || @@ -254,7 +249,11 @@ export class KubernetesFanOutHandler { clusterDetails: clusterDetailsItem, objectTypesToFetch: objectTypesToFetch, labelSelector, - customResources: customResources.map(c => ({ + customResources: ( + customResources || + clusterDetailsItem.customResources || + this.customResources + ).map(c => ({ ...c, objectType: 'customresources', })), diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index 0826c4158c..6bdcfc24da 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -193,6 +193,11 @@ export interface ClusterDetails { * @see dashboardApp */ dashboardParameters?: JsonObject; + /** + * Specifies which custom resources to look for when returning an entity's + * Kubernetes resources. + */ + customResources?: CustomResourceMatcher[]; } /**