Merge pull request #6852 from bolcom/gitlab-discovery

Gitlab discovery
This commit is contained in:
Patrik Oldsberg
2021-08-24 10:55:59 +02:00
committed by GitHub
11 changed files with 753 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
Added GitLabDiscoveryProcessor, which allows catalog discovery from a GitLab instance
+36
View File
@@ -0,0 +1,36 @@
---
id: discovery
title: GitLab Discovery
sidebar_label: Discovery
# prettier-ignore
description: Automatically discovering catalog entities from repositories in GitLab
---
The GitLab integration has a special discovery processor for discovering catalog
entities from GitLab. The processor will crawl the GitLab instance and register
entities matching the configured path. This can be useful as an alternative to
static locations or manually adding things to the catalog.
To use the discovery processor, you'll need a GitLab integration
[set up](locations.md) with a `token`. Then you can add a location target to the
catalog configuration:
```yaml
catalog:
locations:
- type: gitlab-discovery
target: https://gitlab.com/group/subgroup/blob/main/catalog-info.yaml
```
Note the `gitlab-discovery` type, as this is not a regular `url` processor.
The target is composed of three parts:
- The base URL, `https://gitlab.com` in this case
- The group path, `group/subgroup` in this case. This is optional: If you omit
this path the processor will scan the entire GitLab instance instead.
- The path within each repository to find the catalog YAML file. This will
usually be `/blob/main/catalog-info.yaml`, `/blob/master/catalog-info.yaml` or
a similar variation for catalog files stored in the root directory of each
repository. If you want to use the repository's default branch use the `*`
wildcard, e.g.: `/blob/*/catalog-info.yaml`
+21
View File
@@ -813,6 +813,27 @@ export class GithubOrgReaderProcessor implements CatalogProcessor {
): Promise<boolean>;
}
// Warning: (ae-missing-release-tag) "GitLabDiscoveryProcessor" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export class GitLabDiscoveryProcessor implements CatalogProcessor {
// (undocumented)
static fromConfig(
config: Config,
options: {
logger: Logger_2;
},
): GitLabDiscoveryProcessor;
// (undocumented)
readLocation(
location: LocationSpec,
_optional: boolean,
emit: CatalogProcessorEmit,
): Promise<boolean>;
// (undocumented)
updateLastActivity(): Promise<string | undefined>;
}
// Warning: (ae-missing-release-tag) "HigherOrderOperation" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -0,0 +1,368 @@
/*
* 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 { ConfigReader } from '@backstage/config';
import { getVoidLogger } from '@backstage/backend-common';
import { LocationSpec } from '@backstage/catalog-model';
import { GitLabDiscoveryProcessor, parseUrl } from './GitLabDiscoveryProcessor';
import { setupServer } from 'msw/node';
import { rest } from 'msw';
import { GitLabProject } from './gitlab';
const server = setupServer();
const PROJECTS_URL = 'https://gitlab.fake/api/v4/projects';
const GROUP_PROJECTS_URL =
'https://gitlab.fake/api/v4/groups/group%2Fsubgroup/projects';
const PROJECT_LOCATION: LocationSpec = {
type: 'gitlab-discovery',
target: 'https://gitlab.fake/blob/*/catalog-info.yaml',
};
const PROJECT_LOCATION_MASTER_BRANCH: LocationSpec = {
type: 'gitlab-discovery',
target: 'https://gitlab.fake/blob/master/catalog-info.yaml',
};
const GROUP_LOCATION: LocationSpec = {
type: 'gitlab-discovery',
target: 'https://gitlab.fake/group/subgroup/blob/*/catalog-info.yaml',
};
function setupFakeServer(
url: string,
callback: (request: { page: number; include_subgroups: boolean }) => {
data: GitLabProject[];
nextPage?: number;
},
) {
server.use(
rest.get(url, (req, res, ctx) => {
if (req.headers.get('private-token') !== 'test-token') {
return res(ctx.status(401), ctx.json({}));
}
const page = req.url.searchParams.get('page');
const include_subgroups = req.url.searchParams.get('include_subgroups');
const response = callback({
page: parseInt(page!, 10),
include_subgroups: include_subgroups === 'true',
});
// Filter the fake results based on the `last_activity_after` parameter
const last_activity_after = req.url.searchParams.get(
'last_activity_after',
);
const filteredData = response.data.filter(
v =>
!last_activity_after ||
Date.parse(v.last_activity_at) >= Date.parse(last_activity_after),
);
return res(
ctx.set('x-next-page', response.nextPage?.toString() ?? ''),
ctx.json(filteredData),
);
}),
);
}
function getConfig(): any {
return {
backend: {
cache: { store: 'memory' },
},
integrations: {
gitlab: [
{
host: 'gitlab.fake',
apiBaseUrl: 'https://gitlab.fake/api/v4',
token: 'test-token',
},
],
},
};
}
function getProcessor(config?: any): GitLabDiscoveryProcessor {
return GitLabDiscoveryProcessor.fromConfig(
new ConfigReader(config || getConfig()),
{
logger: getVoidLogger(),
},
);
}
describe('GitlabDiscoveryProcessor', () => {
beforeAll(() => {
server.listen();
jest.useFakeTimers('modern');
jest.setSystemTime(new Date('2001-01-01T12:34:56Z'));
});
afterEach(() => server.resetHandlers());
afterAll(() => {
server.close();
jest.useRealTimers();
});
describe('parseUrl', () => {
it('parses well formed URLs', () => {
expect(
parseUrl('https://gitlab.com/group/subgroup/blob/master/catalog.yaml'),
).toEqual({
group: 'group/subgroup',
host: 'gitlab.com',
branch: 'master',
catalogPath: 'catalog.yaml',
});
expect(
parseUrl('https://gitlab.com/blob/*/subfolder/catalog.yaml'),
).toEqual({
group: undefined,
host: 'gitlab.com',
branch: '*',
catalogPath: 'subfolder/catalog.yaml',
});
});
it('throws on incorrectly formed URLs', () => {
expect(() => parseUrl('https://gitlab.com')).toThrow();
expect(() => parseUrl('https://gitlab.com//')).toThrow();
expect(() => parseUrl('https://gitlab.com/foo')).toThrow();
expect(() => parseUrl('https://gitlab.com//foo')).toThrow();
expect(() => parseUrl('https://gitlab.com/org/teams')).toThrow();
expect(() => parseUrl('https://gitlab.com/org//teams')).toThrow();
expect(() =>
parseUrl('https://gitlab.com/org//teams/blob/catalog.yaml'),
).toThrow();
});
});
describe('handles repositories', () => {
it('pages through all repositories', async () => {
const processor = getProcessor();
setupFakeServer(PROJECTS_URL, request => {
switch (request.page) {
case 1:
return {
data: [
{
id: 1,
archived: false,
default_branch: 'main',
last_activity_at: '2021-08-05T11:03:05.774Z',
web_url: 'https://gitlab.fake/1',
},
],
nextPage: 2,
};
case 2:
return {
data: [
{
id: 2,
archived: false,
default_branch: 'master',
last_activity_at: '2021-08-05T11:03:05.774Z',
web_url: 'https://gitlab.fake/2',
},
{
id: 3,
archived: true, // ARCHIVED
default_branch: 'master',
last_activity_at: '2021-08-05T11:03:05.774Z',
web_url: 'https://gitlab.fake/3',
},
],
};
default:
throw new Error('Invalid request');
}
});
const result: any[] = [];
await processor.readLocation(PROJECT_LOCATION, false, e => {
result.push(e);
});
expect(result).toEqual([
{
type: 'location',
location: {
type: 'url',
target: 'https://gitlab.fake/1/-/blob/main/catalog-info.yaml',
},
optional: true,
},
{
type: 'location',
location: {
type: 'url',
target: 'https://gitlab.fake/2/-/blob/master/catalog-info.yaml',
},
optional: true,
},
]);
});
it('can force a branch name', async () => {
const processor = getProcessor();
setupFakeServer(PROJECTS_URL, request => {
switch (request.page) {
case 1:
return {
data: [
{
id: 1,
archived: false,
default_branch: 'main',
last_activity_at: '2021-08-05T11:03:05.774Z',
web_url: 'https://gitlab.fake/1',
},
],
};
default:
throw new Error('Invalid request');
}
});
const result: any[] = [];
await processor.readLocation(PROJECT_LOCATION_MASTER_BRANCH, false, e => {
result.push(e);
});
expect(result).toEqual([
{
type: 'location',
location: {
type: 'url',
target: 'https://gitlab.fake/1/-/blob/master/catalog-info.yaml',
},
optional: true,
},
]);
});
it('can filter based on group', async () => {
const processor = getProcessor();
setupFakeServer(GROUP_PROJECTS_URL, request => {
if (!request.include_subgroups) {
throw new Error('include_subgroups should be set');
}
switch (request.page) {
case 1:
return {
data: [
{
id: 1,
archived: false,
default_branch: 'main',
last_activity_at: '2021-08-05T11:03:05.774Z',
web_url: 'https://gitlab.fake/1',
},
],
};
default:
throw new Error('Invalid request');
}
});
const result: any[] = [];
await processor.readLocation(GROUP_LOCATION, false, e => {
result.push(e);
});
// If everything was set up correctly, we should have received the fake repo specified above
expect(result).toHaveLength(1);
});
it('uses the previous scan timestamp to filter', async () => {
const processor = getProcessor();
setupFakeServer(PROJECTS_URL, request => {
switch (request.page) {
case 1:
return {
data: [
{
id: 1,
archived: false,
default_branch: 'main',
last_activity_at: '2000-01-01T00:00:00Z',
web_url: 'https://gitlab.fake/1',
},
{
id: 2,
archived: false,
default_branch: 'main',
last_activity_at: '2002-01-01T00:00:00Z',
web_url: 'https://gitlab.fake/2',
},
],
};
default:
throw new Error('Invalid request');
}
});
const result: any[] = [];
// First scan should find all repos, since no last activity was cached
await processor.readLocation(PROJECT_LOCATION, false, e => {
result.push(e);
});
expect(result).toHaveLength(2);
// Second scan should have used the mocked Date to set the last scanned time to 2001
// This should result in only the second repo being scanned, since that has a timestamp of 2002
const result2: any[] = [];
await processor.readLocation(PROJECT_LOCATION, false, e => {
result2.push(e);
});
expect(result2).toHaveLength(1);
});
});
describe('handles failure', () => {
it('invalid token', async () => {
// Setup an empty fake gitlab, since we don't care about actual results
setupFakeServer(PROJECTS_URL, _ => {
return {
data: [],
};
});
const config = getConfig();
config.integrations.gitlab[0].token = 'invalid';
await expect(
getProcessor(config).readLocation(PROJECT_LOCATION, false, _ => {}),
).rejects.toThrow(/Unauthorized/);
});
it('missing integration', async () => {
const config = getConfig();
delete config.integrations;
await expect(
getProcessor(config).readLocation(PROJECT_LOCATION, false, _ => {}),
).rejects.toThrow(/no GitLab integration/);
});
it('location type', async () => {
const incorrectLocation: LocationSpec = {
type: 'something-that-is-not-gitlab-discovery',
target: 'https://gitlab.fake/oh-dear',
};
await expect(
getProcessor().readLocation(incorrectLocation, false, _ => {}),
).resolves.toBeFalsy();
});
});
});
@@ -0,0 +1,170 @@
/*
* 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 { GitLabClient, GitLabProject, paginated } from './gitlab';
import {
CacheClient,
CacheManager,
PluginCacheManager,
} from '@backstage/backend-common';
/**
* Extracts repositories out of an GitLab instance.
*/
export class GitLabDiscoveryProcessor implements CatalogProcessor {
private readonly integrations: ScmIntegrations;
private readonly logger: Logger;
private readonly cache: CacheClient;
static fromConfig(config: Config, options: { logger: Logger }) {
const integrations = ScmIntegrations.fromConfig(config);
const pluginCache =
CacheManager.fromConfig(config).forPlugin('gitlab-discovery');
return new GitLabDiscoveryProcessor({
...options,
integrations,
pluginCache,
});
}
private constructor(options: {
integrations: ScmIntegrations;
pluginCache: PluginCacheManager;
logger: Logger;
}) {
this.integrations = options.integrations;
this.cache = options.pluginCache.getClient();
this.logger = options.logger;
}
async readLocation(
location: LocationSpec,
_optional: boolean,
emit: CatalogProcessorEmit,
): Promise<boolean> {
if (location.type !== 'gitlab-discovery') {
return false;
}
const { group, host, branch, catalogPath } = parseUrl(location.target);
const integration = this.integrations.gitlab.byUrl(`https://${host}`);
if (!integration) {
throw new Error(
`There is no GitLab integration that matches ${host}. Please add a configuration entry for it under integrations.gitlab`,
);
}
const client = new GitLabClient({
config: integration.config,
logger: this.logger,
});
const startTimestamp = Date.now();
this.logger.debug(`Reading GitLab projects from ${location.target}`);
const projects = paginated(options => client.listProjects(options), {
group,
last_activity_after: await this.updateLastActivity(),
page: 1,
});
const result: Result = {
scanned: 0,
matches: [],
};
for await (const project of projects) {
result.scanned++;
if (!project.archived) {
result.matches.push(project);
}
}
for (const project of result.matches) {
const project_branch = branch === '*' ? project.default_branch : branch;
emit(
results.location(
{
type: 'url',
// The format expected by the GitLabUrlReader:
// https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath
//
// This unfortunately will trigger another API call in `getGitLabFileFetchUrl` to get the project ID.
// The alternative is using the `buildRawUrl` function, which does not support subgroups, so providing a raw
// URL here won't work either.
target: `${project.web_url}/-/blob/${project_branch}/${catalogPath}`,
},
true,
),
);
}
const duration = ((Date.now() - startTimestamp) / 1000).toFixed(1);
this.logger.debug(
`Read ${result.scanned} GitLab repositories in ${duration} seconds`,
);
return true;
}
async updateLastActivity(): Promise<string | undefined> {
const lastActivity = await this.cache.get('last-activity');
await this.cache.set('last-activity', new Date().toISOString());
return lastActivity as string | undefined;
}
}
type Result = {
scanned: number;
matches: GitLabProject[];
};
/*
* Helpers
*/
export function parseUrl(urlString: string): {
group?: string;
host: string;
branch: string;
catalogPath: string;
} {
const url = new URL(urlString);
const path = url.pathname.substr(1).split('/');
// (/group/subgroup)/blob/branch|*/filepath
const blobIndex = path.findIndex(p => p === 'blob');
if (blobIndex !== -1 && path.length > blobIndex + 2) {
const group =
blobIndex > 0 ? path.slice(0, blobIndex).join('/') : undefined;
return {
group,
host: url.host,
branch: decodeURIComponent(path[blobIndex + 1]),
catalogPath: decodeURIComponent(path.slice(blobIndex + 2).join('/')),
};
}
throw new Error(`Failed to parse ${urlString}`);
}
@@ -0,0 +1,107 @@
/*
* 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 {
getGitLabRequestOptions,
GitLabIntegrationConfig,
} from '@backstage/integration';
import { Logger } from 'winston';
export class GitLabClient {
private readonly config: GitLabIntegrationConfig;
private readonly logger: Logger;
constructor(options: { config: GitLabIntegrationConfig; logger: Logger }) {
this.config = options.config;
this.logger = options.logger;
}
async listProjects(options?: ListOptions): Promise<PagedResponse<any>> {
if (options?.group) {
return this.pagedRequest(
`${this.config.apiBaseUrl}/groups/${encodeURIComponent(
options?.group,
)}/projects`,
{
...options,
include_subgroups: true,
},
);
}
return this.pagedRequest(`${this.config.apiBaseUrl}/projects`, options);
}
private async pagedRequest(
endpoint: string,
options?: ListOptions,
): Promise<PagedResponse<any>> {
const request = new URL(endpoint);
for (const key in options) {
if (options[key]) {
request.searchParams.append(key, options[key]!.toString());
}
}
this.logger.debug(`Fetching: ${request.toString()}`);
const response = await fetch(
request.toString(),
getGitLabRequestOptions(this.config),
);
if (!response.ok) {
throw new Error(
`Unexpected response when fetching ${request.toString()}. Expected 200 but got ${
response.status
} - ${response.statusText}`,
);
}
return response.json().then(items => {
const nextPage = response.headers.get('x-next-page');
return {
items,
nextPage: nextPage ? Number(nextPage) : null,
} as PagedResponse<any>;
});
}
}
export type ListOptions = {
[key: string]: string | number | boolean | undefined;
group?: string;
per_page?: number | undefined;
page?: number | undefined;
};
export type PagedResponse<T> = {
items: T[];
nextPage?: number;
};
export async function* paginated(
request: (options: ListOptions) => Promise<PagedResponse<any>>,
options: ListOptions,
) {
let res;
do {
res = await request(options);
options.page = res.nextPage;
for (const item of res.items) {
yield item;
}
} while (res.nextPage);
}
@@ -0,0 +1,18 @@
/*
* 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 { GitLabClient, paginated } from './client';
export type { GitLabProject } from './types';
@@ -0,0 +1,23 @@
/*
* 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 type GitLabProject = {
id: number;
default_branch: string;
archived: boolean;
last_activity_at: string;
web_url: string;
};
@@ -26,6 +26,7 @@ export { FileReaderProcessor } from './FileReaderProcessor';
export { GithubDiscoveryProcessor } from './GithubDiscoveryProcessor';
export { GithubOrgReaderProcessor } from './GithubOrgReaderProcessor';
export { GithubMultiOrgReaderProcessor } from './GithubMultiOrgReaderProcessor';
export { GitLabDiscoveryProcessor } from './GitLabDiscoveryProcessor';
export { LocationEntityProcessor } from './LocationEntityProcessor';
export { PlaceholderProcessor } from './PlaceholderProcessor';
export type { PlaceholderResolver } from './PlaceholderProcessor';
@@ -50,6 +50,7 @@ import {
FileReaderProcessor,
GithubDiscoveryProcessor,
GithubOrgReaderProcessor,
GitLabDiscoveryProcessor,
PlaceholderProcessor,
PlaceholderResolver,
UrlReaderProcessor,
@@ -395,6 +396,7 @@ export class NextCatalogBuilder {
BitbucketDiscoveryProcessor.fromConfig(config, { logger }),
GithubDiscoveryProcessor.fromConfig(config, { logger }),
GithubOrgReaderProcessor.fromConfig(config, { logger }),
GitLabDiscoveryProcessor.fromConfig(config, { logger }),
new UrlReaderProcessor({ reader, logger }),
CodeOwnersProcessor.fromConfig(config, { logger, reader }),
new AnnotateLocationEntityProcessor({ integrations }),
@@ -46,6 +46,7 @@ import {
FileReaderProcessor,
GithubDiscoveryProcessor,
GithubOrgReaderProcessor,
GitLabDiscoveryProcessor,
HigherOrderOperation,
HigherOrderOperations,
LocationEntityProcessor,
@@ -316,6 +317,7 @@ export class CatalogBuilder {
BitbucketDiscoveryProcessor.fromConfig(config, { logger }),
GithubDiscoveryProcessor.fromConfig(config, { logger }),
GithubOrgReaderProcessor.fromConfig(config, { logger }),
GitLabDiscoveryProcessor.fromConfig(config, { logger }),
new UrlReaderProcessor({ reader, logger }),
CodeOwnersProcessor.fromConfig(config, { logger, reader }),
new LocationEntityProcessor({ integrations }),