Initial work on jenkins backend
Signed-off-by: Andrew Shirley <andrew.shirley@sainsburys.co.uk> Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
# Jenkins Plugin (Alpha)
|
||||
|
||||
Welcome to the jenkins backend plugin! Website: [https://jenkins.io/](https://jenkins.io/)
|
||||
|
||||
This is the backend half of the 2 jenkins plugins and is responsible for:
|
||||
|
||||
- finding an appropriate instance of jenkins for an entity
|
||||
- finding the appropriate job(s) on that instance for an entity
|
||||
- connecting to jenkins and gathering data to present to the frontend
|
||||
|
||||
## Integrating into a backstage instance
|
||||
|
||||
This plugin needs to be added to an existing backstage instance.
|
||||
|
||||
Typically, this means creating a `src/plugins/jenkins.ts` file and adding a reference to it to `src/index.ts`
|
||||
|
||||
### jenkins.ts
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createRouter,
|
||||
SingleJenkinsInfoProvider,
|
||||
} from '@backstage/plugin-jenkins-backend';
|
||||
import { Router } from 'express';
|
||||
import { PluginEnvironment } from '../types';
|
||||
|
||||
export default async function createPlugin({
|
||||
logger,
|
||||
}: PluginEnvironment): Promise<Router> {
|
||||
return await createRouter({
|
||||
logger,
|
||||
jenkinsInfoProvider: new SingleJenkinsInfoProvider(),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
This plugin must be provided with a JenkinsInfoProvider, this is a strategy object for finding the jenkins instance and job for an entity.
|
||||
|
||||
There is a selection of standard ones provided, but the Integrator is free to build their own.
|
||||
|
||||
### SingleJenkinsInfoProvider
|
||||
|
||||
Allows configuration of a single global jenkins instance and annotating entities with the job name on that instance.
|
||||
|
||||
The following will look for jobs for this entity at `https://jenkins.example.com/job/teamA/job/artistLookup-build`
|
||||
|
||||
#### Config
|
||||
|
||||
```yaml
|
||||
jenkins:
|
||||
baseUrl: https://jenkins.example.com
|
||||
username: backstage-bot
|
||||
apikey: 123456789abcdef0123456789abcedf012
|
||||
```
|
||||
|
||||
#### Catalog
|
||||
|
||||
```yaml
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: artist-lookup
|
||||
annotations:
|
||||
'jenkins.io/github-folder': teamA/artistLookup-build
|
||||
```
|
||||
|
||||
### PrefixedJenkinsInfoProvider
|
||||
|
||||
Allows configuration of multiple global jenkins instance and annotating entities with the name of the instance and job name on that instance.
|
||||
|
||||
The following will look for jobs for this entity at `https://jenkins-foo.example.com/job/teamA/job/artistLookup-build`
|
||||
|
||||
#### Config
|
||||
|
||||
```yaml
|
||||
jenkins:
|
||||
instances:
|
||||
- name: default
|
||||
baseUrl: https://jenkins.example.com
|
||||
username: backstage-bot
|
||||
apikey: 123456789abcdef0123456789abcedf012
|
||||
- name: departmentFoo
|
||||
baseUrl: https://jenkins-foo.example.com
|
||||
username: backstage-bot
|
||||
apikey: 123456789abcdef0123456789abcedf012
|
||||
```
|
||||
|
||||
#### Catalog
|
||||
|
||||
```yaml
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: artist-lookup
|
||||
annotations:
|
||||
'jenkins.io/github-folder': departmentFoo#teamA/artistLookup-build
|
||||
```
|
||||
|
||||
If the `departmentFoo#` part is omitted, the default instance will be assumed.
|
||||
|
||||
### DefaultJenkinsInfoProvider
|
||||
|
||||
The default jenkins info provider makes it clear it is replaceable in the config but is otherwise the same as PrefixedJenkinsInfoProvider
|
||||
|
||||
The following will look for jobs for this entity at `https://jenkins-foo.example.com/job/teamA/job/artistLookup-build`
|
||||
|
||||
#### Config
|
||||
|
||||
```yaml
|
||||
jenkins:
|
||||
DefaultJenkinsInfoProvider:
|
||||
instances:
|
||||
- name: default
|
||||
baseUrl: https://jenkins.example.com
|
||||
username: backstage-bot
|
||||
apikey: 123456789abcdef0123456789abcedf012
|
||||
- name: departmentFoo
|
||||
baseUrl: https://jenkins-foo.example.com
|
||||
username: backstage-bot
|
||||
apikey: 123456789abcdef0123456789abcedf012
|
||||
```
|
||||
|
||||
#### Catalog
|
||||
|
||||
```yaml
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: artist-lookup
|
||||
annotations:
|
||||
'jenkins.io/github-folder': departmentFoo#teamA/artistLookup-build
|
||||
```
|
||||
|
||||
### AcmeJenkinsInfoProvider
|
||||
|
||||
An example of a bespoke JenkinsInfoProvider which uses an organisation specific annotation to look up the jenkins info (including jobName)
|
||||
|
||||
#### Config
|
||||
|
||||
None needed
|
||||
|
||||
#### Catalog
|
||||
|
||||
```yaml
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: artist-lookup
|
||||
annotations:
|
||||
'acme.example.com/paas-project-name': artistLookupService
|
||||
```
|
||||
|
||||
The following will look for jobs for this entity at `https://jenkins-departmentFoo.example.com/job/teamA/job/artistLookupService`
|
||||
|
||||
## Jenkins' terminology notes
|
||||
|
||||
The domain model for Jenkins is not particularly clear but for the purposes of this plugin the following model has been assumed:
|
||||
|
||||
Jenkins contains a tree of *job*s which have children of either; other *job*s (making it a _folder_) or *build*s (making it a _project_).
|
||||
Concepts like _pipeline_ and *view*s are meaningless (pipelines are just jobs for our purposes, views are (as the name suggests) just views of subsets of jobs)
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@backstage/plugin-jenkins-backend",
|
||||
"version": "0.1.1",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "backstage-cli backend:dev",
|
||||
"build": "backstage-cli backend:build",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test",
|
||||
"prepack": "backstage-cli prepack",
|
||||
"postpack": "backstage-cli postpack",
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "^0.8.2",
|
||||
"@backstage/catalog-client": "^0.3.13",
|
||||
"@backstage/catalog-model": "^0.8.3",
|
||||
"@backstage/config": "^0.1.5",
|
||||
"@backstage/core": "^0.7.13",
|
||||
"@types/express": "^4.17.6",
|
||||
"@types/jenkins": "^0.23.1",
|
||||
"cross-fetch": "^3.0.6",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^4.1.0",
|
||||
"jenkins": "^0.28.1",
|
||||
"winston": "^3.2.1",
|
||||
"yn": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.7.0",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"msw": "^0.21.2",
|
||||
"supertest": "^4.0.2"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2020 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 './service/router';
|
||||
export type {
|
||||
JenkinsInfo,
|
||||
JenkinsInfoProvider,
|
||||
} from './service/jenkinsInfoProvider';
|
||||
export { DummyJenkinsInfoProvider } from './service/jenkinsInfoProvider';
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 { getRootLogger } from '@backstage/backend-common';
|
||||
import yn from 'yn';
|
||||
import { startStandaloneServer } from './service/standaloneServer';
|
||||
|
||||
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000;
|
||||
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
|
||||
const logger = getRootLogger();
|
||||
|
||||
startStandaloneServer({ port, enableCors, logger }).catch(err => {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('CTRL+C pressed; exiting.');
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -0,0 +1,310 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* 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 { EntityName, stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import { CatalogClient } from '@backstage/catalog-client';
|
||||
import { Config } from '@backstage/config';
|
||||
|
||||
export interface JenkinsInfoProvider {
|
||||
getInstance(options: {
|
||||
/**
|
||||
* The entity to get the info about.
|
||||
*/
|
||||
entityRef: EntityName;
|
||||
/**
|
||||
* A specific job to get. This is only passed in when we know about a job name we are interested in.
|
||||
*/
|
||||
jobName?: string;
|
||||
}): Promise<JenkinsInfo>;
|
||||
}
|
||||
|
||||
export interface JenkinsInfo {
|
||||
baseUrl: string;
|
||||
headers?: any;
|
||||
jobName: string; // TODO: make this an array
|
||||
}
|
||||
|
||||
export class DummyJenkinsInfoProvider implements JenkinsInfoProvider {
|
||||
async getInstance(_: {
|
||||
entityRef: EntityName;
|
||||
jobName?: string;
|
||||
}): Promise<JenkinsInfo> {
|
||||
return {
|
||||
baseUrl: 'https://jenkins.internal.example.com/',
|
||||
headers: {
|
||||
Authorization:
|
||||
'Basic YWRtaW46MTFlYzI1NmU0Mzg1MDFjM2Y1Yzc2Yjc1MWE3ZTQ3YWY4Mw==',
|
||||
},
|
||||
jobName: 'department-A/team-1/project-foo',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the original annotation scheme and a simple config
|
||||
*/
|
||||
export class SingleJenkinsInfoProvider implements JenkinsInfoProvider {
|
||||
constructor(
|
||||
private readonly catalog: CatalogClient,
|
||||
private readonly config: Config,
|
||||
) {}
|
||||
|
||||
async getInstance(opt: {
|
||||
entityRef: EntityName;
|
||||
jobName?: string;
|
||||
}): Promise<JenkinsInfo> {
|
||||
const JENKINS_ANNOTATION = 'jenkins.io/github-folder';
|
||||
|
||||
// lookup jobName from entity annotation
|
||||
const entity = await this.catalog.getEntityByName(opt.entityRef);
|
||||
if (!entity) {
|
||||
throw new Error(
|
||||
`Couldn't find entity with name: ${stringifyEntityRef(opt.entityRef)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const jobName = entity.metadata.annotations?.[JENKINS_ANNOTATION];
|
||||
if (!jobName) {
|
||||
throw new Error(
|
||||
`Couldn't find jenkins annotation (${JENKINS_ANNOTATION}) on entity with name: ${stringifyEntityRef(
|
||||
opt.entityRef,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// lookup baseURL + creds from config
|
||||
const baseUrl = this.config.getString('jenkins.baseUrl');
|
||||
const username = this.config.getString('jenkins.username');
|
||||
const apiKey = this.config.getString('jenkins.apiKey');
|
||||
const creds = btoa(`${username}:${apiKey}`);
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
headers: {
|
||||
Authorization: `Basic ${creds}`,
|
||||
},
|
||||
jobName,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use a prefixed version of the original annotation scheme and a multiple instance config
|
||||
*/
|
||||
export class PrefixedJenkinsInfoProvider implements JenkinsInfoProvider {
|
||||
constructor(
|
||||
private readonly catalog: CatalogClient,
|
||||
private readonly config: Config,
|
||||
) {}
|
||||
|
||||
async getInstance(opt: {
|
||||
entityRef: EntityName;
|
||||
jobName?: string;
|
||||
}): Promise<JenkinsInfo> {
|
||||
const JENKINS_ANNOTATION = 'jenkins.io/github-folder';
|
||||
const DEFAULT_JENKINS_NAME = 'default';
|
||||
|
||||
// lookup `[jenkinsName#]jobName` from entity annotation
|
||||
const entity = await this.catalog.getEntityByName(opt.entityRef);
|
||||
if (!entity) {
|
||||
throw new Error(
|
||||
`Couldn't find entity with name: ${stringifyEntityRef(opt.entityRef)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const jenkinsAndJobName = entity.metadata.annotations?.[JENKINS_ANNOTATION];
|
||||
if (!jenkinsAndJobName) {
|
||||
throw new Error(
|
||||
`Couldn't find jenkins annotation (${JENKINS_ANNOTATION}) on entity with name: ${stringifyEntityRef(
|
||||
opt.entityRef,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
let jobName;
|
||||
let jenkinsName: string;
|
||||
const splitIndex = jenkinsAndJobName.indexOf('#');
|
||||
if (splitIndex === -1) {
|
||||
// no jenkinsName specified, use default
|
||||
jenkinsName = DEFAULT_JENKINS_NAME;
|
||||
jobName = jenkinsAndJobName;
|
||||
} else {
|
||||
// There is a jenkinsName specified
|
||||
jenkinsName = jenkinsAndJobName.substring(0, splitIndex);
|
||||
jobName = jenkinsAndJobName.substring(
|
||||
splitIndex + 1,
|
||||
jenkinsAndJobName.length,
|
||||
);
|
||||
}
|
||||
|
||||
// lookup baseURL + creds from config
|
||||
const instanceConfig = this.config
|
||||
.getConfigArray('jenkins.instances')
|
||||
.filter(c => c.getString('name') === jenkinsName)[0];
|
||||
if (!instanceConfig) {
|
||||
throw new Error(
|
||||
`Couldn't find a jenkins instance in the config with name ${jenkinsName}`,
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = instanceConfig.getString('baseUrl');
|
||||
const username = instanceConfig.getString('username');
|
||||
const apiKey = instanceConfig.getString('apiKey');
|
||||
const creds = btoa(`${username}:${apiKey}`);
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
headers: {
|
||||
Authorization: `Basic ${creds}`,
|
||||
},
|
||||
jobName,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use a prefixed version of the original annotation scheme and a multiple instance config with clear "default" name
|
||||
*/
|
||||
export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider {
|
||||
constructor(
|
||||
private readonly catalog: CatalogClient,
|
||||
private readonly config: Config,
|
||||
) {}
|
||||
|
||||
async getInstance(opt: {
|
||||
entityRef: EntityName;
|
||||
jobName?: string;
|
||||
}): Promise<JenkinsInfo> {
|
||||
const JENKINS_ANNOTATION = 'jenkins.io/github-folder';
|
||||
const DEFAULT_JENKINS_NAME = 'default';
|
||||
|
||||
// lookup `[jenkinsName#]jobName` from entity annotation
|
||||
const entity = await this.catalog.getEntityByName(opt.entityRef);
|
||||
if (!entity) {
|
||||
throw new Error(
|
||||
`Couldn't find entity with name: ${stringifyEntityRef(opt.entityRef)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const jenkinsAndJobName = entity.metadata.annotations?.[JENKINS_ANNOTATION];
|
||||
if (!jenkinsAndJobName) {
|
||||
throw new Error(
|
||||
`Couldn't find jenkins annotation (${JENKINS_ANNOTATION}) on entity with name: ${stringifyEntityRef(
|
||||
opt.entityRef,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
let jobName;
|
||||
let jenkinsName: string;
|
||||
const splitIndex = jenkinsAndJobName.indexOf('#');
|
||||
if (splitIndex === -1) {
|
||||
// no jenkinsName specified, use default
|
||||
jenkinsName = DEFAULT_JENKINS_NAME;
|
||||
jobName = jenkinsAndJobName;
|
||||
} else {
|
||||
// There is a jenkinsName specified
|
||||
jenkinsName = jenkinsAndJobName.substring(0, splitIndex);
|
||||
jobName = jenkinsAndJobName.substring(
|
||||
splitIndex + 1,
|
||||
jenkinsAndJobName.length,
|
||||
);
|
||||
}
|
||||
|
||||
// lookup baseURL + creds from config
|
||||
const instanceConfig = this.config
|
||||
.getConfigArray('jenkins.DefaultJenkinsInfoProvider.instances')
|
||||
.filter(c => c.getString('name') === jenkinsName)[0];
|
||||
if (!instanceConfig) {
|
||||
throw new Error(
|
||||
`Couldn't find a jenkins instance in the config with name ${jenkinsName}`,
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = instanceConfig.getString('baseUrl');
|
||||
const username = instanceConfig.getString('username');
|
||||
const apiKey = instanceConfig.getString('apiKey');
|
||||
const creds = btoa(`${username}:${apiKey}`);
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
headers: {
|
||||
Authorization: `Basic ${creds}`,
|
||||
},
|
||||
jobName,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use a bespoke annotation and no config
|
||||
*/
|
||||
export class AcmeJenkinsInfoProvider implements JenkinsInfoProvider {
|
||||
constructor(private readonly catalog: CatalogClient) {}
|
||||
|
||||
async getInstance(opt: {
|
||||
entityRef: EntityName;
|
||||
jobName?: string;
|
||||
}): Promise<JenkinsInfo> {
|
||||
const PAAS_ANNOTATION = 'acme.example.com/paas-project-name';
|
||||
|
||||
// lookup pass-project-name from entity annotation
|
||||
const entity = await this.catalog.getEntityByName(opt.entityRef);
|
||||
if (!entity) {
|
||||
throw new Error(
|
||||
`Couldn't find entity with name: ${stringifyEntityRef(opt.entityRef)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const paasProjectName = entity.metadata.annotations?.[PAAS_ANNOTATION];
|
||||
if (!paasProjectName) {
|
||||
throw new Error(
|
||||
`Couldn't find paas annotation (${PAAS_ANNOTATION}) on entity with name: ${stringifyEntityRef(
|
||||
opt.entityRef,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// lookup department and team for paas project name
|
||||
const { team, dept } = this.lookupPaasInfo(paasProjectName);
|
||||
|
||||
const baseUrl = `https://jenkins-${dept}.example.com/`;
|
||||
const jobName = `${team}/${paasProjectName}`;
|
||||
const username = 'backstage-bot';
|
||||
const apiKey = this.getJenkinsApiKey(paasProjectName);
|
||||
const creds = btoa(`${username}:${apiKey}`);
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
headers: {
|
||||
Authorization: `Basic ${creds}`,
|
||||
},
|
||||
jobName,
|
||||
};
|
||||
}
|
||||
|
||||
private lookupPaasInfo(_: string): { team: string; dept: string } {
|
||||
// Mock implementation, this would get info from the paas system somehow in reality.
|
||||
return {
|
||||
team: 'teamA',
|
||||
dept: 'DepartmentA',
|
||||
};
|
||||
}
|
||||
|
||||
private getJenkinsApiKey(_: string): string {
|
||||
// Mock implementation, this would get info from the paas system somehow in reality.
|
||||
return '123456789abcdef0123456789abcedf012';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 { errorHandler } from '@backstage/backend-common';
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { Logger } from 'winston';
|
||||
import jenkins from 'jenkins';
|
||||
import {
|
||||
BackstageBuild,
|
||||
BackstageProject,
|
||||
JenkinsBuild,
|
||||
JenkinsProject,
|
||||
ScmDetails,
|
||||
} from '../types';
|
||||
import { JenkinsInfo, JenkinsInfoProvider } from './jenkinsInfoProvider';
|
||||
|
||||
const lastBuildTreeSpec = `lastBuild[
|
||||
number,
|
||||
url,
|
||||
fullDisplayName,
|
||||
displayName,
|
||||
building,
|
||||
result,
|
||||
timestamp,
|
||||
duration,
|
||||
actions[
|
||||
*[
|
||||
*[
|
||||
*[
|
||||
*
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
],`;
|
||||
|
||||
const jobTreeSpec = `actions[*],
|
||||
${lastBuildTreeSpec}
|
||||
jobs{0,1},
|
||||
name,
|
||||
displayName,
|
||||
fullDisplayName`;
|
||||
|
||||
const jobsTreeSpec = `jobs[
|
||||
${jobTreeSpec}
|
||||
]{0,50}`;
|
||||
|
||||
export interface RouterOptions {
|
||||
logger: Logger;
|
||||
jenkinsInfoProvider: JenkinsInfoProvider;
|
||||
}
|
||||
|
||||
export async function createRouter(
|
||||
options: RouterOptions,
|
||||
): Promise<express.Router> {
|
||||
// @ts-ignore keeping unused logger for future use
|
||||
const { logger, jenkinsInfoProvider } = options;
|
||||
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
|
||||
router.get(
|
||||
'/v1/entity/:namespace/:kind/:name/projects',
|
||||
async (request, response) => {
|
||||
const { namespace, kind, name } = request.params;
|
||||
const branch = request.query.branch;
|
||||
|
||||
const jenkinsInfo = await jenkinsInfoProvider.getInstance({
|
||||
entityRef: {
|
||||
kind,
|
||||
namespace,
|
||||
name,
|
||||
},
|
||||
});
|
||||
|
||||
const client = await getClient(jenkinsInfo);
|
||||
const projects: BackstageProject[] = [];
|
||||
|
||||
if (branch) {
|
||||
// we have been asked to filter to a single branch.
|
||||
// Assume jenkinsInfo.jobName is a folder which contains one job per branch.
|
||||
// TODO: extract a strategy interface for this
|
||||
// @ts-ignore
|
||||
const job = await client.job.get({
|
||||
name: `${jenkinsInfo.jobName}/${branch}`,
|
||||
tree: jobTreeSpec.replace(/\s/g, ''),
|
||||
});
|
||||
projects.push(augmentProject(job));
|
||||
} else {
|
||||
// We aren't filtering
|
||||
// Assume jenkinsInfo.jobName is a folder which contains one job per branch.
|
||||
// @ts-ignore
|
||||
const folder = await client.job.get({
|
||||
name: jenkinsInfo.jobName,
|
||||
// Filter only be the information we need, instead of loading all fields.
|
||||
// Limit to only show the latest build for each job and only load 50 jobs
|
||||
// at all.
|
||||
// Whitespaces are only included for readablity here and stripped out
|
||||
// before sending to Jenkins
|
||||
tree: jobsTreeSpec.replace(/\s/g, ''),
|
||||
});
|
||||
|
||||
// TODO: support this being a project itself.
|
||||
for (const jobDetails of folder.jobs) {
|
||||
// for each branch (we assume)
|
||||
if (jobDetails?.jobs) {
|
||||
// skipping folders inside folders for now
|
||||
// TODO: recurse
|
||||
} else {
|
||||
projects.push(augmentProject(jobDetails));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response.send({
|
||||
projects: projects,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/v1/entity/:namespace/:kind/:name/job/:jobName/:buildNumber',
|
||||
async (request, response) => {
|
||||
const {
|
||||
namespace,
|
||||
kind,
|
||||
name,
|
||||
jobName: jobNameEnc,
|
||||
buildNumber,
|
||||
} = request.params;
|
||||
|
||||
const jobName = decodeURIComponent(jobNameEnc);
|
||||
|
||||
const jenkinsInfo = await jenkinsInfoProvider.getInstance({
|
||||
entityRef: {
|
||||
kind,
|
||||
namespace,
|
||||
name,
|
||||
},
|
||||
jobName,
|
||||
});
|
||||
|
||||
const client = await getClient(jenkinsInfo);
|
||||
|
||||
// @ts-ignore
|
||||
const project = await client.job.get({
|
||||
name: jobName,
|
||||
depth: 1,
|
||||
});
|
||||
|
||||
const build = await client.build.get(jobName, parseInt(buildNumber, 10));
|
||||
|
||||
const jobScmInfo = extractScmDetailsFromJob(project);
|
||||
|
||||
response.send({
|
||||
build: augmentBuild(build, jobScmInfo),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
router.use(errorHandler());
|
||||
return router;
|
||||
}
|
||||
|
||||
function augmentProject(project: JenkinsProject): BackstageProject {
|
||||
const jobScmInfo = extractScmDetailsFromJob(project);
|
||||
|
||||
let status: string;
|
||||
if (project.inQueue) {
|
||||
status = 'queued';
|
||||
} else if (project.lastBuild.building) {
|
||||
status = 'running';
|
||||
} else if (!project.lastBuild.result) {
|
||||
status = 'unknown';
|
||||
} else {
|
||||
status = project.lastBuild.result;
|
||||
}
|
||||
|
||||
return {
|
||||
...project,
|
||||
lastBuild: augmentBuild(project.lastBuild, jobScmInfo),
|
||||
status,
|
||||
// actions: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function augmentBuild(
|
||||
build: JenkinsBuild,
|
||||
jobScmInfo: ScmDetails | undefined,
|
||||
): BackstageBuild {
|
||||
const source =
|
||||
build.actions
|
||||
.filter(
|
||||
(action: any) => action._class === 'hudson.plugins.git.util.BuildData',
|
||||
)
|
||||
.map((action: any) => {
|
||||
const [first]: any = Object.values(action.buildsByBranchName);
|
||||
const branch = first.revision.branch[0];
|
||||
return {
|
||||
branchName: branch.name,
|
||||
commit: {
|
||||
hash: branch.SHA1.substring(0, 8),
|
||||
},
|
||||
};
|
||||
})
|
||||
.pop() || {};
|
||||
|
||||
if (jobScmInfo) {
|
||||
source.url = jobScmInfo.url;
|
||||
source.displayName = jobScmInfo.displayName;
|
||||
source.author = jobScmInfo.author;
|
||||
}
|
||||
|
||||
let status: string;
|
||||
if (build.building) {
|
||||
status = 'running';
|
||||
} else if (!build.result) {
|
||||
status = 'unknown';
|
||||
} else {
|
||||
status = build.result;
|
||||
}
|
||||
return {
|
||||
...build,
|
||||
status,
|
||||
source: source,
|
||||
tests: getTestReport(build),
|
||||
};
|
||||
}
|
||||
|
||||
function extractScmDetailsFromJob(
|
||||
project: JenkinsProject,
|
||||
): ScmDetails | undefined {
|
||||
const scmInfo: ScmDetails | undefined = project.actions
|
||||
.filter(
|
||||
(action: any) =>
|
||||
action._class === 'jenkins.scm.api.metadata.ObjectMetadataAction',
|
||||
)
|
||||
.map((action: any) => {
|
||||
return {
|
||||
url: action?.objectUrl,
|
||||
// https://javadoc.jenkins.io/plugin/scm-api/jenkins/scm/api/metadata/ObjectMetadataAction.html
|
||||
// branch name for regular builds, pull request title on pull requests
|
||||
displayName: action?.objectDisplayName,
|
||||
};
|
||||
})
|
||||
.pop();
|
||||
|
||||
if (!scmInfo) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const author = project.actions
|
||||
.filter(
|
||||
(action: any) =>
|
||||
action._class === 'jenkins.scm.api.metadata.ContributorMetadataAction',
|
||||
)
|
||||
.map((action: any) => {
|
||||
return action.contributorDisplayName;
|
||||
})
|
||||
.pop();
|
||||
|
||||
if (author) {
|
||||
scmInfo.author = author;
|
||||
}
|
||||
|
||||
return scmInfo;
|
||||
}
|
||||
|
||||
function getTestReport(
|
||||
build: JenkinsBuild,
|
||||
): {
|
||||
total: number;
|
||||
passed: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
testUrl: string;
|
||||
} {
|
||||
return build.actions
|
||||
.filter(
|
||||
(action: any) => action._class === 'hudson.tasks.junit.TestResultAction',
|
||||
)
|
||||
.map((action: any) => {
|
||||
return {
|
||||
total: action.totalCount,
|
||||
passed: action.totalCount - action.failCount - action.skipCount,
|
||||
skipped: action.skipCount,
|
||||
failed: action.failCount,
|
||||
testUrl: `${build.url}${action.urlName}/`,
|
||||
};
|
||||
})
|
||||
.pop();
|
||||
}
|
||||
|
||||
async function getClient(jenkinsInfo: JenkinsInfo) {
|
||||
return jenkins({
|
||||
baseUrl: jenkinsInfo.baseUrl,
|
||||
headers: jenkinsInfo.headers,
|
||||
promisify: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 { createServiceBuilder } from '@backstage/backend-common';
|
||||
import { Server } from 'http';
|
||||
import { Logger } from 'winston';
|
||||
import { createRouter } from './router';
|
||||
import { EntityRef } from '@backstage/catalog-model';
|
||||
import { JenkinsInfo } from './jenkinsInfoProvider';
|
||||
|
||||
export interface ServerOptions {
|
||||
port: number;
|
||||
enableCors: boolean;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export async function startStandaloneServer(
|
||||
options: ServerOptions,
|
||||
): Promise<Server> {
|
||||
const logger = options.logger.child({ service: 'jenkins-backend' });
|
||||
logger.debug('Starting application server...');
|
||||
const router = await createRouter({
|
||||
logger,
|
||||
jenkinsInfoProvider: {
|
||||
async getInstance(_: { entityRef: EntityRef }): Promise<JenkinsInfo> {
|
||||
return { baseUrl: 'https://example.com/', jobName: 'build-foo' };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const service = createServiceBuilder(module)
|
||||
.enableCors({ origin: 'http://localhost:3000' })
|
||||
.addRouter('/jenkins', router);
|
||||
|
||||
return await service.start().catch(err => {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.hot?.accept();
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 {};
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* 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 interface ScmDetails {
|
||||
url?: String;
|
||||
displayName?: String;
|
||||
author?: String;
|
||||
}
|
||||
|
||||
interface CommonBuild {
|
||||
// standard Jenkins
|
||||
timestamp: number;
|
||||
building: boolean;
|
||||
duration: number;
|
||||
result?: string;
|
||||
fullDisplayName: string;
|
||||
displayName: string;
|
||||
url: string;
|
||||
number: number;
|
||||
}
|
||||
|
||||
export interface JenkinsBuild extends CommonBuild {
|
||||
// read by us from jenkins but not passed to frontend
|
||||
actions: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* A build as presented by this plugin to the backstage jenkins plugin
|
||||
*/
|
||||
export interface BackstageBuild extends CommonBuild {
|
||||
// added by us
|
||||
source?: {
|
||||
branchName: string;
|
||||
displayName: string;
|
||||
url: string;
|
||||
commit: {
|
||||
hash: string;
|
||||
};
|
||||
author: string;
|
||||
};
|
||||
tests: {
|
||||
passed: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
total: number;
|
||||
testUrl: string;
|
||||
};
|
||||
status: string; // == building ? 'running' : result,
|
||||
}
|
||||
|
||||
export interface CommonProject {
|
||||
// standard Jenkins
|
||||
lastBuild: CommonBuild;
|
||||
displayName: string;
|
||||
fullDisplayName: string;
|
||||
fullName: string;
|
||||
inQueue: string;
|
||||
}
|
||||
|
||||
export interface JenkinsProject extends CommonProject {
|
||||
// standard Jenkins
|
||||
lastBuild: JenkinsBuild;
|
||||
|
||||
// read by us from jenkins but not passed to frontend
|
||||
actions: any;
|
||||
}
|
||||
|
||||
export interface BackstageProject extends CommonProject {
|
||||
// standard Jenkins
|
||||
lastBuild: BackstageBuild;
|
||||
|
||||
// added by us
|
||||
status: string; // == inQueue ? 'queued' : lastBuild.building ? 'running' : lastBuild.result,
|
||||
}
|
||||
Reference in New Issue
Block a user