(feat): New kubernetes backend endpoints (#12393)

* feat: add new kubernetes backend endpoints

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* add more tests

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* add eslint exceptions

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* register new endpoints

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* add changeset

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* add breaking change ot changeset

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* fix tsc in test

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* changeset typo

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* api report update

Signed-off-by: Matthew Clarke <mclarke@spotify.com>
This commit is contained in:
Matthew Clarke
2022-07-05 13:13:49 -04:00
committed by GitHub
parent decd72c7fc
commit 1454bf98e7
10 changed files with 701 additions and 4 deletions
+18
View File
@@ -0,0 +1,18 @@
---
'@backstage/plugin-kubernetes-backend': minor
---
Add new endpoints to Kubernetes backend plugin
BREAKING: Kubernetes backend plugin now depends on CatalogApi
```typescript
// Create new CatalogClient
const catalogApi = new CatalogClient({ discoveryApi: env.discovery });
const { router } = await KubernetesBuilder.createBuilder({
logger: env.logger,
config: env.config,
// Inject it into createBuilder params
catalogApi,
}).build();
```
@@ -17,13 +17,16 @@
import { KubernetesBuilder } from '@backstage/plugin-kubernetes-backend';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
import { CatalogClient } from '@backstage/catalog-client';
export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
const catalogApi = new CatalogClient({ discoveryApi: env.discovery });
const { router } = await KubernetesBuilder.createBuilder({
logger: env.logger,
config: env.config,
catalogApi,
}).build();
return router;
}
+6
View File
@@ -3,6 +3,7 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { CatalogApi } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import { Duration } from 'luxon';
import { Entity } from '@backstage/catalog-model';
@@ -108,6 +109,7 @@ export class KubernetesBuilder {
protected buildRouter(
objectsProvider: KubernetesObjectsProvider,
clusterSupplier: KubernetesClustersSupplier,
catalogApi: CatalogApi,
): express.Router;
// (undocumented)
protected buildServiceLocator(
@@ -155,6 +157,8 @@ export interface KubernetesClustersSupplier {
// @alpha (undocumented)
export interface KubernetesEnvironment {
// (undocumented)
catalogApi: CatalogApi;
// (undocumented)
config: Config;
// (undocumented)
@@ -268,6 +272,8 @@ export interface ObjectToFetch {
// @alpha (undocumented)
export interface RouterOptions {
// (undocumented)
catalogApi: CatalogApi;
// (undocumented)
clusterSupplier?: KubernetesClustersSupplier;
// (undocumented)
+3 -1
View File
@@ -37,10 +37,12 @@
"dependencies": {
"@azure/identity": "^2.0.4",
"@backstage/backend-common": "^0.14.1-next.2",
"@backstage/catalog-client": "^1.0.4-next.1",
"@backstage/catalog-model": "^1.1.0-next.2",
"@backstage/config": "^1.0.1",
"@backstage/errors": "^1.1.0-next.0",
"@backstage/plugin-kubernetes-common": "^0.4.0-next.1",
"@backstage/plugin-auth-node": "^0.2.3-next.1",
"@backstage/plugin-kubernetes-common": "^0.4.0-next.0",
"@google-cloud/container": "^4.0.0",
"@kubernetes/client-node": "^0.16.0",
"@types/express": "^4.17.6",
@@ -0,0 +1,545 @@
/*
* 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 { errorHandler } from '@backstage/backend-common';
import express from 'express';
import request from 'supertest';
import Router from 'express-promise-router';
import { addResourceRoutesToRouter } from './resourcesRoutes';
import { Entity } from '@backstage/catalog-model';
describe('resourcesRoutes', () => {
let app: express.Express;
beforeAll(() => {
app = express();
app.use(express.json());
const router = Router();
addResourceRoutesToRouter(
router,
{
getEntityByRef: jest.fn().mockImplementation(entityRef => {
if (entityRef.name === 'noentity') {
return Promise.resolve(undefined);
}
return Promise.resolve({
kind: entityRef.kind,
metadata: {
name: entityRef.name,
namespace: entityRef.namespace,
},
} as Entity);
}),
} as any,
{
getKubernetesObjectsByEntity: jest.fn().mockImplementation(args => {
if (args.entity.metadata.name === 'inject500') {
return Promise.reject(new Error('some internal error'));
}
return Promise.resolve({
items: [
{
clusterOne: {
pods: [
{
metadata: {
name: 'pod1',
},
},
],
},
},
],
});
}),
getCustomResourcesByEntity: jest.fn().mockImplementation(args => {
if (args.entity.metadata.name === 'inject500') {
return Promise.reject(new Error('some internal error'));
}
return Promise.resolve({
items: [
{
clusterOne: {
pods: [
{
metadata: {
name: 'pod1',
},
},
],
},
},
],
});
}),
} as any,
);
app.use('/', router);
app.use(errorHandler());
});
describe('POST /resources/workloads/query', () => {
// eslint-disable-next-line jest/expect-expect
it('200 happy path', async () => {
await request(app)
.post('/resources/workloads/query')
.send({
entityRef: 'kind:namespacec/someComponent',
auth: {
google: 'something',
},
})
.set('Content-Type', 'application/json')
.set('Authorization', 'Bearer Zm9vYmFy')
.expect(200, {
items: [
{
clusterOne: {
pods: [
{
metadata: {
name: 'pod1',
},
},
],
},
},
],
});
});
// eslint-disable-next-line jest/expect-expect
it('400 when missing entity ref', async () => {
await request(app)
.post('/resources/workloads/query')
.send({
auth: {
google: 'something',
},
})
.set('Content-Type', 'application/json')
.set('Authorization', 'Bearer Zm9vYmFy')
.expect(400, {
error: { name: 'InputError', message: 'entity is a required field' },
request: {
method: 'POST',
url: '/resources/workloads/query',
},
response: { statusCode: 400 },
});
});
// eslint-disable-next-line jest/expect-expect
it('400 when bad entity ref', async () => {
await request(app)
.post('/resources/workloads/query')
.send({
entityRef: 'ffff',
auth: {
google: 'something',
},
})
.set('Content-Type', 'application/json')
.set('Authorization', 'Bearer Zm9vYmFy')
.expect(400, {
error: {
name: 'InputError',
message:
'Invalid entity ref, Error: Entity reference "ffff" had missing or empty kind (e.g. did not start with "component:" or similar)',
},
request: {
method: 'POST',
url: '/resources/workloads/query',
},
response: { statusCode: 400 },
});
});
// eslint-disable-next-line jest/expect-expect
it('400 when no entity in catalog', async () => {
await request(app)
.post('/resources/workloads/query')
.send({
entityRef: 'noentity:noentity',
auth: {
google: 'something',
},
})
.set('Content-Type', 'application/json')
.set('Authorization', 'Bearer Zm9vYmFy')
.expect(400, {
error: {
name: 'InputError',
message: 'Entity ref missing, noentity:default/noentity',
},
request: {
method: 'POST',
url: '/resources/workloads/query',
},
response: { statusCode: 400 },
});
});
// eslint-disable-next-line jest/expect-expect
it('401 when no Auth header', async () => {
await request(app)
.post('/resources/workloads/query')
.send({
entityRef: 'component:someComponent',
auth: {
google: 'something',
},
})
.set('Content-Type', 'application/json')
.expect(401, {
error: { name: 'AuthenticationError', message: 'No Backstage token' },
request: {
method: 'POST',
url: '/resources/workloads/query',
},
response: { statusCode: 401 },
});
});
// eslint-disable-next-line jest/expect-expect
it('401 when invalid Auth header', async () => {
await request(app)
.post('/resources/workloads/query')
.send({
entityRef: 'component:someComponent',
auth: {
google: 'something',
},
})
.set('Content-Type', 'application/json')
.set('Authorization', 'ffffff')
.expect(401, {
error: { name: 'AuthenticationError', message: 'No Backstage token' },
request: {
method: 'POST',
url: '/resources/workloads/query',
},
response: { statusCode: 401 },
});
});
// eslint-disable-next-line jest/expect-expect
it('500 handle gracefully', async () => {
await request(app)
.post('/resources/workloads/query')
.send({
entityRef: 'inject500:inject500/inject500',
auth: {
google: 'something',
},
})
.set('Content-Type', 'application/json')
.set('Authorization', 'Bearer Zm9vYmFy')
.expect(500, {
error: {
name: 'Error',
message: 'some internal error',
level: 'error',
service: 'backstage',
},
request: { method: 'POST', url: '/resources/workloads/query' },
response: { statusCode: 500 },
});
});
});
describe('POST /resources/custom/query', () => {
// eslint-disable-next-line jest/expect-expect
it('200 happy path', async () => {
await request(app)
.post('/resources/custom/query')
.send({
entityRef: 'component:someComponent',
auth: {
google: 'something',
},
customResources: [
{
group: 'someGroup',
apiVersion: 'someApiVersion',
plural: 'somePlural',
},
],
})
.set('Content-Type', 'application/json')
.set('Authorization', 'Bearer Zm9vYmFy')
.expect(200, {
items: [
{
clusterOne: {
pods: [
{
metadata: {
name: 'pod1',
},
},
],
},
},
],
});
});
// eslint-disable-next-line jest/expect-expect
it('400 when missing custom resources', async () => {
await request(app)
.post('/resources/custom/query')
.send({
entityRef: 'component:someComponent',
auth: {
google: 'something',
},
})
.set('Content-Type', 'application/json')
.set('Authorization', 'Bearer Zm9vYmFy')
.expect(400, {
error: {
name: 'InputError',
message: 'customResources is a required field',
},
request: {
method: 'POST',
url: '/resources/custom/query',
},
response: { statusCode: 400 },
});
});
// eslint-disable-next-line jest/expect-expect
it('400 when custom resources not array', async () => {
await request(app)
.post('/resources/custom/query')
.send({
entityRef: 'component:someComponent',
auth: {
google: 'something',
},
customResources: 'somestring',
})
.set('Content-Type', 'application/json')
.set('Authorization', 'Bearer Zm9vYmFy')
.expect(400, {
error: {
name: 'InputError',
message: 'customResources must be an array',
},
request: {
method: 'POST',
url: '/resources/custom/query',
},
response: { statusCode: 400 },
});
});
// eslint-disable-next-line jest/expect-expect
it('400 when custom resources empty', async () => {
await request(app)
.post('/resources/custom/query')
.send({
entityRef: 'component:someComponent',
auth: {
google: 'something',
},
customResources: [],
})
.set('Content-Type', 'application/json')
.set('Authorization', 'Bearer Zm9vYmFy')
.expect(400, {
error: {
name: 'InputError',
message: 'at least 1 customResource is required',
},
request: {
method: 'POST',
url: '/resources/custom/query',
},
response: { statusCode: 400 },
});
});
// eslint-disable-next-line jest/expect-expect
it('400 when missing entity ref', async () => {
await request(app)
.post('/resources/custom/query')
.send({
auth: {
google: 'something',
},
customResources: [
{
group: 'someGroup',
apiVersion: 'someApiVersion',
plural: 'somePlural',
},
],
})
.set('Content-Type', 'application/json')
.set('Authorization', 'Bearer Zm9vYmFy')
.expect(400, {
error: { name: 'InputError', message: 'entity is a required field' },
request: {
method: 'POST',
url: '/resources/custom/query',
},
response: { statusCode: 400 },
});
});
// eslint-disable-next-line jest/expect-expect
it('400 when bad entity ref', async () => {
await request(app)
.post('/resources/custom/query')
.send({
entityRef: 'ffff',
auth: {
google: 'something',
},
customResources: [
{
group: 'someGroup',
apiVersion: 'someApiVersion',
plural: 'somePlural',
},
],
})
.set('Content-Type', 'application/json')
.set('Authorization', 'Bearer Zm9vYmFy')
.expect(400, {
error: {
name: 'InputError',
message:
'Invalid entity ref, Error: Entity reference "ffff" had missing or empty kind (e.g. did not start with "component:" or similar)',
},
request: {
method: 'POST',
url: '/resources/custom/query',
},
response: { statusCode: 400 },
});
});
// eslint-disable-next-line jest/expect-expect
it('400 when no entity in catalog', async () => {
await request(app)
.post('/resources/custom/query')
.send({
entityRef: 'noentity:noentity',
auth: {
google: 'something',
},
customResources: [
{
group: 'someGroup',
apiVersion: 'someApiVersion',
plural: 'somePlural',
},
],
})
.set('Content-Type', 'application/json')
.set('Authorization', 'Bearer Zm9vYmFy')
.expect(400, {
error: {
name: 'InputError',
message: 'Entity ref missing, noentity:default/noentity',
},
request: {
method: 'POST',
url: '/resources/custom/query',
},
response: { statusCode: 400 },
});
});
// eslint-disable-next-line jest/expect-expect
it('401 when no Auth header', async () => {
await request(app)
.post('/resources/custom/query')
.send({
entityRef: 'component:someComponent',
auth: {
google: 'something',
},
customResources: [
{
group: 'someGroup',
apiVersion: 'someApiVersion',
plural: 'somePlural',
},
],
})
.set('Content-Type', 'application/json')
.expect(401, {
error: { name: 'AuthenticationError', message: 'No Backstage token' },
request: {
method: 'POST',
url: '/resources/custom/query',
},
response: { statusCode: 401 },
});
});
// eslint-disable-next-line jest/expect-expect
it('401 when invalid Auth header', async () => {
await request(app)
.post('/resources/custom/query')
.send({
entityRef: 'component:someComponent',
auth: {
google: 'something',
},
customResources: [
{
group: 'someGroup',
apiVersion: 'someApiVersion',
plural: 'somePlural',
},
],
})
.set('Content-Type', 'application/json')
.set('Authorization', 'ffffff')
.expect(401, {
error: { name: 'AuthenticationError', message: 'No Backstage token' },
request: {
method: 'POST',
url: '/resources/custom/query',
},
response: { statusCode: 401 },
});
});
// eslint-disable-next-line jest/expect-expect
it('500 handle gracefully', async () => {
await request(app)
.post('/resources/custom/query')
.send({
entityRef: 'inject500:inject500/inject500',
auth: {
google: 'something',
},
customResources: [
{
group: 'someGroup',
apiVersion: 'someApiVersion',
plural: 'somePlural',
},
],
})
.set('Content-Type', 'application/json')
.set('Authorization', 'Bearer Zm9vYmFy')
.expect(500, {
error: {
name: 'Error',
message: 'some internal error',
level: 'error',
service: 'backstage',
},
request: { method: 'POST', url: '/resources/custom/query' },
response: { statusCode: 500 },
});
});
});
});
@@ -0,0 +1,94 @@
/*
* 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 {
CompoundEntityRef,
parseEntityRef,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { CatalogApi } from '@backstage/catalog-client';
import { InputError, AuthenticationError } from '@backstage/errors';
import express, { Request } from 'express';
import { KubernetesObjectsProvider } from '../types/types';
import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node';
export const addResourceRoutesToRouter = (
router: express.Router,
catalogApi: CatalogApi,
objectsProvider: KubernetesObjectsProvider,
) => {
const getEntityByReq = async (req: Request<any>) => {
const rawEntityRef = req.body.entityRef;
if (rawEntityRef && typeof rawEntityRef !== 'string') {
throw new InputError(`entity query must be a string`);
} else if (!rawEntityRef) {
throw new InputError('entity is a required field');
}
let entityRef: CompoundEntityRef | undefined = undefined;
try {
entityRef = parseEntityRef(rawEntityRef);
} catch (error) {
throw new InputError(`Invalid entity ref, ${error}`);
}
const token = getBearerTokenFromAuthorizationHeader(
req.headers.authorization,
);
if (!token) {
throw new AuthenticationError('No Backstage token');
}
const entity = await catalogApi.getEntityByRef(entityRef, {
token: token,
});
if (!entity) {
throw new InputError(
`Entity ref missing, ${stringifyEntityRef(entityRef)}`,
);
}
return entity;
};
router.post('/resources/workloads/query', async (req, res) => {
const entity = await getEntityByReq(req);
const response = await objectsProvider.getKubernetesObjectsByEntity({
entity,
auth: req.body.auth,
});
res.json(response);
});
router.post('/resources/custom/query', async (req, res) => {
const entity = await getEntityByReq(req);
if (!req.body.customResources) {
throw new InputError('customResources is a required field');
} else if (!Array.isArray(req.body.customResources)) {
throw new InputError('customResources must be an array');
} else if (req.body.customResources.length === 0) {
throw new InputError('at least 1 customResource is required');
}
const response = await objectsProvider.getCustomResourcesByEntity({
entity,
customResources: req.body.customResources,
auth: req.body.auth,
});
res.json(response);
});
};
@@ -31,11 +31,13 @@ import {
import { KubernetesBuilder } from './KubernetesBuilder';
import { KubernetesFanOutHandler } from './KubernetesFanOutHandler';
import { PodStatus } from '@kubernetes/client-node';
import { CatalogApi } from '@backstage/catalog-client';
describe('KubernetesBuilder', () => {
let app: express.Express;
let kubernetesFanOutHandler: jest.Mocked<KubernetesFanOutHandler>;
let config: Config;
let catalogApi: CatalogApi;
beforeAll(async () => {
const logger = getVoidLogger();
@@ -69,7 +71,13 @@ describe('KubernetesBuilder', () => {
getKubernetesObjectsByEntity: jest.fn(),
} as any;
const { router } = await KubernetesBuilder.createBuilder({ config, logger })
catalogApi = {} as CatalogApi;
const { router } = await KubernetesBuilder.createBuilder({
config,
logger,
catalogApi,
})
.setObjectsProvider(kubernetesFanOutHandler)
.setClusterSupplier(clusterSupplier)
.build();
@@ -240,6 +248,7 @@ describe('KubernetesBuilder', () => {
const { router } = await KubernetesBuilder.createBuilder({
logger,
config,
catalogApi,
})
.setClusterSupplier(clusterSupplier)
.setServiceLocator(serviceLocator)
@@ -37,6 +37,8 @@ import {
KubernetesFanOutHandler,
} from './KubernetesFanOutHandler';
import { KubernetesClientBasedFetcher } from './KubernetesFetcher';
import { addResourceRoutesToRouter } from '../routes/resourcesRoutes';
import { CatalogApi } from '@backstage/catalog-client';
/**
*
@@ -45,6 +47,7 @@ import { KubernetesClientBasedFetcher } from './KubernetesFetcher';
export interface KubernetesEnvironment {
logger: Logger;
config: Config;
catalogApi: CatalogApi;
}
/**
@@ -119,7 +122,11 @@ export class KubernetesBuilder {
objectTypesToFetch: this.getObjectTypesToFetch(),
});
const router = this.buildRouter(objectsProvider, clusterSupplier);
const router = this.buildRouter(
objectsProvider,
clusterSupplier,
this.env.catalogApi,
);
return {
clusterSupplier,
@@ -226,11 +233,13 @@ export class KubernetesBuilder {
protected buildRouter(
objectsProvider: KubernetesObjectsProvider,
clusterSupplier: KubernetesClustersSupplier,
catalogApi: CatalogApi,
): express.Router {
const logger = this.env.logger;
const router = Router();
router.use(express.json());
// @deprecated
router.post('/services/:serviceId', async (req, res) => {
const serviceId = req.params.serviceId;
const requestBody: ObjectsByEntityRequest = req.body;
@@ -259,6 +268,9 @@ export class KubernetesBuilder {
})),
});
});
addResourceRoutesToRouter(router, catalogApi, objectsProvider);
return router;
}
@@ -19,6 +19,7 @@ import { Logger } from 'winston';
import { KubernetesClustersSupplier } from '../types/types';
import express from 'express';
import { KubernetesBuilder } from './KubernetesBuilder';
import { CatalogApi } from '@backstage/catalog-client';
/**
*
@@ -27,6 +28,7 @@ import { KubernetesBuilder } from './KubernetesBuilder';
export interface RouterOptions {
logger: Logger;
config: Config;
catalogApi: CatalogApi;
clusterSupplier?: KubernetesClustersSupplier;
}
@@ -18,6 +18,7 @@ import {
errorHandler,
notFoundHandler,
requestLoggingHandler,
SingleHostDiscovery,
} from '@backstage/backend-common';
import compression from 'compression';
import cors from 'cors';
@@ -26,6 +27,7 @@ import helmet from 'helmet';
import { Logger } from 'winston';
import { createRouter } from './router';
import { ConfigReader } from '@backstage/config';
import { CatalogClient } from '@backstage/catalog-client';
export interface ApplicationOptions {
enableCors: boolean;
@@ -39,6 +41,10 @@ export async function createStandaloneApplication(
const config = new ConfigReader({});
const app = express();
const catalogApi = new CatalogClient({
discoveryApi: SingleHostDiscovery.fromConfig(config),
});
app.use(helmet());
if (enableCors) {
app.use(cors());
@@ -46,7 +52,7 @@ export async function createStandaloneApplication(
app.use(compression());
app.use(express.json());
app.use(requestLoggingHandler());
app.use('/', await createRouter({ logger, config }));
app.use('/', await createRouter({ logger, config, catalogApi }));
app.use(notFoundHandler());
app.use(errorHandler());