Merge pull request #11925 from soprasteria/neemys/implement-multiple-sonarqube-instances

Add ability to configure multiple sonarqube instances
This commit is contained in:
Ben Lambert
2022-08-02 10:36:36 +02:00
committed by GitHub
25 changed files with 1851 additions and 246 deletions
+5 -39
View File
@@ -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.
+7 -1
View File
@@ -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>;
};
+88 -134
View File
@@ -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');
});
+35 -56
View File
@@ -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,
)}`,
};
+3 -7
View File
@@ -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 };
};
+1 -4
View File
@@ -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,
}),
}),