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