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"
|
||||
]
|
||||
}
|
||||
+6
-7
@@ -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';
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user