Merge pull request #11789 from backjo/feature/EKSCatalog

Add EKS Cluster Processor and Kubernetes Catalog Collector
This commit is contained in:
Ben Lambert
2022-07-14 09:48:10 +02:00
committed by GitHub
22 changed files with 446 additions and 6 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/catalog-model': patch
---
Add shared annotations for Kubernetes clusters
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend-module-aws': patch
---
Add processor for ingesting EKS clusters into the catalog
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-kubernetes-backend': patch
---
Add support for Kubernetes clusters in the catalog.
@@ -57,11 +57,16 @@ This is an array used to determine where to retrieve cluster configuration from.
Valid cluster locator methods are:
- [`catalog`](#catalog)
- [`localKubectlProxy`](#localKubectlProxy)
- [`config`](#config)
- [`gke`](#gke)
- [custom `KubernetesClustersSupplier`](#custom-kubernetesclusterssupplier)
#### `catalog`
This cluster locator method will read cluster information from the catalog.
#### `localKubectlProxy`
This cluster locator method will assume a locally running [`kubectl proxy`](https://kubernetes.io/docs/tasks/extend-kubernetes/http-proxy-access-api/#using-kubectl-to-start-a-proxy-server) process using the default port (8001).
+11
View File
@@ -14,6 +14,17 @@ export interface AlphaEntity extends Entity {
// @public
export const ANNOTATION_EDIT_URL = 'backstage.io/edit-url';
// @public
export const ANNOTATION_KUBERNETES_API_SERVER = 'kubernetes.io/api-server';
// @public
export const ANNOTATION_KUBERNETES_API_SERVER_CA =
'kubernetes.io/api-server-certificate-authority';
// @public
export const ANNOTATION_KUBERNETES_AUTH_PROVIDER =
'kubernetes.io/auth-provider';
// @public
export const ANNOTATION_LOCATION = 'backstage.io/managed-by-location';
@@ -34,3 +34,26 @@ export const ANNOTATION_VIEW_URL = 'backstage.io/view-url';
* @public
*/
export const ANNOTATION_EDIT_URL = 'backstage.io/edit-url';
/**
* Annotation for specifying the API server of a Kubernetes cluster
*
* @public
*/
export const ANNOTATION_KUBERNETES_API_SERVER = 'kubernetes.io/api-server';
/**
* Annotation for specifying the Certificate Authority of an API server for a Kubernetes cluster
*
* @public
*/
export const ANNOTATION_KUBERNETES_API_SERVER_CA =
'kubernetes.io/api-server-certificate-authority';
/**
* Annotation for specifying the auth provider for a Kubernetes cluster
*
* @public
*/
export const ANNOTATION_KUBERNETES_AUTH_PROVIDER =
'kubernetes.io/auth-provider';
@@ -19,6 +19,9 @@ export {
DEFAULT_NAMESPACE,
ANNOTATION_EDIT_URL,
ANNOTATION_VIEW_URL,
ANNOTATION_KUBERNETES_API_SERVER,
ANNOTATION_KUBERNETES_API_SERVER_CA,
ANNOTATION_KUBERNETES_AUTH_PROVIDER,
} from './constants';
export type {
AlphaEntity,
@@ -7,6 +7,7 @@ import { CatalogProcessor } from '@backstage/plugin-catalog-backend';
import { CatalogProcessorEmit } from '@backstage/plugin-catalog-backend';
import { CatalogProcessorParser } from '@backstage/plugin-catalog-backend';
import { Config } from '@backstage/config';
import { Credentials } from 'aws-sdk';
import { EntityProvider } from '@backstage/plugin-catalog-backend';
import { EntityProviderConnection } from '@backstage/plugin-catalog-backend';
import { LocationSpec } from '@backstage/plugin-catalog-backend';
@@ -14,6 +15,26 @@ import { Logger } from 'winston';
import { TaskRunner } from '@backstage/backend-tasks';
import { UrlReader } from '@backstage/backend-common';
// @public
export type AWSCredentialFactory = (
awsAccountId: string,
) => Promise<Credentials>;
// @public
export class AwsEKSClusterProcessor implements CatalogProcessor {
constructor(options: { credentialsFactory?: AWSCredentialFactory });
// (undocumented)
getProcessorName(): string;
// (undocumented)
normalizeName(name: string): string;
// (undocumented)
readLocation(
location: LocationSpec,
_optional: boolean,
emit: CatalogProcessorEmit,
): Promise<boolean>;
}
// @public
export class AwsOrganizationCloudAccountProcessor implements CatalogProcessor {
// (undocumented)
@@ -22,3 +22,4 @@
export * from './processors';
export * from './providers';
export * from './types';
@@ -0,0 +1,74 @@
/*
* Copyright 2022 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 { AwsEKSClusterProcessor } from './AwsEKSClusterProcessor';
import AWSMock from 'aws-sdk-mock';
import aws from 'aws-sdk';
describe('AwsEKSClusterProcessor', () => {
AWSMock.setSDKInstance(aws);
describe('readLocation', () => {
const processor = new (AwsEKSClusterProcessor as any)({});
const location = { type: 'aws-eks', target: '957140518395/us-west-2' };
const emit = jest.fn();
it('generates cluster correctly', async () => {
const clusters: aws.EKS.Types.ListClustersResponse = {
clusters: ['backstage-test'],
nextToken: undefined,
};
const cluster: aws.EKS.Types.DescribeClusterResponse = {
cluster: {
name: 'backstage-test',
arn: 'arn:aws:1',
endpoint: 'https://backstage.io/kubernetes-api-server',
certificateAuthority: {
data: 'cert',
},
},
};
AWSMock.mock('EKS', 'listClusters', clusters);
AWSMock.mock('EKS', 'describeCluster', cluster);
await processor.readLocation(location, false, emit);
expect(emit).toBeCalledWith({
type: 'entity',
location,
entity: {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Resource',
metadata: {
annotations: {
'amazonaws.com/account-id': '957140518395',
'amazonaws.com/arn': cluster.cluster?.arn,
'kubernetes.io/api-server': cluster.cluster?.endpoint,
'kubernetes.io/api-server-certificate-authority':
cluster.cluster?.certificateAuthority?.data,
'kubernetes.io/auth-provider': 'aws',
},
name: 'backstage-test',
namespace: 'default',
},
spec: {
type: 'kubernetes-cluster',
owner: 'unknown',
},
},
});
});
});
});
@@ -0,0 +1,123 @@
/*
* Copyright 2022 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 {
CatalogProcessor,
CatalogProcessorEmit,
LocationSpec,
} from '@backstage/plugin-catalog-backend';
import {
ANNOTATION_KUBERNETES_API_SERVER,
ANNOTATION_KUBERNETES_API_SERVER_CA,
ANNOTATION_KUBERNETES_AUTH_PROVIDER,
} from '@backstage/catalog-model';
import { Credentials, EKS } from 'aws-sdk';
import { AWSCredentialFactory } from '../types';
const ACCOUNTID_ANNOTATION: string = 'amazonaws.com/account-id';
const ARN_ANNOTATION: string = 'amazonaws.com/arn';
/**
* A processor for automatic discovery of resources from EKS clusters. Handles the
* `aws-eks` location type, and target accounts/regions of the form
* `<accountId>/<region>`.
*
* @public
*/
export class AwsEKSClusterProcessor implements CatalogProcessor {
private credentialsFactory?: AWSCredentialFactory;
constructor(options: { credentialsFactory?: AWSCredentialFactory }) {
this.credentialsFactory = options.credentialsFactory;
}
getProcessorName(): string {
return 'aws-eks';
}
normalizeName(name: string): string {
return name
.trim()
.toLocaleLowerCase('en-US')
.replace(/[^a-zA-Z0-9\-]/g, '-');
}
async readLocation(
location: LocationSpec,
_optional: boolean,
emit: CatalogProcessorEmit,
): Promise<boolean> {
if (location.type !== 'aws-eks') {
return false;
}
// location target is of format "account-id/region"
const [accountId, region] = location.target.split('/');
if (!accountId || !region) {
throw new Error(
'AWS EKS location specified without account or region information',
);
}
let credentials: Credentials | undefined;
if (this.credentialsFactory) {
credentials = await this.credentialsFactory(accountId);
}
const eksClient = new EKS({ credentials, region });
const clusters = await eksClient.listClusters({}).promise();
if (clusters.clusters === undefined) {
return true;
}
const results = clusters.clusters
.map(cluster => eksClient.describeCluster({ name: cluster }).promise())
.map(async describedClusterPromise => {
const describedCluster = await describedClusterPromise;
if (describedCluster.cluster) {
const entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Resource',
metadata: {
annotations: {
[ACCOUNTID_ANNOTATION]: accountId,
[ARN_ANNOTATION]: describedCluster.cluster.arn || '',
[ANNOTATION_KUBERNETES_API_SERVER]:
describedCluster.cluster.endpoint || '',
[ANNOTATION_KUBERNETES_API_SERVER_CA]:
describedCluster.cluster.certificateAuthority?.data || '',
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'aws',
},
name: this.normalizeName(describedCluster.cluster.name as string),
namespace: 'default',
},
spec: {
type: 'kubernetes-cluster',
owner: 'unknown',
},
};
emit({
type: 'entity',
entity,
location,
});
}
});
await Promise.all(results);
return true;
}
}
@@ -14,5 +14,6 @@
* limitations under the License.
*/
export { AwsEKSClusterProcessor } from './AwsEKSClusterProcessor';
export { AwsOrganizationCloudAccountProcessor } from './AwsOrganizationCloudAccountProcessor';
export { AwsS3DiscoveryProcessor } from './AwsS3DiscoveryProcessor';
@@ -0,0 +1,25 @@
/*
* Copyright 2022 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 { Credentials } from 'aws-sdk';
/**
* A factory for providing user-specified AWS credentials for a given AWS account.
*
* @public
*/
export type AWSCredentialFactory = (
awsAccountId: string,
) => Promise<Credentials>;
+3
View File
@@ -15,6 +15,7 @@ import type { KubernetesRequestAuth } from '@backstage/plugin-kubernetes-common'
import type { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common';
import { Logger } from 'winston';
import type { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { PodStatus } from '@kubernetes/client-node/dist/top';
// @alpha (undocumented)
@@ -279,6 +280,8 @@ export interface RouterOptions {
// (undocumented)
config: Config;
// (undocumented)
discovery: PluginEndpointDiscovery;
// (undocumented)
logger: Logger;
}
@@ -0,0 +1,56 @@
/*
* 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.
*/
import '@backstage/backend-common';
import { CatalogClusterLocator } from './CatalogClusterLocator';
import { CatalogApi } from '@backstage/catalog-client';
describe('CatalogClusterLocator', () => {
it('empty clusters returns empty cluster details', async () => {
const mockCatalogApi = {
getEntityByRef: jest.fn(),
getEntities: async () => ({
items: [
{
apiVersion: 'version',
kind: 'User',
metadata: {
annotations: {
'kubernetes.io/api-server': 'https://apiserver.com',
'kubernetes.io/api-server-certificate-authority': 'caData',
'kubernetes.io/auth-provider': 'aws',
},
name: 'owned',
namespace: 'default',
},
},
],
}),
} as Partial<CatalogApi> as CatalogApi;
const sut = CatalogClusterLocator.fromConfig(mockCatalogApi);
const result = await sut.getClusters();
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
name: 'owned',
url: 'https://apiserver.com',
caData: 'caData',
authProvider: 'aws',
});
});
});
@@ -0,0 +1,65 @@
/*
* Copyright 2022 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 { ClusterDetails, KubernetesClustersSupplier } from '../types/types';
import { CATALOG_FILTER_EXISTS, CatalogApi } from '@backstage/catalog-client';
import {
ANNOTATION_KUBERNETES_API_SERVER,
ANNOTATION_KUBERNETES_API_SERVER_CA,
ANNOTATION_KUBERNETES_AUTH_PROVIDER,
} from '@backstage/catalog-model';
export class CatalogClusterLocator implements KubernetesClustersSupplier {
private catalogClient: CatalogApi;
constructor(catalogClient: CatalogApi) {
this.catalogClient = catalogClient;
}
static fromConfig(catalogApi: CatalogApi): CatalogClusterLocator {
return new CatalogClusterLocator(catalogApi);
}
async getClusters(): Promise<ClusterDetails[]> {
const apiServerKey = `metadata.annotations.${ANNOTATION_KUBERNETES_API_SERVER}`;
const apiServerCaKey = `metadata.annotations.${ANNOTATION_KUBERNETES_API_SERVER_CA}`;
const authProviderKey = `metadata.annotations.${ANNOTATION_KUBERNETES_AUTH_PROVIDER}`;
const filter: Record<string, symbol | string> = {
kind: 'Resource',
'spec.type': 'kubernetes-cluster',
[apiServerKey]: CATALOG_FILTER_EXISTS,
[apiServerCaKey]: CATALOG_FILTER_EXISTS,
[authProviderKey]: CATALOG_FILTER_EXISTS,
};
const clusters = await this.catalogClient.getEntities({
filter: [filter],
});
return clusters.items.map(entity => {
const clusterDetails: ClusterDetails = {
name: entity.metadata.name,
url: entity.metadata.annotations![ANNOTATION_KUBERNETES_API_SERVER]!,
caData:
entity.metadata.annotations![ANNOTATION_KUBERNETES_API_SERVER_CA]!,
authProvider:
entity.metadata.annotations![ANNOTATION_KUBERNETES_AUTH_PROVIDER]!,
};
return clusterDetails;
});
}
}
@@ -16,8 +16,11 @@
import { Config, ConfigReader } from '@backstage/config';
import { getCombinedClusterSupplier } from './index';
import { CatalogApi } from '@backstage/catalog-client';
describe('getCombinedClusterSupplier', () => {
let catalogApi: CatalogApi;
it('should retrieve cluster details from config', async () => {
const config: Config = new ConfigReader(
{
@@ -45,7 +48,7 @@ describe('getCombinedClusterSupplier', () => {
'ctx',
);
const clusterSupplier = getCombinedClusterSupplier(config);
const clusterSupplier = getCombinedClusterSupplier(config, catalogApi);
const result = await clusterSupplier.getClusters();
expect(result).toStrictEqual([
@@ -100,7 +103,7 @@ describe('getCombinedClusterSupplier', () => {
'ctx',
);
expect(() => getCombinedClusterSupplier(config)).toThrowError(
expect(() => getCombinedClusterSupplier(config, catalogApi)).toThrowError(
new Error('Unsupported kubernetes.clusterLocatorMethods: "magic"'),
);
});
@@ -19,6 +19,8 @@ import { Duration } from 'luxon';
import { ClusterDetails, KubernetesClustersSupplier } from '../types/types';
import { ConfigClusterLocator } from './ConfigClusterLocator';
import { GkeClusterLocator } from './GkeClusterLocator';
import { CatalogClusterLocator } from './CatalogClusterLocator';
import { CatalogApi } from '@backstage/catalog-client';
import { LocalKubectlProxyClusterLocator } from './LocalKubectlProxyLocator';
class CombinedClustersSupplier implements KubernetesClustersSupplier {
@@ -39,6 +41,7 @@ class CombinedClustersSupplier implements KubernetesClustersSupplier {
export const getCombinedClusterSupplier = (
rootConfig: Config,
catalogClient: CatalogApi,
refreshInterval: Duration | undefined = undefined,
): KubernetesClustersSupplier => {
const clusterSuppliers = rootConfig
@@ -46,6 +49,8 @@ export const getCombinedClusterSupplier = (
.map(clusterLocatorMethod => {
const type = clusterLocatorMethod.getString('type');
switch (type) {
case 'catalog':
return CatalogClusterLocator.fromConfig(catalogClient);
case 'localKubectlProxy':
return new LocalKubectlProxyClusterLocator();
case 'config':
@@ -71,8 +71,6 @@ describe('KubernetesBuilder', () => {
getKubernetesObjectsByEntity: jest.fn(),
} as any;
catalogApi = {} as CatalogApi;
const { router } = await KubernetesBuilder.createBuilder({
config,
logger,
@@ -186,7 +186,11 @@ export class KubernetesBuilder {
refreshInterval: Duration,
): KubernetesClustersSupplier {
const config = this.env.config;
return getCombinedClusterSupplier(config, refreshInterval);
return getCombinedClusterSupplier(
config,
this.env.catalogApi,
refreshInterval,
);
}
protected buildObjectsProvider(
@@ -19,6 +19,7 @@ import { Logger } from 'winston';
import { KubernetesClustersSupplier } from '../types/types';
import express from 'express';
import { KubernetesBuilder } from './KubernetesBuilder';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { CatalogApi } from '@backstage/catalog-client';
/**
@@ -30,6 +31,7 @@ export interface RouterOptions {
config: Config;
catalogApi: CatalogApi;
clusterSupplier?: KubernetesClustersSupplier;
discovery: PluginEndpointDiscovery;
}
/**
@@ -39,6 +39,8 @@ export async function createStandaloneApplication(
): Promise<express.Application> {
const { enableCors, logger } = options;
const config = new ConfigReader({});
const discovery = SingleHostDiscovery.fromConfig(config);
const app = express();
const catalogApi = new CatalogClient({
@@ -52,7 +54,7 @@ export async function createStandaloneApplication(
app.use(compression());
app.use(express.json());
app.use(requestLoggingHandler());
app.use('/', await createRouter({ logger, config, catalogApi }));
app.use('/', await createRouter({ logger, config, discovery, catalogApi }));
app.use(notFoundHandler());
app.use(errorHandler());