K8s add cluster lookup (#2571)

* Kubernetes plugins boilerplate

* pr feedback; check for k8s annotation

* temp

* fix config

* add example local setup

* dice rolelr

* rever yarn lock update

* forgotten prettier

* prettier again

* master lock

* update lock with k8s deps

* prettier

* router tests

* internal error

* fix package.json

* PR feedback

* more pr feedback

* rename component file

* kubernetes fetcher tests

* fix test

* option constructor
This commit is contained in:
Matthew Clarke
2020-09-24 16:30:48 +01:00
committed by GitHub
parent b41ffc8c0f
commit b16e164815
26 changed files with 1671 additions and 56 deletions
@@ -0,0 +1,42 @@
# Dice roller
An app to roll dice (it doesn't actually do that).
# Viewing in local Minikube running Backstage locally
## Prerequisites
- kubectl installed
- Minikube installed
- jq installed
- Backstage locally built and ready to run
## Steps
1. Start minikube
2. Get the Kubernetes master base url `kubectl cluster-info`
3. Apply manifests `kubectl apply -f dice-roller-manifests.yaml`
4. Get service account token (see below)
5. Start Backstage UI and backend
6. Register existing component in Backstage
- https://github.com/mclarke47/dice-roller/blob/master/catalog-info.yaml
Update `app-config.yaml` as follows.
```yaml
---
kubernetes:
clusterLocatorMethod: 'configMultiTenant'
clusters:
- url: <KUBERNETES MASTER BASE URL FROM STEP 2>
name: minikube
serviceAccountToken: <TOKEN FROM STEP 4>
```
### Getting the service account token
```
kubectl get secret DICE_ROLLER_TOKEN_NAME -o=json | jq -r '.data["token"]' | base64 --decode | pbcopy
```
Paste into `app-config.yaml` `kubernetes.clusters[].serviceAccountToken`
@@ -0,0 +1,13 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: dice-roller
description: It rolls dice
tags:
- go
annotations:
'backstage.io/kubernetes-id': dice-roller
spec:
type: service
lifecycle: production
owner: guest
@@ -0,0 +1,79 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: dice-roller
labels:
'backstage.io/kubernetes-id': dice-roller
spec:
selector:
matchLabels:
app: dice-roller
replicas: 2
template:
metadata:
labels:
app: dice-roller
'backstage.io/kubernetes-id': dice-roller
spec:
containers:
- name: nginx
image: nginx:1.14.2
ports:
- containerPort: 80
---
apiVersion: v1
kind: ConfigMap
metadata:
name: dice-roller
namespace: default
labels:
'backstage.io/kubernetes-id': dice-roller
data:
foo: bar
---
apiVersion: v1
kind: Secret
metadata:
name: dice-roller
labels:
'backstage.io/kubernetes-id': dice-roller
type: Opaque
data:
username: YWRtaW4=
---
apiVersion: v1
kind: Service
metadata:
name: dice-roller
labels:
'backstage.io/kubernetes-id': dice-roller
spec:
selector:
app: dice-roller
ports:
- protocol: TCP
port: 80
targetPort: 9376
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: dice-roller
automountServiceAccountToken: false
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: sa-admin
roleRef:
kind: ClusterRole
name: cluster-admin
apiGroup: rbac.authorization.k8s.io
subjects:
- kind: ServiceAccount
name: dice-roller
namespace: default
+6 -2
View File
@@ -21,7 +21,9 @@
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.23",
"@backstage/config": "^0.1.1-alpha.23",
"@types/express": "^4.17.6",
"@kubernetes/client-node": "^0.12.1",
"compression": "^1.7.4",
"cors": "^2.8.5",
"express": "^4.17.1",
@@ -29,12 +31,14 @@
"fs-extra": "^9.0.0",
"helmet": "^4.0.0",
"morgan": "^1.10.0",
"stream-buffers": "^3.0.2",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.23",
"jest-fetch-mock": "^3.0.3"
"jest-fetch-mock": "^3.0.3",
"supertest": "^4.0.2",
"@backstage/cli": "^0.1.1-alpha.23"
},
"files": [
"dist"
@@ -0,0 +1,105 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { MultiTenantConfigClusterLocator } from './MultiTenantConfigClusterLocator';
import { ConfigReader, Config } from '@backstage/config';
describe('MultiTenantConfigClusterLocator', () => {
it('empty clusters returns empty cluster details', async () => {
const config: Config = new ConfigReader(
{
clusters: [],
},
'ctx',
);
const sut = MultiTenantConfigClusterLocator.fromConfig(
config.getConfigArray('clusters'),
);
const result = await sut.getClusterByServiceId('ignored');
expect(result).toStrictEqual([]);
});
it('one clusters returns one cluster details', async () => {
const config: Config = new ConfigReader(
{
clusters: [
{
name: 'cluster1',
url: 'http://localhost:8080',
},
],
},
'ctx',
);
const sut = MultiTenantConfigClusterLocator.fromConfig(
config.getConfigArray('clusters'),
);
const result = await sut.getClusterByServiceId('ignored');
expect(result).toStrictEqual([
{
name: 'cluster1',
serviceAccountToken: undefined,
url: 'http://localhost:8080',
},
]);
});
it('two clusters returns two cluster details', async () => {
const config: Config = new ConfigReader(
{
clusters: [
{
name: 'cluster1',
serviceAccountToken: undefined,
url: 'http://localhost:8080',
},
{
name: 'cluster2',
serviceAccountToken: undefined,
url: 'http://localhost:8081',
},
],
},
'ctx',
);
const sut = MultiTenantConfigClusterLocator.fromConfig(
config.getConfigArray('clusters'),
);
const result = await sut.getClusterByServiceId('ignored');
expect(result).toStrictEqual([
{
name: 'cluster1',
serviceAccountToken: undefined,
url: 'http://localhost:8080',
},
{
name: 'cluster2',
serviceAccountToken: undefined,
url: 'http://localhost:8081',
},
]);
});
});
@@ -0,0 +1,47 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { Config } from '@backstage/config';
import { ClusterDetails, KubernetesClusterLocator } from '..';
// This cluster locator assumes that every service is located on every cluster
// Therefore it will always return all clusters in an app configuration file
export class MultiTenantConfigClusterLocator
implements KubernetesClusterLocator {
private readonly clusterDetails: ClusterDetails[];
constructor(clusterDetails: ClusterDetails[]) {
this.clusterDetails = clusterDetails;
}
static fromConfig(config: Config[]): MultiTenantConfigClusterLocator {
return new MultiTenantConfigClusterLocator(
config.map(c => {
return {
name: c.getString('name'),
url: c.getString('url'),
serviceAccountToken: c.getOptionalString('serviceAccountToken'),
};
}),
);
}
// As this implementation always returns all clusters serviceId is ignored here
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async getClusterByServiceId(_serviceId: string): Promise<ClusterDetails[]> {
return this.clusterDetails;
}
}
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
export type ClusterLocatorMethod = 'configMultiTenant' | 'http';
+1
View File
@@ -15,3 +15,4 @@
*/
export * from './service/router';
export * from './types/types';
@@ -0,0 +1,136 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { KubernetesClientProvider } from './KubernetesClientProvider';
describe('KubernetesClientProvider', () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('can get core client by cluster details', async () => {
const sut = new KubernetesClientProvider();
const mockGetKubeConfig = jest.fn(sut.getKubeConfig.bind({}));
sut.getKubeConfig = mockGetKubeConfig;
const result = sut.getCoreClientByClusterDetails({
name: 'cluster-name',
url: 'http://localhost:9999',
serviceAccountToken: 'TOKEN',
});
expect(result.basePath).toBe('http://localhost:9999');
// These fields aren't on the type but are there
const auth = (result as any).authentications.default;
expect(auth.users[0].token).toBe('TOKEN');
expect(auth.clusters[0].name).toBe('cluster-name');
expect(mockGetKubeConfig.mock.calls.length).toBe(1);
});
it('can get cached core client by cluster details', async () => {
const sut = new KubernetesClientProvider();
const mockGetKubeConfig = jest.fn(sut.getKubeConfig.bind({}));
sut.getKubeConfig = mockGetKubeConfig;
const result1 = sut.getCoreClientByClusterDetails({
name: 'cluster-name',
url: 'http://localhost:9999',
serviceAccountToken: 'TOKEN',
});
const result2 = sut.getCoreClientByClusterDetails({
name: 'cluster-name',
url: 'http://localhost:9999',
serviceAccountToken: 'TOKEN',
});
expect(result1.basePath).toBe('http://localhost:9999');
// These fields aren't on the type but are there
const auth1 = (result1 as any).authentications.default;
expect(auth1.users[0].token).toBe('TOKEN');
expect(auth1.clusters[0].name).toBe('cluster-name');
expect(result2.basePath).toBe('http://localhost:9999');
// These fields aren't on the type but are there
const auth2 = (result2 as any).authentications.default;
expect(auth2.users[0].token).toBe('TOKEN');
expect(auth2.clusters[0].name).toBe('cluster-name');
expect(mockGetKubeConfig.mock.calls.length).toBe(1);
});
it('can get apps client by cluster details', async () => {
const sut = new KubernetesClientProvider();
const mockGetKubeConfig = jest.fn(sut.getKubeConfig.bind({}));
sut.getKubeConfig = mockGetKubeConfig;
const result = sut.getAppsClientByClusterDetails({
name: 'cluster-name',
url: 'http://localhost:9999',
serviceAccountToken: 'TOKEN',
});
expect(result.basePath).toBe('http://localhost:9999');
// These fields aren't on the type but are there
const auth = (result as any).authentications.default;
expect(auth.users[0].token).toBe('TOKEN');
expect(auth.clusters[0].name).toBe('cluster-name');
expect(mockGetKubeConfig.mock.calls.length).toBe(1);
});
it('can get cached apps client by cluster details', async () => {
const sut = new KubernetesClientProvider();
const mockGetKubeConfig = jest.fn(sut.getKubeConfig.bind({}));
sut.getKubeConfig = mockGetKubeConfig;
const result1 = sut.getAppsClientByClusterDetails({
name: 'cluster-name',
url: 'http://localhost:9999',
serviceAccountToken: 'TOKEN',
});
const result2 = sut.getAppsClientByClusterDetails({
name: 'cluster-name',
url: 'http://localhost:9999',
serviceAccountToken: 'TOKEN',
});
expect(result1.basePath).toBe('http://localhost:9999');
// These fields aren't on the type but are there
const auth1 = (result1 as any).authentications.default;
expect(auth1.users[0].token).toBe('TOKEN');
expect(auth1.clusters[0].name).toBe('cluster-name');
expect(result2.basePath).toBe('http://localhost:9999');
// These fields aren't on the type but are there
const auth2 = (result2 as any).authentications.default;
expect(auth2.users[0].token).toBe('TOKEN');
expect(auth2.clusters[0].name).toBe('cluster-name');
expect(mockGetKubeConfig.mock.calls.length).toBe(1);
});
});
@@ -0,0 +1,96 @@
/*
* Copyright 2020 Spotify AB
*
* 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 } from '..';
import { AppsV1Api, CoreV1Api, KubeConfig } from '@kubernetes/client-node';
export class KubernetesClientProvider {
private readonly coreClientMap: {
[key: string]: CoreV1Api;
};
private readonly appsClientMap: {
[key: string]: AppsV1Api;
};
constructor() {
this.coreClientMap = {};
this.appsClientMap = {};
}
// visible for testing
getKubeConfig(clusterDetails: ClusterDetails) {
const cluster = {
name: clusterDetails.name,
server: clusterDetails.url,
// TODO configure this
skipTLSVerify: true,
};
// TODO configure
const user = {
name: 'service-account',
token: clusterDetails.serviceAccountToken,
};
const context = {
name: `${clusterDetails.name}`,
user: user.name,
cluster: cluster.name,
};
const kc = new KubeConfig();
kc.loadFromOptions({
clusters: [cluster],
users: [user],
contexts: [context],
currentContext: context.name,
});
return kc;
}
getCoreClientByClusterDetails(clusterDetails: ClusterDetails) {
const clientMapKey = clusterDetails.name;
if (this.coreClientMap.hasOwnProperty(clientMapKey)) {
return this.coreClientMap[clientMapKey];
}
const kc = this.getKubeConfig(clusterDetails);
const client = kc.makeApiClient(CoreV1Api);
this.coreClientMap[clientMapKey] = client;
return client;
}
getAppsClientByClusterDetails(clusterDetails: ClusterDetails) {
const clientMapKey = clusterDetails.name;
if (this.appsClientMap.hasOwnProperty(clientMapKey)) {
return this.appsClientMap[clientMapKey];
}
const kc = this.getKubeConfig(clusterDetails);
const client = kc.makeApiClient(AppsV1Api);
this.appsClientMap[clientMapKey] = client;
return client;
}
}
@@ -0,0 +1,108 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { getVoidLogger } from '@backstage/backend-common';
import { KubernetesClientBasedFetcher } from './KubernetesFetcher';
describe('KubernetesClientProvider', () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('should return pods, services', async () => {
const clientMock: any = {
listPodForAllNamespaces: jest.fn(),
listServiceForAllNamespaces: jest.fn(),
};
const kubernetesClientProvider: any = {
getCoreClientByClusterDetails: jest.fn(() => clientMock),
getAppsClientByClusterDetails: jest.fn(() => clientMock),
};
const sut = new KubernetesClientBasedFetcher({
kubernetesClientProvider,
logger: getVoidLogger(),
});
clientMock.listPodForAllNamespaces.mockResolvedValueOnce({
body: {
items: [
{
metadata: {
name: 'pod-name',
},
},
],
},
});
clientMock.listServiceForAllNamespaces.mockResolvedValueOnce({
body: {
items: [
{
metadata: {
name: 'service-name',
},
},
],
},
});
const result = await sut.fetchObjectsByServiceId(
'some-service',
{
name: 'cluster1',
url: 'http://localhost:9999',
serviceAccountToken: undefined,
},
new Set(['pods', 'services']),
);
expect(result).toStrictEqual([
{
type: 'pods',
resources: [
{
metadata: {
name: 'pod-name',
},
},
],
},
{
type: 'services',
resources: [
{
metadata: {
name: 'service-name',
},
},
],
},
]);
expect(clientMock.listPodForAllNamespaces.mock.calls.length).toBe(1);
expect(clientMock.listServiceForAllNamespaces.mock.calls.length).toBe(1);
expect(
kubernetesClientProvider.getAppsClientByClusterDetails.mock.calls.length,
).toBe(2);
expect(
kubernetesClientProvider.getCoreClientByClusterDetails.mock.calls.length,
).toBe(2);
});
});
@@ -0,0 +1,212 @@
/*
* Copyright 2020 Spotify AB
*
* 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 {
AppsV1Api,
CoreV1Api,
V1ConfigMap,
V1Deployment,
V1Pod,
V1ReplicaSet,
V1Secret,
} from '@kubernetes/client-node';
import { KubernetesClientProvider } from './KubernetesClientProvider';
import { V1Service } from '@kubernetes/client-node/dist/gen/model/v1Service';
import { Logger } from 'winston';
import {
KubernetesFetcher,
ClusterDetails,
KubernetesObjectTypes,
FetchResponse,
} from '..';
export interface Clients {
core: CoreV1Api;
apps: AppsV1Api;
}
export interface KubernetesClientBasedFetcherOptions {
kubernetesClientProvider: KubernetesClientProvider;
logger: Logger;
}
export class KubernetesClientBasedFetcher implements KubernetesFetcher {
private readonly kubernetesClientProvider: KubernetesClientProvider;
private readonly logger: Logger;
constructor({
kubernetesClientProvider,
logger,
}: KubernetesClientBasedFetcherOptions) {
this.kubernetesClientProvider = kubernetesClientProvider;
this.logger = logger;
}
fetchObjectsByServiceId(
serviceId: string,
clusterDetails: ClusterDetails,
objectTypesToFetch: Set<KubernetesObjectTypes>,
): Promise<FetchResponse[]> {
return Promise.all(
Array.from(objectTypesToFetch).map(type => {
return this.fetchByObjectType(serviceId, clusterDetails, type);
}),
);
}
private fetchByObjectType(
serviceId: string,
clusterDetails: ClusterDetails,
type: KubernetesObjectTypes,
): Promise<FetchResponse> {
switch (type) {
case 'pods':
return this.fetchPodsByServiceId(serviceId, clusterDetails).then(r => ({
type: type,
resources: r,
}));
case 'configmaps':
return this.fetchConfigMapsByServiceId(
serviceId,
clusterDetails,
).then(r => ({ type: type, resources: r }));
case 'deployments':
return this.fetchDeploymentsByServiceId(
serviceId,
clusterDetails,
).then(r => ({ type: type, resources: r }));
case 'replicasets':
return this.fetchReplicaSetsByServiceId(
serviceId,
clusterDetails,
).then(r => ({ type: type, resources: r }));
case 'secrets':
return this.fetchSecretsByServiceId(
serviceId,
clusterDetails,
).then(r => ({ type: type, resources: r }));
case 'services':
return this.fetchServicesByServiceId(
serviceId,
clusterDetails,
).then(r => ({ type: type, resources: r }));
default:
// unrecognised type
throw new Error(`unrecognised type=${type}`);
}
}
private singleClusterFetch<T>(
clusterDetails: ClusterDetails,
fn: (client: Clients) => Promise<{ body: { items: Array<T> } }>,
): Promise<Array<T>> {
const core = this.kubernetesClientProvider.getCoreClientByClusterDetails(
clusterDetails,
);
const apps = this.kubernetesClientProvider.getAppsClientByClusterDetails(
clusterDetails,
);
this.logger.debug(`calling cluster=${clusterDetails.name}`);
return fn({ core, apps }).then(result => {
return result.body.items;
});
}
private fetchServicesByServiceId(
serviceId: string,
clusterDetails: ClusterDetails,
): Promise<Array<V1Service>> {
return this.singleClusterFetch<V1Service>(clusterDetails, ({ core }) =>
core.listServiceForAllNamespaces(
false,
'',
'',
`backstage.io/kubernetes-id=${serviceId}`,
),
);
}
private fetchPodsByServiceId(
serviceId: string,
clusterDetails: ClusterDetails,
): Promise<Array<V1Pod>> {
return this.singleClusterFetch<V1Pod>(clusterDetails, ({ core }) =>
core.listPodForAllNamespaces(
false,
'',
'',
`backstage.io/kubernetes-id=${serviceId}`,
),
);
}
private fetchConfigMapsByServiceId(
serviceId: string,
clusterDetails: ClusterDetails,
): Promise<Array<V1ConfigMap>> {
return this.singleClusterFetch<V1Pod>(clusterDetails, ({ core }) =>
core.listConfigMapForAllNamespaces(
false,
'',
'',
`backstage.io/kubernetes-id=${serviceId}`,
),
);
}
private fetchSecretsByServiceId(
serviceId: string,
clusterDetails: ClusterDetails,
): Promise<Array<V1Secret>> {
return this.singleClusterFetch<V1Secret>(clusterDetails, ({ core }) =>
core.listSecretForAllNamespaces(
false,
'',
'',
`backstage.io/kubernetes-id=${serviceId}`,
),
);
}
private fetchDeploymentsByServiceId(
serviceId: string,
clusterDetails: ClusterDetails,
): Promise<Array<V1Deployment>> {
return this.singleClusterFetch<V1Deployment>(clusterDetails, ({ apps }) =>
apps.listDeploymentForAllNamespaces(
false,
'',
'',
`backstage.io/kubernetes-id=${serviceId}`,
),
);
}
private fetchReplicaSetsByServiceId(
serviceId: string,
clusterDetails: ClusterDetails,
): Promise<Array<V1ReplicaSet>> {
return this.singleClusterFetch<V1ReplicaSet>(clusterDetails, ({ apps }) =>
apps.listReplicaSetForAllNamespaces(
false,
'',
'',
`backstage.io/kubernetes-id=${serviceId}`,
),
);
}
}
@@ -0,0 +1,242 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { handleGetKubernetesObjectsByServiceId } from './getKubernetesObjectsByServiceIdHandler';
import { getVoidLogger } from '@backstage/backend-common';
import { ClusterDetails } from '..';
const TEST_SERVICE_ID = 'my-service';
const fetchObjectsByServiceId = jest.fn();
const getClusterByServiceId = jest.fn();
const mockFetch = (mock: jest.Mock) => {
mock.mockImplementation((serviceId: string, clusterDetails: ClusterDetails) =>
Promise.resolve([
{
type: 'pods',
resources: [
{
metadata: {
name: `my-pods-${serviceId}-${clusterDetails.name}`,
},
},
],
},
{
type: 'configmaps',
resources: [
{
metadata: {
name: `my-configmaps-${serviceId}-${clusterDetails.name}`,
},
},
],
},
{
type: 'services',
resources: [
{
metadata: {
name: `my-services-${serviceId}-${clusterDetails.name}`,
},
},
],
},
]),
);
};
describe('handleGetKubernetesObjectsByServiceId', () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('retrieve objects for one cluster', async () => {
getClusterByServiceId.mockImplementation(() =>
Promise.resolve([
{
name: 'test-cluster',
},
]),
);
mockFetch(fetchObjectsByServiceId);
const result = await handleGetKubernetesObjectsByServiceId(
TEST_SERVICE_ID,
{
fetchObjectsByServiceId,
},
{
getClusterByServiceId,
},
getVoidLogger(),
);
expect(getClusterByServiceId.mock.calls.length).toBe(1);
expect(fetchObjectsByServiceId.mock.calls.length).toBe(1);
expect(result).toStrictEqual({
items: [
{
cluster: {
name: 'test-cluster',
},
resources: [
{
resources: [
{
metadata: {
name: 'my-pods-my-service-test-cluster',
},
},
],
type: 'pods',
},
{
resources: [
{
metadata: {
name: 'my-configmaps-my-service-test-cluster',
},
},
],
type: 'configmaps',
},
{
resources: [
{
metadata: {
name: 'my-services-my-service-test-cluster',
},
},
],
type: 'services',
},
],
},
],
});
});
it('retrieve objects for two clusters', async () => {
getClusterByServiceId.mockImplementation(() =>
Promise.resolve([
{
name: 'test-cluster',
},
{
name: 'other-cluster',
},
]),
);
mockFetch(fetchObjectsByServiceId);
const result = await handleGetKubernetesObjectsByServiceId(
TEST_SERVICE_ID,
{
fetchObjectsByServiceId,
},
{
getClusterByServiceId,
},
getVoidLogger(),
);
expect(getClusterByServiceId.mock.calls.length).toBe(1);
expect(fetchObjectsByServiceId.mock.calls.length).toBe(2);
expect(result).toStrictEqual({
items: [
{
cluster: {
name: 'test-cluster',
},
resources: [
{
resources: [
{
metadata: {
name: 'my-pods-my-service-test-cluster',
},
},
],
type: 'pods',
},
{
resources: [
{
metadata: {
name: 'my-configmaps-my-service-test-cluster',
},
},
],
type: 'configmaps',
},
{
resources: [
{
metadata: {
name: 'my-services-my-service-test-cluster',
},
},
],
type: 'services',
},
],
},
{
cluster: {
name: 'other-cluster',
},
resources: [
{
resources: [
{
metadata: {
name: 'my-pods-my-service-other-cluster',
},
},
],
type: 'pods',
},
{
resources: [
{
metadata: {
name: 'my-configmaps-my-service-other-cluster',
},
},
],
type: 'configmaps',
},
{
resources: [
{
metadata: {
name: 'my-services-my-service-other-cluster',
},
},
],
type: 'services',
},
],
},
],
});
});
});
@@ -0,0 +1,69 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { Logger } from 'winston';
import {
KubernetesClusterLocator,
KubernetesFetcher,
KubernetesObjectTypes,
ObjectsByServiceIdResponse,
} from '..';
export type GetKubernetesObjectsByServiceIdHandler = (
serviceId: string,
fetcher: KubernetesFetcher,
clusterLocator: KubernetesClusterLocator,
logger: Logger,
objectsToFetch?: Set<KubernetesObjectTypes>,
) => Promise<ObjectsByServiceIdResponse>;
const DEFAULT_OBJECTS = new Set<KubernetesObjectTypes>([
'pods',
'services',
'configmaps',
'secrets',
'deployments',
'replicasets',
]);
export const handleGetKubernetesObjectsByServiceId: GetKubernetesObjectsByServiceIdHandler = async (
serviceId,
fetcher,
clusterLocator,
logger,
objectsToFetch = DEFAULT_OBJECTS,
) => {
const clusterDetails = await clusterLocator.getClusterByServiceId(serviceId);
logger.info(
`serviceId=${serviceId} clusterDetails=${clusterDetails.map(c => c.name)}`,
);
return Promise.all(
clusterDetails.map(cd => {
return fetcher
.fetchObjectsByServiceId(serviceId, cd, objectsToFetch)
.then(result => {
return {
cluster: {
name: cd.name,
},
resources: result,
};
});
}),
).then(r => ({ items: r }));
};
@@ -0,0 +1,87 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { getVoidLogger } from '@backstage/backend-common';
import express from 'express';
import request from 'supertest';
import { makeRouter } from './router';
import {
KubernetesClusterLocator,
KubernetesFetcher,
ObjectsByServiceIdResponse,
} from '..';
describe('router', () => {
let app: express.Express;
let kubernetesFetcher: jest.Mocked<KubernetesFetcher>;
let kubernetesClusterLocator: jest.Mocked<KubernetesClusterLocator>;
let handleGetByServiceId: jest.Mock<Promise<ObjectsByServiceIdResponse>>;
beforeAll(async () => {
kubernetesFetcher = {
fetchObjectsByServiceId: jest.fn(),
};
kubernetesClusterLocator = {
getClusterByServiceId: jest.fn(),
};
handleGetByServiceId = jest.fn();
const router = makeRouter(
getVoidLogger(),
kubernetesFetcher,
kubernetesClusterLocator,
handleGetByServiceId as any,
);
app = express().use(router);
});
beforeEach(() => {
jest.resetAllMocks();
});
describe('GET /services/:serviceId', () => {
it('happy path: lists kubernetes objects', async () => {
const result = {
clusterOne: {
pods: [
{
metadata: {
name: 'pod1',
},
},
],
},
} as any;
handleGetByServiceId.mockReturnValueOnce(Promise.resolve(result));
const response = await request(app).get('/services/test-service');
expect(response.status).toEqual(200);
expect(response.body).toEqual(result);
});
it('internal error: lists kubernetes objects', async () => {
handleGetByServiceId.mockRejectedValue(Error('some internal error'));
const response = await request(app).get('/services/test-service');
expect(response.status).toEqual(500);
expect(response.body).toEqual({ error: 'some internal error' });
});
});
});
@@ -17,19 +17,65 @@
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import { Config } from '@backstage/config';
import { ClusterLocatorMethod } from '../cluster-locator/types';
import { MultiTenantConfigClusterLocator } from '../cluster-locator/MultiTenantConfigClusterLocator';
import { KubernetesClientBasedFetcher } from './KubernetesFetcher';
import { KubernetesClientProvider } from './KubernetesClientProvider';
import {
GetKubernetesObjectsByServiceIdHandler,
handleGetKubernetesObjectsByServiceId,
} from './getKubernetesObjectsByServiceIdHandler';
import { KubernetesClusterLocator, KubernetesFetcher } from '..';
export interface RouterOptions {
logger: Logger;
config: Config;
}
const makeRouter = (logger: Logger): express.Router => {
const getClusterLocator = (config: Config): KubernetesClusterLocator => {
const clusterLocatorMethod = config.getString(
'kubernetes.clusterLocatorMethod',
) as ClusterLocatorMethod;
switch (clusterLocatorMethod) {
case 'configMultiTenant':
return MultiTenantConfigClusterLocator.fromConfig(
config.getConfigArray('kubernetes.clusters'),
);
case 'http':
throw new Error('not implemented');
default:
throw new Error(
`Unsupported kubernetes.clusterLocatorMethod "${clusterLocatorMethod}"`,
);
}
};
export const makeRouter = (
logger: Logger,
fetcher: KubernetesFetcher,
clusterLocator: KubernetesClusterLocator,
handleGetByServiceId: GetKubernetesObjectsByServiceIdHandler,
): express.Router => {
const router = Router();
router.use(express.json());
// TODO error handling
router.get('/services/:serviceId', async (req, res) => {
const serviceId = req.params.serviceId;
logger.info(`HERE ${serviceId}`);
res.send({ serviceId });
try {
const response = await handleGetByServiceId(
serviceId,
fetcher,
clusterLocator,
logger,
);
res.send(response);
} catch (e) {
res.status(500).send({ error: e.message });
}
});
return router;
@@ -41,5 +87,18 @@ export async function createRouter(
const logger = options.logger;
logger.info('Initializing Kubernetes backend');
return makeRouter(logger);
const clusterLocator = getClusterLocator(options.config);
const fetcher = new KubernetesClientBasedFetcher({
kubernetesClientProvider: new KubernetesClientProvider(),
logger,
});
return makeRouter(
logger,
fetcher,
clusterLocator,
handleGetKubernetesObjectsByServiceId,
);
}
@@ -25,6 +25,7 @@ import express from 'express';
import helmet from 'helmet';
import { Logger } from 'winston';
import { createRouter } from './router';
import { ConfigReader } from '@backstage/config';
export interface ApplicationOptions {
enableCors: boolean;
@@ -35,6 +36,7 @@ export async function createStandaloneApplication(
options: ApplicationOptions,
): Promise<express.Application> {
const { enableCors, logger } = options;
const config = ConfigReader.fromConfigs([]);
const app = express();
app.use(helmet());
@@ -44,7 +46,7 @@ export async function createStandaloneApplication(
app.use(compression());
app.use(express.json());
app.use(requestLoggingHandler());
app.use('/', await createRouter({ logger }));
app.use('/', await createRouter({ logger, config }));
app.use(notFoundHandler());
app.use(errorHandler());
@@ -0,0 +1,103 @@
/*
* Copyright 2020 Spotify AB
*
* 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 {
V1ConfigMap,
V1Deployment,
V1Pod,
V1ReplicaSet,
V1Secret,
V1Service,
} from '@kubernetes/client-node';
export interface ClusterDetails {
name: string;
url: string;
// TODO this will eventually be configured by the auth translation work
serviceAccountToken: string | undefined;
}
export interface ClusterObjects {
cluster: { name: string };
resources: FetchResponse[];
}
export interface ObjectsByServiceIdResponse {
items: ClusterObjects[];
}
export type FetchResponse =
| PodFetchResponse
| ServiceFetchResponse
| ConfigMapFetchResponse
| SecretFetchResponse
| DeploymentFetchResponse
| ReplicaSetsFetchResponse;
// TODO fairly sure there's a easier way to do this
export type KubernetesObjectTypes =
| 'pods'
| 'services'
| 'configmaps'
| 'secrets'
| 'deployments'
| 'replicasets';
export interface PodFetchResponse {
type: 'pods';
resources: Array<V1Pod>;
}
export interface ServiceFetchResponse {
type: 'services';
resources: Array<V1Service>;
}
export interface ConfigMapFetchResponse {
type: 'configmaps';
resources: Array<V1ConfigMap>;
}
export interface SecretFetchResponse {
type: 'secrets';
resources: Array<V1Secret>;
}
export interface DeploymentFetchResponse {
type: 'deployments';
resources: Array<V1Deployment>;
}
export interface ReplicaSetsFetchResponse {
type: 'replicasets';
resources: Array<V1ReplicaSet>;
}
// Fetches information from a kubernetes cluster using the cluster details object
// to target a specific cluster
export interface KubernetesFetcher {
fetchObjectsByServiceId(
serviceId: string,
clusterDetails: ClusterDetails,
objectTypesToFetch: Set<KubernetesObjectTypes>,
): Promise<FetchResponse[]>;
}
// Used to locate which cluster(s) a service is running on
export interface KubernetesClusterLocator {
getClusterByServiceId(serviceId: string): Promise<ClusterDetails[]>;
}