Merge pull request #8172 from goenning/go/azure-devops-discovery

adding AzureDevOps Discovery Processor
This commit is contained in:
Fredrik Adelöw
2021-11-24 13:07:13 +01:00
committed by GitHub
13 changed files with 842 additions and 1 deletions
+20
View File
@@ -174,6 +174,26 @@ export class AwsS3DiscoveryProcessor implements CatalogProcessor {
): Promise<boolean>;
}
// Warning: (ae-missing-release-tag) "AzureDevOpsDiscoveryProcessor" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export class AzureDevOpsDiscoveryProcessor implements CatalogProcessor {
constructor(options: { integrations: ScmIntegrations; logger: Logger_2 });
// (undocumented)
static fromConfig(
config: Config,
options: {
logger: Logger_2;
},
): AzureDevOpsDiscoveryProcessor;
// (undocumented)
readLocation(
location: LocationSpec,
_optional: boolean,
emit: CatalogProcessorEmit,
): Promise<boolean>;
}
// Warning: (ae-missing-release-tag) "BitbucketDiscoveryProcessor" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -0,0 +1,277 @@
/*
* Copyright 2021 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 { LocationSpec } from '@backstage/catalog-model';
import { ConfigReader } from '@backstage/config';
import { codeSearch } from './azure';
import {
AzureDevOpsDiscoveryProcessor,
parseUrl,
} from './AzureDevOpsDiscoveryProcessor';
jest.mock('./azure');
const mockCodeSearch = codeSearch as jest.MockedFunction<typeof codeSearch>;
describe('AzureDevOpsDiscoveryProcessor', () => {
describe('parseUrl', () => {
it('parses well formed URLs', () => {
expect(parseUrl('https://dev.azure.com/my-org/my-proj')).toEqual({
baseUrl: 'https://dev.azure.com',
org: 'my-org',
project: 'my-proj',
repo: '',
catalogPath: '/catalog-info.yaml',
});
expect(
parseUrl(
'https://dev.azure.com/spotify/engineering/_git/backstage?path=/catalog.yaml',
),
).toEqual({
baseUrl: 'https://dev.azure.com',
org: 'spotify',
project: 'engineering',
repo: 'backstage',
catalogPath: '/catalog.yaml',
});
expect(
parseUrl(
'https://azuredevops.mycompany.com/spotify/engineering/_git/backstage?path=/src/*/catalog.yaml',
),
).toEqual({
baseUrl: 'https://azuredevops.mycompany.com',
org: 'spotify',
project: 'engineering',
repo: 'backstage',
catalogPath: '/src/*/catalog.yaml',
});
});
it('throws on incorrectly formed URLs', () => {
expect(() => parseUrl('https://dev.azure.com')).toThrow();
expect(() => parseUrl('https://dev.azure.com//')).toThrow();
expect(() => parseUrl('https://dev.azure.com//foo')).toThrow();
});
});
describe('reject unrelated entries', () => {
it('rejects unknown types', async () => {
const processor = AzureDevOpsDiscoveryProcessor.fromConfig(
new ConfigReader({
integrations: {
azure: [{ host: 'dev.azure.com', token: 'blob' }],
},
}),
{ logger: getVoidLogger() },
);
const location: LocationSpec = {
type: 'not-azure-discovery',
target: 'https://dev.azure.com',
};
await expect(
processor.readLocation(location, false, () => {}),
).resolves.toBeFalsy();
});
});
it('rejects unknown targets', async () => {
const processor = AzureDevOpsDiscoveryProcessor.fromConfig(
new ConfigReader({
integrations: {
github: [
{ host: 'dev.azure.com', token: 'blob' },
{ host: 'azure.myorg.com', token: 'blob' },
],
},
}),
{ logger: getVoidLogger() },
);
const location: LocationSpec = {
type: 'azure-discovery',
target: 'https://not.azure.com/org/project',
};
await expect(
processor.readLocation(location, false, () => {}),
).rejects.toThrow(
/There is no Azure integration that matches https:\/\/not.azure.com\/org\/project. Please add a configuration entry for it under integrations.azure/,
);
});
describe('handles repositories', () => {
const processor = AzureDevOpsDiscoveryProcessor.fromConfig(
new ConfigReader({
integrations: {
github: [{ host: 'dev.azure.com', token: 'blob' }],
},
}),
{ logger: getVoidLogger() },
);
beforeEach(() => {
mockCodeSearch.mockClear();
});
it('output all locations found on from code search', async () => {
const location: LocationSpec = {
type: 'azure-discovery',
target: 'https://dev.azure.com/shopify/engineering',
};
mockCodeSearch.mockResolvedValueOnce([
{
fileName: 'catalog-info.yaml',
path: '/catalog-info.yaml',
repository: {
name: 'backstage',
},
},
{
fileName: 'catalog-info.yaml',
path: '/src/catalog-info.yaml',
repository: {
name: 'ios-app',
},
},
]);
const emitter = jest.fn();
await processor.readLocation(location, false, emitter);
expect(mockCodeSearch).toHaveBeenCalledWith(
{ host: 'dev.azure.com' },
'shopify',
'engineering',
'',
'/catalog-info.yaml',
);
expect(emitter).toHaveBeenCalledTimes(2);
expect(emitter).toHaveBeenCalledWith({
type: 'location',
location: {
type: 'url',
target:
'https://dev.azure.com/shopify/engineering/_git/backstage?path=/catalog-info.yaml',
},
optional: true,
});
expect(emitter).toHaveBeenCalledWith({
type: 'location',
location: {
type: 'url',
target:
'https://dev.azure.com/shopify/engineering/_git/ios-app?path=/src/catalog-info.yaml',
},
optional: true,
});
});
it('output single locations from code search', async () => {
const location: LocationSpec = {
type: 'azure-discovery',
target: 'https://dev.azure.com/shopify/engineering/_git/backstage',
};
mockCodeSearch.mockResolvedValueOnce([
{
fileName: 'catalog-info.yaml',
path: '/catalog-info.yaml',
repository: {
name: 'backstage',
},
},
]);
const emitter = jest.fn();
await processor.readLocation(location, false, emitter);
expect(mockCodeSearch).toHaveBeenCalledWith(
{ host: 'dev.azure.com' },
'shopify',
'engineering',
'backstage',
'/catalog-info.yaml',
);
expect(emitter).toHaveBeenCalledTimes(1);
expect(emitter).toHaveBeenCalledWith({
type: 'location',
location: {
type: 'url',
target:
'https://dev.azure.com/shopify/engineering/_git/backstage?path=/catalog-info.yaml',
},
optional: true,
});
});
it('output single locations with different file name from code search', async () => {
const location: LocationSpec = {
type: 'azure-discovery',
target:
'https://dev.azure.com/shopify/engineering?path=/src/*/catalog.yaml',
};
mockCodeSearch.mockResolvedValueOnce([
{
fileName: 'catalog.yaml',
path: '/src/main/catalog.yaml',
repository: {
name: 'backstage',
},
},
]);
const emitter = jest.fn();
await processor.readLocation(location, false, emitter);
expect(mockCodeSearch).toHaveBeenCalledWith(
{ host: 'dev.azure.com' },
'shopify',
'engineering',
'',
'/src/*/catalog.yaml',
);
expect(emitter).toHaveBeenCalledTimes(1);
expect(emitter).toHaveBeenCalledWith({
type: 'location',
location: {
type: 'url',
target:
'https://dev.azure.com/shopify/engineering/_git/backstage?path=/src/main/catalog.yaml',
},
optional: true,
});
});
it('output nothing when code search does not find anything', async () => {
const location: LocationSpec = {
type: 'azure-discovery',
target: 'https://dev.azure.com/shopify/engineering/_git/backstage',
};
mockCodeSearch.mockResolvedValueOnce([]);
const emitter = jest.fn();
await processor.readLocation(location, false, emitter);
expect(mockCodeSearch).toHaveBeenCalledWith(
{ host: 'dev.azure.com' },
'shopify',
'engineering',
'backstage',
'/catalog-info.yaml',
);
expect(emitter).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,151 @@
/*
* Copyright 2021 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 { LocationSpec } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
import { Logger } from 'winston';
import * as results from './results';
import { CatalogProcessor, CatalogProcessorEmit } from './types';
import { codeSearch } from './azure';
/**
* Extracts repositories out of an Azure DevOps org.
*
* The following will create locations for all projects which have a catalog-info.yaml
* on the default branch. The first is shorthand for the second.
*
* target: "https://dev.azure.com/org/project"
* or
* target: https://dev.azure.com/org/project?path=/catalog-info.yaml
*
* You may also explicitly specify a single repo:
*
* target: https://dev.azure.com/org/project/_git/repo
**/
export class AzureDevOpsDiscoveryProcessor implements CatalogProcessor {
private readonly integrations: ScmIntegrations;
private readonly logger: Logger;
static fromConfig(config: Config, options: { logger: Logger }) {
const integrations = ScmIntegrations.fromConfig(config);
return new AzureDevOpsDiscoveryProcessor({
...options,
integrations,
});
}
constructor(options: { integrations: ScmIntegrations; logger: Logger }) {
this.integrations = options.integrations;
this.logger = options.logger;
}
async readLocation(
location: LocationSpec,
_optional: boolean,
emit: CatalogProcessorEmit,
): Promise<boolean> {
if (location.type !== 'azure-discovery') {
return false;
}
const azureConfig = this.integrations.azure.byUrl(location.target)?.config;
if (!azureConfig) {
throw new Error(
`There is no Azure integration that matches ${location.target}. Please add a configuration entry for it under integrations.azure`,
);
}
const { baseUrl, org, project, repo, catalogPath } = parseUrl(
location.target,
);
this.logger.info(
`Reading Azure DevOps repositories from ${location.target}`,
);
const files = await codeSearch(
azureConfig,
org,
project,
repo,
catalogPath,
);
this.logger.debug(
`Found ${files.length} files in Azure DevOps from ${location.target}.`,
);
for (const file of files) {
emit(
results.location(
{
type: 'url',
target: `${baseUrl}/${org}/${project}/_git/${file.repository.name}?path=${file.path}`,
},
// Not all locations may actually exist, since the user defined them as a wildcard pattern.
// Thus, we emit them as optional and let the downstream processor find them while not outputting
// an error if it couldn't.
true,
),
);
}
return true;
}
}
/**
* parseUrl extracts segments from the Azure DevOps URL.
**/
export function parseUrl(urlString: string): {
baseUrl: string;
org: string;
project: string;
repo: string;
catalogPath: string;
} {
const url = new URL(urlString);
const path = url.pathname.substr(1).split('/');
const catalogPath = url.searchParams.get('path') || '/catalog-info.yaml';
if (path.length === 2 && path[0].length && path[1].length) {
return {
baseUrl: url.origin,
org: decodeURIComponent(path[0]),
project: decodeURIComponent(path[1]),
repo: '',
catalogPath,
};
} else if (
path.length === 4 &&
path[0].length &&
path[1].length &&
path[2].length &&
path[3].length
) {
return {
baseUrl: url.origin,
org: decodeURIComponent(path[0]),
project: decodeURIComponent(path[1]),
repo: decodeURIComponent(path[3]),
catalogPath,
};
}
throw new Error(`Failed to parse ${urlString}`);
}
@@ -0,0 +1,232 @@
/*
* Copyright 2021 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 { setupServer } from 'msw/node';
import { setupRequestMockHandlers } from '@backstage/test-utils';
import { rest } from 'msw';
import { codeSearch, CodeSearchResponse } from './azure';
describe('azure', () => {
const server = setupServer();
setupRequestMockHandlers(server);
describe('codeSearch', () => {
it('returns empty when nothing is found', async () => {
const response: CodeSearchResponse = { count: 0, results: [] };
server.use(
rest.post(
`https://almsearch.dev.azure.com/shopify/engineering/_apis/search/codesearchresults`,
(req, res, ctx) => {
expect(req.headers.get('Authorization')).toBe('Basic OkFCQw==');
expect(req.body).toEqual({
searchText: 'path:/catalog-info.yaml repo:*',
$skip: 0,
$top: 1000,
});
return res(ctx.json(response));
},
),
);
await expect(
codeSearch(
{ host: 'dev.azure.com', token: 'ABC' },
'shopify',
'engineering',
'',
'/catalog-info.yaml',
),
).resolves.toEqual([]);
});
});
it('returns entries when request matches some files', async () => {
const response: CodeSearchResponse = {
count: 2,
results: [
{
fileName: 'catalog-info.yaml',
path: '/catalog-info.yaml',
repository: {
name: 'backstage',
},
},
{
fileName: 'catalog-info.yaml',
path: '/catalog-info.yaml',
repository: {
name: 'ios-app',
},
},
],
};
server.use(
rest.post(
`https://almsearch.dev.azure.com/shopify/engineering/_apis/search/codesearchresults`,
(req, res, ctx) => {
expect(req.headers.get('Authorization')).toBe('Basic OkFCQw==');
expect(req.body).toEqual({
searchText: 'path:/catalog-info.yaml repo:*',
$skip: 0,
$top: 1000,
});
return res(ctx.json(response));
},
),
);
await expect(
codeSearch(
{ host: 'dev.azure.com', token: 'ABC' },
'shopify',
'engineering',
'',
'/catalog-info.yaml',
),
).resolves.toEqual(response.results);
});
it('searches in specific repo if parameter is set', async () => {
const response: CodeSearchResponse = {
count: 1,
results: [
{
fileName: 'catalog-info.yaml',
path: '/catalog-info.yaml',
repository: {
name: 'backstage',
},
},
],
};
server.use(
rest.post(
`https://almsearch.dev.azure.com/shopify/engineering/_apis/search/codesearchresults`,
(req, res, ctx) => {
expect(req.headers.get('Authorization')).toBe('Basic OkFCQw==');
expect(req.body).toEqual({
searchText: 'path:/catalog-info.yaml repo:backstage',
$skip: 0,
$top: 1000,
});
return res(ctx.json(response));
},
),
);
await expect(
codeSearch(
{ host: 'dev.azure.com', token: 'ABC' },
'shopify',
'engineering',
'backstage',
'/catalog-info.yaml',
),
).resolves.toEqual(response.results);
});
it('can search using onpremise api', async () => {
const response: CodeSearchResponse = {
count: 1,
results: [
{
fileName: 'catalog-info.yaml',
path: '/catalog-info.yaml',
repository: {
name: 'backstage',
},
},
],
};
server.use(
rest.post(
`https://azuredevops.mycompany.com/shopify/engineering/_apis/search/codesearchresults`,
(req, res, ctx) => {
expect(req.headers.get('Authorization')).toBe('Basic OkFCQw==');
expect(req.body).toEqual({
searchText: 'path:/catalog-info.yaml repo:*',
$skip: 0,
$top: 1000,
});
return res(ctx.json(response));
},
),
);
await expect(
codeSearch(
{ host: 'azuredevops.mycompany.com', token: 'ABC' },
'shopify',
'engineering',
'',
'/catalog-info.yaml',
),
).resolves.toEqual(response.results);
});
it('searches multiple pages if response contains many items', async () => {
const totalCount = 2401;
const generateItems = (count: number) => {
return Array.from(Array(count).keys()).map(_ => ({
fileName: 'catalog-info.yaml',
path: '/catalog-info.yaml',
repository: {
name: 'backstage',
},
}));
};
server.use(
rest.post(
`https://almsearch.dev.azure.com/shopify/engineering/_apis/search/codesearchresults`,
(req, res, ctx) => {
expect(req.headers.get('Authorization')).toBe('Basic OkFCQw==');
expect(req.body).toMatchObject({
searchText: 'path:/catalog-info.yaml repo:backstage',
$top: 1000,
});
const body = req.body as { $skip: number; $top: number };
const countItemsToReturn =
body.$top + body.$skip > totalCount
? totalCount - body.$skip
: body.$top;
return res(
ctx.json({
count: totalCount,
results: generateItems(countItemsToReturn),
}),
);
},
),
);
await expect(
codeSearch(
{ host: 'dev.azure.com', token: 'ABC' },
'shopify',
'engineering',
'backstage',
'/catalog-info.yaml',
),
).resolves.toHaveLength(totalCount);
});
});
@@ -0,0 +1,80 @@
/*
* Copyright 2021 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 fetch from 'cross-fetch';
import {
AzureIntegrationConfig,
getAzureRequestOptions,
} from '@backstage/integration';
export interface CodeSearchResponse {
count: number;
results: CodeSearchResultItem[];
}
export interface CodeSearchResultItem {
fileName: string;
path: string;
repository: {
name: string;
};
}
const isCloud = (host: string) => host === 'dev.azure.com';
const PAGE_SIZE = 1000;
// codeSearch returns all files that matches the given search path.
export async function codeSearch(
azureConfig: AzureIntegrationConfig,
org: string,
project: string,
repo: string,
path: string,
): Promise<CodeSearchResultItem[]> {
const searchBaseUrl = isCloud(azureConfig.host)
? 'https://almsearch.dev.azure.com'
: `https://${azureConfig.host}`;
const searchUrl = `${searchBaseUrl}/${org}/${project}/_apis/search/codesearchresults?api-version=6.0-preview.1`;
let items: CodeSearchResultItem[] = [];
let hasMorePages = true;
do {
const response = await fetch(searchUrl, {
...getAzureRequestOptions(azureConfig, {
'Content-Type': 'application/json',
}),
method: 'POST',
body: JSON.stringify({
searchText: `path:${path} repo:${repo || '*'}`,
$skip: items.length,
$top: PAGE_SIZE,
}),
});
if (response.status !== 200) {
throw new Error(
`Azure DevOps search failed with response status ${response.status}`,
);
}
const body: CodeSearchResponse = await response.json();
items = [...items, ...body.results];
hasMorePages = body.count > items.length;
} while (hasMorePages);
return items;
}
@@ -0,0 +1,17 @@
/*
* Copyright 2021 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 './azure';
@@ -26,6 +26,7 @@ export { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor';
export { CodeOwnersProcessor } from './CodeOwnersProcessor';
export { FileReaderProcessor } from './FileReaderProcessor';
export { GithubDiscoveryProcessor } from './GithubDiscoveryProcessor';
export { AzureDevOpsDiscoveryProcessor } from './AzureDevOpsDiscoveryProcessor';
export { GithubOrgReaderProcessor } from './GithubOrgReaderProcessor';
export { GithubMultiOrgReaderProcessor } from './GithubMultiOrgReaderProcessor';
export { GitLabDiscoveryProcessor } from './GitLabDiscoveryProcessor';
@@ -42,6 +42,7 @@ import {
CodeOwnersProcessor,
FileReaderProcessor,
GithubDiscoveryProcessor,
AzureDevOpsDiscoveryProcessor,
GithubOrgReaderProcessor,
GitLabDiscoveryProcessor,
LocationEntityProcessor,
@@ -317,6 +318,7 @@ export class CatalogBuilder {
new FileReaderProcessor(),
BitbucketDiscoveryProcessor.fromConfig(config, { logger }),
GithubDiscoveryProcessor.fromConfig(config, { logger }),
AzureDevOpsDiscoveryProcessor.fromConfig(config, { logger }),
GithubOrgReaderProcessor.fromConfig(config, { logger }),
GitLabDiscoveryProcessor.fromConfig(config, { logger }),
new UrlReaderProcessor({ reader, logger }),
@@ -44,6 +44,7 @@ import {
CatalogProcessorParser,
CodeOwnersProcessor,
FileReaderProcessor,
AzureDevOpsDiscoveryProcessor,
GithubDiscoveryProcessor,
GithubOrgReaderProcessor,
GitLabDiscoveryProcessor,
@@ -292,6 +293,7 @@ export class NextCatalogBuilder {
return [
new FileReaderProcessor(),
BitbucketDiscoveryProcessor.fromConfig(config, { logger }),
AzureDevOpsDiscoveryProcessor.fromConfig(config, { logger }),
GithubDiscoveryProcessor.fromConfig(config, { logger }),
GithubOrgReaderProcessor.fromConfig(config, { logger }),
GitLabDiscoveryProcessor.fromConfig(config, { logger }),