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:
Andrew Shirley
2021-04-30 12:33:37 +01:00
committed by blam
parent 12a62ea5f6
commit 449b995261
26 changed files with 1338 additions and 384 deletions
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
};
+160
View File
@@ -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)
+46
View File
@@ -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"
]
}
+22
View File
@@ -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';
+33
View File
@@ -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();
+17
View File
@@ -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 {};
+87
View File
@@ -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,
}