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 <luc@spreadshirt.net>
This commit is contained in:
Luna Stadler
2022-04-04 14:26:11 +02:00
parent 38ea1f136c
commit 8d050f714d
13 changed files with 166 additions and 76 deletions
+6 -13
View File
@@ -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<void> {
+ 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<Router> {
@@ -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`.
+6 -13
View File
@@ -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<void> {
+ 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<Router> {
@@ -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
+3 -1
View File
@@ -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<ClusterDetails[]>;
refreshClusters(): Promise<void>;
}
// Warning: (ae-missing-release-tag) "KubernetesEnvironment" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -77,6 +77,8 @@ export class ConfigClusterLocator implements KubernetesClustersSupplier {
);
}
async refreshClusters(): Promise<void> {}
async getClusters(): Promise<ClusterDetails[]> {
return this.clusterDetails;
}
@@ -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([
@@ -61,16 +61,11 @@ export class GkeClusterLocator implements KubernetesClustersSupplier {
}
async getClusters(): Promise<ClusterDetails[]> {
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<GKEClusterDetails[]> {
async refreshClusters(): Promise<void> {
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 ?? ''}`,
@@ -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"'),
);
});
@@ -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<void> {
this.clusterDetails = await Promise.all(
this.clusterSuppliers.map(supplier => supplier.getClusters()),
)
.then(res => {
return res.flat();
})
.catch(e => {
throw e;
});
}
async getClusters(): Promise<ClusterDetails[]> {
return this.clusterDetails ?? [];
}
}
export const getCombinedClusterSupplier = (
rootConfig: Config,
): Promise<ClusterDetails[]> => {
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);
};
@@ -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 [
{
@@ -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;
},
@@ -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(
@@ -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<void>(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!;
}
@@ -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<void>;
/**
* Returns the cached list of clusters.
*/
getClusters(): Promise<ClusterDetails[]>;
}