From 3d4542766619d52a1fbf6feb6e4e92d07f336873 Mon Sep 17 00:00:00 2001 From: Luna Stadler Date: Thu, 24 Mar 2022 15:37:33 +0100 Subject: [PATCH 1/5] Make Kubernetes clusters refreshable To make it possible to refresh Kubernetes clusters responsible all places that used ClusterDetails[] before now use the KubernetesClustersSupplier directly and call getClusters() on it. This allows people to use implement custom KubernetesClustersSuppliers that fetch the clusters from somewhere and refresh them using whatever mechanism they might need. Signed-off-by: Luna Stadler --- .changeset/hot-items-smoke.md | 63 +++++++++++++++++++ docs/features/kubernetes/configuration.md | 6 ++ docs/features/kubernetes/installation.md | 59 +++++++++++++++++ plugins/kubernetes-backend/api-report.md | 9 ++- .../MultiTenantServiceLocator.test.ts | 48 ++++++++------ .../MultiTenantServiceLocator.ts | 14 +++-- .../src/service/KubernetesBuilder.ts | 24 +++---- 7 files changed, 179 insertions(+), 44 deletions(-) create mode 100644 .changeset/hot-items-smoke.md diff --git a/.changeset/hot-items-smoke.md b/.changeset/hot-items-smoke.md new file mode 100644 index 0000000000..832604cd35 --- /dev/null +++ b/.changeset/hot-items-smoke.md @@ -0,0 +1,63 @@ +--- +'@backstage/plugin-kubernetes-backend': minor +--- + +**BREAKING** Custom cluster suppliers need to cache their getClusters result + +To allow custom `KubernetesClustersSupplier` instances to refresh the list of clusters +the `getClusters` method is now called whenever the list of clusters is needed. + +Existing `KubernetesClustersSupplier` implementations will need to ensure that `getClusters` +can be called frequently and should return a cached result from `getClusters` instead. + +For example, here's a simple example of this in `packages/backend/src/plugins/kubernetes.ts`: + +```diff +-import { KubernetesBuilder } from '@backstage/plugin-kubernetes-backend'; ++import { ++ ClusterDetails, ++ KubernetesBuilder, ++ KubernetesClustersSupplier, ++} from '@backstage/plugin-kubernetes-backend'; + import { Router } from 'express'; + import { PluginEnvironment } from '../types'; ++import { Duration } from 'luxon'; ++ ++export class CustomClustersSupplier implements KubernetesClustersSupplier { ++ private clusterDetails: ClusterDetails[] = []; ++ ++ async retrieveClusters() { ++ this.clusterDetails = []; // fetch from somewhere ++ } ++ ++ async getClusters(): Promise { ++ return this.clusterDetails; ++ } ++} + + export default async function createPlugin( + env: PluginEnvironment, + ): Promise { +- const { router } = await KubernetesBuilder.createBuilder({ ++ const builder = await KubernetesBuilder.createBuilder({ + logger: env.logger, + config: env.config, +- }).build(); ++ }); ++ ++ const clusterSupplier = new CustomClustersSupplier(); ++ env.scheduler ++ .createScheduledTaskRunner({ ++ frequency: Duration.fromObject({ minutes: 60 }), ++ timeout: Duration.fromObject({ minutes: 15 }), ++ }) ++ .run({ ++ id: 'refresh-kubernetes-clusters', ++ fn: clusterSupplier.retrieveClusters, ++ }); ++ builder.setClusterSupplier(clusterSupplier); ++ ++ const { router } = await builder.build(); + return router; + } +``` diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md index 612f4903d9..d082d46df2 100644 --- a/docs/features/kubernetes/configuration.md +++ b/docs/features/kubernetes/configuration.md @@ -261,6 +261,12 @@ Kubernetes plugin. Defaults to `false`. +#### Custom `KubernetesClustersSupplier` + +If the configuration-based cluster locators do not work for your use-case, +it is also possible to implement a +[custom `KubernetesClustersSupplier`](installation.md#custom-cluster-discovery). + ### `customResources` (optional) Configures which [custom resources][3] to look for when returning an entity's diff --git a/docs/features/kubernetes/installation.md b/docs/features/kubernetes/installation.md index 2698580491..125889b858 100644 --- a/docs/features/kubernetes/installation.md +++ b/docs/features/kubernetes/installation.md @@ -90,6 +90,65 @@ async function main() { That's it! The Kubernetes frontend and backend have now been added to your Backstage app. +### Custom cluster discovery + +If either existing +[cluster locators](https://backstage.io/docs/features/kubernetes/configuration#clusterlocatormethods) +don't work for your use-case, it is possible to implement a custom +[KubernetesClustersSupplier](https://backstage.io/docs/reference/plugin-kubernetes-backend.kubernetesclusterssupplier). + +Change the following in `packages/backend/src/plugin/kubernetes.ts`: + +```diff +-import { KubernetesBuilder } from '@backstage/plugin-kubernetes-backend'; ++import { ++ ClusterDetails, ++ KubernetesBuilder, ++ KubernetesClustersSupplier, ++} from '@backstage/plugin-kubernetes-backend'; + import { Router } from 'express'; + import { PluginEnvironment } from '../types'; ++import { Duration } from 'luxon'; ++ ++export class CustomClustersSupplier implements KubernetesClustersSupplier { ++ private clusterDetails: ClusterDetails[] = []; ++ ++ async retrieveClusters() { ++ this.clusterDetails = []; // fetch from somewhere ++ } ++ ++ async getClusters(): Promise { ++ return this.clusterDetails; ++ } ++} + + export default async function createPlugin( + env: PluginEnvironment, + ): Promise { +- const { router } = await KubernetesBuilder.createBuilder({ ++ const builder = await KubernetesBuilder.createBuilder({ + logger: env.logger, + config: env.config, +- }).build(); ++ }); ++ ++ const clusterSupplier = new CustomClustersSupplier(); ++ env.scheduler ++ .createScheduledTaskRunner({ ++ frequency: Duration.fromObject({ minutes: 60 }), ++ timeout: Duration.fromObject({ minutes: 15 }), ++ }) ++ .run({ ++ id: 'refresh-kubernetes-clusters', ++ fn: clusterSupplier.retrieveClusters, ++ }); ++ builder.setClusterSupplier(clusterSupplier); ++ ++ const { router } = await builder.build(); + return router; + } +``` + ## Running Backstage locally Start the frontend and the backend app by diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index 8a3b64bd93..c00f68a13f 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -92,11 +92,11 @@ export class KubernetesBuilder { protected buildFetcher(): KubernetesFetcher; // (undocumented) protected buildHttpServiceLocator( - _clusterDetails: ClusterDetails[], + _clusterSupplier: KubernetesClustersSupplier, ): KubernetesServiceLocator; // (undocumented) protected buildMultiTenantServiceLocator( - clusterDetails: ClusterDetails[], + clusterSupplier: KubernetesClustersSupplier, ): KubernetesServiceLocator; // (undocumented) protected buildObjectsProvider( @@ -105,12 +105,12 @@ export class KubernetesBuilder { // (undocumented) protected buildRouter( objectsProvider: KubernetesObjectsProvider, - clusterDetails: ClusterDetails[], + clusterSupplier: KubernetesClustersSupplier, ): express.Router; // (undocumented) protected buildServiceLocator( method: ServiceLocatorMethod, - clusterDetails: ClusterDetails[], + clusterSupplier: KubernetesClustersSupplier, ): KubernetesServiceLocator; // (undocumented) static createBuilder(env: KubernetesEnvironment): KubernetesBuilder; @@ -137,7 +137,6 @@ export class KubernetesBuilder { // @public export type KubernetesBuilderReturn = Promise<{ router: express.Router; - clusterDetails: ClusterDetails[]; clusterSupplier: KubernetesClustersSupplier; customResources: CustomResource[]; fetcher: KubernetesFetcher; diff --git a/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts b/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts index 8586c5e445..f97161591f 100644 --- a/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts +++ b/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts @@ -19,7 +19,7 @@ import { MultiTenantServiceLocator } from './MultiTenantServiceLocator'; describe('MultiTenantConfigClusterLocator', () => { it('empty clusters returns empty cluster details', async () => { - const sut = new MultiTenantServiceLocator([]); + const sut = new MultiTenantServiceLocator({ getClusters: async () => [] }); const result = await sut.getClustersByServiceId('ignored'); @@ -27,14 +27,18 @@ describe('MultiTenantConfigClusterLocator', () => { }); it('one clusters returns one cluster details', async () => { - const sut = new MultiTenantServiceLocator([ - { - name: 'cluster1', - url: 'http://localhost:8080', - authProvider: 'serviceAccount', - serviceAccountToken: '12345', + const sut = new MultiTenantServiceLocator({ + getClusters: async () => { + return [ + { + name: 'cluster1', + url: 'http://localhost:8080', + authProvider: 'serviceAccount', + serviceAccountToken: '12345', + }, + ]; }, - ]); + }); const result = await sut.getClustersByServiceId('ignored'); @@ -49,19 +53,23 @@ describe('MultiTenantConfigClusterLocator', () => { }); it('two clusters returns two cluster details', async () => { - const sut = new MultiTenantServiceLocator([ - { - name: 'cluster1', - serviceAccountToken: 'token', - url: 'http://localhost:8080', - authProvider: 'serviceAccount', + const sut = new MultiTenantServiceLocator({ + getClusters: async () => { + return [ + { + name: 'cluster1', + serviceAccountToken: 'token', + url: 'http://localhost:8080', + authProvider: 'serviceAccount', + }, + { + name: 'cluster2', + url: 'http://localhost:8081', + authProvider: 'google', + }, + ]; }, - { - name: 'cluster2', - url: 'http://localhost:8081', - authProvider: 'google', - }, - ]); + }); const result = await sut.getClustersByServiceId('ignored'); diff --git a/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.ts b/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.ts index 9f874c7fc8..30c66a38d1 100644 --- a/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.ts +++ b/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.ts @@ -14,20 +14,24 @@ * limitations under the License. */ -import { ClusterDetails, KubernetesServiceLocator } from '../types/types'; +import { + ClusterDetails, + KubernetesClustersSupplier, + KubernetesServiceLocator, +} from '../types/types'; // This locator assumes that every service is located on every cluster // Therefore it will always return all clusters provided export class MultiTenantServiceLocator implements KubernetesServiceLocator { - private readonly clusterDetails: ClusterDetails[]; + private readonly clusterSupplier: KubernetesClustersSupplier; - constructor(clusterDetails: ClusterDetails[]) { - this.clusterDetails = clusterDetails; + constructor(clusterSupplier: KubernetesClustersSupplier) { + this.clusterSupplier = clusterSupplier; } // As this implementation always returns all clusters serviceId is ignored here // eslint-disable-next-line @typescript-eslint/no-unused-vars async getClustersByServiceId(_serviceId: string): Promise { - return this.clusterDetails; + return this.clusterSupplier.getClusters(); } } diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts index a1eb60a239..6f5cfd0add 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts @@ -20,7 +20,6 @@ import { Logger } from 'winston'; import { getCombinedClusterDetails } from '../cluster-locator'; import { MultiTenantServiceLocator } from '../service-locator/MultiTenantServiceLocator'; import { - ClusterDetails, KubernetesObjectTypes, ServiceLocatorMethod, CustomResource, @@ -50,7 +49,6 @@ export interface KubernetesEnvironment { */ export type KubernetesBuilderReturn = Promise<{ router: express.Router; - clusterDetails: ClusterDetails[]; clusterSupplier: KubernetesClustersSupplier; customResources: CustomResource[]; fetcher: KubernetesFetcher; @@ -93,11 +91,9 @@ export class KubernetesBuilder { const clusterSupplier = this.clusterSupplier ?? this.buildClusterSupplier(); - const clusterDetails = await this.fetchClusterDetails(clusterSupplier); - const serviceLocator = this.serviceLocator ?? - this.buildServiceLocator(this.getServiceLocatorMethod(), clusterDetails); + this.buildServiceLocator(this.getServiceLocatorMethod(), clusterSupplier); const objectsProvider = this.objectsProvider ?? @@ -109,10 +105,9 @@ export class KubernetesBuilder { objectTypesToFetch: this.getObjectTypesToFetch(), }); - const router = this.buildRouter(objectsProvider, clusterDetails); + const router = this.buildRouter(objectsProvider, clusterSupplier); return { - clusterDetails, clusterSupplier, customResources, fetcher, @@ -185,13 +180,13 @@ export class KubernetesBuilder { protected buildServiceLocator( method: ServiceLocatorMethod, - clusterDetails: ClusterDetails[], + clusterSupplier: KubernetesClustersSupplier, ): KubernetesServiceLocator { switch (method) { case 'multiTenant': - return this.buildMultiTenantServiceLocator(clusterDetails); + return this.buildMultiTenantServiceLocator(clusterSupplier); case 'http': - return this.buildHttpServiceLocator(clusterDetails); + return this.buildHttpServiceLocator(clusterSupplier); default: throw new Error( `Unsupported kubernetes.clusterLocatorMethod "${method}"`, @@ -200,20 +195,20 @@ export class KubernetesBuilder { } protected buildMultiTenantServiceLocator( - clusterDetails: ClusterDetails[], + clusterSupplier: KubernetesClustersSupplier, ): KubernetesServiceLocator { - return new MultiTenantServiceLocator(clusterDetails); + return new MultiTenantServiceLocator(clusterSupplier); } protected buildHttpServiceLocator( - _clusterDetails: ClusterDetails[], + _clusterSupplier: KubernetesClustersSupplier, ): KubernetesServiceLocator { throw new Error('not implemented'); } protected buildRouter( objectsProvider: KubernetesObjectsProvider, - clusterDetails: ClusterDetails[], + clusterSupplier: KubernetesClustersSupplier, ): express.Router { const logger = this.env.logger; const router = Router(); @@ -236,6 +231,7 @@ export class KubernetesBuilder { }); router.get('/clusters', async (_, res) => { + const clusterDetails = await this.fetchClusterDetails(clusterSupplier); res.json({ items: clusterDetails.map(cd => ({ name: cd.name, From 5818bead7d20bb59b3989ca3f9b1348e9e3fd570 Mon Sep 17 00:00:00 2001 From: Luna Stadler Date: Tue, 29 Mar 2022 11:22:22 +0200 Subject: [PATCH 2/5] Ensure GkeClusterLocator only fetches the clusters once This is now necessary because cluster suppliers are called whenever the list of clusters is required and thus should cache their clusters. Signed-off-by: Luna Stadler --- .../src/cluster-locator/GkeClusterLocator.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts index 8b70db40eb..989b69c7c8 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts @@ -17,7 +17,11 @@ import { Config } from '@backstage/config'; import { ForwardedError } from '@backstage/errors'; import * as container from '@google-cloud/container'; -import { GKEClusterDetails, KubernetesClustersSupplier } from '../types/types'; +import { + ClusterDetails, + GKEClusterDetails, + KubernetesClustersSupplier, +} from '../types/types'; type GkeClusterLocatorOptions = { projectId: string; @@ -31,6 +35,7 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { constructor( private readonly options: GkeClusterLocatorOptions, private readonly client: container.v1.ClusterManagerClient, + private clusterDetails: GKEClusterDetails[] | undefined = undefined, ) {} static fromConfigWithClient( @@ -55,8 +60,17 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { ); } + async getClusters(): Promise { + if (this.clusterDetails) { + return this.clusterDetails; + } + + this.clusterDetails = await this.retrieveClusters(); + return this.clusterDetails; + } + // TODO pass caData into the object - async getClusters(): Promise { + async retrieveClusters(): Promise { const { projectId, region, From 38ea1f136c7f61ec5fcad1adbca45b1ac237a240 Mon Sep 17 00:00:00 2001 From: Luna Stadler Date: Tue, 29 Mar 2022 12:03:21 +0200 Subject: [PATCH 3/5] List existing cluster locator methods in one place I found the nested headings difficult to read, so this now lists all available methods in one place. Signed-off-by: Luna Stadler --- docs/features/kubernetes/configuration.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md index d082d46df2..dd7e666be4 100644 --- a/docs/features/kubernetes/configuration.md +++ b/docs/features/kubernetes/configuration.md @@ -57,6 +57,10 @@ This is an array used to determine where to retrieve cluster configuration from. Valid cluster locator methods are: +- [`config`](#config) +- [`gke`](#gke) +- [custom `KubernetesClustersSupplier`](#custom-kubernetesclusterssupplier) + #### `config` This cluster locator method will read cluster information from your app-config From 8d050f714d4e73662772f82b50cce176c2c8cbfb Mon Sep 17 00:00:00 2001 From: Luna Stadler Date: Mon, 4 Apr 2022 14:26:11 +0200 Subject: [PATCH 4/5] Make Kubernetes cluster refresh more explicit The `refreshClusters` method is now part of the `KubernetesClustersSupplier` interface definition and also documented. All existing cluster suppliers now implement it as well and the examples in the docs and in the CHANGELOG have been adjusted. Signed-off-by: Luna Stadler --- .changeset/hot-items-smoke.md | 19 ++--- docs/features/kubernetes/installation.md | 19 ++--- plugins/kubernetes-backend/api-report.md | 4 +- .../cluster-locator/ConfigClusterLocator.ts | 2 + .../cluster-locator/GkeClusterLocator.test.ts | 7 +- .../src/cluster-locator/GkeClusterLocator.ts | 11 +-- .../src/cluster-locator/index.test.ts | 10 +-- .../src/cluster-locator/index.ts | 71 +++++++++++-------- .../MultiTenantServiceLocator.test.ts | 7 +- .../src/service/KubernetesBuilder.test.ts | 2 + .../src/service/KubernetesBuilder.ts | 25 +++++-- .../src/service/runPeriodically.ts | 54 ++++++++++++++ plugins/kubernetes-backend/src/types/types.ts | 11 +++ 13 files changed, 166 insertions(+), 76 deletions(-) create mode 100644 plugins/kubernetes-backend/src/service/runPeriodically.ts diff --git a/.changeset/hot-items-smoke.md b/.changeset/hot-items-smoke.md index 832604cd35..52c5088d3e 100644 --- a/.changeset/hot-items-smoke.md +++ b/.changeset/hot-items-smoke.md @@ -21,12 +21,11 @@ For example, here's a simple example of this in `packages/backend/src/plugins/ku +} from '@backstage/plugin-kubernetes-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; -+import { Duration } from 'luxon'; -+ + +export class CustomClustersSupplier implements KubernetesClustersSupplier { + private clusterDetails: ClusterDetails[] = []; + -+ async retrieveClusters() { ++ async refreshClusters(): Promise { + this.clusterDetails = []; // fetch from somewhere + } + @@ -34,7 +33,7 @@ For example, here's a simple example of this in `packages/backend/src/plugins/ku + return this.clusterDetails; + } +} - ++ export default async function createPlugin( env: PluginEnvironment, ): Promise { @@ -46,18 +45,12 @@ For example, here's a simple example of this in `packages/backend/src/plugins/ku + }); + + const clusterSupplier = new CustomClustersSupplier(); -+ env.scheduler -+ .createScheduledTaskRunner({ -+ frequency: Duration.fromObject({ minutes: 60 }), -+ timeout: Duration.fromObject({ minutes: 15 }), -+ }) -+ .run({ -+ id: 'refresh-kubernetes-clusters', -+ fn: clusterSupplier.retrieveClusters, -+ }); + builder.setClusterSupplier(clusterSupplier); + + const { router } = await builder.build(); return router; } ``` + +If you need to adjust the refresh interval from the default once per hour +you can call `builder.setClusterRefreshInterval`. diff --git a/docs/features/kubernetes/installation.md b/docs/features/kubernetes/installation.md index 125889b858..4b4ba1139d 100644 --- a/docs/features/kubernetes/installation.md +++ b/docs/features/kubernetes/installation.md @@ -108,12 +108,11 @@ Change the following in `packages/backend/src/plugin/kubernetes.ts`: +} from '@backstage/plugin-kubernetes-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; -+import { Duration } from 'luxon'; -+ + +export class CustomClustersSupplier implements KubernetesClustersSupplier { + private clusterDetails: ClusterDetails[] = []; + -+ async retrieveClusters() { ++ async refreshClusters(): Promise { + this.clusterDetails = []; // fetch from somewhere + } + @@ -121,7 +120,7 @@ Change the following in `packages/backend/src/plugin/kubernetes.ts`: + return this.clusterDetails; + } +} - ++ export default async function createPlugin( env: PluginEnvironment, ): Promise { @@ -133,15 +132,6 @@ Change the following in `packages/backend/src/plugin/kubernetes.ts`: + }); + + const clusterSupplier = new CustomClustersSupplier(); -+ env.scheduler -+ .createScheduledTaskRunner({ -+ frequency: Duration.fromObject({ minutes: 60 }), -+ timeout: Duration.fromObject({ minutes: 15 }), -+ }) -+ .run({ -+ id: 'refresh-kubernetes-clusters', -+ fn: clusterSupplier.retrieveClusters, -+ }); + builder.setClusterSupplier(clusterSupplier); + + const { router } = await builder.build(); @@ -149,6 +139,9 @@ Change the following in `packages/backend/src/plugin/kubernetes.ts`: } ``` +If you need to adjust the refresh interval from the default once per hour +you can call `builder.setClusterRefreshInterval`. + ## Running Backstage locally Start the frontend and the backend app by diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index c00f68a13f..573198acf0 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -125,6 +125,8 @@ export class KubernetesBuilder { // (undocumented) protected getServiceLocatorMethod(): ServiceLocatorMethod; // (undocumented) + setClusterRefreshInterval(refreshMs: number): this; + // (undocumented) setClusterSupplier(clusterSupplier?: KubernetesClustersSupplier): this; // (undocumented) setFetcher(fetcher?: KubernetesFetcher): this; @@ -148,8 +150,8 @@ export type KubernetesBuilderReturn = Promise<{ // // @public (undocumented) export interface KubernetesClustersSupplier { - // (undocumented) getClusters(): Promise; + refreshClusters(): Promise; } // Warning: (ae-missing-release-tag) "KubernetesEnvironment" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts index 1bde1226dd..893db0f5d0 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts @@ -77,6 +77,8 @@ export class ConfigClusterLocator implements KubernetesClustersSupplier { ); } + async refreshClusters(): Promise {} + async getClusters(): Promise { return this.clusterDetails; } diff --git a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts index 6bef8e0009..51062066da 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts @@ -69,6 +69,7 @@ describe('GkeClusterLocator', () => { listClusters: mockedListClusters, } as any); + await sut.refreshClusters(); const result = await sut.getClusters(); expect(result).toStrictEqual([]); @@ -100,6 +101,7 @@ describe('GkeClusterLocator', () => { listClusters: mockedListClusters, } as any); + await sut.refreshClusters(); const result = await sut.getClusters(); expect(result).toStrictEqual([ @@ -137,6 +139,7 @@ describe('GkeClusterLocator', () => { listClusters: mockedListClusters, } as any); + await sut.refreshClusters(); const result = await sut.getClusters(); expect(result).toStrictEqual([ @@ -179,6 +182,7 @@ describe('GkeClusterLocator', () => { listClusters: mockedListClusters, } as any); + await sut.refreshClusters(); const result = await sut.getClusters(); expect(result).toStrictEqual([ @@ -217,7 +221,7 @@ describe('GkeClusterLocator', () => { listClusters: mockedListClusters, } as any); - await expect(sut.getClusters()).rejects.toThrow( + await expect(sut.refreshClusters()).rejects.toThrow( 'There was an error retrieving clusters from GKE for projectId=some-project region=some-region; caused by Error: some error', ); @@ -250,6 +254,7 @@ describe('GkeClusterLocator', () => { listClusters: mockedListClusters, } as any); + await sut.refreshClusters(); const result = await sut.getClusters(); expect(result).toStrictEqual([ diff --git a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts index 989b69c7c8..8484f5006b 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts @@ -61,16 +61,11 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { } async getClusters(): Promise { - if (this.clusterDetails) { - return this.clusterDetails; - } - - this.clusterDetails = await this.retrieveClusters(); - return this.clusterDetails; + return this.clusterDetails ?? []; } // TODO pass caData into the object - async retrieveClusters(): Promise { + async refreshClusters(): Promise { const { projectId, region, @@ -84,7 +79,7 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { try { const [response] = await this.client.listClusters(request); - return (response.clusters ?? []).map(r => ({ + this.clusterDetails = (response.clusters ?? []).map(r => ({ // TODO filter out clusters which don't have name or endpoint name: r.name ?? 'unknown', url: `https://${r.endpoint ?? ''}`, diff --git a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts index 2bc2c719a0..2bdc37c31c 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts @@ -15,9 +15,9 @@ */ import { Config, ConfigReader } from '@backstage/config'; -import { getCombinedClusterDetails } from './index'; +import { getCombinedClusterSupplier } from './index'; -describe('getCombinedClusterDetails', () => { +describe('getCombinedClusterSupplier', () => { it('should retrieve cluster details from config', async () => { const config: Config = new ConfigReader( { @@ -45,7 +45,9 @@ describe('getCombinedClusterDetails', () => { 'ctx', ); - const result = await getCombinedClusterDetails(config); + const clusterSupplier = getCombinedClusterSupplier(config); + await clusterSupplier.refreshClusters(); + const result = await clusterSupplier.getClusters(); expect(result).toStrictEqual([ { @@ -99,7 +101,7 @@ describe('getCombinedClusterDetails', () => { 'ctx', ); - await expect(getCombinedClusterDetails(config)).rejects.toStrictEqual( + expect(() => getCombinedClusterSupplier(config)).toThrowError( new Error('Unsupported kubernetes.clusterLocatorMethods: "magic"'), ); }); diff --git a/plugins/kubernetes-backend/src/cluster-locator/index.ts b/plugins/kubernetes-backend/src/cluster-locator/index.ts index a4bcf77395..fec92d06d7 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/index.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/index.ts @@ -15,38 +15,51 @@ */ import { Config } from '@backstage/config'; -import { ClusterDetails } from '../types/types'; +import { ClusterDetails, KubernetesClustersSupplier } from '../types/types'; import { ConfigClusterLocator } from './ConfigClusterLocator'; import { GkeClusterLocator } from './GkeClusterLocator'; -export const getCombinedClusterDetails = async ( +class CombinedClustersSupplier implements KubernetesClustersSupplier { + constructor( + readonly clusterSuppliers: KubernetesClustersSupplier[], + private clusterDetails: ClusterDetails[] | undefined = undefined, + ) {} + + async refreshClusters(): Promise { + this.clusterDetails = await Promise.all( + this.clusterSuppliers.map(supplier => supplier.getClusters()), + ) + .then(res => { + return res.flat(); + }) + .catch(e => { + throw e; + }); + } + + async getClusters(): Promise { + return this.clusterDetails ?? []; + } +} + +export const getCombinedClusterSupplier = ( rootConfig: Config, -): Promise => { - return Promise.all( - rootConfig - .getConfigArray('kubernetes.clusterLocatorMethods') - .map(clusterLocatorMethod => { - const type = clusterLocatorMethod.getString('type'); - switch (type) { - case 'config': - return ConfigClusterLocator.fromConfig( - clusterLocatorMethod, - ).getClusters(); - case 'gke': - return GkeClusterLocator.fromConfig( - clusterLocatorMethod, - ).getClusters(); - default: - throw new Error( - `Unsupported kubernetes.clusterLocatorMethods: "${type}"`, - ); - } - }), - ) - .then(res => { - return res.flat(); - }) - .catch(e => { - throw e; +): KubernetesClustersSupplier => { + const clusterSuppliers = rootConfig + .getConfigArray('kubernetes.clusterLocatorMethods') + .map(clusterLocatorMethod => { + const type = clusterLocatorMethod.getString('type'); + switch (type) { + case 'config': + return ConfigClusterLocator.fromConfig(clusterLocatorMethod); + case 'gke': + return GkeClusterLocator.fromConfig(clusterLocatorMethod); + default: + throw new Error( + `Unsupported kubernetes.clusterLocatorMethods: "${type}"`, + ); + } }); + + return new CombinedClustersSupplier(clusterSuppliers); }; diff --git a/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts b/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts index f97161591f..974f554f45 100644 --- a/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts +++ b/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts @@ -19,7 +19,10 @@ import { MultiTenantServiceLocator } from './MultiTenantServiceLocator'; describe('MultiTenantConfigClusterLocator', () => { it('empty clusters returns empty cluster details', async () => { - const sut = new MultiTenantServiceLocator({ getClusters: async () => [] }); + const sut = new MultiTenantServiceLocator({ + refreshClusters: async () => {}, + getClusters: async () => [], + }); const result = await sut.getClustersByServiceId('ignored'); @@ -28,6 +31,7 @@ describe('MultiTenantConfigClusterLocator', () => { it('one clusters returns one cluster details', async () => { const sut = new MultiTenantServiceLocator({ + refreshClusters: async () => {}, getClusters: async () => { return [ { @@ -54,6 +58,7 @@ describe('MultiTenantConfigClusterLocator', () => { it('two clusters returns two cluster details', async () => { const sut = new MultiTenantServiceLocator({ + refreshClusters: async () => {}, getClusters: async () => { return [ { diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts index 37dad8c3e4..c3afa4fb6a 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts @@ -59,6 +59,7 @@ describe('KubernetesBuilder', () => { }, ]; const clusterSupplier: KubernetesClustersSupplier = { + async refreshClusters() {}, async getClusters() { return clusters; }, @@ -179,6 +180,7 @@ describe('KubernetesBuilder', () => { }, ]; const clusterSupplier: KubernetesClustersSupplier = { + async refreshClusters() {}, async getClusters() { return clusters; }, diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts index 6f5cfd0add..9cdef9f00d 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts @@ -17,7 +17,7 @@ import { Config } from '@backstage/config'; import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; -import { getCombinedClusterDetails } from '../cluster-locator'; +import { getCombinedClusterSupplier } from '../cluster-locator'; import { MultiTenantServiceLocator } from '../service-locator/MultiTenantServiceLocator'; import { KubernetesObjectTypes, @@ -36,6 +36,7 @@ import { KubernetesFanOutHandler, } from './KubernetesFanOutHandler'; import { KubernetesClientBasedFetcher } from './KubernetesFetcher'; +import { runPeriodically } from './runPeriodically'; export interface KubernetesEnvironment { logger: Logger; @@ -58,6 +59,7 @@ export type KubernetesBuilderReturn = Promise<{ export class KubernetesBuilder { private clusterSupplier?: KubernetesClustersSupplier; + private clusterRefreshMs: number = 60 * 60 * 1000; // defaults to once per hour private objectsProvider?: KubernetesObjectsProvider; private fetcher?: KubernetesFetcher; private serviceLocator?: KubernetesServiceLocator; @@ -91,6 +93,16 @@ export class KubernetesBuilder { const clusterSupplier = this.clusterSupplier ?? this.buildClusterSupplier(); + // we cannot use the regular scheduler here because all instances need this info + // and it is not persisted anywhere. + runPeriodically(async () => { + try { + await clusterSupplier.refreshClusters(); + } catch (e) { + logger.warn(`Failed to refresh kubernetes clusters: ${e}`); + } + }, this.clusterRefreshMs); + const serviceLocator = this.serviceLocator ?? this.buildServiceLocator(this.getServiceLocatorMethod(), clusterSupplier); @@ -122,6 +134,11 @@ export class KubernetesBuilder { return this; } + public setClusterRefreshInterval(refreshMs: number) { + this.clusterRefreshMs = refreshMs; + return this; + } + public setObjectsProvider(objectsProvider?: KubernetesObjectsProvider) { this.objectsProvider = objectsProvider; return this; @@ -158,11 +175,7 @@ export class KubernetesBuilder { protected buildClusterSupplier(): KubernetesClustersSupplier { const config = this.env.config; - return { - getClusters() { - return getCombinedClusterDetails(config); - }, - }; + return getCombinedClusterSupplier(config); } protected buildObjectsProvider( diff --git a/plugins/kubernetes-backend/src/service/runPeriodically.ts b/plugins/kubernetes-backend/src/service/runPeriodically.ts new file mode 100644 index 0000000000..2f3104e221 --- /dev/null +++ b/plugins/kubernetes-backend/src/service/runPeriodically.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2020 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. + */ + +/** + * Runs a function repeatedly, with a fixed wait between invocations. + * + * Supports async functions, and silently ignores exceptions and rejections. + * + * @param fn - The function to run. May return a Promise. + * @param delayMs - The delay between a completed function invocation and the + * next. + * @returns A function that, when called, stops the invocation loop. + */ +export function runPeriodically(fn: () => any, delayMs: number): () => void { + let cancel: () => void; + let cancelled = false; + const cancellationPromise = new Promise(resolve => { + cancel = () => { + resolve(); + cancelled = true; + }; + }); + + const startRefresh = async () => { + while (!cancelled) { + try { + await fn(); + } catch { + // ignore intentionally + } + + await Promise.race([ + new Promise(resolve => setTimeout(resolve, delayMs)), + cancellationPromise, + ]); + } + }; + startRefresh(); + + return cancel!; +} diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index 37c26956fe..512d916639 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -80,6 +80,17 @@ export type KubernetesObjectTypes = // Used to load cluster details from different sources export interface KubernetesClustersSupplier { + /** + * Refreshes the list of cluster from the source. + * + * This will be called periodically on a schedule to refresh the list + * of clusters. + */ + refreshClusters(): Promise; + + /** + * Returns the cached list of clusters. + */ getClusters(): Promise; } From 2fc0e86616246b02aa1f7812633ec294fbe272c5 Mon Sep 17 00:00:00 2001 From: Luna Stadler Date: Fri, 8 Apr 2022 16:30:54 +0200 Subject: [PATCH 5/5] Move refresh handling into KubernetesClustersSupplier implementations Signed-off-by: Luna Stadler --- .changeset/hot-items-smoke.md | 31 +++++++++++-------- docs/features/kubernetes/installation.md | 29 ++++++++++------- plugins/kubernetes-backend/api-report.md | 10 +++--- plugins/kubernetes-backend/package.json | 6 ++-- .../cluster-locator/ConfigClusterLocator.ts | 2 -- .../cluster-locator/GkeClusterLocator.test.ts | 7 +---- .../src/cluster-locator/GkeClusterLocator.ts | 24 ++++++++++++-- .../src/cluster-locator/index.test.ts | 1 - .../src/cluster-locator/index.ts | 20 ++++++------ .../MultiTenantServiceLocator.test.ts | 3 -- .../src/service/KubernetesBuilder.test.ts | 2 -- .../src/service/KubernetesBuilder.ts | 30 ++++++++---------- plugins/kubernetes-backend/src/types/types.ts | 11 ++----- 13 files changed, 93 insertions(+), 83 deletions(-) diff --git a/.changeset/hot-items-smoke.md b/.changeset/hot-items-smoke.md index 52c5088d3e..c9b9c8a135 100644 --- a/.changeset/hot-items-smoke.md +++ b/.changeset/hot-items-smoke.md @@ -10,7 +10,7 @@ the `getClusters` method is now called whenever the list of clusters is needed. Existing `KubernetesClustersSupplier` implementations will need to ensure that `getClusters` can be called frequently and should return a cached result from `getClusters` instead. -For example, here's a simple example of this in `packages/backend/src/plugins/kubernetes.ts`: +For example, here's a simple example of a custom supplier in `packages/backend/src/plugins/kubernetes.ts`: ```diff -import { KubernetesBuilder } from '@backstage/plugin-kubernetes-backend'; @@ -21,9 +21,20 @@ For example, here's a simple example of this in `packages/backend/src/plugins/ku +} from '@backstage/plugin-kubernetes-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; - ++import { Duration } from 'luxon'; ++ +export class CustomClustersSupplier implements KubernetesClustersSupplier { -+ private clusterDetails: ClusterDetails[] = []; ++ constructor(private clusterDetails: ClusterDetails[] = []) {} ++ ++ static create(refreshInterval: Duration) { ++ const clusterSupplier = new CustomClustersSupplier(); ++ // setup refresh, e.g. using a copy of https://github.com/backstage/backstage/blob/master/plugins/search-backend-node/src/runPeriodically.ts ++ runPeriodically( ++ () => clusterSupplier.refreshClusters(), ++ refreshInterval.toMillis(), ++ ); ++ return clusterSupplier; ++ } + + async refreshClusters(): Promise { + this.clusterDetails = []; // fetch from somewhere @@ -33,7 +44,7 @@ For example, here's a simple example of this in `packages/backend/src/plugins/ku + return this.clusterDetails; + } +} -+ + export default async function createPlugin( env: PluginEnvironment, ): Promise { @@ -43,14 +54,8 @@ For example, here's a simple example of this in `packages/backend/src/plugins/ku config: env.config, - }).build(); + }); -+ -+ const clusterSupplier = new CustomClustersSupplier(); -+ builder.setClusterSupplier(clusterSupplier); -+ ++ builder.setClusterSupplier( ++ CustomClustersSupplier.create(Duration.fromObject({ minutes: 60 })), ++ ); + const { router } = await builder.build(); - return router; - } ``` - -If you need to adjust the refresh interval from the default once per hour -you can call `builder.setClusterRefreshInterval`. diff --git a/docs/features/kubernetes/installation.md b/docs/features/kubernetes/installation.md index 4b4ba1139d..e920bd44ea 100644 --- a/docs/features/kubernetes/installation.md +++ b/docs/features/kubernetes/installation.md @@ -108,9 +108,20 @@ Change the following in `packages/backend/src/plugin/kubernetes.ts`: +} from '@backstage/plugin-kubernetes-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; - ++import { Duration } from 'luxon'; ++ +export class CustomClustersSupplier implements KubernetesClustersSupplier { -+ private clusterDetails: ClusterDetails[] = []; ++ constructor(private clusterDetails: ClusterDetails[] = []) {} ++ ++ static create(refreshInterval: Duration) { ++ const clusterSupplier = new CustomClustersSupplier(); ++ // setup refresh, e.g. using a copy of https://github.com/backstage/backstage/blob/master/plugins/search-backend-node/src/runPeriodically.ts ++ runPeriodically( ++ () => clusterSupplier.refreshClusters(), ++ refreshInterval.toMillis(), ++ ); ++ return clusterSupplier; ++ } + + async refreshClusters(): Promise { + this.clusterDetails = []; // fetch from somewhere @@ -120,7 +131,7 @@ Change the following in `packages/backend/src/plugin/kubernetes.ts`: + return this.clusterDetails; + } +} -+ + export default async function createPlugin( env: PluginEnvironment, ): Promise { @@ -130,18 +141,12 @@ Change the following in `packages/backend/src/plugin/kubernetes.ts`: config: env.config, - }).build(); + }); -+ -+ const clusterSupplier = new CustomClustersSupplier(); -+ builder.setClusterSupplier(clusterSupplier); -+ ++ builder.setClusterSupplier( ++ CustomClustersSupplier.create(Duration.fromObject({ minutes: 60 })), ++ ); + const { router } = await builder.build(); - return router; - } ``` -If you need to adjust the refresh interval from the default once per hour -you can call `builder.setClusterRefreshInterval`. - ## Running Backstage locally Start the frontend and the backend app by diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index 573198acf0..691b9443e5 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -4,6 +4,7 @@ ```ts import { Config } from '@backstage/config'; +import { Duration } from 'luxon'; import express from 'express'; import type { FetchResponse } from '@backstage/plugin-kubernetes-common'; import type { JsonObject } from '@backstage/types'; @@ -85,7 +86,9 @@ export class KubernetesBuilder { // (undocumented) build(): KubernetesBuilderReturn; // (undocumented) - protected buildClusterSupplier(): KubernetesClustersSupplier; + protected buildClusterSupplier( + refreshInterval: Duration, + ): KubernetesClustersSupplier; // (undocumented) protected buildCustomResources(): CustomResource[]; // (undocumented) @@ -125,10 +128,10 @@ export class KubernetesBuilder { // (undocumented) protected getServiceLocatorMethod(): ServiceLocatorMethod; // (undocumented) - setClusterRefreshInterval(refreshMs: number): this; - // (undocumented) setClusterSupplier(clusterSupplier?: KubernetesClustersSupplier): this; // (undocumented) + setDefaultClusterRefreshInterval(refreshInterval: Duration): this; + // (undocumented) setFetcher(fetcher?: KubernetesFetcher): this; // (undocumented) setObjectsProvider(objectsProvider?: KubernetesObjectsProvider): this; @@ -151,7 +154,6 @@ export type KubernetesBuilderReturn = Promise<{ // @public (undocumented) export interface KubernetesClustersSupplier { getClusters(): Promise; - refreshClusters(): Promise; } // Warning: (ae-missing-release-tag) "KubernetesEnvironment" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index a55ec70780..f5eb974431 100644 --- a/plugins/kubernetes-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -43,6 +43,7 @@ "@google-cloud/container": "^3.0.0", "@kubernetes/client-node": "^0.16.0", "@types/express": "^4.17.6", + "@types/luxon": "^2.0.4", "aws-sdk": "^2.840.0", "aws4": "^1.11.0", "compression": "^1.7.4", @@ -52,6 +53,7 @@ "fs-extra": "10.0.1", "helmet": "^5.0.2", "lodash": "^4.17.21", + "luxon": "^2.0.2", "morgan": "^1.10.0", "stream-buffers": "^3.0.2", "winston": "^3.2.1", @@ -60,8 +62,8 @@ "devDependencies": { "@backstage/cli": "^0.17.0-next.1", "@types/aws4": "^1.5.1", - "supertest": "^6.1.3", - "aws-sdk-mock": "^5.2.1" + "aws-sdk-mock": "^5.2.1", + "supertest": "^6.1.3" }, "files": [ "dist", diff --git a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts index 893db0f5d0..1bde1226dd 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts @@ -77,8 +77,6 @@ export class ConfigClusterLocator implements KubernetesClustersSupplier { ); } - async refreshClusters(): Promise {} - async getClusters(): Promise { return this.clusterDetails; } diff --git a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts index 51062066da..6bef8e0009 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts @@ -69,7 +69,6 @@ describe('GkeClusterLocator', () => { listClusters: mockedListClusters, } as any); - await sut.refreshClusters(); const result = await sut.getClusters(); expect(result).toStrictEqual([]); @@ -101,7 +100,6 @@ describe('GkeClusterLocator', () => { listClusters: mockedListClusters, } as any); - await sut.refreshClusters(); const result = await sut.getClusters(); expect(result).toStrictEqual([ @@ -139,7 +137,6 @@ describe('GkeClusterLocator', () => { listClusters: mockedListClusters, } as any); - await sut.refreshClusters(); const result = await sut.getClusters(); expect(result).toStrictEqual([ @@ -182,7 +179,6 @@ describe('GkeClusterLocator', () => { listClusters: mockedListClusters, } as any); - await sut.refreshClusters(); const result = await sut.getClusters(); expect(result).toStrictEqual([ @@ -221,7 +217,7 @@ describe('GkeClusterLocator', () => { listClusters: mockedListClusters, } as any); - await expect(sut.refreshClusters()).rejects.toThrow( + await expect(sut.getClusters()).rejects.toThrow( 'There was an error retrieving clusters from GKE for projectId=some-project region=some-region; caused by Error: some error', ); @@ -254,7 +250,6 @@ describe('GkeClusterLocator', () => { listClusters: mockedListClusters, } as any); - await sut.refreshClusters(); const result = await sut.getClusters(); expect(result).toStrictEqual([ diff --git a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts index 8484f5006b..0eb25c2acd 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts @@ -17,6 +17,8 @@ import { Config } from '@backstage/config'; import { ForwardedError } from '@backstage/errors'; import * as container from '@google-cloud/container'; +import { Duration } from 'luxon'; +import { runPeriodically } from '../service/runPeriodically'; import { ClusterDetails, GKEClusterDetails, @@ -36,11 +38,13 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { private readonly options: GkeClusterLocatorOptions, private readonly client: container.v1.ClusterManagerClient, private clusterDetails: GKEClusterDetails[] | undefined = undefined, + private hasClusterDetails: boolean = false, ) {} static fromConfigWithClient( config: Config, client: container.v1.ClusterManagerClient, + refreshInterval: Duration | undefined = undefined, ): GkeClusterLocator { const options = { projectId: config.getString('projectId'), @@ -50,17 +54,32 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { config.getOptionalBoolean('skipMetricsLookup') ?? false, exposeDashboard: config.getOptionalBoolean('exposeDashboard') ?? false, }; - return new GkeClusterLocator(options, client); + const gkeClusterLocator = new GkeClusterLocator(options, client); + if (refreshInterval) { + runPeriodically( + () => gkeClusterLocator.refreshClusters(), + refreshInterval.toMillis(), + ); + } + return gkeClusterLocator; } - static fromConfig(config: Config): GkeClusterLocator { + static fromConfig( + config: Config, + refreshInterval: Duration | undefined = undefined, + ): GkeClusterLocator { return GkeClusterLocator.fromConfigWithClient( config, new container.v1.ClusterManagerClient(), + refreshInterval, ); } async getClusters(): Promise { + if (!this.hasClusterDetails) { + // refresh at least once when first called, when retries are disabled and in tests + await this.refreshClusters(); + } return this.clusterDetails ?? []; } @@ -97,6 +116,7 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { } : {}), })); + this.hasClusterDetails = true; } catch (e) { throw new ForwardedError( `There was an error retrieving clusters from GKE for projectId=${projectId} region=${region}`, diff --git a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts index 2bdc37c31c..cc7e7d07d6 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts @@ -46,7 +46,6 @@ describe('getCombinedClusterSupplier', () => { ); const clusterSupplier = getCombinedClusterSupplier(config); - await clusterSupplier.refreshClusters(); const result = await clusterSupplier.getClusters(); expect(result).toStrictEqual([ diff --git a/plugins/kubernetes-backend/src/cluster-locator/index.ts b/plugins/kubernetes-backend/src/cluster-locator/index.ts index fec92d06d7..53aeb44f8b 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/index.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/index.ts @@ -15,18 +15,16 @@ */ import { Config } from '@backstage/config'; +import { Duration } from 'luxon'; import { ClusterDetails, KubernetesClustersSupplier } from '../types/types'; import { ConfigClusterLocator } from './ConfigClusterLocator'; import { GkeClusterLocator } from './GkeClusterLocator'; class CombinedClustersSupplier implements KubernetesClustersSupplier { - constructor( - readonly clusterSuppliers: KubernetesClustersSupplier[], - private clusterDetails: ClusterDetails[] | undefined = undefined, - ) {} + constructor(readonly clusterSuppliers: KubernetesClustersSupplier[]) {} - async refreshClusters(): Promise { - this.clusterDetails = await Promise.all( + async getClusters(): Promise { + return await Promise.all( this.clusterSuppliers.map(supplier => supplier.getClusters()), ) .then(res => { @@ -36,14 +34,11 @@ class CombinedClustersSupplier implements KubernetesClustersSupplier { throw e; }); } - - async getClusters(): Promise { - return this.clusterDetails ?? []; - } } export const getCombinedClusterSupplier = ( rootConfig: Config, + refreshInterval: Duration | undefined = undefined, ): KubernetesClustersSupplier => { const clusterSuppliers = rootConfig .getConfigArray('kubernetes.clusterLocatorMethods') @@ -53,7 +48,10 @@ export const getCombinedClusterSupplier = ( case 'config': return ConfigClusterLocator.fromConfig(clusterLocatorMethod); case 'gke': - return GkeClusterLocator.fromConfig(clusterLocatorMethod); + return GkeClusterLocator.fromConfig( + clusterLocatorMethod, + refreshInterval, + ); default: throw new Error( `Unsupported kubernetes.clusterLocatorMethods: "${type}"`, diff --git a/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts b/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts index 974f554f45..0dce2305bf 100644 --- a/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts +++ b/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts @@ -20,7 +20,6 @@ import { MultiTenantServiceLocator } from './MultiTenantServiceLocator'; describe('MultiTenantConfigClusterLocator', () => { it('empty clusters returns empty cluster details', async () => { const sut = new MultiTenantServiceLocator({ - refreshClusters: async () => {}, getClusters: async () => [], }); @@ -31,7 +30,6 @@ describe('MultiTenantConfigClusterLocator', () => { it('one clusters returns one cluster details', async () => { const sut = new MultiTenantServiceLocator({ - refreshClusters: async () => {}, getClusters: async () => { return [ { @@ -58,7 +56,6 @@ describe('MultiTenantConfigClusterLocator', () => { it('two clusters returns two cluster details', async () => { const sut = new MultiTenantServiceLocator({ - refreshClusters: async () => {}, getClusters: async () => { return [ { diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts index c3afa4fb6a..37dad8c3e4 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts @@ -59,7 +59,6 @@ describe('KubernetesBuilder', () => { }, ]; const clusterSupplier: KubernetesClustersSupplier = { - async refreshClusters() {}, async getClusters() { return clusters; }, @@ -180,7 +179,6 @@ describe('KubernetesBuilder', () => { }, ]; const clusterSupplier: KubernetesClustersSupplier = { - async refreshClusters() {}, async getClusters() { return clusters; }, diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts index 9cdef9f00d..a3dfc8b005 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts @@ -17,6 +17,7 @@ import { Config } from '@backstage/config'; import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; +import { Duration } from 'luxon'; import { getCombinedClusterSupplier } from '../cluster-locator'; import { MultiTenantServiceLocator } from '../service-locator/MultiTenantServiceLocator'; import { @@ -36,7 +37,6 @@ import { KubernetesFanOutHandler, } from './KubernetesFanOutHandler'; import { KubernetesClientBasedFetcher } from './KubernetesFetcher'; -import { runPeriodically } from './runPeriodically'; export interface KubernetesEnvironment { logger: Logger; @@ -59,7 +59,9 @@ export type KubernetesBuilderReturn = Promise<{ export class KubernetesBuilder { private clusterSupplier?: KubernetesClustersSupplier; - private clusterRefreshMs: number = 60 * 60 * 1000; // defaults to once per hour + private defaultClusterRefreshInterval: Duration = Duration.fromObject({ + minutes: 60, + }); private objectsProvider?: KubernetesObjectsProvider; private fetcher?: KubernetesFetcher; private serviceLocator?: KubernetesServiceLocator; @@ -91,17 +93,9 @@ export class KubernetesBuilder { const fetcher = this.fetcher ?? this.buildFetcher(); - const clusterSupplier = this.clusterSupplier ?? this.buildClusterSupplier(); - - // we cannot use the regular scheduler here because all instances need this info - // and it is not persisted anywhere. - runPeriodically(async () => { - try { - await clusterSupplier.refreshClusters(); - } catch (e) { - logger.warn(`Failed to refresh kubernetes clusters: ${e}`); - } - }, this.clusterRefreshMs); + const clusterSupplier = + this.clusterSupplier ?? + this.buildClusterSupplier(this.defaultClusterRefreshInterval); const serviceLocator = this.serviceLocator ?? @@ -134,8 +128,8 @@ export class KubernetesBuilder { return this; } - public setClusterRefreshInterval(refreshMs: number) { - this.clusterRefreshMs = refreshMs; + public setDefaultClusterRefreshInterval(refreshInterval: Duration) { + this.defaultClusterRefreshInterval = refreshInterval; return this; } @@ -173,9 +167,11 @@ export class KubernetesBuilder { return customResources; } - protected buildClusterSupplier(): KubernetesClustersSupplier { + protected buildClusterSupplier( + refreshInterval: Duration, + ): KubernetesClustersSupplier { const config = this.env.config; - return getCombinedClusterSupplier(config); + return getCombinedClusterSupplier(config, refreshInterval); } protected buildObjectsProvider( diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index 512d916639..2d7e2d3da7 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -80,16 +80,11 @@ export type KubernetesObjectTypes = // Used to load cluster details from different sources export interface KubernetesClustersSupplier { - /** - * Refreshes the list of cluster from the source. - * - * This will be called periodically on a schedule to refresh the list - * of clusters. - */ - refreshClusters(): Promise; - /** * Returns the cached list of clusters. + * + * Implementations _should_ cache the clusters and refresh them periodically, + * as getClusters is called whenever the list of clusters is needed. */ getClusters(): Promise; }