Skip to content

Commit

Permalink
First pass at Gitlab catalog backend plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
bbckr committed Mar 19, 2024
1 parent 804cc2d commit dace74c
Show file tree
Hide file tree
Showing 6 changed files with 360 additions and 0 deletions.
1 change: 1 addition & 0 deletions plugins/gitlab-catalog-backend/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
51 changes: 51 additions & 0 deletions plugins/gitlab-catalog-backend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# @seatgeek/backstage-plugin-gitlab-catalog-backend

This plugin offers catalog integrations for ingesting data from the Slack API into the Software Catalog.

[![npm latest version](https://img.shields.io/npm/v/@seatgeek/backstage-plugin-gitlab-catalog-backend/latest.svg)](https://www.npmjs.com/package/@seatgeek/backstage-plugin-gitlab-catalog-backend)

## Installation

Install the `@seatgeek/backstage-plugin-gitlab-catalog-backend` package in your backend package:

```shell
# From your Backstage root directory
yarn add --cwd packages/backend @seatgeek/backstage-plugin-gitlab-catalog-backend
```

Add the following config to your `app-config.yaml`:

```yml
gitlabCatalog:
host: ${GITLAB_HOST_CATALOG} # defaults to https://gitlab.com
token: ${GITLAB_TOKEN_CATALOG}
```
Requires `read_user` scope with administrator level permissions to be able to view the email, see [List Users (for administrators)](https://docs.gitlab.com/ee/api/users.html#for-administrators).

## Processors

### `GitlabUserProcessor`

Enriches existing `User` entities with information from Gitlab, notably the user's Gitlab ID, based on the user's `.profile.email`.

#### Installation

Add the following to your `packages/backend/catalog.ts`:

```ts
import { GitlabUserProcessor } from '@seatgeek/backstage-plugin-gitlab-catalog-backend';
export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
const builder = CatalogBuilder.create(env);
builder.addProcessor(
// Add the gitlab user processor
GitlabUserProcessor.fromConfig(env.config, env.logger),
);
const { processingEngine, router } = await builder.build();
processingEngine.start();
return router;
}
```
46 changes: 46 additions & 0 deletions plugins/gitlab-catalog-backend/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"name": "@seatgeek/backstage-plugin-gitlab-catalog-backend",
"version": "0.0.0-semantically-released",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"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.20.1",
"@backstage/catalog-model": "^1.4.3",
"@backstage/config": "^1.1.1",
"@backstage/plugin-catalog-common": "^1.0.20",
"@gitbeaker/rest": "^40.0.1",
"@types/express": "*",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"node-fetch": "^2.6.7",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.25.1",
"@types/supertest": "^2.0.12",
"msw": "^1.0.0",
"supertest": "^6.2.4"
},
"files": [
"dist"
]
}
119 changes: 119 additions & 0 deletions plugins/gitlab-catalog-backend/src/GitlabUserProcessor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/*
* Copyright SeatGeek
* Licensed under the terms of the Apache-2.0 license. See LICENSE file in project root for terms.
*/
import { SystemEntity, UserEntity } from '@backstage/catalog-model';
import { Gitlab } from '@gitbeaker/rest';
import * as winston from 'winston';
import { GitlabUserProcessor } from './GitlabUserProcessor';

jest.mock('@gitbeaker/rest', () => {
return {
Gitlab: jest.fn().mockImplementation(() => {
return {
Users: {
all: jest.fn().mockResolvedValue([
{
id: 123,
email: '[email protected]',
},
{
id: 999,
email: '[email protected]',
},
]),
// mock other methods as needed
},
};
}),
};
});

describe('GitlabUserProcessor', () => {
let processor: GitlabUserProcessor;
let mockGitlabClient: Gitlab;
let mockLogger: winston.Logger;

beforeEach(() => {
mockGitlabClient = new Gitlab();
mockLogger = winston.createLogger({
transports: [
new winston.transports.Console({
format: winston.format.simple(),
level: 'debug',
}),
],
});

processor = new GitlabUserProcessor(mockGitlabClient, mockLogger);

// Reset the mocks before each test
jest.clearAllMocks();
});

test('should add gitlab info', async () => {
const before: UserEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: {
name: 'rufus',
},
spec: {
profile: {
email: '[email protected]',
},
},
};
const result = await processor.postProcessEntity(
before,
{} as any,
() => {},
);

const expected: UserEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: {
name: 'rufus',
annotations: {
'gitlab.com/user_id': '123',
},
},
spec: {
profile: {
email: '[email protected]',
},
},
};

expect(result).toEqual(expected);
expect(mockGitlabClient.Users.all).toHaveBeenCalled();

// make sure that the slack users are only fetched once
await processor.postProcessEntity(before, {} as any, () => {});
expect(mockGitlabClient.Users.all).toHaveBeenCalledTimes(1);
});

test('should no op if not a user', async () => {
const before: SystemEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'System',
metadata: {
name: 'rufus',
annotations: {},
},
spec: {
owner: 'rufus',
},
};

const result = await processor.postProcessEntity(
before,
{} as any,
() => {},
);

expect(result).toEqual(before);
expect(mockGitlabClient.Users.all).not.toHaveBeenCalled();
});
});
138 changes: 138 additions & 0 deletions plugins/gitlab-catalog-backend/src/GitlabUserProcessor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/*
* Copyright SeatGeek
* Licensed under the terms of the Apache-2.0 license. See LICENSE file in project root for terms.
*/
import { Entity, isUserEntity } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { LocationSpec } from '@backstage/plugin-catalog-common';
import type {
CatalogProcessor,
CatalogProcessorEmit,
} from '@backstage/plugin-catalog-node';
import { Gitlab, ExpandedUserSchema } from '@gitbeaker/rest';
import { Logger } from 'winston';

const GITLAB_PER_PAGE_LIMIT = 500;
const GITLAB_DEFAULT_HOST = 'https://gitlab.com';

/**
* The GitlabUserProcessor is used to enrich our User entities with information
* from Gitlab: notably the user's Gitlab ID.
*
* @public
*/
export class GitlabUserProcessor implements CatalogProcessor {
private readonly gitlab: Gitlab;
private readonly logger: Logger;
private cacheLoaded: boolean;
private userLookup: Map<string, ExpandedUserSchema>;
// guarantee that users are loaded only once
private loadUserPromise: Promise<Map<string, ExpandedUserSchema>> | null = null;

private async fetchUsers(): Promise<Map<string, ExpandedUserSchema>> {
if (!this.gitlab) {
return new Map();
}

if (!this.cacheLoaded) {
// we use a shared promise to make sure that the load is only
// executed once even if `GitlabUserProcessor.postProcessEntity`
// is called "concurrently".
if (!this.loadUserPromise) {
this.loadUserPromise = this.loadUsers();
}

// Wait for the data to load
await this.loadUserPromise;
}

return this.userLookup;
}

private async loadUsers(): Promise<Map<string, ExpandedUserSchema>> {
this.logger.info('Loading gitlab users');

let members: ExpandedUserSchema[] = [];
try {
members = await this.gitlab!.Users.all({ perPage: GITLAB_PER_PAGE_LIMIT });
} catch (error) {
this.logger.error(`Error loading gitlab users: ${error}`);
return this.userLookup;
}

members
.forEach(user => {
if (user.email) {
this.userLookup.set(user.email, user);
}
});

this.logger.info(`Loaded ${this.userLookup.size} gitlab users`);
this.cacheLoaded = true;

return this.userLookup;
}

static fromConfig(config: Config, logger: Logger): GitlabUserProcessor[] {
const token = config.getOptionalString('gitlabCatalog.token');
if (!token) {
logger.warn(
'No token provided for GitlabUserProcessor, skipping Gitlab user lookup',
);
return [];
}
let host = config.getOptionalString('gitlabCatalog.host');
if (!host) {
logger.info(
`No host provided for GitlabUserProcessor, defaulting to ${GITLAB_DEFAULT_HOST}`,
);
host = GITLAB_DEFAULT_HOST
}
return [new GitlabUserProcessor(new Gitlab({
host: host,
token: token,
}), logger)];
}

constructor(gitlab: Gitlab, logger: Logger) {
this.gitlab = gitlab;
this.logger = logger;
this.userLookup = new Map();
this.cacheLoaded = false;
}

getProcessorName(): string {
return 'GitlabUserProcessor';
}

async postProcessEntity(
entity: Entity,
_location: LocationSpec,
_emit: CatalogProcessorEmit,
): Promise<Entity> {
if (!isUserEntity(entity)) {
return entity;
}

const email = entity.spec.profile?.email;
if (!email) {
return entity;
}

const userLookup = await this.fetchUsers();
const gitlabUser = userLookup.get(email);
if (!gitlabUser) {
return entity;
}

if (!entity.metadata.annotations) {
entity.metadata.annotations = {};
}

if (gitlabUser.id) {
entity.metadata.annotations[`gitlab.com/user_id`] = gitlabUser.id.toString();
}

return entity;
}
}
5 changes: 5 additions & 0 deletions plugins/gitlab-catalog-backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/*
* Copyright SeatGeek
* Licensed under the terms of the Apache-2.0 license. See LICENSE file in project root for terms.
*/
export { GitlabUserProcessor } from './GitlabUserProcessor';

0 comments on commit dace74c

Please sign in to comment.