diff --git a/.changeset/eleven-adults-add.md b/.changeset/eleven-adults-add.md new file mode 100644 index 0000000000..0594aa40b1 --- /dev/null +++ b/.changeset/eleven-adults-add.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes-backend': patch +--- + +Added a new service locator `CatalogRelationServiceLocator` that only returns clusters an entity lists in `relations.dependsOn`. diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md index ff3a42c7ed..064f7c803c 100644 --- a/docs/features/kubernetes/configuration.md +++ b/docs/features/kubernetes/configuration.md @@ -59,6 +59,8 @@ Valid values are: - `singleTenant` - This configuration assumes that current component run on one cluster in provided clusters. +- `catalogRelation` - This configuration assumes that the current component runs only on all clusters it is dependant on. + ### `clusterLocatorMethods` This is an array used to determine where to retrieve cluster configuration from. diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index aabdba355b..3c0402a7ee 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -163,6 +163,10 @@ export class KubernetesBuilder { [key: string]: AuthenticationStrategy_2; }; // (undocumented) + protected buildCatalogRelationServiceLocator( + clusterSupplier: KubernetesClustersSupplier_2, + ): KubernetesServiceLocator_2; + // (undocumented) protected buildClusterSupplier( refreshInterval: Duration, ): KubernetesClustersSupplier_2; @@ -397,7 +401,11 @@ export class ServiceAccountStrategy implements AuthenticationStrategy_2 { } // @public (undocumented) -export type ServiceLocatorMethod = 'multiTenant' | 'singleTenant' | 'http'; +export type ServiceLocatorMethod = + | 'multiTenant' + | 'singleTenant' + | 'catalogRelation' + | 'http'; // @public @deprecated (undocumented) export type ServiceLocatorRequestContext = diff --git a/plugins/kubernetes-backend/src/service-locator/CatalogRelationServiceLocator.test.ts b/plugins/kubernetes-backend/src/service-locator/CatalogRelationServiceLocator.test.ts new file mode 100644 index 0000000000..8f5f426787 --- /dev/null +++ b/plugins/kubernetes-backend/src/service-locator/CatalogRelationServiceLocator.test.ts @@ -0,0 +1,169 @@ +/* + * Copyright 2024 The Backstage Authors + * + * 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. + */ +import { Entity } from '@backstage/catalog-model'; +import { ServiceLocatorRequestContext } from '@backstage/plugin-kubernetes-node'; +import { CatalogRelationServiceLocator } from './CatalogRelationServiceLocator'; + +describe('CatalogRelationServiceLocator', () => { + let serviceLocator: CatalogRelationServiceLocator; + + beforeEach(() => { + // Mock the KubernetesClustersSupplier dependency + const clusterSupplier = { + getClusters: jest.fn().mockResolvedValue([ + { + name: 'cluster1', + url: 'http://localhost:8080', + authMetadata: {}, + }, + { + name: 'cluster2', + url: 'http://localhost:8081', + authMetadata: {}, + }, + ]), + }; + + serviceLocator = new CatalogRelationServiceLocator(clusterSupplier); + }); + + it('empty entity returns empty cluster details', async () => { + const result = await serviceLocator.getClustersByEntity( + {} as Entity, + {} as ServiceLocatorRequestContext, + ); + + expect(result).toStrictEqual({ clusters: [] }); + }); + + it('empty clusters returns empty cluster details', async () => { + const crsl = new CatalogRelationServiceLocator({ + getClusters: async () => [], + }); + + const testEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + relations: [ + { type: 'dependsOn', targetRef: 'resource:default/cluster1' }, + { type: 'dependsOn', targetRef: 'resource:default/cluster2' }, + { type: 'ownedBy', targetRef: 'group:default/group1' }, + ], + metadata: { + namespace: 'default', + name: 'testEntity', + }, + }; + + const result = await crsl.getClustersByEntity( + testEntity, + {} as ServiceLocatorRequestContext, + ); + + expect(result).toStrictEqual({ clusters: [] }); + }); + + it('should return all referenced clusters when entity depends on multiple clusters', async () => { + const testEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + relations: [ + { type: 'dependsOn', targetRef: 'resource:default/cluster1' }, + { type: 'dependsOn', targetRef: 'resource:default/cluster2' }, + { type: 'ownedBy', targetRef: 'group:default/group1' }, + ], + metadata: { + namespace: 'default', + name: 'testEntity', + }, + }; + + const result = await serviceLocator.getClustersByEntity( + testEntity, + {} as ServiceLocatorRequestContext, + ); + + expect(result).toEqual({ + clusters: [ + { + name: 'cluster1', + url: 'http://localhost:8080', + authMetadata: {}, + }, + { + name: 'cluster2', + url: 'http://localhost:8081', + authMetadata: {}, + }, + ], + }); + }); + + it('should return one cluster when entity has one dependant cluster', async () => { + const testEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + relations: [ + { type: 'dependsOn', targetRef: 'resource:default/cluster1' }, + { type: 'ownedBy', targetRef: 'group:default/group1' }, + ], + metadata: { + namespace: 'default', + name: 'testEntity', + }, + }; + + const result = await serviceLocator.getClustersByEntity( + testEntity, + {} as ServiceLocatorRequestContext, + ); + + expect(result).toEqual({ + clusters: [ + { + name: 'cluster1', + url: 'http://localhost:8080', + authMetadata: {}, + }, + ], + }); + }); + + it('should return an empty array when entity does not have dependsOn relation with resource target', async () => { + const testEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + relations: [ + { type: 'dependsOn', targetRef: 'resource:default/database1' }, + { type: 'dependsOn', targetRef: 'resource:default/database2' }, + { type: 'ownedBy', targetRef: 'group:default/group1' }, + ], + metadata: { + namespace: 'default', + name: 'testEntity', + }, + }; + + const result = await serviceLocator.getClustersByEntity( + testEntity, + {} as ServiceLocatorRequestContext, + ); + + expect(result).toEqual({ + clusters: [], + }); + }); +}); diff --git a/plugins/kubernetes-backend/src/service-locator/CatalogRelationServiceLocator.ts b/plugins/kubernetes-backend/src/service-locator/CatalogRelationServiceLocator.ts new file mode 100644 index 0000000000..9ac945bd84 --- /dev/null +++ b/plugins/kubernetes-backend/src/service-locator/CatalogRelationServiceLocator.ts @@ -0,0 +1,66 @@ +/* + * Copyright 2024 The Backstage Authors + * + * 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. + */ +import { Entity } from '@backstage/catalog-model'; +import { + ClusterDetails, + KubernetesClustersSupplier, + KubernetesServiceLocator, + ServiceLocatorRequestContext, +} from '@backstage/plugin-kubernetes-node'; + +// This locator assumes that service is located on the clusters it depends on +// Therefore it will return the clusters based on the relations it has or none otherwise +export class CatalogRelationServiceLocator implements KubernetesServiceLocator { + private readonly clusterSupplier: KubernetesClustersSupplier; + + constructor(clusterSupplier: KubernetesClustersSupplier) { + this.clusterSupplier = clusterSupplier; + } + + // As this implementation always returns all clusters serviceId is ignored here + getClustersByEntity( + entity: Entity, + _requestContext: ServiceLocatorRequestContext, + ): Promise<{ clusters: ClusterDetails[] }> { + if ( + entity.relations && + entity.relations.some( + r => r.type === 'dependsOn' && r.targetRef.includes('resource:'), + ) + ) { + return this.clusterSupplier.getClusters().then(clusters => { + return { + clusters: clusters.filter(c => + this.doesEntityDependOnCluster(entity, c), + ), + }; + }); + } + return Promise.resolve({ clusters: [] }); + } + + protected doesEntityDependOnCluster( + entity: Entity, + cluster: ClusterDetails, + ): boolean { + return entity.relations!.some( + rel => + rel.type === 'dependsOn' && + rel.targetRef === + `resource:${entity.metadata.namespace ?? 'default'}/${cluster.name}`, + ); + } +} diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts index aca8ec229b..fd59741e76 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts @@ -27,20 +27,38 @@ import Router from 'express-promise-router'; import { Duration } from 'luxon'; import { Logger } from 'winston'; -import { getCombinedClusterSupplier } from '../cluster-locator'; import { - AnonymousStrategy, - DispatchStrategy, - GoogleStrategy, - ServiceAccountStrategy, - AwsIamStrategy, - GoogleServiceAccountStrategy, - AzureIdentityStrategy, - OidcStrategy, AksStrategy, + AnonymousStrategy, + AwsIamStrategy, + AzureIdentityStrategy, + DispatchStrategy, + GoogleServiceAccountStrategy, + GoogleStrategy, + OidcStrategy, + ServiceAccountStrategy, } from '../auth'; +import { getCombinedClusterSupplier } from '../cluster-locator'; +import { createLegacyAuthAdapters } from '@backstage/backend-common'; +import { + AuthService, + BackstageCredentials, + DiscoveryService, + HttpAuthService, +} from '@backstage/backend-plugin-api'; +import { + AuthMetadata, + AuthenticationStrategy, + CustomResource, + KubernetesClustersSupplier, + KubernetesFetcher, + KubernetesObjectTypes, + KubernetesObjectsProvider, + KubernetesServiceLocator, +} from '@backstage/plugin-kubernetes-node'; import { addResourceRoutesToRouter } from '../routes/resourcesRoutes'; +import { CatalogRelationServiceLocator } from '../service-locator/CatalogRelationServiceLocator'; import { MultiTenantServiceLocator } from '../service-locator/MultiTenantServiceLocator'; import { SingleTenantServiceLocator } from '../service-locator/SingleTenantServiceLocator'; import { @@ -48,29 +66,12 @@ import { ObjectsByEntityRequest, ServiceLocatorMethod, } from '../types/types'; -import { - AuthenticationStrategy, - AuthMetadata, - CustomResource, - KubernetesClustersSupplier, - KubernetesFetcher, - KubernetesObjectsProvider, - KubernetesObjectTypes, - KubernetesServiceLocator, -} from '@backstage/plugin-kubernetes-node'; import { DEFAULT_OBJECTS, KubernetesFanOutHandler, } from './KubernetesFanOutHandler'; import { KubernetesClientBasedFetcher } from './KubernetesFetcher'; import { KubernetesProxy } from './KubernetesProxy'; -import { createLegacyAuthAdapters } from '@backstage/backend-common'; -import { - AuthService, - BackstageCredentials, - DiscoveryService, - HttpAuthService, -} from '@backstage/backend-plugin-api'; /** * @@ -306,6 +307,10 @@ export class KubernetesBuilder { this.serviceLocator = this.buildSingleTenantServiceLocator(clusterSupplier); break; + case 'catalogRelation': + this.serviceLocator = + this.buildCatalogRelationServiceLocator(clusterSupplier); + break; case 'http': this.serviceLocator = this.buildHttpServiceLocator(clusterSupplier); break; @@ -330,6 +335,12 @@ export class KubernetesBuilder { return new SingleTenantServiceLocator(clusterSupplier); } + protected buildCatalogRelationServiceLocator( + clusterSupplier: KubernetesClustersSupplier, + ): KubernetesServiceLocator { + return new CatalogRelationServiceLocator(clusterSupplier); + } + protected buildHttpServiceLocator( _clusterSupplier: KubernetesClustersSupplier, ): KubernetesServiceLocator { diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index 57a12bb58a..c34fad6d39 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -14,16 +14,20 @@ * limitations under the License. */ -import { Logger } from 'winston'; -import type { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; import { Config } from '@backstage/config'; +import type { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; import * as k8sTypes from '@backstage/plugin-kubernetes-node'; +import { Logger } from 'winston'; /** * * @public */ -export type ServiceLocatorMethod = 'multiTenant' | 'singleTenant' | 'http'; // TODO implement http +export type ServiceLocatorMethod = + | 'multiTenant' + | 'singleTenant' + | 'catalogRelation' + | 'http'; // TODO implement http /** *