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
+1
View File
@@ -38,6 +38,7 @@
"@backstage/plugin-catalog-backend": "^0.12.0",
"@backstage/plugin-code-coverage-backend": "^0.1.8",
"@backstage/plugin-graphql-backend": "^0.1.8",
"@backstage/plugin-jenkins-backend": "^0.1.1",
"@backstage/plugin-kubernetes-backend": "^0.3.9",
"@backstage/plugin-kafka-backend": "^0.2.8",
"@backstage/plugin-proxy-backend": "^0.2.9",
+3
View File
@@ -50,6 +50,7 @@ import todo from './plugins/todo';
import graphql from './plugins/graphql';
import app from './plugins/app';
import badges from './plugins/badges';
import jenkins from './plugins/jenkins';
import { PluginEnvironment } from './types';
function makeCreateEnv(config: Config) {
@@ -101,6 +102,7 @@ async function main() {
const graphqlEnv = useHotMemoize(module, () => createEnv('graphql'));
const appEnv = useHotMemoize(module, () => createEnv('app'));
const badgesEnv = useHotMemoize(module, () => createEnv('badges'));
const jenkinsEnv = useHotMemoize(module, () => createEnv('jenkins'));
const apiRouter = Router();
apiRouter.use('/catalog', await catalog(catalogEnv));
@@ -116,6 +118,7 @@ async function main() {
apiRouter.use('/proxy', await proxy(proxyEnv));
apiRouter.use('/graphql', await graphql(graphqlEnv));
apiRouter.use('/badges', await badges(badgesEnv));
apiRouter.use('/jenkins', await jenkins(jenkinsEnv));
apiRouter.use(notFoundHandler());
const service = createServiceBuilder(module)
+31
View File
@@ -0,0 +1,31 @@
/*
* 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 {
createRouter,
DummyJenkinsInfoProvider,
} 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 DummyJenkinsInfoProvider(),
});
}
+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"
]
}
@@ -13,11 +13,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEntity } from '@backstage/plugin-catalog-react';
import { JENKINS_ANNOTATION } from '../constants';
export const useProjectSlugFromEntity = () => {
const { entity } = useEntity();
return entity.metadata.annotations?.[JENKINS_ANNOTATION] ?? '';
};
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,
}
+9 -38
View File
@@ -16,7 +16,11 @@ cd packages/app
yarn add @backstage/plugin-jenkins
```
2. Add the `EntityJenkinsContent` extension to the entity page in the app:
2. Add and configure tha backend plugin according to it's instructions
3. Add the `EntityJenkinsContent` extension to the cicd page and `EntityLatestJenkinsRunCard` to the entity page in the app:
Note that if you configured a custom JenkinsInfoProvider in step 2, you may need a custom isJenkinsAvailable.
```tsx
// In packages/app/src/components/catalog/EntityPage.tsx
@@ -32,29 +36,11 @@ const serviceEntityPage = (
</EntityLayout.Route>
```
3. Add proxy configuration to `app-config.yaml`
4. Run app with `yarn start`
5. Add the Jenkins folder annotation to your `catalog-info.yaml`.
```yaml
proxy:
'/jenkins/api':
target: 'http://localhost:8080' # your Jenkins URL
changeOrigin: true
headers:
Authorization: Basic ${JENKINS_BASIC_AUTH_HEADER}
```
4. Add an environment variable which contains the Jenkins credentials (NOTE:
use an API token, not your password). Here `user` is the name of the user
created in Jenkins.
```shell
export JENKINS_BASIC_AUTH_HEADER=$(echo -n user:api-token | base64)
```
5. Run the app with `yarn start`
6. Add the Jenkins folder annotation to your `catalog-info.yaml`, (NOTE:
currently this plugin only supports folders and Git SCM)
Currently, this plugin only supports folders and Git SCM.
Note that if you configured a custom JenkinsInfoProvider in step 2, you may need to use a different annotation scheme here
```yaml
apiVersion: backstage.io/v1alpha1
@@ -75,21 +61,6 @@ spec:
8. Click the component in the catalog. You should now see Jenkins builds, and a
last build result for your master build.
Note: If you are not using environment variables, you can directly type the API
token into `app-config.yaml`.
```yaml
proxy:
'/jenkins/api':
target: 'http://localhost:8080' # your Jenkins URL
changeOrigin: true
headers:
Authorization: Basic YWRtaW46MTFlYzI1NmU0Mzg1MDFjM2Y1Yzc2Yjc1MWE3ZTQ3YWY4Mw==
```
The string starting with `YWR...` is the base64 encoding of the user and their
API token, e.g. `admin:11ec256e438501c3f5c76b751a7e47af83`.
## Features
- View all runs inside a folder
-1
View File
@@ -39,7 +39,6 @@
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"jenkins": "^0.28.0",
"luxon": "^1.25.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
+136 -222
View File
@@ -14,246 +14,160 @@
* limitations under the License.
*/
import { CITableBuildInfo } from '../components/BuildsPage/lib/CITable';
import { createApiRef, DiscoveryApi } from '@backstage/core-plugin-api';
const jenkins = require('jenkins');
import { createApiRef, DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api';
import { EntityName, EntityRef } from '@backstage/catalog-model';
export const jenkinsApiRef = createApiRef<JenkinsApi>({
id: 'plugin.jenkins.service',
id: 'plugin.jenkins.service2',
description: 'Used by the Jenkins plugin to make requests',
});
const DEFAULT_PROXY_PATH = '/jenkins/api';
export interface Build {
// standard Jenkins
timestamp: number;
building: boolean;
duration: number;
result?: string;
fullDisplayName: string;
displayName: string;
url: string;
number: number;
type Options = {
discoveryApi: DiscoveryApi;
/**
* Path to use for requests via the proxy, defaults to /jenkins/api
*/
proxyPath?: string;
};
export class JenkinsApi {
private readonly discoveryApi: DiscoveryApi;
private readonly proxyPath: string;
constructor(options: Options) {
this.discoveryApi = options.discoveryApi;
this.proxyPath = options.proxyPath ?? DEFAULT_PROXY_PATH;
}
private async getClient() {
const proxyUrl = await this.discoveryApi.getBaseUrl('proxy');
return jenkins({ baseUrl: proxyUrl + this.proxyPath, promisify: true });
}
async retry(buildName: string) {
const client = await this.getClient();
// looks like the current SDK only supports triggering a new build
// can't see any support for replay (re-running the specific build with the same SCM info)
return await client.job.build(buildName);
}
async getLastBuild(jobName: string) {
const client = await this.getClient();
const job = await client.job.get(jobName);
const lastBuild = await client.build.get(jobName, job.lastBuild.number);
return lastBuild;
}
extractScmDetailsFromJob(jobDetails: any): any | undefined {
const scmInfo = jobDetails.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 = jobDetails.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;
}
async getJob(jobName: string) {
const client = await this.getClient();
return client.job.get({
name: jobName,
depth: 1,
});
}
async getFolder(folderName: string) {
const client = await this.getClient();
const folder = await client.job.get({
name: folderName,
// 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: `jobs[
actions[*],
builds[
number,
url,
fullDisplayName,
building,
result,
actions[
*[
*[
*[
*
]
]
]
]
]{0,1},
jobs{0,1},
name
]{0,50}
`.replace(/\s/g, ''),
});
const results = [];
for (const jobDetails of folder.jobs) {
const jobScmInfo = this.extractScmDetailsFromJob(jobDetails);
if (jobDetails?.jobs) {
// skipping folders inside folders for now
} else {
for (const buildDetails of jobDetails.builds) {
const ciTable = this.mapJenkinsBuildToCITable(
buildDetails,
jobScmInfo,
);
results.push(ciTable);
}
}
}
return results;
}
private getTestReport(
jenkinsResult: any,
): {
total: number;
// 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;
} {
return jenkinsResult.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: `${jenkinsResult.url}${action.urlName}/`,
};
})
.pop();
};
status: string; // == building ? 'running' : result,
}
export interface Project {
// standard Jenkins
lastBuild: Build;
displayName: string;
fullDisplayName: string;
fullName: string;
inQueue: string;
// added by us
status: string; // == inQueue ? 'queued' : lastBuild.building ? 'running' : lastBuild.result,
onRestartClick: () => void; // TODO handle.* ?
}
export interface JenkinsApi {
/**
* Get the projects (jobs which have builds, not folders) including info about their lastBuild.
*
* Deciding what jobs are for an entity can be configured by the backstage _Integrator_ in the plugin-jenkins-backend setup
* and by the _Software Engineer_ using annotations agreed with the _Integrator_.
*
* Typically, a folder job will be identified and the backend plugin will recursively look for projects (jobs with builds) within that folder.
*
* @param entity the entity whose jobs should be retrieved.
* @param filter a filter on jobs. Currently this just takes a branch (and assumes certain structures in jenkins)
*/
getProjects(
entity: EntityRef,
filter: { branch?: string },
): Promise<Project[]>;
/**
* Get a single build.
*
* This takes an entity to support selecting between multiple jenkins instances.
*
* TODO: abstract jobName (so we could support differentiating between the same named job on multiple instances).
* @param entity
* @param jobName
* @param buildNumber
*/
getBuild(
entity: EntityName,
jobName: string,
buildNumber: string,
): Promise<Build>;
retry(entity: EntityName, jobName: string, buildNumber: string): Promise<any>;
}
export class JenkinsApiImpl implements JenkinsApi {
private readonly discoveryApi: DiscoveryApi;
private readonly identityApi: IdentityApi;
constructor(options: {
discoveryApi: DiscoveryApi;
identityApi: IdentityApi;
}) {
this.discoveryApi = options.discoveryApi;
this.identityApi = options.identityApi;
}
mapJenkinsBuildToCITable(
jenkinsResult: any,
jobScmInfo?: any,
): CITableBuildInfo {
const source =
jenkinsResult.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() || {};
async getProjects(
entity: EntityName,
filter: { branch?: string },
): Promise<any> {
const url = new URL(
`${await this.discoveryApi.getBaseUrl('jenkins')}/v1/entity/${
entity.namespace
}/${entity.kind}/${entity.name}/projects`,
);
if (jobScmInfo) {
source.url = jobScmInfo?.url;
source.displayName = jobScmInfo?.displayName;
source.author = jobScmInfo?.author;
if (filter.branch) {
url.searchParams.append('branch', filter.branch);
}
const path = new URL(jenkinsResult.url).pathname;
return {
id: path,
buildNumber: jenkinsResult.number,
buildUrl: jenkinsResult.url,
buildName: jenkinsResult.fullDisplayName,
status: jenkinsResult.building ? 'running' : jenkinsResult.result,
onRestartClick: () => {
// TODO: this won't handle non root context path, need a better way to get the job name
const { jobName } = this.extractJobDetailsFromBuildName(path);
return this.retry(jobName);
const idToken = await this.identityApi.getIdToken();
const response = await fetch(url.href, {
method: 'GET',
headers: {
...(idToken && { Authorization: `Bearer ${idToken}` }),
},
source: source,
tests: this.getTestReport(jenkinsResult),
};
});
return (await response.json()).projects;
}
async getBuild(buildName: string) {
const client = await this.getClient();
const { jobName, buildNumber } = this.extractJobDetailsFromBuildName(
buildName,
);
const buildResult = await client.build.get(jobName, buildNumber);
return buildResult;
}
extractJobDetailsFromBuildName(buildName: string) {
const trimmedBuild = buildName.replace(/\/job/g, '').replace(/\/$/, '');
const split = trimmedBuild.split('/');
const buildNumber = parseInt(split[split.length - 1], 10);
const jobName = trimmedBuild.slice(
0,
trimmedBuild.length - buildNumber.toString(10).length - 1,
);
return {
async getBuild(
entity: EntityName,
jobName: string,
buildNumber: string,
): Promise<any> {
const url = `${await this.discoveryApi.getBaseUrl('jenkins')}/v1/entity/${
entity.namespace
}/${entity.kind}/${entity.name}/job/${encodeURIComponent(
jobName,
buildNumber,
};
)}/${buildNumber}`;
const idToken = await this.identityApi.getIdToken();
const response = await fetch(url, {
method: 'GET',
headers: {
...(idToken && { Authorization: `Bearer ${idToken}` }),
},
});
return (await response.json()).build;
}
retry(
// @ts-ignore unused because unimplemented
entity: EntityName,
// @ts-ignore unused because unimplemented
jobName: string,
// @ts-ignore unused because unimplemented
buildNumber: string,
): Promise<any> {
return Promise.resolve(undefined);
}
}
+3 -1
View File
@@ -14,4 +14,6 @@
* limitations under the License.
*/
export { JenkinsApi, jenkinsApiRef } from './JenkinsApi';
export { JenkinsApiImpl, jenkinsApiRef } from './JenkinsApi';
export type { JenkinsApi } from './JenkinsApi';
@@ -30,7 +30,6 @@ import React from 'react';
import { buildRouteRef } from '../../plugin';
import { JenkinsRunStatus } from '../BuildsPage/lib/Status';
import { useBuildWithSteps } from '../useBuildWithSteps';
import { useProjectSlugFromEntity } from '../useProjectSlugFromEntity';
import { Breadcrumbs, Content, Link } from '@backstage/core-components';
import { useRouteRefParams } from '@backstage/core-plugin-api';
@@ -49,17 +48,15 @@ const useStyles = makeStyles(theme => ({
}));
const BuildWithStepsView = () => {
const projectName = useProjectSlugFromEntity();
const { branch, buildNumber } = useRouteRefParams(buildRouteRef);
const { jobName, buildNumber } = useRouteRefParams(buildRouteRef);
const classes = useStyles();
const buildPath = `${projectName}/${encodeURIComponent(
branch,
)}/${buildNumber}`;
const [{ value }] = useBuildWithSteps(buildPath);
const [{ value }] = useBuildWithSteps(jobName, buildNumber);
return (
<div className={classes.root}>
<Breadcrumbs aria-label="breadcrumb">
{/* TODO: don't hardcode this link */}
<Link to="../../..">Projects</Link>
<Typography>Run</Typography>
</Breadcrumbs>
@@ -104,7 +101,7 @@ const BuildWithStepsView = () => {
<Typography noWrap>Jenkins</Typography>
</TableCell>
<TableCell>
<MaterialLink target="_blank" href={value?.buildUrl}>
<MaterialLink target="_blank" href={value?.url}>
View on Jenkins{' '}
<ExternalLinkIcon className={classes.externalLinkIcon} />
</MaterialLink>
@@ -112,10 +109,11 @@ const BuildWithStepsView = () => {
</TableRow>
<TableRow>
<TableCell>
{/* TODO: be SCM agnostic */}
<Typography noWrap>GitHub</Typography>
</TableCell>
<TableCell>
<MaterialLink target="_blank" href={value?.source.url}>
<MaterialLink target="_blank" href={value?.source?.url}>
View on GitHub{' '}
<ExternalLinkIcon className={classes.externalLinkIcon} />
</MaterialLink>
@@ -20,34 +20,9 @@ import JenkinsLogo from '../../../../assets/JenkinsLogo.svg';
import { generatePath, Link as RouterLink } from 'react-router-dom';
import { JenkinsRunStatus } from '../Status';
import { useBuilds } from '../../../useBuilds';
import { useProjectSlugFromEntity } from '../../../useProjectSlugFromEntity';
import { buildRouteRef } from '../../../../plugin';
import { Table, TableColumn } from '@backstage/core-components';
export type CITableBuildInfo = {
id: string;
buildName: string;
buildNumber: number;
buildUrl: string;
source: {
branchName: string;
url: string;
displayName: string;
author?: string;
commit: {
hash: string;
};
};
status: string;
tests?: {
total: number;
passed: number;
skipped: number;
failed: number;
testUrl: string;
};
onRestartClick: () => void;
};
import { Project } from '../../../../api/JenkinsApi';
const FailCount = ({ count }: { count: number }): JSX.Element | null => {
if (count !== 0) {
@@ -107,44 +82,51 @@ const FailSkippedWidget = ({
const generatedColumns: TableColumn[] = [
{
title: 'Build',
field: 'buildName',
field: 'fullName',
highlight: true,
render: (row: Partial<CITableBuildInfo>) => {
if (!row.source?.branchName || !row.buildNumber) {
return <>{row.buildName}</>;
render: (row: Partial<Project>) => {
if (!row.fullName || !row.lastBuild?.number) {
return (
<>
{row.fullName ||
row.fullDisplayName ||
row.displayName ||
'Unknown'}
</>
);
}
return (
<Link
component={RouterLink}
to={generatePath(buildRouteRef.path, {
branch: encodeURIComponent(row.source.branchName),
buildNumber: row.buildNumber.toString(),
jobName: row.fullName,
buildNumber: row.lastBuild?.number.toString(),
})}
>
{row.buildName}
{row.fullName}
</Link>
);
},
},
{
title: 'Source',
field: 'source.branchName',
render: (row: Partial<CITableBuildInfo>) => (
field: 'lastBuild.source.branchName',
render: (row: Partial<Project>) => (
<>
<p>
<Link href={row.source?.url || ''} target="_blank">
{row.source?.branchName}
<Link href={row.lastBuild?.source?.url || ''} target="_blank">
{row.lastBuild?.source?.branchName}
</Link>
</p>
<p>{row.source?.commit?.hash}</p>
<p>{row.lastBuild?.source?.commit?.hash}</p>
</>
),
},
{
title: 'Status',
field: 'status',
render: (row: Partial<CITableBuildInfo>) => {
render: (row: Partial<Project>) => {
return (
<Box display="flex" alignItems="center">
<JenkinsRunStatus status={row.status} />
@@ -155,21 +137,22 @@ const generatedColumns: TableColumn[] = [
{
title: 'Tests',
sorting: false,
render: (row: Partial<CITableBuildInfo>) => {
render: (row: Partial<Project>) => {
return (
<>
<p>
{row.tests && (
<Link href={row.tests.testUrl || ''} target="_blank">
{row.tests.passed} / {row.tests.total} passed
{row.lastBuild?.tests && (
<Link href={row.lastBuild?.tests.testUrl || ''} target="_blank">
{row.lastBuild?.tests.passed} / {row.lastBuild?.tests.total}{' '}
passed
<FailSkippedWidget
skipped={row.tests.skipped}
failed={row.tests.failed}
skipped={row.lastBuild?.tests.skipped}
failed={row.lastBuild?.tests.failed}
/>
</Link>
)}
{!row.tests && 'n/a'}
{!row.lastBuild?.tests && 'n/a'}
</p>
</>
);
@@ -178,7 +161,7 @@ const generatedColumns: TableColumn[] = [
{
title: 'Actions',
sorting: false,
render: (row: Partial<CITableBuildInfo>) => (
render: (row: Partial<Project>) => (
<Tooltip title="Rerun build">
<IconButton onClick={row.onRestartClick}>
<RetryIcon />
@@ -192,8 +175,7 @@ const generatedColumns: TableColumn[] = [
type Props = {
loading: boolean;
retry: () => void;
builds: CITableBuildInfo[];
projectName: string;
projects?: Project[];
page: number;
onChangePage: (page: number) => void;
total: number;
@@ -202,12 +184,11 @@ type Props = {
};
export const CITableView = ({
projectName,
loading,
pageSize,
page,
retry,
builds,
projects,
onChangePage,
onChangePageSize,
total,
@@ -226,14 +207,14 @@ export const CITableView = ({
onClick: () => retry(),
},
]}
data={builds ?? []}
data={projects ?? []}
onChangePage={onChangePage}
onChangeRowsPerPage={onChangePageSize}
title={
<Box display="flex" alignItems="center">
<img src={JenkinsLogo} alt="Jenkins logo" height="50px" />
<Box mr={2} />
<Typography variant="h6">Project: {projectName}</Typography>
<Typography variant="h6">Projects</Typography>
</Box>
}
columns={generatedColumns}
@@ -242,9 +223,7 @@ export const CITableView = ({
};
export const CITable = () => {
const projectName = useProjectSlugFromEntity();
const [tableProps, { setPage, retry, setPageSize }] = useBuilds(projectName);
const [tableProps, { setPage, retry, setPageSize }] = useBuilds();
return (
<CITableView
@@ -14,4 +14,3 @@
* limitations under the License.
*/
export { CITable } from './CITable';
export type { CITableBuildInfo } from './CITable';
@@ -20,6 +20,7 @@ import { LatestRunCard } from './Cards';
import { EntityProvider } from '@backstage/plugin-catalog-react';
import { JenkinsApi, jenkinsApiRef } from '../../api';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { Project } from '../../api/JenkinsApi';
describe('<LatestRunCard />', () => {
const entity = {
@@ -33,7 +34,10 @@ describe('<LatestRunCard />', () => {
};
const jenkinsApi: Partial<JenkinsApi> = {
getLastBuild: () => Promise.resolve({ timestamp: 0, result: 'success' }),
getProjects: () =>
Promise.resolve([
{ lastBuild: { timestamp: 0, status: 'success' } },
] as Project[]),
};
it('should show success status of latest build', async () => {
@@ -52,7 +56,7 @@ describe('<LatestRunCard />', () => {
it('should show the appropriate error in case of a connection error', async () => {
const jenkinsApiWithError: Partial<JenkinsApi> = {
getLastBuild: () => Promise.reject(new Error('Unauthorized')),
getProjects: () => Promise.reject(new Error('Unauthorized')),
};
const apis = ApiRegistry.from([[jenkinsApiRef, jenkinsApiWithError]]);
@@ -71,7 +75,7 @@ describe('<LatestRunCard />', () => {
it('should show the appropriate error in case Jenkins project is not found', async () => {
const jenkinsApiWithError: Partial<JenkinsApi> = {
getLastBuild: () =>
getProjects: () =>
Promise.reject({
notFound: true,
message: 'jenkins-project not found',
+11 -12
View File
@@ -19,13 +19,13 @@ import { DateTime, Duration } from 'luxon';
import React from 'react';
import { JenkinsRunStatus } from '../BuildsPage/lib/Status';
import { ErrorType, useBuilds } from '../useBuilds';
import { useProjectSlugFromEntity } from '../useProjectSlugFromEntity';
import {
InfoCard,
InfoCardVariants,
StructuredMetadataTable,
WarningPanel,
} from '@backstage/core-components';
import { Project } from '../../api/JenkinsApi';
const useStyles = makeStyles<Theme>({
externalLinkIcon: {
@@ -39,16 +39,18 @@ const WidgetContent = ({
latestRun,
}: {
loading?: boolean;
latestRun: any;
latestRun?: Project;
branch: string;
}) => {
const classes = useStyles();
if (loading || !latestRun) return <LinearProgress />;
const displayDate = DateTime.fromMillis(latestRun.timestamp).toRelative();
const displayDate = DateTime.fromMillis(
latestRun.lastBuild.timestamp,
).toRelative();
const displayDuration =
(latestRun.building ? 'Running for ' : '') +
(latestRun.lastBuild.building ? 'Running for ' : '') +
DateTime.local()
.minus(Duration.fromMillis(latestRun.duration))
.minus(Duration.fromMillis(latestRun.lastBuild.duration))
.toRelative({ locale: 'en' })
?.replace(' ago', '');
@@ -57,16 +59,14 @@ const WidgetContent = ({
metadata={{
status: (
<>
<JenkinsRunStatus
status={latestRun.building ? 'running' : latestRun.result}
/>
<JenkinsRunStatus status={latestRun.lastBuild.status} />
</>
),
build: latestRun.fullDisplayName,
'latest run': displayDate,
duration: displayDuration,
link: (
<Link href={latestRun.url} target="_blank">
<Link href={latestRun.lastBuild.url} target="_blank">
See more on Jenkins{' '}
<ExternalLinkIcon className={classes.externalLinkIcon} />
</Link>
@@ -100,9 +100,8 @@ export const LatestRunCard = ({
branch: string;
variant?: InfoCardVariants;
}) => {
const projectName = useProjectSlugFromEntity();
const [{ builds, loading, error }] = useBuilds(projectName, branch);
const latestRun = builds ?? {};
const [{ projects, loading, error }] = useBuilds(branch);
const latestRun = projects?.[0];
return (
<InfoCard title={`Latest ${branch} build`} variant={variant}>
{!error ? (
@@ -18,30 +18,34 @@ import { useAsyncRetry } from 'react-use';
import { jenkinsApiRef } from '../api';
import { useAsyncPolling } from './useAsyncPolling';
import { errorApiRef, useApi } from '@backstage/core-plugin-api';
import { useEntity } from '@backstage/plugin-catalog-react';
import { getEntityName } from '@backstage/catalog-model';
const INTERVAL_AMOUNT = 1500;
export function useBuildWithSteps(buildName: string) {
/**
* Hook to expose a specific build.
* @param jobName the full name of the project (job with builds, not a folder). e.g. "department-A/team-1/project-foo/master"
* @param buildNumber the number of the build. e.g. "13"
*/
export function useBuildWithSteps(jobName: string, buildNumber: string) {
const api = useApi(jenkinsApiRef);
const errorApi = useApi(errorApiRef);
const { entity } = useEntity();
const entityName = getEntityName(entity);
const getBuildWithSteps = useCallback(async () => {
try {
const build = await api.getBuild(buildName);
const { jobName } = api.extractJobDetailsFromBuildName(buildName);
const job = await api.getJob(jobName);
const jobInfo = api.extractScmDetailsFromJob(job);
return Promise.resolve(api.mapJenkinsBuildToCITable(build, jobInfo));
return api.getBuild(entityName, jobName, buildNumber);
} catch (e) {
errorApi.post(e);
return Promise.reject(e);
}
}, [buildName, api, errorApi]);
}, [buildNumber, jobName, entityName, api, errorApi]);
const restartBuild = async () => {
try {
await api.retry(buildName);
await api.retry(entityName, jobName, buildNumber);
} catch (e) {
errorApi.post(e);
}
+20 -18
View File
@@ -17,13 +17,23 @@ import { useState } from 'react';
import { useAsyncRetry } from 'react-use';
import { jenkinsApiRef } from '../api';
import { errorApiRef, useApi } from '@backstage/core-plugin-api';
import { useEntity } from '@backstage/plugin-catalog-react';
import { getEntityName } from '@backstage/catalog-model';
export enum ErrorType {
CONNECTION_ERROR,
NOT_FOUND,
}
export function useBuilds(projectName: string, branch?: string) {
/**
* Hook to expose the latest build for all the pipelines/projects for an entity.
* If `branch` is provided, the latest build for only that branch will be given (but still as a list)
*
* TODO: deprecate branch and add a generic filter concept.
*/
export function useBuilds(branch?: string) {
const { entity } = useEntity();
const entityName = getEntityName(entity);
const api = useApi(jenkinsApiRef);
const errorApi = useApi(errorApiRef);
@@ -35,27 +45,21 @@ export function useBuilds(projectName: string, branch?: string) {
errorType: ErrorType;
}>();
const restartBuild = async (buildName: string) => {
const restartBuild = async (jobName: string, buildNumber: string) => {
try {
await api.retry(buildName);
await api.retry(entityName, jobName, buildNumber);
} catch (e) {
errorApi.post(e);
}
};
const { loading, value: builds, retry } = useAsyncRetry(async () => {
const { loading, value: projects, retry } = useAsyncRetry(async () => {
try {
let build;
if (branch) {
build = await api.getLastBuild(`${projectName}/${branch}`);
} else {
build = await api.getFolder(`${projectName}`);
}
const build = await api.getProjects(getEntityName(entity), { branch });
const size = Array.isArray(build) ? build?.[0].build_num! : 1;
setTotal(size);
setTotal(build.length);
return build || [];
return build;
} catch (e) {
const errorType = e.notFound
? ErrorType.NOT_FOUND
@@ -63,24 +67,22 @@ export function useBuilds(projectName: string, branch?: string) {
setError({ message: e.message, errorType });
throw e;
}
}, [api, errorApi, projectName, branch]);
}, [api, errorApi, entity, branch]);
return [
{
page,
pageSize,
loading,
builds,
projectName,
projects,
total,
error,
},
{
builds,
setPage,
setPageSize,
restartBuild,
retry,
retry, // fetch data again
},
] as const;
}
+8 -5
View File
@@ -22,7 +22,9 @@ import {
createRoutableExtension,
createRouteRef,
discoveryApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
import { JenkinsApiImpl, jenkinsApiRef } from './api';
export const rootRouteRef = createRouteRef({
path: '',
@@ -30,9 +32,9 @@ export const rootRouteRef = createRouteRef({
});
export const buildRouteRef = createRouteRef({
path: 'run/:branch/:buildNumber',
params: ['branch', 'buildNumber'],
title: 'Jenkins run',
path: 'build/:jobName/:buildNumber',
params: ['jobName', 'buildNumber'],
title: 'Jenkins build',
});
export const jenkinsPlugin = createPlugin({
@@ -40,8 +42,9 @@ export const jenkinsPlugin = createPlugin({
apis: [
createApiFactory({
api: jenkinsApiRef,
deps: { discoveryApi: discoveryApiRef },
factory: ({ discoveryApi }) => new JenkinsApi({ discoveryApi }),
deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef },
factory: ({ discoveryApi, identityApi }) =>
new JenkinsApiImpl({ discoveryApi, identityApi }),
}),
],
routes: {
+8 -1
View File
@@ -5828,6 +5828,13 @@
dependencies:
"@types/istanbul-lib-report" "*"
"@types/jenkins@^0.23.1":
version "0.23.1"
resolved "https://registry.npmjs.org/@types/jenkins/-/jenkins-0.23.1.tgz#d0f0ef5511beff975c91cbd2365e580d700ca7f9"
integrity sha512-3oGxVCq+5esbjb0BQXUv0Iz0/7ogJxmzaxKtxwwMik5vGtRvfjWf/sXGA1RzkVAG0+rJUZNKStjKRdtqJfEyRg==
dependencies:
"@types/node" "*"
"@types/jest-when@^2.7.2":
version "2.7.2"
resolved "https://registry.npmjs.org/@types/jest-when/-/jest-when-2.7.2.tgz#619fbc5f623bcd0b29efde0e4993c7f0d50d026d"
@@ -15766,7 +15773,7 @@ jake@^10.6.1:
filelist "^1.0.1"
minimatch "^3.0.4"
jenkins@^0.28.0:
jenkins@^0.28.1:
version "0.28.1"
resolved "https://registry.npmjs.org/jenkins/-/jenkins-0.28.1.tgz#f7951798ee5d2bb501a831979b6b5ecc1a922a64"
integrity sha512-gcC4QUrP4VzdqOMHoVzh36XlJprxJkI2HGLQSY7w84KoCTVNDcR/8O00tYyXp9vrZOx4wl5zCXLVKMgH2IoyJQ==