Merge branch 'master' into fact-retriever-async

This commit is contained in:
Sam Blausten
2022-07-06 13:55:57 +02:00
committed by GitHub
77 changed files with 1163 additions and 338 deletions
@@ -140,6 +140,7 @@ export async function createRouter(
}
if (provider.refresh) {
r.get('/refresh', provider.refresh.bind(provider));
r.post('/refresh', provider.refresh.bind(provider));
}
router.use(`/${providerId}`, r);
+2 -19
View File
@@ -10,27 +10,10 @@ import { PluginDatabaseManager } from '@backstage/backend-common';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { UrlReader } from '@backstage/backend-common';
// Warning: (ae-missing-release-tag) "CodeCoverageApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export interface CodeCoverageApi {
// (undocumented)
name: string;
}
// Warning: (ae-missing-release-tag) "createRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
// @public
export function createRouter(options: RouterOptions): Promise<express.Router>;
// Warning: (ae-missing-release-tag) "makeRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const makeRouter: (options: RouterOptions) => Promise<express.Router>;
// Warning: (ae-missing-release-tag) "RouterOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
// @public
export interface RouterOptions {
// (undocumented)
config: Config;
+2 -1
View File
@@ -20,4 +20,5 @@
* @packageDocumentation
*/
export * from './service/router';
export { createRouter } from './service/router';
export type { RouterOptions } from './service/router';
@@ -35,6 +35,11 @@ import { Jacoco } from './converter/jacoco';
import { Converter } from './converter';
import { getEntitySourceLocation } from '@backstage/catalog-model';
/**
* Options for {@link createRouter}.
*
* @public
*/
export interface RouterOptions {
config: Config;
discovery: PluginEndpointDiscovery;
@@ -211,6 +216,11 @@ export const makeRouter = async (
return router;
};
/**
* Creates a code-coverage plugin backend router.
*
* @public
*/
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
+5 -15
View File
@@ -9,8 +9,6 @@ import { BackstagePlugin } from '@backstage/core-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { RouteRef } from '@backstage/core-plugin-api';
// Warning: (ae-missing-release-tag) "codeCoveragePlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const codeCoveragePlugin: BackstagePlugin<
{
@@ -19,20 +17,12 @@ export const codeCoveragePlugin: BackstagePlugin<
{}
>;
// Warning: (ae-missing-release-tag) "EntityCodeCoverageContent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
// @public
export const EntityCodeCoverageContent: () => JSX.Element;
// Warning: (ae-missing-release-tag) "isCodeCoverageAvailable" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const isCodeCoverageAvailable: (entity: Entity) => boolean;
export { isCodeCoverageAvailable };
export { isCodeCoverageAvailable as isPluginApplicableToEntity };
// @public
export function isCodeCoverageAvailable(entity: Entity): boolean;
// Warning: (ae-missing-release-tag) "Router" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const Router: () => JSX.Element;
// @public @deprecated (undocumented)
export const isPluginApplicableToEntity: typeof isCodeCoverageAvailable;
```
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'highlight.js/styles/atom-one-dark.css';
import highlight from 'highlight.js';
@@ -13,16 +13,26 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Entity } from '@backstage/catalog-model';
import { useEntity } from '@backstage/plugin-catalog-react';
import { CodeCoveragePage } from './CodeCoveragePage';
import { MissingAnnotationEmptyState } from '@backstage/core-components';
export const isCodeCoverageAvailable = (entity: Entity) =>
Boolean(entity.metadata.annotations?.['backstage.io/code-coverage']);
/**
* Returns true if the given entity has code coverage enabled.
*
* @public
*/
export function isCodeCoverageAvailable(entity: Entity) {
return Boolean(entity.metadata.annotations?.['backstage.io/code-coverage']);
}
export const Router = () => {
/**
* @public
*/
export const Router = (): JSX.Element => {
const { entity } = useEntity();
if (!isCodeCoverageAvailable(entity)) {
@@ -30,5 +40,6 @@ export const Router = () => {
<MissingAnnotationEmptyState annotation="backstage.io/code-coverage" />
);
}
return <CodeCoveragePage />;
};
+9 -5
View File
@@ -20,9 +20,13 @@
* @packageDocumentation
*/
import { isCodeCoverageAvailable } from './components/Router';
export { codeCoveragePlugin, EntityCodeCoverageContent } from './plugin';
export {
Router,
isCodeCoverageAvailable,
isCodeCoverageAvailable as isPluginApplicableToEntity,
} from './components/Router';
export { isCodeCoverageAvailable };
/**
* @public
* @deprecated Use `isPluginApplicableToEntity` instead.
*/
export const isPluginApplicableToEntity = isCodeCoverageAvailable;
+8
View File
@@ -23,6 +23,9 @@ import {
discoveryApiRef,
} from '@backstage/core-plugin-api';
/**
* @public
*/
export const codeCoveragePlugin = createPlugin({
id: 'code-coverage',
routes: {
@@ -37,6 +40,11 @@ export const codeCoveragePlugin = createPlugin({
],
});
/**
* An entity code coverage page.
*
* @public
*/
export const EntityCodeCoverageContent = codeCoveragePlugin.provide(
createRoutableExtension({
name: 'EntityCodeCoverageContent',
+4 -1
View File
@@ -43,9 +43,12 @@ proxy:
headers:
Authorization: token ${FOSSA_API_TOKEN}
# if you have a fossa organization, configure your id here
fossa:
# if you have a fossa organization, configure your id here
organizationId: <your-fossa-organization-id>
# if you have a self-managed fossa instance,
# configure the baseUrl to use for links to the fossa page here
externalLinkBaseUrl: <your fossa url>
```
4. Get an api-token and provide `FOSSA_AUTH_HEADER` as env variable (https://app.fossa.com/account/settings/integrations/api_tokens)
+7 -1
View File
@@ -20,6 +20,12 @@ export interface Config {
* The organization id in fossa.
* @visibility frontend
*/
organizationId: string;
organizationId?: string;
/**
* The base url to use for external links (from Backstage to Fossa).
* @visibility frontend
*/
externalLinkBaseUrl?: string;
};
}
+52
View File
@@ -175,6 +175,58 @@ describe('FossaClient', () => {
expect(summary).toBeUndefined();
});
it('should allow custom external link base url', async () => {
client = new FossaClient({
discoveryApi,
identityApi,
organizationId: '8736',
externalLinkBaseUrl: 'https://custom.fossa.com', // overrides the default app.fossa.com
});
server.use(
rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => {
const expectedQuery =
'count=1000&page=0&sort=title%2B&organizationId=8736&title=our-service';
if (req.url.searchParams.toString() !== expectedQuery) {
return res(
ctx.status(500),
ctx.body(
`${req.url.searchParams.toString()} !== ${expectedQuery}`,
),
);
}
return res(
ctx.json([
{
locator: 'custom+8736/our-service',
title: 'our-service',
default_branch: 'develop',
revisions: [
{
updatedAt: '2020-01-01T00:00:00Z',
dependency_count: 160,
unresolved_licensing_issue_count: 5,
unresolved_issue_count: 100,
},
],
},
]),
);
}),
);
const summary = await client.getFindingSummary('our-service');
expect(summary).toEqual({
timestamp: '2020-01-01T00:00:00Z',
issueCount: 5,
dependencyCount: 160,
projectDefaultBranch: 'develop',
projectUrl:
'https://custom.fossa.com/projects/custom%2B8736%2Four-service',
} as FindingSummary);
});
it('should handle 404 status', async () => {
server.use(
rest.get(`${mockBaseUrl}/fossa/projects`, (_req, res, ctx) => {
+7 -3
View File
@@ -36,20 +36,24 @@ export class FossaClient implements FossaApi {
discoveryApi: DiscoveryApi;
identityApi: IdentityApi;
organizationId?: string;
externalLinkBaseUrl: string;
private readonly limit = pLimit(5);
constructor({
discoveryApi,
identityApi,
organizationId,
externalLinkBaseUrl = 'https://app.fossa.com',
}: {
discoveryApi: DiscoveryApi;
identityApi: IdentityApi;
organizationId?: string;
externalLinkBaseUrl?: string;
}) {
this.discoveryApi = discoveryApi;
this.identityApi = identityApi;
this.organizationId = organizationId;
this.externalLinkBaseUrl = externalLinkBaseUrl;
}
private async callApi<T>(
@@ -104,9 +108,9 @@ export class FossaClient implements FossaApi {
revision.unresolved_issue_count,
dependencyCount: revision.dependency_count,
projectDefaultBranch: project.default_branch,
projectUrl: `https://app.fossa.com/projects/${encodeURIComponent(
project.locator,
)}`,
projectUrl: `${
this.externalLinkBaseUrl
}/projects/${encodeURIComponent(project.locator)}`,
},
};
}
+3
View File
@@ -39,6 +39,9 @@ export const fossaPlugin = createPlugin({
discoveryApi,
identityApi,
organizationId: configApi.getOptionalString('fossa.organizationId'),
externalLinkBaseUrl: configApi.getOptionalString(
'fossa.externalLinkBaseUrl',
),
}),
}),
],
+1 -1
View File
@@ -40,7 +40,7 @@
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
"@react-hookz/web": "^14.0.0",
"@react-hookz/web": "^15.0.0",
"react-router-dom": "6.0.0-beta.0"
},
"peerDependencies": {
+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)
+4 -2
View File
@@ -37,12 +37,14 @@
"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",
"@kubernetes/client-node": "^0.17.0",
"@types/express": "^4.17.6",
"@types/luxon": "^2.0.4",
"aws-sdk": "^2.840.0",
@@ -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());
+2 -1
View File
@@ -38,8 +38,9 @@
"url": "https://github.com/backstage/backstage/issues"
},
"dependencies": {
"@backstage/catalog-model": "^1.1.0-next.1",
"@backstage/catalog-model": "^1.1.0-next.2",
"@kubernetes/client-node": "^0.16.0"
"@kubernetes/client-node": "^0.17.0"
},
"devDependencies": {
"@backstage/cli": "^0.18.0-next.2"
+1 -1
View File
@@ -41,7 +41,7 @@
"@backstage/plugin-catalog-react": "^1.1.2-next.2",
"@backstage/plugin-kubernetes-common": "^0.4.0-next.1",
"@backstage/theme": "^0.2.16-next.1",
"@kubernetes/client-node": "^0.16.0",
"@kubernetes/client-node": "^0.17.0",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
+11
View File
@@ -14,3 +14,14 @@
* limitations under the License.
*/
import '@testing-library/jest-dom';
// eslint-disable-next-line no-restricted-imports
import { TextDecoder, TextEncoder } from 'util';
// These are missing from jest-node, so not available on global.
Object.defineProperty(global, 'TextEncoder', {
value: TextEncoder,
});
Object.defineProperty(global, 'TextDecoder', {
value: TextDecoder,
});
+1
View File
@@ -388,6 +388,7 @@ export function createPublishGitlabAction(options: {
gitCommitMessage?: string | undefined;
gitAuthorName?: string | undefined;
gitAuthorEmail?: string | undefined;
setUserAsOwner?: boolean | undefined;
}>;
// @public
@@ -32,6 +32,9 @@ const mockGitlabClient = {
Users: {
current: jest.fn(),
},
ProjectMembers: {
add: jest.fn(),
},
};
jest.mock('@gitbeaker/node', () => ({
Gitlab: class {
@@ -113,6 +116,7 @@ describe('publish:gitlab', () => {
});
it('should work when there is a token provided through ctx.input', async () => {
mockGitlabClient.Users.current.mockResolvedValue({ id: 12345 });
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
mockGitlabClient.Projects.create.mockResolvedValue({
http_url_to_repo: 'http://mockurl.git',
@@ -135,6 +139,7 @@ describe('publish:gitlab', () => {
});
it('should call the correct Gitlab APIs when the owner is an organization', async () => {
mockGitlabClient.Users.current.mockResolvedValue({ id: 12345 });
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
mockGitlabClient.Projects.create.mockResolvedValue({
http_url_to_repo: 'http://mockurl.git',
@@ -168,6 +173,7 @@ describe('publish:gitlab', () => {
});
it('should call initRepoAndPush with the correct values', async () => {
mockGitlabClient.Users.current.mockResolvedValue({ id: 12345 });
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
mockGitlabClient.Projects.create.mockResolvedValue({
http_url_to_repo: 'http://mockurl.git',
@@ -187,6 +193,7 @@ describe('publish:gitlab', () => {
});
it('should call initRepoAndPush with the correct default branch', async () => {
mockGitlabClient.Users.current.mockResolvedValue({ id: 12345 });
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
mockGitlabClient.Projects.create.mockResolvedValue({
http_url_to_repo: 'http://mockurl.git',
@@ -241,6 +248,7 @@ describe('publish:gitlab', () => {
config: customAuthorConfig,
});
mockGitlabClient.Users.current.mockResolvedValue({ id: 12345 });
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
mockGitlabClient.Projects.create.mockResolvedValue({
http_url_to_repo: 'http://mockurl.git',
@@ -286,6 +294,7 @@ describe('publish:gitlab', () => {
config: customAuthorConfig,
});
mockGitlabClient.Users.current.mockResolvedValue({ id: 12345 });
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
mockGitlabClient.Projects.create.mockResolvedValue({
http_url_to_repo: 'http://mockurl.git',
@@ -305,6 +314,7 @@ describe('publish:gitlab', () => {
});
it('should call output with the remoteUrl and repoContentsUrl', async () => {
mockGitlabClient.Users.current.mockResolvedValue({ id: 12345 });
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
mockGitlabClient.Projects.create.mockResolvedValue({
http_url_to_repo: 'http://mockurl.git',
@@ -321,4 +331,54 @@ describe('publish:gitlab', () => {
'http://mockurl/-/blob/master',
);
});
it('should call the correct Gitlab APIs when setUserAsOwner option is true and integration config has a token', async () => {
const customAuthorConfig = new ConfigReader({
integrations: {
gitlab: [
{
host: 'gitlab.com',
token: 'tokenlols',
apiBaseUrl: 'https://api.gitlab.com',
},
],
},
});
const customAuthorIntegrations =
ScmIntegrations.fromConfig(customAuthorConfig);
const customAuthorAction = createPublishGitlabAction({
integrations: customAuthorIntegrations,
config: customAuthorConfig,
});
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
mockGitlabClient.Users.current.mockResolvedValue({ id: 12345 });
mockGitlabClient.Projects.create.mockResolvedValue({
id: 123456,
http_url_to_repo: 'http://mockurl.git',
});
await customAuthorAction.handler({
...mockContext,
input: {
repoUrl: 'gitlab.com?repo=repo&owner=owner',
token: 'token',
setUserAsOwner: true,
},
});
expect(mockGitlabClient.Namespaces.show).toHaveBeenCalledWith('owner');
expect(mockGitlabClient.Users.current).toHaveBeenCalled();
expect(mockGitlabClient.ProjectMembers.add).toHaveBeenCalledWith(
123456,
12345,
50,
);
expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({
namespace_id: 1234,
name: 'repo',
visibility: 'private',
});
});
});
@@ -43,6 +43,7 @@ export function createPublishGitlabAction(options: {
gitCommitMessage?: string;
gitAuthorName?: string;
gitAuthorEmail?: string;
setUserAsOwner?: boolean;
}>({
id: 'publish:gitlab',
description:
@@ -92,6 +93,12 @@ export function createPublishGitlabAction(options: {
type: 'string',
description: 'The token to use for authorization to GitLab',
},
setUserAsOwner: {
title: 'Set User As Owner',
type: 'boolean',
description:
'Set the token user as owner of the newly created repository. Requires a token authorized to do the edit in the integration configuration for the matching host',
},
},
},
output: {
@@ -116,6 +123,7 @@ export function createPublishGitlabAction(options: {
gitCommitMessage = 'initial commit',
gitAuthorName,
gitAuthorEmail,
setUserAsOwner = false,
} = ctx.input;
const { owner, repo, host } = parseRepoUrl(repoUrl, integrations);
@@ -149,19 +157,35 @@ export function createPublishGitlabAction(options: {
id: number;
};
const { id: userId } = (await client.Users.current()) as {
id: number;
};
if (!targetNamespace) {
const { id } = (await client.Users.current()) as {
id: number;
};
targetNamespace = id;
targetNamespace = userId;
}
const { http_url_to_repo } = await client.Projects.create({
const { id: projectId, http_url_to_repo } = await client.Projects.create({
namespace_id: targetNamespace,
name: repo,
visibility: repoVisibility,
});
// When setUserAsOwner is true the input token is expected to come from an unprivileged user GitLab
// OAuth flow. In this case GitLab works in a way that allows the unprivileged user to
// create the repository, but not to push the default protected branch (e.g. master).
// In order to set the user as owner of the newly created repository we need to check that the
// GitLab integration configuration for the matching host contains a token and use
// such token to bootstrap a new privileged client.
if (setUserAsOwner && integrationConfig.config.token) {
const adminClient = new Gitlab({
host: integrationConfig.config.baseUrl,
token: integrationConfig.config.token,
});
await adminClient.ProjectMembers.add(projectId, userId, 50);
}
const remoteUrl = (http_url_to_repo as string).replace(/\.git$/, '');
const repoContentsUrl = `${remoteUrl}/-/blob/${defaultBranch}`;
+1 -1
View File
@@ -55,7 +55,7 @@
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
"@react-hookz/web": "^13.0.0",
"@react-hookz/web": "^15.0.0",
"@rjsf/core": "^3.2.1",
"@rjsf/material-ui": "^3.2.1",
"@types/json-schema": "^7.0.9",
+11 -3
View File
@@ -90,9 +90,17 @@ export class MyOwnClient implements TechRadarApi {
const data = await fetch('https://mydata.json').then(res => res.json());
// maybe you'll need to do some data transformation here to make it look like TechRadarLoaderResponse
return data;
// For example, this converts the timeline dates into date objects
return {
...data,
entries: data.entries.map(entry => ({
...entry,
timeline: entry.timeline.map(timeline => ({
...timeline,
date: new Date(timeline.date),
})),
})),
};
}
}
@@ -43,7 +43,7 @@
"@material-ui/core": "^4.9.13",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
"@react-hookz/web": "^14.0.0",
"@react-hookz/web": "^15.0.0",
"git-url-parse": "^12.0.0",
"react-use": "^17.2.4"
},