Merge pull request #11925 from soprasteria/neemys/implement-multiple-sonarqube-instances
Add ability to configure multiple sonarqube instances
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-sonarqube-backend': minor
|
||||
---
|
||||
|
||||
Initial creation of the plugin
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
'@backstage/plugin-sonarqube': minor
|
||||
---
|
||||
|
||||
**BREAKING** This plugin now call the `sonarqube-backend` plugin instead of relying on the proxy plugin
|
||||
|
||||
The whole proxy's `'/sonarqube':` key can be removed from your configuration files.
|
||||
|
||||
Then head to the [README in sonarqube-backend plugin page](https://github.com/backstage/backstage/tree/master/plugins/sonarqube-backend/README.md) to learn how to set-up the link to your Sonarqube instances.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-sonarqube': patch
|
||||
---
|
||||
|
||||
Add ability to provide an optional Sonarqube instance into the annotation in the `catalog-info.yaml` file
|
||||
@@ -283,6 +283,7 @@ shoutout
|
||||
siloed
|
||||
Sinon
|
||||
Snyk
|
||||
Sonarqube
|
||||
sourcemaps
|
||||
sparklines
|
||||
Splunk
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -0,0 +1,139 @@
|
||||
# sonarqube-backend
|
||||
|
||||
Welcome to the sonarqube-backend backend plugin!
|
||||
|
||||
## Integrating into a backstage instance
|
||||
|
||||
This plugin needs to be added to an existing backstage instance.
|
||||
|
||||
```bash
|
||||
# From your Backstage root directory
|
||||
yarn add --cwd packages/backend @backstage/plugin-sonarqube-backend
|
||||
```
|
||||
|
||||
Typically, this means creating a `src/plugins/sonarqube.ts` file and adding a reference to it to `src/index.ts` in the backend package.
|
||||
|
||||
### sonarqube.ts
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createRouter,
|
||||
DefaultSonarqubeInfoProvider,
|
||||
} from '@backstage/plugin-sonarqube-backend';
|
||||
import { Router } from 'express';
|
||||
import { PluginEnvironment } from '../types';
|
||||
|
||||
export default async function createPlugin(
|
||||
env: PluginEnvironment,
|
||||
): Promise<Router> {
|
||||
return await createRouter({
|
||||
logger: env.logger,
|
||||
sonarqubeInfoProvider: DefaultSonarqubeInfoProvider.fromConfig(env.config),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### src/index.ts
|
||||
|
||||
```diff
|
||||
diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts
|
||||
index 1942c36ad1..7fdc48ba24 100644
|
||||
--- a/packages/backend/src/index.ts
|
||||
+++ b/packages/backend/src/index.ts
|
||||
@@ -50,6 +50,7 @@ import scaffolder from './plugins/scaffolder';
|
||||
import proxy from './plugins/proxy';
|
||||
import search from './plugins/search';
|
||||
import techdocs from './plugins/techdocs';
|
||||
+import sonarqube from './plugins/sonarqube';
|
||||
import techInsights from './plugins/techInsights';
|
||||
import todo from './plugins/todo';
|
||||
import graphql from './plugins/graphql';
|
||||
@@ -133,6 +134,7 @@ async function main() {
|
||||
createEnv('tech-insights'),
|
||||
);
|
||||
const permissionEnv = useHotMemoize(module, () => createEnv('permission'));
|
||||
+ const sonarqubeEnv = useHotMemoize(module, () => createEnv('sonarqube'));
|
||||
|
||||
const apiRouter = Router();
|
||||
apiRouter.use('/catalog', await catalog(catalogEnv));
|
||||
@@ -152,6 +154,7 @@ async function main() {
|
||||
apiRouter.use('/badges', await badges(badgesEnv));
|
||||
apiRouter.use('/jenkins', await jenkins(jenkinsEnv));
|
||||
apiRouter.use('/permission', await permission(permissionEnv));
|
||||
+ apiRouter.use('/sonarqube', await sonarqube(sonarqubeEnv));
|
||||
apiRouter.use(notFoundHandler());
|
||||
|
||||
const service = createServiceBuilder(module)
|
||||
|
||||
```
|
||||
|
||||
This plugin must be provided with a `SonarqubeInfoProvider`, this is a strategy object for finding Sonarqube instances in configuration and retrieving data from an instance.
|
||||
|
||||
There is a standard one provided (`DefaultSonarqubeInfoProvider`), but the Integrator is free to build their own.
|
||||
|
||||
### DefaultSonarqubeInfoProvider
|
||||
|
||||
Allows configuration of either a single or multiple global Sonarqube instances and annotating entities with the instance name. This instance name in the entities is optional, if not provided the default instance in configuration will be used. That allow to keep configuration from before multiple instances capability to keep working without changes.
|
||||
|
||||
#### Example - Single global instance
|
||||
|
||||
##### Config
|
||||
|
||||
```yaml
|
||||
sonarqube:
|
||||
baseUrl: https://sonarqube.example.com
|
||||
apiKey: 123456789abcdef0123456789abcedf012
|
||||
```
|
||||
|
||||
##### Catalog
|
||||
|
||||
```yaml
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: backstage
|
||||
annotations:
|
||||
sonarqube.org/project-key: YOUR_INSTANCE_NAME/YOUR_PROJECT_KEY
|
||||
```
|
||||
|
||||
#### Example - Multiple global instance
|
||||
|
||||
The following will look for findings at `https://special-project-sonarqube.example.com` for the project of key `YOUR_PROJECT_KEY`.
|
||||
|
||||
##### Config
|
||||
|
||||
```yaml
|
||||
sonarqube:
|
||||
instances:
|
||||
- name: default
|
||||
baseUrl: https://default-sonarqube.example.com
|
||||
apiKey: 123456789abcdef0123456789abcedf012
|
||||
- name: specialProject
|
||||
baseUrl: https://special-project-sonarqube.example.com
|
||||
apiKey: abcdef0123456789abcedf0123456789ab
|
||||
```
|
||||
|
||||
##### Catalog
|
||||
|
||||
```yaml
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: backstage
|
||||
annotations:
|
||||
sonarqube.org/project-key: specialProject/YOUR_PROJECT_KEY
|
||||
```
|
||||
|
||||
If the `specialProject/` part is omitted (or replaced with `default/`), the Sonarqube instance of name `default` will be used.
|
||||
|
||||
The following config is an equivalent (but less clear) version of the above:
|
||||
|
||||
```yaml
|
||||
sonarqube:
|
||||
baseUrl: https://default-sonarqube.example.com
|
||||
apiKey: 123456789abcdef0123456789abcedf012
|
||||
instances:
|
||||
- name: specialProject
|
||||
baseUrl: https://special-project-sonarqube.example.com
|
||||
apiKey: abcdef0123456789abcedf0123456789ab
|
||||
```
|
||||
@@ -0,0 +1,81 @@
|
||||
## API Report File for "@backstage/plugin-sonarqube-backend"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { Config } from '@backstage/config';
|
||||
import express from 'express';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
// @public
|
||||
export function createRouter(options: RouterOptions): Promise<express.Router>;
|
||||
|
||||
// @public
|
||||
export class DefaultSonarqubeInfoProvider implements SonarqubeInfoProvider {
|
||||
static fromConfig(config: Config): DefaultSonarqubeInfoProvider;
|
||||
getBaseUrl({ instanceName }?: { instanceName?: string }): {
|
||||
baseUrl: string;
|
||||
};
|
||||
getFindings({
|
||||
componentKey,
|
||||
instanceName,
|
||||
}: {
|
||||
componentKey: string;
|
||||
instanceName?: string;
|
||||
}): Promise<SonarqubeFindings | undefined>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface RouterOptions {
|
||||
logger: Logger;
|
||||
sonarqubeInfoProvider: SonarqubeInfoProvider;
|
||||
}
|
||||
|
||||
// @public
|
||||
export class SonarqubeConfig {
|
||||
constructor(instances: SonarqubeInstanceConfig[]);
|
||||
static fromConfig(config: Config): SonarqubeConfig;
|
||||
getInstanceConfig({
|
||||
sonarqubeName,
|
||||
}?: {
|
||||
sonarqubeName?: string;
|
||||
}): SonarqubeInstanceConfig;
|
||||
// (undocumented)
|
||||
readonly instances: SonarqubeInstanceConfig[];
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface SonarqubeFindings {
|
||||
analysisDate: string;
|
||||
measures: SonarqubeMeasure[];
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface SonarqubeInfoProvider {
|
||||
getBaseUrl({ instanceName }?: { instanceName?: string }): {
|
||||
baseUrl: string;
|
||||
};
|
||||
getFindings({
|
||||
componentKey,
|
||||
instanceName,
|
||||
}: {
|
||||
componentKey: string;
|
||||
instanceName?: string;
|
||||
}): Promise<SonarqubeFindings | undefined>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface SonarqubeInstanceConfig {
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface SonarqubeMeasure {
|
||||
metric: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@backstage/plugin-sonarqube-backend",
|
||||
"version": "0.0.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"backstage": {
|
||||
"role": "backend-plugin"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "backstage-cli package start",
|
||||
"build": "backstage-cli package build",
|
||||
"lint": "backstage-cli package lint",
|
||||
"test": "backstage-cli package test",
|
||||
"clean": "backstage-cli package clean",
|
||||
"prepack": "backstage-cli package prepack",
|
||||
"postpack": "backstage-cli package postpack"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "^0.15.0-next.0",
|
||||
"@backstage/config": "^1.0.1",
|
||||
"@types/express": "*",
|
||||
"express": "^4.18.1",
|
||||
"express-promise-router": "^4.1.0",
|
||||
"node-fetch": "^2.6.7",
|
||||
"winston": "^3.8.1",
|
||||
"yn": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.18.1-next.0",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"msw": "^0.44.2",
|
||||
"supertest": "^6.2.4"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './service/router';
|
||||
export * from './service/sonarqubeInfoProvider';
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { getRootLogger } from '@backstage/backend-common';
|
||||
import yn from 'yn';
|
||||
import { startStandaloneServer } from './service/standaloneServer';
|
||||
|
||||
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007;
|
||||
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
|
||||
const logger = getRootLogger();
|
||||
|
||||
startStandaloneServer({ port, enableCors, logger }).catch(err => {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('CTRL+C pressed; exiting.');
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
|
||||
import { createRouter } from './router';
|
||||
import { SonarqubeFindings } from './sonarqubeInfoProvider';
|
||||
|
||||
describe('createRouter', () => {
|
||||
let app: express.Express;
|
||||
const getBaseUrlMock: jest.Mock<
|
||||
{ baseUrl: string },
|
||||
[{ instanceName: string }]
|
||||
> = jest.fn();
|
||||
const getFindingsMock: jest.Mock<
|
||||
Promise<SonarqubeFindings | undefined>,
|
||||
[
|
||||
{
|
||||
componentKey: string;
|
||||
instanceName: string;
|
||||
},
|
||||
]
|
||||
> = jest.fn();
|
||||
|
||||
beforeAll(async () => {
|
||||
const router = await createRouter({
|
||||
logger: getVoidLogger(),
|
||||
sonarqubeInfoProvider: {
|
||||
getBaseUrl: getBaseUrlMock,
|
||||
getFindings: getFindingsMock,
|
||||
},
|
||||
});
|
||||
app = express().use(router);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /findings', () => {
|
||||
const DUMMY_COMPONENT_KEY = 'my:component';
|
||||
const DUMMY_INSTANCE_KEY = 'myInstance';
|
||||
it('returns ok', async () => {
|
||||
const measures = {
|
||||
analysisDate: '2022-01-01T00:00:00Z',
|
||||
measures: [{ metric: 'vulnerabilities', value: '54' }],
|
||||
};
|
||||
|
||||
getFindingsMock.mockReturnValue(Promise.resolve(measures));
|
||||
const response = await request(app)
|
||||
.get('/findings')
|
||||
.query({
|
||||
componentKey: DUMMY_COMPONENT_KEY,
|
||||
instanceKey: DUMMY_INSTANCE_KEY,
|
||||
})
|
||||
.send();
|
||||
expect(getFindingsMock).toBeCalledTimes(1);
|
||||
expect(getFindingsMock).toBeCalledWith({
|
||||
componentKey: DUMMY_COMPONENT_KEY,
|
||||
instanceName: DUMMY_INSTANCE_KEY,
|
||||
});
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(measures);
|
||||
});
|
||||
it('returns an error when component key is not defined', async () => {
|
||||
const response = await request(app)
|
||||
.get('/findings')
|
||||
.query({
|
||||
instanceKey: DUMMY_INSTANCE_KEY,
|
||||
})
|
||||
.send();
|
||||
|
||||
expect(response.status).toEqual(400);
|
||||
});
|
||||
|
||||
it('use the value as instance name when instance key not provided', async () => {
|
||||
const measures = {
|
||||
analysisDate: '2021-04-08',
|
||||
measures: [{ metric: 'vulnerabilities', value: '54' }],
|
||||
};
|
||||
|
||||
getFindingsMock.mockReturnValue(Promise.resolve(measures));
|
||||
const response = await request(app)
|
||||
.get('/findings')
|
||||
.query({
|
||||
componentKey: DUMMY_COMPONENT_KEY,
|
||||
instanceKey: undefined,
|
||||
})
|
||||
.send();
|
||||
|
||||
expect(getFindingsMock).toBeCalledTimes(1);
|
||||
expect(getFindingsMock).toBeCalledWith({
|
||||
componentKey: DUMMY_COMPONENT_KEY,
|
||||
instanceName: undefined,
|
||||
});
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(measures);
|
||||
});
|
||||
});
|
||||
describe('GET /instanceUrl', () => {
|
||||
const DUMMY_INSTANCE_KEY = 'myInstance';
|
||||
const DUMMY_INSTANCE_URL = 'http://sonarqube.example.com';
|
||||
it('returns ok', async () => {
|
||||
getBaseUrlMock.mockReturnValue({ baseUrl: DUMMY_INSTANCE_URL });
|
||||
const response = await request(app)
|
||||
.get('/instanceUrl')
|
||||
.query({
|
||||
instanceKey: DUMMY_INSTANCE_KEY,
|
||||
})
|
||||
.send();
|
||||
expect(getBaseUrlMock).toBeCalledTimes(1);
|
||||
expect(getBaseUrlMock).toBeCalledWith({
|
||||
instanceName: DUMMY_INSTANCE_KEY,
|
||||
});
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual({ instanceUrl: DUMMY_INSTANCE_URL });
|
||||
});
|
||||
|
||||
it('query default instance when instanceKey not provided', async () => {
|
||||
getBaseUrlMock.mockReturnValue({ baseUrl: DUMMY_INSTANCE_URL });
|
||||
const response = await request(app).get('/instanceUrl').send();
|
||||
expect(getBaseUrlMock).toBeCalledTimes(1);
|
||||
expect(getBaseUrlMock).toBeCalledWith({
|
||||
instanceName: undefined,
|
||||
});
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual({ instanceUrl: DUMMY_INSTANCE_URL });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { errorHandler } from '@backstage/backend-common';
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { Logger } from 'winston';
|
||||
import { SonarqubeInfoProvider } from './sonarqubeInfoProvider';
|
||||
import { InputError } from '../../../../packages/errors';
|
||||
|
||||
/**
|
||||
* Dependencies needed by the router
|
||||
* @public
|
||||
*/
|
||||
export interface RouterOptions {
|
||||
/**
|
||||
* Logger for logging purposes
|
||||
*/
|
||||
logger: Logger;
|
||||
/**
|
||||
* Info provider to be able to get all necessary information for the APIs
|
||||
*/
|
||||
sonarqubeInfoProvider: SonarqubeInfoProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*
|
||||
* Constructs a sonarqube router.
|
||||
*
|
||||
* Expose endpoint to get information on or for a sonarqube instance.
|
||||
*
|
||||
* @param options - Dependencies of the router
|
||||
*/
|
||||
export async function createRouter(
|
||||
options: RouterOptions,
|
||||
): Promise<express.Router> {
|
||||
const { logger, sonarqubeInfoProvider } = options;
|
||||
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
router.get('/findings', async (request, response) => {
|
||||
const componentKey = request.query.componentKey as string;
|
||||
const instanceKey = request.query.instanceKey as string;
|
||||
|
||||
if (!componentKey)
|
||||
throw new InputError('ComponentKey must be provided as a single string.');
|
||||
|
||||
logger.info(
|
||||
instanceKey
|
||||
? `Retrieving findings for component ${componentKey} in sonarqube instance name ${instanceKey}`
|
||||
: `Retrieving findings for component ${componentKey} in default sonarqube instance`,
|
||||
);
|
||||
|
||||
response.json(
|
||||
await sonarqubeInfoProvider.getFindings({
|
||||
componentKey,
|
||||
instanceName: instanceKey,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
router.get('/instanceUrl', (request, response) => {
|
||||
const instanceKey = request.query.instanceKey as string;
|
||||
|
||||
logger.info(
|
||||
instanceKey
|
||||
? `Retrieving sonarqube instance URL for key ${instanceKey}`
|
||||
: `Retrieving default sonarqube instance URL as instanceKey is not provided`,
|
||||
);
|
||||
const { baseUrl } = sonarqubeInfoProvider.getBaseUrl({
|
||||
instanceName: instanceKey,
|
||||
});
|
||||
response.json({
|
||||
instanceUrl: baseUrl,
|
||||
});
|
||||
});
|
||||
|
||||
router.use(errorHandler());
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ConfigReader } from '../../../../packages/config';
|
||||
import {
|
||||
DefaultSonarqubeInfoProvider,
|
||||
SonarqubeConfig,
|
||||
} from './sonarqubeInfoProvider';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { setupRequestMockHandlers } from '../../../../packages/test-utils';
|
||||
import { rest, RestRequest } from 'msw';
|
||||
|
||||
describe('SonarqubeConfig', () => {
|
||||
const SONARQUBE_DEFAULT_INSTANCE_NAME = 'default';
|
||||
const DUMMY_SONAR_URL = 'https://sonarqube.example.com';
|
||||
const DUMMY_SONAR_APIKEY = '123456789abcdef0123456789abcedf012';
|
||||
const DUMMY_SIMPLE_OBJECT_FOR_DEFAULT_SONARQUBE_CONFIG = {
|
||||
name: SONARQUBE_DEFAULT_INSTANCE_NAME,
|
||||
baseUrl: DUMMY_SONAR_URL,
|
||||
apiKey: DUMMY_SONAR_APIKEY,
|
||||
};
|
||||
const DUMMY_SIMPLE_CONFIG = {
|
||||
sonarqube: {
|
||||
baseUrl: DUMMY_SONAR_URL,
|
||||
apiKey: DUMMY_SONAR_APIKEY,
|
||||
},
|
||||
};
|
||||
const DUMMY_NAMED_CONFIG = {
|
||||
sonarqube: {
|
||||
instances: [
|
||||
{
|
||||
name: SONARQUBE_DEFAULT_INSTANCE_NAME,
|
||||
baseUrl: DUMMY_SONAR_URL,
|
||||
apiKey: DUMMY_SONAR_APIKEY,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
describe('fromConfig', () => {
|
||||
it('Reads simple config and annotation', async () => {
|
||||
const config = SonarqubeConfig.fromConfig(
|
||||
new ConfigReader(DUMMY_SIMPLE_CONFIG),
|
||||
);
|
||||
|
||||
expect(config.instances).toEqual([
|
||||
{
|
||||
name: SONARQUBE_DEFAULT_INSTANCE_NAME,
|
||||
baseUrl: DUMMY_SONAR_URL,
|
||||
apiKey: DUMMY_SONAR_APIKEY,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('Reads named default config and annotation', async () => {
|
||||
const config = SonarqubeConfig.fromConfig(
|
||||
new ConfigReader(DUMMY_NAMED_CONFIG),
|
||||
);
|
||||
|
||||
expect(config.instances).toEqual([
|
||||
{
|
||||
name: SONARQUBE_DEFAULT_INSTANCE_NAME,
|
||||
baseUrl: DUMMY_SONAR_URL,
|
||||
apiKey: DUMMY_SONAR_APIKEY,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('Parses named default config (amongst named other configs)', async () => {
|
||||
const config = SonarqubeConfig.fromConfig(
|
||||
new ConfigReader({
|
||||
sonarqube: {
|
||||
instances: [
|
||||
{
|
||||
name: SONARQUBE_DEFAULT_INSTANCE_NAME,
|
||||
baseUrl: DUMMY_SONAR_URL,
|
||||
apiKey: DUMMY_SONAR_APIKEY,
|
||||
},
|
||||
{
|
||||
name: 'other',
|
||||
baseUrl: 'https://sonarqube-other.example.com',
|
||||
apiKey: 'abcdef0123456789abcedf0123456789abc',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(config.instances).toEqual([
|
||||
{
|
||||
name: SONARQUBE_DEFAULT_INSTANCE_NAME,
|
||||
baseUrl: DUMMY_SONAR_URL,
|
||||
apiKey: DUMMY_SONAR_APIKEY,
|
||||
},
|
||||
{
|
||||
name: 'other',
|
||||
baseUrl: 'https://sonarqube-other.example.com',
|
||||
apiKey: 'abcdef0123456789abcedf0123456789abc',
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('Throw an error if both a named default config and top level config', async () => {
|
||||
expect(() =>
|
||||
SonarqubeConfig.fromConfig(
|
||||
new ConfigReader({
|
||||
sonarqube: {
|
||||
baseUrl: DUMMY_SONAR_URL,
|
||||
apiKey: DUMMY_SONAR_APIKEY,
|
||||
instances: [
|
||||
{
|
||||
name: SONARQUBE_DEFAULT_INSTANCE_NAME,
|
||||
baseUrl: DUMMY_SONAR_URL,
|
||||
apiKey: DUMMY_SONAR_APIKEY,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrowError(Error);
|
||||
});
|
||||
|
||||
it('Throw an error if default config is partially provided', async () => {
|
||||
expect(() =>
|
||||
SonarqubeConfig.fromConfig(
|
||||
new ConfigReader({
|
||||
sonarqube: {
|
||||
baseUrl: DUMMY_SONAR_URL,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrowError(Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInstanceConfig', () => {
|
||||
it('Gets default instance when no parameter given', async () => {
|
||||
const config = new SonarqubeConfig([
|
||||
DUMMY_SIMPLE_OBJECT_FOR_DEFAULT_SONARQUBE_CONFIG,
|
||||
{
|
||||
name: 'other',
|
||||
baseUrl: 'https://sonarqube-other.example.com',
|
||||
apiKey: '123456789abcdef0123456789abcedf012',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(config.getInstanceConfig()).toEqual({
|
||||
name: SONARQUBE_DEFAULT_INSTANCE_NAME,
|
||||
baseUrl: DUMMY_SONAR_URL,
|
||||
apiKey: DUMMY_SONAR_APIKEY,
|
||||
});
|
||||
});
|
||||
|
||||
it('Gets default instance when "default" given', async () => {
|
||||
const config = new SonarqubeConfig([
|
||||
DUMMY_SIMPLE_OBJECT_FOR_DEFAULT_SONARQUBE_CONFIG,
|
||||
{
|
||||
name: 'other',
|
||||
baseUrl: 'https://sonarqube-other.example.com',
|
||||
apiKey: 'abcdef0123456789abcedf0123456789abc',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(config.getInstanceConfig({ sonarqubeName: 'default' })).toEqual({
|
||||
name: 'default',
|
||||
baseUrl: 'https://sonarqube.example.com',
|
||||
apiKey: '123456789abcdef0123456789abcedf012',
|
||||
});
|
||||
});
|
||||
|
||||
it('Gets named instance', async () => {
|
||||
const config = new SonarqubeConfig([
|
||||
DUMMY_SIMPLE_OBJECT_FOR_DEFAULT_SONARQUBE_CONFIG,
|
||||
{
|
||||
name: 'other',
|
||||
baseUrl: 'https://sonarqube-other.example.com',
|
||||
apiKey: '123456789abcdef0123456789abcedf012',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(config.getInstanceConfig({ sonarqubeName: 'other' })).toEqual({
|
||||
name: 'other',
|
||||
baseUrl: 'https://sonarqube-other.example.com',
|
||||
apiKey: '123456789abcdef0123456789abcedf012',
|
||||
});
|
||||
});
|
||||
|
||||
it('Throw an error if default instance could not be found', async () => {
|
||||
const config = new SonarqubeConfig([
|
||||
{
|
||||
name: 'other',
|
||||
baseUrl: 'https://sonarqube-other.example.com',
|
||||
apiKey: '123456789abcdef0123456789abcedf012',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(() =>
|
||||
config.getInstanceConfig({ sonarqubeName: 'default' }),
|
||||
).toThrowError(Error);
|
||||
});
|
||||
|
||||
it('Throw an error if named instance could not be found', async () => {
|
||||
const config = new SonarqubeConfig([
|
||||
DUMMY_SIMPLE_OBJECT_FOR_DEFAULT_SONARQUBE_CONFIG,
|
||||
]);
|
||||
|
||||
expect(() =>
|
||||
config.getInstanceConfig({ sonarqubeName: 'other' }),
|
||||
).toThrowError(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const server = setupServer();
|
||||
|
||||
describe('DefaultSonarqubeInfoProvider', () => {
|
||||
function configureProvider(configData: any) {
|
||||
const config = new ConfigReader(configData);
|
||||
|
||||
return DefaultSonarqubeInfoProvider.fromConfig(config);
|
||||
}
|
||||
|
||||
describe('getBaseUrl', () => {
|
||||
it('Provide base url for default from simple config and non provided instanceName', async () => {
|
||||
const provider = configureProvider({
|
||||
sonarqube: {
|
||||
baseUrl: 'https://sonarqube.example.com',
|
||||
apiKey: '123456789abcdef0123456789abcedf012',
|
||||
},
|
||||
});
|
||||
|
||||
expect(provider.getBaseUrl()).toEqual({
|
||||
baseUrl: 'https://sonarqube.example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('Provide base url for default from simple config and empty string', async () => {
|
||||
const provider = configureProvider({
|
||||
sonarqube: {
|
||||
baseUrl: 'https://sonarqube.example.com',
|
||||
apiKey: '123456789abcdef0123456789abcedf012',
|
||||
},
|
||||
});
|
||||
|
||||
expect(provider.getBaseUrl({ instanceName: '' })).toEqual({
|
||||
baseUrl: 'https://sonarqube.example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('Provide base url for named default config and "default" string', async () => {
|
||||
const provider = configureProvider({
|
||||
sonarqube: {
|
||||
instances: [
|
||||
{
|
||||
name: 'default',
|
||||
baseUrl: 'https://sonarqube.example.com',
|
||||
apiKey: '123456789abcdef0123456789abcedf012',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(provider.getBaseUrl({ instanceName: 'default' })).toEqual({
|
||||
baseUrl: 'https://sonarqube.example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('Provide base url for named config', async () => {
|
||||
const provider = configureProvider({
|
||||
sonarqube: {
|
||||
instances: [
|
||||
{
|
||||
name: 'default',
|
||||
baseUrl: 'https://sonarqube.example.com',
|
||||
apiKey: '123456789abcdef0123456789abcedf012',
|
||||
},
|
||||
{
|
||||
name: 'other',
|
||||
baseUrl: 'https://sonarqube-other.example.com',
|
||||
apiKey: '123456789abcdef0123456789abcedf012',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(provider.getBaseUrl({ instanceName: 'other' })).toEqual({
|
||||
baseUrl: 'https://sonarqube-other.example.com',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFindings', () => {
|
||||
setupRequestMockHandlers(server);
|
||||
const MOCK_BASE_URL = 'http://backstage:9191';
|
||||
const DUMMY_COMPONENT_KEY = 'dummyComponentKey';
|
||||
const DUMMY_ANALYSIS_DATE = '2022-01-01T00:00:00Z';
|
||||
const DUMMY_API_KEY = '123456789abcdef0123456789abcedf012';
|
||||
|
||||
const checkBasicAuthToken = (req: RestRequest<never>) => {
|
||||
if (req.headers && req.headers.has('Authorization')) {
|
||||
expect(req.headers.get('Authorization')).toEqual(
|
||||
`Basic MTIzNDU2Nzg5YWJjZGVmMDEyMzQ1Njc4OWFiY2VkZjAxMjo=`,
|
||||
);
|
||||
} else {
|
||||
throw new Error('Basic auth token not provided');
|
||||
}
|
||||
};
|
||||
|
||||
const setupComponentHandler = () => {
|
||||
server.use(
|
||||
rest.get(`${MOCK_BASE_URL}/api/components/show`, (req, res, ctx) => {
|
||||
checkBasicAuthToken(req);
|
||||
expect(req.url.searchParams.toString()).toBe(
|
||||
`component=${DUMMY_COMPONENT_KEY}`,
|
||||
);
|
||||
return res(
|
||||
ctx.json({
|
||||
component: { analysisDate: DUMMY_ANALYSIS_DATE },
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
const setupMetricsHandler = () => {
|
||||
server.use(
|
||||
rest.get(`${MOCK_BASE_URL}/api/metrics/search`, (req, res, ctx) => {
|
||||
checkBasicAuthToken(req);
|
||||
return res(
|
||||
ctx.json({
|
||||
total: 4,
|
||||
metrics: [
|
||||
{ key: 'coverage' },
|
||||
{ key: 'code_smells' },
|
||||
{ key: 'vulnerabilities' },
|
||||
{ key: 'unused_metric' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
const setupMeasureHandler = () => {
|
||||
server.use(
|
||||
rest.get(`${MOCK_BASE_URL}/api/measures/component`, (req, res, ctx) => {
|
||||
checkBasicAuthToken(req);
|
||||
expect(req.url.searchParams.toString()).toBe(
|
||||
`component=${DUMMY_COMPONENT_KEY}&metricKeys=vulnerabilities%2Ccode_smells%2Ccoverage`,
|
||||
);
|
||||
return res(
|
||||
ctx.json({
|
||||
component: {
|
||||
measures: [
|
||||
{ metric: 'coverage', value: '86' },
|
||||
{ metric: 'code_smells', value: '40' },
|
||||
{ metric: 'vulnerabilities', value: '3' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setupHandlers = () => {
|
||||
setupComponentHandler();
|
||||
setupMetricsHandler();
|
||||
setupMeasureHandler();
|
||||
};
|
||||
|
||||
const DUMMY_SIMPLE_CONFIG_FOR_PROVIDER = {
|
||||
sonarqube: {
|
||||
baseUrl: MOCK_BASE_URL,
|
||||
apiKey: DUMMY_API_KEY,
|
||||
},
|
||||
};
|
||||
it('Provide findings when everything is ok', async () => {
|
||||
setupHandlers();
|
||||
const provider = configureProvider(DUMMY_SIMPLE_CONFIG_FOR_PROVIDER);
|
||||
expect(
|
||||
await provider.getFindings({
|
||||
componentKey: DUMMY_COMPONENT_KEY,
|
||||
instanceName: 'default',
|
||||
}),
|
||||
).toEqual({
|
||||
analysisDate: DUMMY_ANALYSIS_DATE,
|
||||
measures: [
|
||||
{ metric: 'coverage', value: '86' },
|
||||
{ metric: 'code_smells', value: '40' },
|
||||
{ metric: 'vulnerabilities', value: '3' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('Provide undefined as finding if component API answer code is not 200', async () => {
|
||||
server.use(
|
||||
rest.get(`${MOCK_BASE_URL}/api/components/show`, (req, res, ctx) => {
|
||||
checkBasicAuthToken(req);
|
||||
expect(req.url.searchParams.toString()).toBe(
|
||||
`component=${DUMMY_COMPONENT_KEY}`,
|
||||
);
|
||||
return res(ctx.status(500));
|
||||
}),
|
||||
);
|
||||
|
||||
const provider = configureProvider({
|
||||
sonarqube: {
|
||||
baseUrl: MOCK_BASE_URL,
|
||||
apiKey: '123456789abcdef0123456789abcedf012',
|
||||
},
|
||||
});
|
||||
expect(
|
||||
await provider.getFindings({
|
||||
componentKey: DUMMY_COMPONENT_KEY,
|
||||
instanceName: 'default',
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
it('Provide undefined as finding if component API answer incorrectly', async () => {
|
||||
server.use(
|
||||
rest.get(`${MOCK_BASE_URL}/api/components/show`, (req, res, ctx) => {
|
||||
checkBasicAuthToken(req);
|
||||
expect(req.url.searchParams.toString()).toBe(
|
||||
`component=${DUMMY_COMPONENT_KEY}`,
|
||||
);
|
||||
return res(
|
||||
ctx.json({
|
||||
invalid: true,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const provider = configureProvider(DUMMY_SIMPLE_CONFIG_FOR_PROVIDER);
|
||||
expect(
|
||||
await provider.getFindings({
|
||||
componentKey: DUMMY_COMPONENT_KEY,
|
||||
instanceName: 'default',
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
it('Provide findings when metrics API uses pages', async () => {
|
||||
setupComponentHandler();
|
||||
setupMeasureHandler();
|
||||
// custom metrics handler that provide two pages
|
||||
server.use(
|
||||
rest.get(`${MOCK_BASE_URL}/api/metrics/search`, (req, res, ctx) => {
|
||||
checkBasicAuthToken(req);
|
||||
if (req.url.searchParams.get('p') === '1')
|
||||
return res(
|
||||
ctx.json({
|
||||
total: 4,
|
||||
metrics: [{ key: 'coverage' }, { key: 'code_smells' }],
|
||||
}),
|
||||
);
|
||||
return res(
|
||||
ctx.json({
|
||||
total: 4,
|
||||
metrics: [{ key: 'vulnerabilities' }, { key: 'unused_metric' }],
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const provider = configureProvider(DUMMY_SIMPLE_CONFIG_FOR_PROVIDER);
|
||||
expect(
|
||||
await provider.getFindings({
|
||||
componentKey: DUMMY_COMPONENT_KEY,
|
||||
instanceName: 'default',
|
||||
}),
|
||||
).toEqual({
|
||||
analysisDate: DUMMY_ANALYSIS_DATE,
|
||||
measures: [
|
||||
{ metric: 'coverage', value: '86' },
|
||||
{ metric: 'code_smells', value: '40' },
|
||||
{ metric: 'vulnerabilities', value: '3' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('Provide undefined as findings when measure API answer code is not 200', async () => {
|
||||
setupComponentHandler();
|
||||
setupMetricsHandler();
|
||||
// custom metrics handler that provide two pages
|
||||
server.use(
|
||||
rest.get(`${MOCK_BASE_URL}/api/measures/component`, (req, res, ctx) => {
|
||||
checkBasicAuthToken(req);
|
||||
expect(req.url.searchParams.toString()).toBe(
|
||||
`component=${DUMMY_COMPONENT_KEY}&metricKeys=vulnerabilities%2Ccode_smells%2Ccoverage`,
|
||||
);
|
||||
return res(ctx.status(500));
|
||||
}),
|
||||
);
|
||||
|
||||
const provider = configureProvider(DUMMY_SIMPLE_CONFIG_FOR_PROVIDER);
|
||||
expect(
|
||||
await provider.getFindings({
|
||||
componentKey: DUMMY_COMPONENT_KEY,
|
||||
instanceName: 'default',
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Provide findings with empty measures when metrics API answer incorrectly', async () => {
|
||||
setupComponentHandler();
|
||||
setupMetricsHandler();
|
||||
// custom metrics handler that provide two pages
|
||||
server.use(
|
||||
rest.get(`${MOCK_BASE_URL}/api/measures/component`, (req, res, ctx) => {
|
||||
checkBasicAuthToken(req);
|
||||
expect(req.url.searchParams.toString()).toBe(
|
||||
`component=${DUMMY_COMPONENT_KEY}&metricKeys=vulnerabilities%2Ccode_smells%2Ccoverage`,
|
||||
);
|
||||
return res(ctx.json({}));
|
||||
}),
|
||||
);
|
||||
|
||||
const provider = configureProvider(DUMMY_SIMPLE_CONFIG_FOR_PROVIDER);
|
||||
expect(
|
||||
await provider.getFindings({
|
||||
componentKey: DUMMY_COMPONENT_KEY,
|
||||
instanceName: 'default',
|
||||
}),
|
||||
).toEqual({
|
||||
analysisDate: DUMMY_ANALYSIS_DATE,
|
||||
measures: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,386 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
/**
|
||||
* Provide information about sonarqube instances and projects contained within
|
||||
* @public
|
||||
*/
|
||||
export interface SonarqubeInfoProvider {
|
||||
/**
|
||||
* Get the sonarqube URL in configuration from a provided instanceName.
|
||||
*
|
||||
* If instanceName is omitted, default sonarqube instance is queried in config
|
||||
*
|
||||
* @param instanceName - Name of the sonarqube instance to get the info from
|
||||
* @returns the url of the instance
|
||||
*/
|
||||
getBaseUrl({ instanceName }?: { instanceName?: string }): { baseUrl: string };
|
||||
|
||||
/**
|
||||
* Query the sonarqube instance corresponding to the instanceName to get all
|
||||
* measures for the component of key componentKey.
|
||||
*
|
||||
* If instanceName is omitted, default sonarqube instance is queried in config
|
||||
*
|
||||
* @param componentKey - component key of the project we want to get measure from.
|
||||
* @param instanceName - name of the instance (in config) where the project is hosted.
|
||||
* @returns All measures with the analysis date. Will return undefined if we
|
||||
* can't provide the full response
|
||||
*/
|
||||
getFindings({
|
||||
componentKey,
|
||||
instanceName,
|
||||
}: {
|
||||
componentKey: string;
|
||||
instanceName?: string;
|
||||
}): Promise<SonarqubeFindings | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Information retrieved for a specific project in Sonarqube
|
||||
* @public
|
||||
*/
|
||||
export interface SonarqubeFindings {
|
||||
/**
|
||||
* Last date of the analysis that have generated this finding
|
||||
*/
|
||||
analysisDate: string;
|
||||
/**
|
||||
* All measures pertaining to the findings
|
||||
*/
|
||||
measures: SonarqubeMeasure[];
|
||||
}
|
||||
|
||||
interface MeasuresWrapper {
|
||||
component: { measures: SonarqubeMeasure[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* A specific measure on a project in Sonarqube
|
||||
* @public
|
||||
*/
|
||||
export interface SonarqubeMeasure {
|
||||
/**
|
||||
* Name of the measure
|
||||
*/
|
||||
metric: string;
|
||||
/**
|
||||
* Value of the measure
|
||||
*/
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about a Sonarqube instance.
|
||||
* @public
|
||||
*/
|
||||
export interface SonarqubeInstanceConfig {
|
||||
/**
|
||||
* Name of the instance. An instance name in configuration and catalog should match.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Base url to access the instance
|
||||
*/
|
||||
baseUrl: string;
|
||||
/**
|
||||
* Access token to access the sonarqube instance as generated in user profile.
|
||||
*/
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
interface ComponentWrapper {
|
||||
component: { analysisDate: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds multiple Sonarqube configurations.
|
||||
* @public
|
||||
*/
|
||||
export class SonarqubeConfig {
|
||||
/**
|
||||
*
|
||||
* @param instances - All information on all sonarqube instance from the config file
|
||||
*/
|
||||
constructor(public readonly instances: SonarqubeInstanceConfig[]) {}
|
||||
|
||||
/**
|
||||
* Read all Sonarqube instance configurations.
|
||||
* @param config - Root configuration
|
||||
* @returns A SonarqubeConfig that contains all configured Sonarqube instances.
|
||||
*/
|
||||
static fromConfig(config: Config): SonarqubeConfig {
|
||||
const DEFAULT_SONARQUBE_NAME = 'default';
|
||||
|
||||
const sonarqubeConfig = config.getConfig('sonarqube');
|
||||
|
||||
// load all named instance config
|
||||
const namedInstanceConfig =
|
||||
sonarqubeConfig.getOptionalConfigArray('instances')?.map(c => ({
|
||||
name: c.getString('name'),
|
||||
baseUrl: c.getString('baseUrl'),
|
||||
apiKey: c.getString('apiKey'),
|
||||
})) || [];
|
||||
|
||||
// load unnamed default config
|
||||
const hasNamedDefault = namedInstanceConfig.some(
|
||||
x => x.name === DEFAULT_SONARQUBE_NAME,
|
||||
);
|
||||
|
||||
// Get these as optional strings and check to give a better error message
|
||||
const baseUrl = sonarqubeConfig.getOptionalString('baseUrl');
|
||||
const apiKey = sonarqubeConfig.getOptionalString('apiKey');
|
||||
|
||||
if (hasNamedDefault && (baseUrl || apiKey)) {
|
||||
throw new Error(
|
||||
`Found both a named sonarqube instance with name ${DEFAULT_SONARQUBE_NAME} and top level baseUrl or apiKey config. Use only one style of config.`,
|
||||
);
|
||||
}
|
||||
|
||||
const unnamedNonePresent = !baseUrl && !apiKey;
|
||||
const unnamedAllPresent = baseUrl && apiKey;
|
||||
if (!(unnamedAllPresent || unnamedNonePresent)) {
|
||||
throw new Error(
|
||||
`Found partial default sonarqube config. All (or none) of baseUrl and apiKey must be provided.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (unnamedAllPresent) {
|
||||
const unnamedInstanceConfig = [
|
||||
{ name: DEFAULT_SONARQUBE_NAME, baseUrl, apiKey },
|
||||
] as {
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
}[];
|
||||
|
||||
return new SonarqubeConfig([
|
||||
...namedInstanceConfig,
|
||||
...unnamedInstanceConfig,
|
||||
]);
|
||||
}
|
||||
|
||||
return new SonarqubeConfig(namedInstanceConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a Sonarqube instance configuration by name, or the default one if no name is provided.
|
||||
* @param sonarqubeName - Optional name of the Sonarqube instance.
|
||||
* @returns The requested Sonarqube instance.
|
||||
* @throws Error when no default config could be found or the requested name couldn't be found in config.
|
||||
*/
|
||||
getInstanceConfig({
|
||||
sonarqubeName,
|
||||
}: { sonarqubeName?: string } = {}): SonarqubeInstanceConfig {
|
||||
const DEFAULT_SONARQUBE_NAME = 'default';
|
||||
|
||||
if (!sonarqubeName || sonarqubeName === DEFAULT_SONARQUBE_NAME) {
|
||||
// no name provided, use default
|
||||
const instanceConfig = this.instances.find(
|
||||
c => c.name === DEFAULT_SONARQUBE_NAME,
|
||||
);
|
||||
|
||||
if (!instanceConfig) {
|
||||
throw new Error(
|
||||
`Couldn't find a default sonarqube instance in the config. Either configure an instance with name ${DEFAULT_SONARQUBE_NAME} or add a prefix to your annotation value.`,
|
||||
);
|
||||
}
|
||||
|
||||
return instanceConfig;
|
||||
}
|
||||
|
||||
// A name is provided, look it up.
|
||||
const instanceConfig = this.instances.find(c => c.name === sonarqubeName);
|
||||
|
||||
if (!instanceConfig) {
|
||||
throw new Error(
|
||||
`Couldn't find a sonarqube instance in the config with name ${sonarqubeName}`,
|
||||
);
|
||||
}
|
||||
return instanceConfig;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*
|
||||
* Use default config and annotations, build using fromConfig static function.
|
||||
*/
|
||||
export class DefaultSonarqubeInfoProvider implements SonarqubeInfoProvider {
|
||||
private constructor(private readonly config: SonarqubeConfig) {}
|
||||
|
||||
/**
|
||||
* Generate an instance from a Config instance
|
||||
* @param config - Backend configuration
|
||||
*/
|
||||
static fromConfig(config: Config): DefaultSonarqubeInfoProvider {
|
||||
return new DefaultSonarqubeInfoProvider(SonarqubeConfig.fromConfig(config));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve all supported metrics from a sonarqube instance.
|
||||
*
|
||||
* @param instanceUrl - URL of the sonarqube instance
|
||||
* @param token - token to access the sonarqube instance
|
||||
* @returns The list of supported metrics, if no metrics are supported an empty list is provided in the promise
|
||||
* @private
|
||||
*/
|
||||
private static async getSupportedMetrics(
|
||||
instanceUrl: string,
|
||||
token: string,
|
||||
): Promise<string[]> {
|
||||
const metrics: string[] = [];
|
||||
let nextPage: number = 1;
|
||||
|
||||
for (;;) {
|
||||
const result = await DefaultSonarqubeInfoProvider.callApi<{
|
||||
metrics: Array<{ key: string }>;
|
||||
total: number;
|
||||
}>(instanceUrl, 'api/metrics/search', token, { ps: 500, p: nextPage });
|
||||
metrics.push(...(result?.metrics?.map(m => m.key) ?? []));
|
||||
|
||||
if (result && metrics.length < result.total) {
|
||||
nextPage++;
|
||||
continue;
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call an API with provided arguments
|
||||
* @param url - URL of the API to call
|
||||
* @param path - path to call
|
||||
* @param authToken - token used as basic auth user without password
|
||||
* @param query - parameters to provide to the call
|
||||
* @returns A promise on the answer to the API call if the answer status code is 200, undefined otherwise.
|
||||
* @private
|
||||
*/
|
||||
private static async callApi<T>(
|
||||
url: string,
|
||||
path: string,
|
||||
authToken: string,
|
||||
query: { [key in string]: any },
|
||||
): Promise<T | undefined> {
|
||||
// Sonarqube auth use basic with token as username and no password
|
||||
// but standard dictate the colon (separator) need to stay here despite the
|
||||
// lack of password
|
||||
const encodedAuthToken = Buffer.from(`${authToken}:`).toString('base64');
|
||||
|
||||
const response = await fetch(
|
||||
`${url}/${path}?${new URLSearchParams(query).toString()}`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Basic ${encodedAuthToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (response.status === 200) {
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc SonarqubeInfoProvider.getBaseUrl}
|
||||
* @throws Error If configuration can't be retrieved.
|
||||
*/
|
||||
getBaseUrl({ instanceName }: { instanceName?: string } = {}): {
|
||||
baseUrl: string;
|
||||
} {
|
||||
const instanceConfig = this.config.getInstanceConfig({
|
||||
sonarqubeName: instanceName,
|
||||
});
|
||||
return { baseUrl: instanceConfig.baseUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc SonarqubeInfoProvider.getFindings}
|
||||
* @throws Error If configuration can't be retrieved.
|
||||
*/
|
||||
async getFindings({
|
||||
componentKey,
|
||||
instanceName,
|
||||
}: {
|
||||
componentKey: string;
|
||||
instanceName?: string;
|
||||
}): Promise<SonarqubeFindings | undefined> {
|
||||
const { baseUrl, apiKey } = this.config.getInstanceConfig({
|
||||
sonarqubeName: instanceName,
|
||||
});
|
||||
|
||||
// get component info to retrieve analysis date
|
||||
const component =
|
||||
await DefaultSonarqubeInfoProvider.callApi<ComponentWrapper>(
|
||||
baseUrl,
|
||||
'api/components/show',
|
||||
apiKey,
|
||||
{
|
||||
component: componentKey,
|
||||
},
|
||||
);
|
||||
if (!component || !component.component) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// select the metrics that are supported by the SonarQube instance
|
||||
const supportedMetrics =
|
||||
await DefaultSonarqubeInfoProvider.getSupportedMetrics(baseUrl, apiKey);
|
||||
const wantedMetrics: string[] = [
|
||||
'alert_status',
|
||||
'bugs',
|
||||
'reliability_rating',
|
||||
'vulnerabilities',
|
||||
'security_rating',
|
||||
'security_hotspots_reviewed',
|
||||
'security_review_rating',
|
||||
'code_smells',
|
||||
'sqale_rating',
|
||||
'coverage',
|
||||
'duplicated_lines_density',
|
||||
];
|
||||
|
||||
// only retrieve wanted metrics that are supported
|
||||
const metricsToQuery = wantedMetrics.filter(el =>
|
||||
supportedMetrics.includes(el),
|
||||
);
|
||||
|
||||
// get all measures
|
||||
const measures =
|
||||
await DefaultSonarqubeInfoProvider.callApi<MeasuresWrapper>(
|
||||
baseUrl,
|
||||
'api/measures/component',
|
||||
apiKey,
|
||||
{
|
||||
component: componentKey,
|
||||
metricKeys: metricsToQuery.join(','),
|
||||
},
|
||||
);
|
||||
if (!measures) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
analysisDate: component.component.analysisDate,
|
||||
measures: measures.component?.measures ?? [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
createServiceBuilder,
|
||||
loadBackendConfig,
|
||||
} from '@backstage/backend-common';
|
||||
import { Server } from 'http';
|
||||
import { Logger } from 'winston';
|
||||
import { createRouter } from './router';
|
||||
import { DefaultSonarqubeInfoProvider } from './sonarqubeInfoProvider';
|
||||
|
||||
export interface ServerOptions {
|
||||
port: number;
|
||||
enableCors: boolean;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export async function startStandaloneServer(
|
||||
options: ServerOptions,
|
||||
): Promise<Server> {
|
||||
const logger = options.logger.child({ service: 'sonarqube-backend-backend' });
|
||||
logger.debug('Starting application server...');
|
||||
const config = await loadBackendConfig({ logger, argv: process.argv });
|
||||
const router = await createRouter({
|
||||
logger,
|
||||
sonarqubeInfoProvider: DefaultSonarqubeInfoProvider.fromConfig(config),
|
||||
});
|
||||
|
||||
let service = createServiceBuilder(module)
|
||||
.setPort(options.port)
|
||||
.addRouter('/sonarqube', router);
|
||||
if (options.enableCors) {
|
||||
service = service.enableCors({ origin: 'http://localhost:3000' });
|
||||
}
|
||||
|
||||
return await service.start().catch(err => {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.hot?.accept();
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export {};
|
||||
@@ -33,50 +33,14 @@ yarn add --cwd packages/app @backstage/plugin-sonarqube
|
||||
);
|
||||
```
|
||||
|
||||
3. Add the proxy config:
|
||||
|
||||
Provide a method for your Backstage backend to get to your SonarQube API end point. Add configuration to your `app-config.yaml` file depending on the product you use. Make sure to keep the trailing colon after the `SONARQUBE_TOKEN`, it is required to call
|
||||
the Web API (see [docs](https://docs.sonarqube.org/latest/extend/web-api/)).
|
||||
|
||||
**SonarCloud**
|
||||
|
||||
```yaml
|
||||
proxy:
|
||||
'/sonarqube':
|
||||
target: https://sonarcloud.io/api
|
||||
allowedMethods: ['GET']
|
||||
# note that the colon after the token is required
|
||||
auth: '${SONARQUBE_TOKEN}:'
|
||||
# Environmental variable: SONARQUBE_TOKEN
|
||||
# Fetch the sonar-auth-token from https://sonarcloud.io/account/security/
|
||||
```
|
||||
|
||||
**SonarQube**
|
||||
|
||||
```yaml
|
||||
proxy:
|
||||
'/sonarqube':
|
||||
target: https://your.sonarqube.instance.com/api
|
||||
allowedMethods: ['GET']
|
||||
# note that the colon after the token is required
|
||||
auth: '${SONARQUBE_TOKEN}:'
|
||||
# Environmental variable: SONARQUBE_TOKEN
|
||||
# Fetch the sonar-auth-token from https://sonarcloud.io/account/security/
|
||||
|
||||
sonarQube:
|
||||
baseUrl: https://your.sonarqube.instance.com
|
||||
```
|
||||
|
||||
4. Get and provide `SONARQUBE_TOKEN` as an env variable (https://sonarcloud.io/account/security or https://docs.sonarqube.org/latest/user-guide/user-token/).
|
||||
|
||||
5. Run the following commands in the root folder of the project to install and compile the changes.
|
||||
3. Run the following commands in the root folder of the project to install and compile the changes.
|
||||
|
||||
```yaml
|
||||
yarn install
|
||||
yarn tsc
|
||||
```
|
||||
|
||||
6. Add the `sonarqube.org/project-key` annotation to the `catalog-info.yaml` file of the target repo for which code quality analysis is needed.
|
||||
4. Add the `sonarqube.org/project-key` annotation to the `catalog-info.yaml` file of the target repo for which code quality analysis is needed.
|
||||
|
||||
```yaml
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
@@ -86,9 +50,11 @@ metadata:
|
||||
description: |
|
||||
Backstage is an open-source developer portal that puts the developer experience first.
|
||||
annotations:
|
||||
sonarqube.org/project-key: YOUR_PROJECT_KEY
|
||||
sonarqube.org/project-key: YOUR_INSTANCE_NAME/YOUR_PROJECT_KEY
|
||||
spec:
|
||||
type: library
|
||||
owner: CNCF
|
||||
lifecycle: experimental
|
||||
```
|
||||
|
||||
`YOUR_INSTANCE_NAME/` is optional and will query the default instance if not provided.
|
||||
|
||||
@@ -38,5 +38,11 @@ export const sonarQubeApiRef = createApiRef<SonarQubeApi>({
|
||||
});
|
||||
|
||||
export type SonarQubeApi = {
|
||||
getFindingSummary(componentKey?: string): Promise<FindingSummary | undefined>;
|
||||
getFindingSummary({
|
||||
componentKey,
|
||||
projectInstance,
|
||||
}: {
|
||||
componentKey?: string;
|
||||
projectInstance?: string;
|
||||
}): Promise<FindingSummary | undefined>;
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ import { setupRequestMockHandlers } from '@backstage/test-utils';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { FindingSummary, SonarQubeClient } from './index';
|
||||
import { ComponentWrapper, MeasuresWrapper } from './types';
|
||||
import { InstanceUrlWrapper, FindingsWrapper } from './types';
|
||||
import { UrlPatternDiscovery } from '@backstage/core-app-api';
|
||||
import { IdentityApi } from '@backstage/core-plugin-api';
|
||||
|
||||
@@ -40,133 +40,77 @@ const identityApiGuest: IdentityApi = {
|
||||
describe('SonarQubeClient', () => {
|
||||
setupRequestMockHandlers(server);
|
||||
|
||||
const mockBaseUrl = 'http://backstage:9191/api/proxy';
|
||||
const mockBaseUrl = 'http://backstage:9191/api/sonarqube';
|
||||
const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl);
|
||||
|
||||
const setupHandlers = (
|
||||
metricKeys = [
|
||||
'alert_status',
|
||||
'bugs',
|
||||
'reliability_rating',
|
||||
'vulnerabilities',
|
||||
'security_rating',
|
||||
'security_hotspots_reviewed',
|
||||
'security_review_rating',
|
||||
'code_smells',
|
||||
'sqale_rating',
|
||||
'coverage',
|
||||
'duplicated_lines_density',
|
||||
],
|
||||
) => {
|
||||
const setupHandlers = () => {
|
||||
server.use(
|
||||
rest.get(`${mockBaseUrl}/sonarqube/metrics/search`, (req, res, ctx) => {
|
||||
expect(req.url.searchParams.get('ps')).toBe('500');
|
||||
|
||||
// emulate paging to check if everything is requested
|
||||
if (req.url.searchParams.get('p') === '1') {
|
||||
return res(
|
||||
ctx.json({
|
||||
metrics: metricKeys.slice(0, 5).map(k => ({ key: k })),
|
||||
total: metricKeys.length,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// make sure this is only called twice
|
||||
expect(req.url.searchParams.get('p')).toBe('2');
|
||||
return res(
|
||||
ctx.json({
|
||||
metrics: metricKeys.slice(5).map(k => ({ key: k })),
|
||||
total: metricKeys.length,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
server.use(
|
||||
rest.get(`${mockBaseUrl}/sonarqube/components/show`, (req, res, ctx) => {
|
||||
expect(req.url.searchParams.toString()).toBe('component=our%3Aservice');
|
||||
return res(
|
||||
ctx.json({
|
||||
component: {
|
||||
analysisDate: '2020-01-01T00:00:00Z',
|
||||
},
|
||||
} as ComponentWrapper),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
server.use(
|
||||
rest.get(`${mockBaseUrl}/sonarqube/measures/search`, (req, res, ctx) => {
|
||||
rest.get(`${mockBaseUrl}/findings`, (req, res, ctx) => {
|
||||
expect(req.url.searchParams.toString()).toBe(
|
||||
`projectKeys=our%3Aservice&metricKeys=${metricKeys.join('%2C')}`,
|
||||
'componentKey=our%3Aservice&instanceKey=',
|
||||
);
|
||||
|
||||
return res(
|
||||
ctx.json({
|
||||
analysisDate: '2020-01-01T00:00:00Z',
|
||||
measures: [
|
||||
{
|
||||
metric: 'alert_status',
|
||||
value: 'OK',
|
||||
component: 'our:service',
|
||||
},
|
||||
{
|
||||
metric: 'alert_status',
|
||||
value: 'ERROR',
|
||||
component: 'other-service',
|
||||
},
|
||||
{
|
||||
metric: 'bugs',
|
||||
value: '2',
|
||||
component: 'our:service',
|
||||
},
|
||||
{
|
||||
metric: 'reliability_rating',
|
||||
value: '3.0',
|
||||
component: 'our:service',
|
||||
},
|
||||
{
|
||||
metric: 'vulnerabilities',
|
||||
value: '4',
|
||||
component: 'our:service',
|
||||
},
|
||||
{
|
||||
metric: 'security_rating',
|
||||
value: '1.0',
|
||||
component: 'our:service',
|
||||
},
|
||||
{
|
||||
metric: 'security_hotspots_reviewed',
|
||||
value: '100',
|
||||
component: 'our:service',
|
||||
},
|
||||
{
|
||||
metric: 'security_review_rating',
|
||||
value: '1.0',
|
||||
component: 'our:service',
|
||||
},
|
||||
{
|
||||
metric: 'code_smells',
|
||||
value: '100',
|
||||
component: 'our:service',
|
||||
},
|
||||
{
|
||||
metric: 'sqale_rating',
|
||||
value: '2.0',
|
||||
component: 'our:service',
|
||||
},
|
||||
{
|
||||
metric: 'coverage',
|
||||
value: '55.5',
|
||||
component: 'our:service',
|
||||
},
|
||||
{
|
||||
metric: 'duplicated_lines_density',
|
||||
value: '1.0',
|
||||
component: 'our:service',
|
||||
},
|
||||
].filter(m => metricKeys.includes(m.metric)),
|
||||
} as MeasuresWrapper),
|
||||
],
|
||||
} as FindingsWrapper),
|
||||
);
|
||||
}),
|
||||
);
|
||||
server.use(
|
||||
rest.get(`${mockBaseUrl}/instanceUrl`, (req, res, ctx) => {
|
||||
expect(req.url.searchParams.toString()).toBe('instanceKey=');
|
||||
|
||||
return res(
|
||||
ctx.json({
|
||||
instanceUrl: 'https://sonarcloud.io',
|
||||
} as InstanceUrlWrapper),
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -180,7 +124,9 @@ describe('SonarQubeClient', () => {
|
||||
identityApi: identityApiAuthenticated,
|
||||
});
|
||||
|
||||
const summary = await client.getFindingSummary('our:service');
|
||||
const summary = await client.getFindingSummary({
|
||||
componentKey: 'our:service',
|
||||
});
|
||||
expect(summary).toEqual(
|
||||
expect.objectContaining({
|
||||
lastAnalysis: '2020-01-01T00:00:00Z',
|
||||
@@ -210,59 +156,63 @@ describe('SonarQubeClient', () => {
|
||||
|
||||
it('should report finding summary (custom baseUrl)', async () => {
|
||||
setupHandlers();
|
||||
server.use(
|
||||
rest.get(`${mockBaseUrl}/instanceUrl`, (req, res, ctx) => {
|
||||
expect(req.url.searchParams.toString()).toBe('instanceKey=custom');
|
||||
|
||||
return res(
|
||||
ctx.json({
|
||||
instanceUrl: 'http://a.instance.local',
|
||||
} as InstanceUrlWrapper),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
server.use(
|
||||
rest.get(`${mockBaseUrl}/findings`, (req, res, ctx) => {
|
||||
expect(req.url.searchParams.toString()).toBe(
|
||||
'componentKey=our%3Aservice&instanceKey=custom',
|
||||
);
|
||||
|
||||
return res(
|
||||
ctx.json({
|
||||
analysisDate: '2020-01-03T00:00:00Z',
|
||||
measures: [
|
||||
{
|
||||
metric: 'alert_status',
|
||||
value: 'ERROR',
|
||||
},
|
||||
{
|
||||
metric: 'bugs',
|
||||
value: '45',
|
||||
},
|
||||
{
|
||||
metric: 'reliability_rating',
|
||||
value: '5.0',
|
||||
},
|
||||
],
|
||||
} as FindingsWrapper),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const client = new SonarQubeClient({
|
||||
discoveryApi,
|
||||
baseUrl: 'http://a.instance.local',
|
||||
identityApi: identityApiAuthenticated,
|
||||
});
|
||||
|
||||
const summary = await client.getFindingSummary('our:service');
|
||||
|
||||
expect(summary).toEqual(
|
||||
expect.objectContaining({
|
||||
lastAnalysis: '2020-01-01T00:00:00Z',
|
||||
metrics: {
|
||||
alert_status: 'OK',
|
||||
bugs: '2',
|
||||
reliability_rating: '3.0',
|
||||
vulnerabilities: '4',
|
||||
security_rating: '1.0',
|
||||
security_hotspots_reviewed: '100',
|
||||
security_review_rating: '1.0',
|
||||
code_smells: '100',
|
||||
sqale_rating: '2.0',
|
||||
coverage: '55.5',
|
||||
duplicated_lines_density: '1.0',
|
||||
},
|
||||
projectUrl: 'http://a.instance.local/dashboard?id=our%3Aservice',
|
||||
}) as FindingSummary,
|
||||
);
|
||||
expect(summary?.getIssuesUrl('CODE_SMELL')).toEqual(
|
||||
'http://a.instance.local/project/issues?id=our%3Aservice&types=CODE_SMELL&resolved=false',
|
||||
);
|
||||
expect(summary?.getComponentMeasuresUrl('COVERAGE')).toEqual(
|
||||
'http://a.instance.local/component_measures?id=our%3Aservice&metric=coverage&resolved=false&view=list',
|
||||
);
|
||||
});
|
||||
|
||||
it('should only request selected metrics', async () => {
|
||||
setupHandlers(['alert_status', 'bugs']);
|
||||
|
||||
const client = new SonarQubeClient({
|
||||
discoveryApi,
|
||||
baseUrl: 'http://a.instance.local',
|
||||
identityApi: identityApiAuthenticated,
|
||||
const summary = await client.getFindingSummary({
|
||||
componentKey: 'our:service',
|
||||
projectInstance: 'custom',
|
||||
});
|
||||
|
||||
const summary = await client.getFindingSummary('our:service');
|
||||
|
||||
expect(summary).toEqual(
|
||||
expect.objectContaining({
|
||||
lastAnalysis: '2020-01-01T00:00:00Z',
|
||||
lastAnalysis: '2020-01-03T00:00:00Z',
|
||||
metrics: {
|
||||
alert_status: 'OK',
|
||||
bugs: '2',
|
||||
alert_status: 'ERROR',
|
||||
bugs: '45',
|
||||
reliability_rating: '5.0',
|
||||
},
|
||||
projectUrl: 'http://a.instance.local/dashboard?id=our%3Aservice',
|
||||
}) as FindingSummary,
|
||||
@@ -278,25 +228,27 @@ describe('SonarQubeClient', () => {
|
||||
it('should add identity token for logged in users', async () => {
|
||||
setupHandlers();
|
||||
server.use(
|
||||
rest.get(`${mockBaseUrl}/sonarqube/components/show`, (req, res, ctx) => {
|
||||
expect(req.url.searchParams.toString()).toBe('component=our%3Aservice');
|
||||
rest.get(`${mockBaseUrl}/findings`, (req, res, ctx) => {
|
||||
expect(req.url.searchParams.toString()).toBe(
|
||||
'componentKey=our%3Aservice&instanceKey=',
|
||||
);
|
||||
expect(req.headers.get('Authorization')).toBe('Bearer fake-id-token');
|
||||
return res(
|
||||
ctx.json({
|
||||
component: {
|
||||
analysisDate: '2020-01-01T00:00:00Z',
|
||||
},
|
||||
} as ComponentWrapper),
|
||||
analysisDate: '2020-01-01T00:00:00Z',
|
||||
measures: [],
|
||||
} as FindingsWrapper),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const client = new SonarQubeClient({
|
||||
discoveryApi,
|
||||
baseUrl: 'http://a.instance.local',
|
||||
identityApi: identityApiAuthenticated,
|
||||
});
|
||||
const summary = await client.getFindingSummary('our:service');
|
||||
const summary = await client.getFindingSummary({
|
||||
componentKey: 'our:service',
|
||||
});
|
||||
|
||||
expect(summary?.lastAnalysis).toBe('2020-01-01T00:00:00Z');
|
||||
});
|
||||
@@ -304,25 +256,27 @@ describe('SonarQubeClient', () => {
|
||||
it('should omit identity token for guest users', async () => {
|
||||
setupHandlers();
|
||||
server.use(
|
||||
rest.get(`${mockBaseUrl}/sonarqube/components/show`, (req, res, ctx) => {
|
||||
expect(req.url.searchParams.toString()).toBe('component=our%3Aservice');
|
||||
rest.get(`${mockBaseUrl}/findings`, (req, res, ctx) => {
|
||||
expect(req.url.searchParams.toString()).toBe(
|
||||
'componentKey=our%3Aservice&instanceKey=',
|
||||
);
|
||||
expect(req.headers.has('Authorization')).toBeFalsy();
|
||||
return res(
|
||||
ctx.json({
|
||||
component: {
|
||||
analysisDate: '2020-01-01T00:00:00Z',
|
||||
},
|
||||
} as ComponentWrapper),
|
||||
analysisDate: '2020-01-01T00:00:00Z',
|
||||
measures: [],
|
||||
} as FindingsWrapper),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const client = new SonarQubeClient({
|
||||
discoveryApi,
|
||||
baseUrl: 'http://a.instance.local',
|
||||
identityApi: identityApiGuest,
|
||||
});
|
||||
const summary = await client.getFindingSummary('our:service');
|
||||
const summary = await client.getFindingSummary({
|
||||
componentKey: 'our:service',
|
||||
});
|
||||
|
||||
expect(summary?.lastAnalysis).toBe('2020-01-01T00:00:00Z');
|
||||
});
|
||||
|
||||
@@ -16,26 +16,22 @@
|
||||
|
||||
import fetch from 'cross-fetch';
|
||||
import { FindingSummary, Metrics, SonarQubeApi } from './SonarQubeApi';
|
||||
import { ComponentWrapper, MeasuresWrapper } from './types';
|
||||
import { InstanceUrlWrapper, FindingsWrapper } from './types';
|
||||
import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api';
|
||||
|
||||
export class SonarQubeClient implements SonarQubeApi {
|
||||
discoveryApi: DiscoveryApi;
|
||||
baseUrl: string;
|
||||
identityApi: IdentityApi;
|
||||
|
||||
constructor({
|
||||
discoveryApi,
|
||||
identityApi,
|
||||
baseUrl = 'https://sonarcloud.io/',
|
||||
}: {
|
||||
discoveryApi: DiscoveryApi;
|
||||
identityApi: IdentityApi;
|
||||
baseUrl?: string;
|
||||
}) {
|
||||
this.discoveryApi = discoveryApi;
|
||||
this.identityApi = identityApi;
|
||||
this.baseUrl = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
||||
}
|
||||
|
||||
private async callApi<T>(
|
||||
@@ -44,7 +40,7 @@ export class SonarQubeClient implements SonarQubeApi {
|
||||
): Promise<T | undefined> {
|
||||
const { token: idToken } = await this.identityApi.getCredentials();
|
||||
|
||||
const apiUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/sonarqube`;
|
||||
const apiUrl = `${await this.discoveryApi.getBaseUrl('sonarqube')}`;
|
||||
const response = await fetch(
|
||||
`${apiUrl}/${path}?${new URLSearchParams(query).toString()}`,
|
||||
{
|
||||
@@ -60,40 +56,18 @@ export class SonarQubeClient implements SonarQubeApi {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async getSupportedMetrics(): Promise<string[]> {
|
||||
const metrics: string[] = [];
|
||||
let nextPage: number = 1;
|
||||
|
||||
for (;;) {
|
||||
const result = await this.callApi<{
|
||||
metrics: Array<{ key: string }>;
|
||||
total: number;
|
||||
}>('metrics/search', { ps: 500, p: nextPage });
|
||||
|
||||
metrics.push(...(result?.metrics?.map(m => m.key) ?? []));
|
||||
|
||||
if (result && metrics.length < result.total) {
|
||||
nextPage++;
|
||||
continue;
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
}
|
||||
|
||||
async getFindingSummary(
|
||||
componentKey?: string,
|
||||
): Promise<FindingSummary | undefined> {
|
||||
async getFindingSummary({
|
||||
componentKey,
|
||||
projectInstance,
|
||||
}: {
|
||||
componentKey?: string;
|
||||
projectInstance?: string;
|
||||
} = {}): Promise<FindingSummary | undefined> {
|
||||
if (!componentKey) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const component = await this.callApi<ComponentWrapper>('components/show', {
|
||||
component: componentKey,
|
||||
});
|
||||
if (!component) {
|
||||
return undefined;
|
||||
}
|
||||
const instanceKey = projectInstance || '';
|
||||
|
||||
const metrics: Metrics = {
|
||||
alert_status: undefined,
|
||||
@@ -109,44 +83,49 @@ export class SonarQubeClient implements SonarQubeApi {
|
||||
duplicated_lines_density: undefined,
|
||||
};
|
||||
|
||||
// select the metrics that are supported by the SonarQube instance
|
||||
const supportedMetrics = await this.getSupportedMetrics();
|
||||
const metricKeys = Object.keys(metrics).filter(m =>
|
||||
supportedMetrics.includes(m),
|
||||
const baseUrlWrapper = await this.callApi<InstanceUrlWrapper>(
|
||||
'instanceUrl',
|
||||
{
|
||||
instanceKey,
|
||||
},
|
||||
);
|
||||
let baseUrl = baseUrlWrapper?.instanceUrl;
|
||||
if (!baseUrl) {
|
||||
return undefined;
|
||||
}
|
||||
// ensure trailing slash for later on
|
||||
if (!baseUrl.endsWith('/')) {
|
||||
baseUrl += '/';
|
||||
}
|
||||
|
||||
const measures = await this.callApi<MeasuresWrapper>('measures/search', {
|
||||
projectKeys: componentKey,
|
||||
metricKeys: metricKeys.join(','),
|
||||
const findings = await this.callApi<FindingsWrapper>('findings', {
|
||||
componentKey,
|
||||
instanceKey,
|
||||
});
|
||||
if (!measures) {
|
||||
if (!findings) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
measures.measures
|
||||
.filter(m => m.component === componentKey)
|
||||
.forEach(m => {
|
||||
metrics[m.metric] = m.value;
|
||||
});
|
||||
findings.measures.forEach(m => {
|
||||
metrics[m.metric] = m.value;
|
||||
});
|
||||
|
||||
return {
|
||||
lastAnalysis: component.component.analysisDate,
|
||||
lastAnalysis: findings.analysisDate,
|
||||
metrics,
|
||||
projectUrl: `${this.baseUrl}dashboard?id=${encodeURIComponent(
|
||||
componentKey,
|
||||
)}`,
|
||||
projectUrl: `${baseUrl}dashboard?id=${encodeURIComponent(componentKey)}`,
|
||||
getIssuesUrl: identifier =>
|
||||
`${this.baseUrl}project/issues?id=${encodeURIComponent(
|
||||
`${baseUrl}project/issues?id=${encodeURIComponent(
|
||||
componentKey,
|
||||
)}&types=${identifier.toLocaleUpperCase('en-US')}&resolved=false`,
|
||||
getComponentMeasuresUrl: identifier =>
|
||||
`${this.baseUrl}component_measures?id=${encodeURIComponent(
|
||||
`${baseUrl}component_measures?id=${encodeURIComponent(
|
||||
componentKey,
|
||||
)}&metric=${identifier.toLocaleLowerCase(
|
||||
'en-US',
|
||||
)}&resolved=false&view=list`,
|
||||
getSecurityHotspotsUrl: () =>
|
||||
`${this.baseUrl}project/security_hotspots?id=${encodeURIComponent(
|
||||
`${baseUrl}project/security_hotspots?id=${encodeURIComponent(
|
||||
componentKey,
|
||||
)}`,
|
||||
};
|
||||
|
||||
@@ -14,15 +14,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export interface ComponentWrapper {
|
||||
component: Component;
|
||||
export interface InstanceUrlWrapper {
|
||||
instanceUrl: string;
|
||||
}
|
||||
|
||||
export interface Component {
|
||||
export interface FindingsWrapper {
|
||||
analysisDate: string;
|
||||
}
|
||||
|
||||
export interface MeasuresWrapper {
|
||||
measures: Measure[];
|
||||
}
|
||||
|
||||
@@ -55,7 +52,6 @@ export type MetricKey =
|
||||
export interface Measure {
|
||||
metric: MetricKey;
|
||||
value: string;
|
||||
component: string;
|
||||
}
|
||||
|
||||
export type SonarUrlProcessorFunc = (identifier: string) => string;
|
||||
|
||||
@@ -26,7 +26,7 @@ import useAsync from 'react-use/lib/useAsync';
|
||||
import { sonarQubeApiRef } from '../../api';
|
||||
import {
|
||||
SONARQUBE_PROJECT_KEY_ANNOTATION,
|
||||
useProjectKey,
|
||||
useProjectInfo,
|
||||
} from '../useProjectKey';
|
||||
import { Percentage } from './Percentage';
|
||||
import { Rating } from './Rating';
|
||||
@@ -95,10 +95,14 @@ export const SonarQubeCard = ({
|
||||
const { entity } = useEntity();
|
||||
const sonarQubeApi = useApi(sonarQubeApiRef);
|
||||
|
||||
const projectTitle = useProjectKey(entity);
|
||||
const { projectKey: projectTitle, projectInstance } = useProjectInfo(entity);
|
||||
|
||||
const { value, loading } = useAsync(
|
||||
async () => sonarQubeApi.getFindingSummary(projectTitle),
|
||||
async () =>
|
||||
sonarQubeApi.getFindingSummary({
|
||||
componentKey: projectTitle,
|
||||
projectInstance: projectInstance,
|
||||
}),
|
||||
[sonarQubeApi, projectTitle],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* 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 {
|
||||
isSonarQubeAvailable,
|
||||
SONARQUBE_PROJECT_INSTANCE_SEPARATOR,
|
||||
SONARQUBE_PROJECT_KEY_ANNOTATION,
|
||||
useProjectInfo,
|
||||
} from './useProjectKey';
|
||||
import { Entity } from '../../../../packages/catalog-model';
|
||||
|
||||
const createDummyEntity = (sonarqubeAnnotationValue: string): Entity => {
|
||||
return {
|
||||
apiVersion: '',
|
||||
kind: '',
|
||||
metadata: {
|
||||
name: 'dummy',
|
||||
annotations: {
|
||||
[SONARQUBE_PROJECT_KEY_ANNOTATION]: sonarqubeAnnotationValue,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe('isSonarQubeAvailable', () => {
|
||||
it('returns true if sonarqube annotation defined', () => {
|
||||
const entity = createDummyEntity('dummy');
|
||||
expect(isSonarQubeAvailable(entity)).toBe(true);
|
||||
});
|
||||
it('returns false if sonarqube annotation empty', () => {
|
||||
const entity = createDummyEntity('');
|
||||
expect(isSonarQubeAvailable(entity)).toBe(false);
|
||||
});
|
||||
it('returns false if sonarqube annotation not defined', () => {
|
||||
const entity = {
|
||||
apiVersion: '',
|
||||
kind: '',
|
||||
metadata: {
|
||||
name: 'dummy',
|
||||
annotations: {},
|
||||
},
|
||||
};
|
||||
expect(isSonarQubeAvailable(entity)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useProjectInfo', () => {
|
||||
const DUMMY_INSTANCE = 'dummyInstance';
|
||||
const DUMMY_KEY = 'dummyKey';
|
||||
it('parse annotation with key and instance', () => {
|
||||
const entity = createDummyEntity(
|
||||
DUMMY_INSTANCE + SONARQUBE_PROJECT_INSTANCE_SEPARATOR + DUMMY_KEY,
|
||||
);
|
||||
expect(useProjectInfo(entity)).toEqual({
|
||||
projectInstance: DUMMY_INSTANCE,
|
||||
projectKey: DUMMY_KEY,
|
||||
});
|
||||
});
|
||||
// compatibility with previous mono-instance sonarqube config
|
||||
it('parse annotation with only key', () => {
|
||||
const entity = createDummyEntity(DUMMY_KEY);
|
||||
expect(useProjectInfo(entity)).toEqual({
|
||||
projectInstance: undefined,
|
||||
projectKey: DUMMY_KEY,
|
||||
});
|
||||
});
|
||||
it('handle empty annotation', () => {
|
||||
const entity = createDummyEntity('');
|
||||
expect(useProjectInfo(entity)).toEqual({
|
||||
projectInstance: undefined,
|
||||
projectKey: undefined,
|
||||
});
|
||||
});
|
||||
it('handle non-existent annotation', () => {
|
||||
const entity = {
|
||||
apiVersion: '',
|
||||
kind: '',
|
||||
metadata: {
|
||||
name: 'dummy',
|
||||
annotations: {},
|
||||
},
|
||||
};
|
||||
expect(useProjectInfo(entity)).toEqual({
|
||||
projectInstance: undefined,
|
||||
projectKey: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,10 +17,38 @@
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
export const SONARQUBE_PROJECT_KEY_ANNOTATION = 'sonarqube.org/project-key';
|
||||
export const SONARQUBE_PROJECT_INSTANCE_SEPARATOR = '/';
|
||||
|
||||
export const isSonarQubeAvailable = (entity: Entity) =>
|
||||
Boolean(entity.metadata.annotations?.[SONARQUBE_PROJECT_KEY_ANNOTATION]);
|
||||
|
||||
export const useProjectKey = (entity: Entity) => {
|
||||
return entity?.metadata.annotations?.[SONARQUBE_PROJECT_KEY_ANNOTATION] ?? '';
|
||||
/**
|
||||
* Try to parse sonarqube information from an entity.
|
||||
*
|
||||
* If part or all info are not found, they will default to undefined
|
||||
*
|
||||
* @param entity entity to find the sonarqube information from.
|
||||
* @return a ProjectInfo properly populated.
|
||||
*/
|
||||
export const useProjectInfo = (
|
||||
entity: Entity,
|
||||
): {
|
||||
projectInstance: string | undefined;
|
||||
projectKey: string | undefined;
|
||||
} => {
|
||||
let projectInstance = undefined;
|
||||
let projectKey = undefined;
|
||||
const annotation =
|
||||
entity?.metadata.annotations?.[SONARQUBE_PROJECT_KEY_ANNOTATION];
|
||||
if (annotation) {
|
||||
if (annotation.indexOf(SONARQUBE_PROJECT_INSTANCE_SEPARATOR) > -1) {
|
||||
[projectInstance, projectKey] = annotation.split(
|
||||
SONARQUBE_PROJECT_INSTANCE_SEPARATOR,
|
||||
2,
|
||||
);
|
||||
} else {
|
||||
projectKey = annotation;
|
||||
}
|
||||
}
|
||||
return { projectInstance, projectKey };
|
||||
};
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
import { sonarQubeApiRef, SonarQubeClient } from './api';
|
||||
import {
|
||||
configApiRef,
|
||||
createApiFactory,
|
||||
createComponentExtension,
|
||||
createPlugin,
|
||||
@@ -30,14 +29,12 @@ export const sonarQubePlugin = createPlugin({
|
||||
createApiFactory({
|
||||
api: sonarQubeApiRef,
|
||||
deps: {
|
||||
configApi: configApiRef,
|
||||
discoveryApi: discoveryApiRef,
|
||||
identityApi: identityApiRef,
|
||||
},
|
||||
factory: ({ configApi, discoveryApi, identityApi }) =>
|
||||
factory: ({ discoveryApi, identityApi }) =>
|
||||
new SonarQubeClient({
|
||||
discoveryApi,
|
||||
baseUrl: configApi.getOptionalString('sonarQube.baseUrl'),
|
||||
identityApi,
|
||||
}),
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user