First part of GitLabDiscoveryProcessor (no target parsing yet)

Signed-off-by: Roy Jacobs <roy.jacobs@gmail.com>
This commit is contained in:
Roy Jacobs
2021-08-17 15:03:51 +02:00
parent 2b7d3455b5
commit 1136ac88b6
8 changed files with 521 additions and 0 deletions
@@ -0,0 +1,252 @@
/*
* 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,
} from './GitLabDiscoveryProcessor';
import { setupServer } from 'msw/node';
import { rest } from 'msw';
import { GitLabProject } from "./gitlab";
const server = setupServer();
function setupFakeGitLab(
callback: (request: {
page: number;
}) => { data: GitLabProject[]; nextPage?: number },
) {
server.use(
rest.get('https://gitlab.fake/api/v4/projects', (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 response = callback({
page: parseInt(page!, 10),
});
// 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();
});
const location: LocationSpec = {
type: 'gitlab-discovery',
target: 'https://gitlab.fake/north-star',
};
describe('handles repositories', () => {
it('pages through all repositories', async () => {
const processor = getProcessor();
setupFakeGitLab(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(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('uses the previous scan timestamp to filter', async () => {
const processor = getProcessor();
setupFakeGitLab(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(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(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
setupFakeGitLab(_ => {
return {
data: [],
};
});
const config = getConfig();
config.integrations.gitlab[0].token = 'invalid';
await expect(
getProcessor(config).readLocation(location, false, _ => {}),
).rejects.toThrow(/Unauthorized/);
});
it('missing integration', async () => {
const config = getConfig();
delete config.integrations;
await expect(
getProcessor(config).readLocation(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/north-star',
};
await expect(
getProcessor().readLocation(incorrectLocation, false, _ => {}),
).resolves.toBeFalsy();
});
});
});
@@ -0,0 +1,129 @@
/*
* 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 a GitLab org.
*/
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 integration = this.integrations.gitlab.byUrl(location.target);
if (!integration) {
throw new Error(
`There is no GitLab integration that matches ${location.target}. 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.info(`Reading GitLab projects from ${location.target}`);
const projects = paginated(options => client.listProjects(options), {
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) {
emit(
results.location(
{
type: 'url',
// The format expected by the GitLabUrlReader: https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath
target: `${project.web_url}/-/blob/${project.default_branch}/catalog-info.yaml`,
},
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[];
};
@@ -0,0 +1,94 @@
/*
* 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>> {
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 | undefined;
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,
@@ -372,6 +373,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 }),