Merge pull request #5642 from ashirley/jenkinsBackend

This commit is contained in:
Ben Lambert
2021-07-14 22:39:44 +02:00
committed by GitHub
36 changed files with 2420 additions and 422 deletions
+71
View File
@@ -0,0 +1,71 @@
---
'@backstage/plugin-jenkins': minor
---
## Extract an entity-oriented Jenkins Backend
Change the Jenkins plugin from talking directly with a single Jenkins instance (via the proxy) to having a specific
backend plugin which talks to the Jenkins instances.
Existing users of the Jenkins plugin will need to configure a Jenkins backend instead of a proxy.
Typically, this means creating a `src/plugins/jenkins.ts` file, adding a reference to it to `src/index.ts` and changing app-config.yaml
### jenkins.ts
```typescript
import {
createRouter,
DefaultJenkinsInfoProvider,
} from '@backstage/plugin-jenkins-backend';
import { CatalogClient } from '@backstage/catalog-client';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
export default async function createPlugin({
logger,
config,
discovery,
}: PluginEnvironment): Promise<Router> {
const catalogClient = new CatalogClient({ discoveryApi: discovery });
return await createRouter({
logger,
jenkinsInfoProvider: new DefaultJenkinsInfoProvider(catalogClient, config),
});
}
```
### app-config.yaml
For example
```yaml
proxy:
'/jenkins/api':
target: 'https://jenkins.example.com:8080' # your Jenkins URL
changeOrigin: true
headers:
Authorization: Basic ${JENKINS_BASIC_AUTH_HEADER}
```
Would become:
```yaml
jenkins:
baseUrl: https://jenkins.example.com:8080
username: backstage-bot
apiKey: ${JENKINS_PASSWORD}
```
## Change JavaScript API
As part of the above change, the JavaScript API exposed by the plugin as a possible place for customisation has changed.
The ApiRef now has an id of `plugin.jenkins.service2` and has entity-based functions to match the endpoints exposed by
the new backend plugin
## Change BuildWithStepsPage route
The plugin originally provided a route to view a particular build of a particular branch so that if you wanted to view
the master branch of an entity annotated with `'jenkins.io/github-folder': teamA/artistLookup-build` you could typically
access `/catalog/default/component/artist-lookup/ci-cd/run/master/7` but would now have to access
`/catalog/default/component/artist-lookup/ci-cd/build/teamA%2FartistLookup-build%2Fmaster/7`
+6
View File
@@ -370,3 +370,9 @@ homepage:
timezone: 'Asia/Tokyo'
pagerduty:
eventsBaseUrl: 'https://events.pagerduty.com/v2'
jenkins:
instances:
- name: default
baseUrl: https://jenkins.example.com
username: backstage-bot
apiKey: 123456789abcdef0123456789abcedf012
@@ -101,18 +101,22 @@ repository itself. If the URL points to a folder, it is important that it is
suffixed with a `'/'` in order for relative path resolution to work
consistently.
### jenkins.io/github-folder
### jenkins.io/job-full-name
```yaml
# Example:
metadata:
annotations:
jenkins.io/github-folder: folder-name/job-name
jenkins.io/job-full-name: folder-name/job-name
```
The value of this annotation is the path to a job on Jenkins, that builds this
entity.
The value can be the format of just `[folder-path]` or
`[instanceName]:[folder-path]`, if multiple instances are configured in
`app-config.yaml`
Specifying this annotation may enable Jenkins related features in Backstage for
that entity.
@@ -304,6 +308,10 @@ This annotation allowed to load the API definition from another location. Use
[substitution](./descriptor-format.md#substitutions-in-the-descriptor-format)
instead.
### jenkins.io/github-folder
Use the `jenkins.io/job-full-name` instead.
## Links
- [Descriptor Format: annotations](descriptor-format.md#annotations-optional)
+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)
+39
View File
@@ -0,0 +1,39 @@
/*
* 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,
DefaultJenkinsInfoProvider,
} from '@backstage/plugin-jenkins-backend';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
import { CatalogClient } from '@backstage/catalog-client';
export default async function createPlugin({
logger,
config,
discovery,
}: PluginEnvironment): Promise<Router> {
const catalog = new CatalogClient({ discoveryApi: discovery });
return await createRouter({
logger,
jenkinsInfoProvider: DefaultJenkinsInfoProvider.fromConfig({
catalog,
config,
}),
});
}
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
};
+209
View File
@@ -0,0 +1,209 @@
# 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,
DefaultJenkinsInfoProvider,
} from '@backstage/plugin-jenkins-backend';
import { CatalogClient } from '@backstage/catalog-client';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
export default async function createPlugin({
logger,
config,
discovery,
}: PluginEnvironment): Promise<Router> {
const catalog = new CatalogClient({ discoveryApi: discovery });
return await createRouter({
logger,
jenkinsInfoProvider: DefaultJenkinsInfoProvider.fromConfig({
config,
catalog,
}),
});
}
```
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 standard one provided, but the Integrator is free to build their own.
### DefaultJenkinsInfoProvider
Allows configuration of either a single or multiple global Jenkins instances and annotating entities with the job name on that instance (and optionally the name of the instance).
#### Example - Single global 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/job-full-name': teamA/artistLookup-build
```
The old annotation name of `jenkins.io/github-folder` is equivalent to `jenkins.io/job-full-name`
#### Example - Multiple global instances
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/job-full-name': departmentFoo:teamA/artistLookup-build
```
If the `departmentFoo:` part is omitted, the default instance will be assumed.
The following config is an equivalent (but less clear) version of the above:
```yaml
jenkins:
baseUrl: https://jenkins.example.com
username: backstage-bot
apiKey: 123456789abcdef0123456789abcedf012
instances:
- name: departmentFoo
baseUrl: https://jenkins-foo.example.com
username: backstage-bot
apiKey: 123456789abcdef0123456789abcedf012
```
### Custom JenkinsInfoProvider
An example of a bespoke JenkinsInfoProvider which uses an organisation specific annotation to look up the Jenkins info (including jobFullName):
```typescript
class AcmeJenkinsInfoProvider implements JenkinsInfoProvider {
constructor(private readonly catalog: CatalogClient) {}
async getInstance(opt: {
entityRef: EntityName;
jobFullName?: 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 jobFullName = `${team}/${paasProjectName}`;
const username = 'backstage-bot';
const apiKey = this.getJenkinsApiKey(paasProjectName);
const creds = btoa(`${username}:${apiKey}`);
return {
baseUrl,
headers: {
Authorization: `Basic ${creds}`,
},
jobFullName,
};
}
private lookupPaasInfo(_: string): { team: string; dept: string } {
// Mock implementation, this would get info from the paas system somehow in reality.
return {
team: 'teamA',
dept: 'DepartmentFoo',
};
}
private getJenkinsApiKey(_: string): string {
// Mock implementation, this would get info from the paas system somehow in reality.
return '123456789abcdef0123456789abcedf012';
}
}
```
No config would be needed if using this JenkinsInfoProvider
A Catalog entity of the following will look for jobs for this entity at `https://jenkins-departmentFoo.example.com/job/teamA/job/artistLookupService`
```yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: artist-lookup
annotations:
'acme.example.com/paas-project-name': 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)
A _job full name_ is a slash separated list of the names of the job, and the folders which contain it. For example `teamA/artistLookupService/develop`, and the same way that a filesystem path has folders and file names.
+61
View File
@@ -0,0 +1,61 @@
## API Report File for "@backstage/plugin-jenkins-backend"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { CatalogClient } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import { EntityName } from '@backstage/catalog-model';
import express from 'express';
import { Logger as Logger_2 } from 'winston';
// @public (undocumented)
export function createRouter(options: RouterOptions): Promise<express.Router>;
// @public
export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider {
// (undocumented)
static fromConfig(options: {
config: Config;
catalog: CatalogClient;
}): DefaultJenkinsInfoProvider;
// (undocumented)
getInstance(opt: {
entityRef: EntityName;
jobFullName?: string;
}): Promise<JenkinsInfo>;
// (undocumented)
static readonly NEW_JENKINS_ANNOTATION = 'jenkins.io/job-full-name';
// (undocumented)
static readonly OLD_JENKINS_ANNOTATION = 'jenkins.io/github-folder';
}
// @public (undocumented)
export interface JenkinsInfo {
// (undocumented)
baseUrl: string;
// (undocumented)
headers?: Record<string, string | string[]>;
// (undocumented)
jobFullName: string;
}
// @public (undocumented)
export interface JenkinsInfoProvider {
// (undocumented)
getInstance(options: {
entityRef: EntityName;
jobFullName?: string;
}): Promise<JenkinsInfo>;
}
// @public (undocumented)
export interface RouterOptions {
// (undocumented)
jenkinsInfoProvider: JenkinsInfoProvider;
// (undocumented)
logger: Logger_2;
}
// (No @packageDocumentation comment for this package)
```
+51
View File
@@ -0,0 +1,51 @@
/*
* 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 interface Config {
jenkins?: {
/**
* Default instance baseUrl, can be specified on a named instance called "default"
* @pattern "^https?://"
*/
baseUrl?: string;
/**
* Default instance username, can be specified on a named instance called "default"
*/
username?: string;
/**
* Default Instance apiKey, can be specified on a named instance called "default"
* @visibility secret
*/
apiKey?: string;
instances?: {
/**
* Name of the instance, this will be used in an annotation on catalog entities to refer to jobs on this instance.
*
* Use a name of "default" to specify the jenkins instance details if the annotation doesn't explicitly name an
* instance.
*/
name: string;
/**
* @pattern "^https?://"
*/
baseUrl: string;
username: string;
/** @visibility secret */
apiKey: string;
}[];
};
}
+47
View File
@@ -0,0 +1,47 @@
{
"name": "@backstage/plugin-jenkins-backend",
"version": "0.1.1",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"configSchema": "config.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.5s",
"@backstage/catalog-client": "^0.3.16",
"@backstage/catalog-model": "^0.9.0",
"@backstage/config": "^0.1.5",
"@types/express": "^4.17.6",
"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.3",
"@types/jenkins": "^0.23.1",
"@types/supertest": "^2.0.8",
"msw": "^0.29.0",
"supertest": "^4.0.2"
},
"files": [
"dist",
"config.d.ts"
]
}
@@ -13,11 +13,5 @@
* 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';
+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,20 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export type { RouterOptions } from './router';
export { createRouter } from './router';
export type { JenkinsInfo, JenkinsInfoProvider } from './jenkinsInfoProvider';
export { DefaultJenkinsInfoProvider } from './jenkinsInfoProvider';
@@ -0,0 +1,397 @@
/*
* 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 { JenkinsApiImpl } from './jenkinsApi';
import jenkins from 'jenkins';
import { JenkinsInfo } from './jenkinsInfoProvider';
import { JenkinsBuild, JenkinsProject } from '../types';
jest.mock('jenkins');
const mockedJenkinsClient = {
job: {
get: jest.fn(),
build: jest.fn(),
},
build: {
get: jest.fn(),
},
};
const mockedJenkins = jenkins as jest.Mocked<any>;
mockedJenkins.mockReturnValue(mockedJenkinsClient);
const jobFullName = 'example-jobName/foo';
const buildNumber = 19;
const jenkinsInfo: JenkinsInfo = {
baseUrl: 'https://jenkins.example.com',
headers: { headerName: 'headerValue' },
jobFullName: 'example-jobName',
};
describe('JenkinsApi', () => {
const jenkinsApi = new JenkinsApiImpl();
describe('getProjects', () => {
const project: JenkinsProject = {
actions: [],
displayName: 'Example Build',
fullDisplayName: 'Example jobName » Example Build',
fullName: 'example-jobName/exampleBuild',
inQueue: false,
lastBuild: {
actions: [],
timestamp: 1,
building: false,
duration: 10,
result: 'success',
displayName: '#7',
fullDisplayName: 'Example jobName » Example Build #7',
url: 'https://jenkins.example.com/job/example-jobName/job/exampleBuild',
number: 7,
},
};
describe('unfiltered', () => {
it('standard github layout', async () => {
mockedJenkinsClient.job.get.mockResolvedValueOnce({ jobs: [project] });
const result = await jenkinsApi.getProjects(jenkinsInfo);
expect(mockedJenkins).toHaveBeenCalledWith({
baseUrl: jenkinsInfo.baseUrl,
headers: jenkinsInfo.headers,
promisify: true,
});
expect(mockedJenkinsClient.job.get).toBeCalledWith({
name: jenkinsInfo.jobFullName,
tree: expect.anything(),
});
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
actions: [],
displayName: 'Example Build',
fullDisplayName: 'Example jobName » Example Build',
fullName: 'example-jobName/exampleBuild',
inQueue: false,
lastBuild: {
actions: [],
timestamp: 1,
building: false,
duration: 10,
result: 'success',
displayName: '#7',
fullDisplayName: 'Example jobName » Example Build #7',
url:
'https://jenkins.example.com/job/example-jobName/job/exampleBuild',
number: 7,
status: 'success',
source: {},
},
status: 'success',
});
});
});
describe('filtered by branch', () => {
it('standard github layout', async () => {
mockedJenkinsClient.job.get.mockResolvedValueOnce(project);
const result = await jenkinsApi.getProjects(
jenkinsInfo,
'testBranchName',
);
expect(mockedJenkins).toHaveBeenCalledWith({
baseUrl: jenkinsInfo.baseUrl,
headers: jenkinsInfo.headers,
promisify: true,
});
expect(mockedJenkinsClient.job.get).toBeCalledWith({
name: `${jenkinsInfo.jobFullName}/testBranchName`,
tree: expect.anything(),
});
expect(result).toHaveLength(1);
});
});
describe('augmented', () => {
const projectWithScmActions: JenkinsProject = {
actions: [
{},
{},
{},
{},
{
_class: 'jenkins.scm.api.metadata.ContributorMetadataAction',
contributor: 'testuser',
contributorDisplayName: 'Mr. T User',
contributorEmail: null,
},
{},
{
_class: 'jenkins.scm.api.metadata.ObjectMetadataAction',
objectDescription: '',
objectDisplayName: 'Add LICENSE, CoC etc',
objectUrl: 'https://github.com/backstage/backstage/pull/1',
},
{},
{},
{
_class: 'com.cloudbees.plugins.credentials.ViewCredentialsAction',
stores: {},
},
],
displayName: 'Example Build',
fullDisplayName: 'Example jobName » Example Build',
fullName: 'example-jobName/exampleBuild',
inQueue: false,
lastBuild: {
actions: [
{
_class: 'hudson.model.CauseAction',
causes: [
{
_class: 'jenkins.branch.BranchIndexingCause',
shortDescription: 'Branch indexing',
},
],
},
{},
{},
{},
{
_class: 'org.jenkinsci.plugins.workflow.cps.EnvActionImpl',
environment: {},
},
{},
{},
{},
{},
{},
{
_class: 'hudson.plugins.git.util.BuildData',
buildsByBranchName: {
'PR-1': {
_class: 'hudson.plugins.git.util.Build',
buildNumber: 5,
buildResult: null,
marked: {
SHA1: '14d31bde346fcad64ab939f82d195db36701cfcb',
branch: [
{
SHA1: '14d31bde346fcad64ab939f82d195db36701cfcb',
name: 'PR-1',
},
],
},
revision: {
SHA1: '6c6b34c0fb91cf077a01fe62d3e8e996b4ea5861',
branch: [
{
SHA1: '14d31bde346fcad64ab939f82d195db36701cfcb',
name: 'PR-1',
},
],
},
},
},
lastBuiltRevision: {
SHA1: '6c6b34c0fb91cf077a01fe62d3e8e996b4ea5861',
branch: [
{
SHA1: '14d31bde346fcad64ab939f82d195db36701cfcb',
name: 'PR-1',
},
],
},
remoteUrls: ['https://github.com/backstage/backstage.git'],
scmName: '',
},
{
_class: 'hudson.plugins.git.util.BuildData',
buildsByBranchName: {
master: {
_class: 'hudson.plugins.git.util.Build',
buildNumber: 5,
buildResult: null,
marked: {
SHA1: '14d31bde346fcad64ab939f82d195db36701cfcb',
branch: [
{
SHA1: '14d31bde346fcad64ab939f82d195db36701cfcb',
name: 'master',
},
],
},
revision: {
SHA1: '6c6b34c0fb91cf077a01fe62d3e8e996b4ea5861',
branch: [
{
SHA1: '14d31bde346fcad64ab939f82d195db36701cfcb',
name: 'master',
},
],
},
},
},
lastBuiltRevision: {
SHA1: '6c6b34c0fb91cf077a01fe62d3e8e996b4ea5861',
branch: [
{
SHA1: '14d31bde346fcad64ab939f82d195db36701cfcb',
name: 'master',
},
],
},
remoteUrls: ['https://github.com/backstage/backstage.git'],
scmName: '',
},
{},
{},
{
_class: 'hudson.tasks.junit.TestResultAction',
failCount: 2,
skipCount: 1,
totalCount: 635,
urlName: 'testReport',
},
{},
{},
{
_class:
'org.jenkinsci.plugins.pipeline.modeldefinition.actions.RestartDeclarativePipelineAction',
restartEnabled: false,
restartableStages: [],
},
{},
],
timestamp: 1,
building: false,
duration: 10,
result: 'success',
displayName: '#7',
fullDisplayName: 'Example jobName » Example Build #7',
url:
'https://jenkins.example.com/job/example-jobName/job/exampleBuild/7/',
number: 7,
},
};
it('augments project', async () => {
mockedJenkinsClient.job.get.mockResolvedValueOnce({
jobs: [projectWithScmActions],
});
const result = await jenkinsApi.getProjects(jenkinsInfo);
expect(result).toHaveLength(1);
expect(result[0].status).toEqual('success');
});
it('augments build', async () => {
mockedJenkinsClient.job.get.mockResolvedValueOnce({
jobs: [projectWithScmActions],
});
const result = await jenkinsApi.getProjects(jenkinsInfo);
expect(result).toHaveLength(1);
// TODO: I am really just asserting the previous behaviour wth no understanding here.
// In my 2 Jenkins instances, 1 returns a lot of different and confusing BuildData sections and 1 returns none ☹️
expect(result[0].lastBuild.source).toEqual({
branchName: 'master',
commit: {
hash: '14d31bde',
},
url: 'https://github.com/backstage/backstage/pull/1',
displayName: 'Add LICENSE, CoC etc',
author: 'Mr. T User',
});
});
it('finds test report', async () => {
mockedJenkinsClient.job.get.mockResolvedValueOnce({
jobs: [projectWithScmActions],
});
const result = await jenkinsApi.getProjects(jenkinsInfo);
expect(result).toHaveLength(1);
expect(result[0].lastBuild.tests).toEqual({
total: 635,
passed: 632,
skipped: 1,
failed: 2,
testUrl:
'https://jenkins.example.com/job/example-jobName/job/exampleBuild/7/testReport/',
});
});
});
});
it('getBuild', async () => {
const project: JenkinsProject = {
actions: [],
displayName: 'Example Build',
fullDisplayName: 'Example jobName » Example Build',
fullName: 'example-jobName/exampleBuild',
inQueue: false,
lastBuild: {
actions: [],
timestamp: 1,
building: false,
duration: 10,
result: 'success',
displayName: '#7',
fullDisplayName: 'Example jobName » Example Build #7',
url: 'https://jenkins.example.com/job/example-jobName/job/exampleBuild',
number: 7,
},
};
const build: JenkinsBuild = {
actions: [],
timestamp: 1,
building: false,
duration: 10,
result: 'success',
fullDisplayName: 'example-jobName/exampleBuild',
displayName: 'exampleBuild',
url: `https://jenkins.example.com/job/example-jobName/job/exampleBuild/build/${buildNumber}`,
number: buildNumber,
};
mockedJenkinsClient.job.get.mockResolvedValueOnce(project);
mockedJenkinsClient.build.get.mockResolvedValueOnce(build);
await jenkinsApi.getBuild(jenkinsInfo, jobFullName, buildNumber);
expect(mockedJenkins).toHaveBeenCalledWith({
baseUrl: jenkinsInfo.baseUrl,
headers: jenkinsInfo.headers,
promisify: true,
});
expect(mockedJenkinsClient.job.get).toBeCalledWith({
name: jobFullName,
depth: 1,
});
expect(mockedJenkinsClient.build.get).toBeCalledWith(
jobFullName,
buildNumber,
);
});
it('buildProject', async () => {
await jenkinsApi.buildProject(jenkinsInfo, jobFullName);
expect(mockedJenkins).toHaveBeenCalledWith({
baseUrl: jenkinsInfo.baseUrl,
headers: jenkinsInfo.headers,
promisify: true,
});
expect(mockedJenkinsClient.job.build).toBeCalledWith(jobFullName);
});
});
@@ -0,0 +1,283 @@
/*
* 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 { JenkinsInfo } from './jenkinsInfoProvider';
import jenkins from 'jenkins';
import {
BackstageBuild,
BackstageProject,
JenkinsBuild,
JenkinsProject,
ScmDetails,
} from '../types';
export class JenkinsApiImpl {
private static readonly lastBuildTreeSpec = `lastBuild[
number,
url,
fullDisplayName,
displayName,
building,
result,
timestamp,
duration,
actions[
*[
*[
*[
*
]
]
]
]
],`;
private static readonly jobTreeSpec = `actions[*],
${JenkinsApiImpl.lastBuildTreeSpec}
jobs{0,1},
name,
fullName,
displayName,
fullDisplayName,
inQueue`;
private static readonly jobsTreeSpec = `jobs[
${JenkinsApiImpl.jobTreeSpec}
]{0,50}`;
/**
* Get a list of projects for the given JenkinsInfo.
* @see ../../../jenkins/src/api/JenkinsApi.ts#getProjects
*/
async getProjects(jenkinsInfo: JenkinsInfo, branch?: string) {
const client = await JenkinsApiImpl.getClient(jenkinsInfo);
const projects: BackstageProject[] = [];
if (branch) {
// we have been asked to filter to a single branch.
// Assume jenkinsInfo.jobFullName is a folder which contains one job per branch.
// TODO: extract a strategy interface for this
const job = await client.job.get({
name: `${jenkinsInfo.jobFullName}/${branch}`,
tree: JenkinsApiImpl.jobTreeSpec.replace(/\s/g, ''),
});
projects.push(this.augmentProject(job));
} else {
// We aren't filtering
// Assume jenkinsInfo.jobFullName is a folder which contains one job per branch.
const folder = await client.job.get({
name: jenkinsInfo.jobFullName,
// 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: JenkinsApiImpl.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(this.augmentProject(jobDetails));
}
}
}
return projects;
}
/**
* Get a single build.
* @see ../../../jenkins/src/api/JenkinsApi.ts#getBuild
*/
async getBuild(
jenkinsInfo: JenkinsInfo,
jobFullName: string,
buildNumber: number,
) {
const client = await JenkinsApiImpl.getClient(jenkinsInfo);
const project = await client.job.get({
name: jobFullName,
depth: 1,
});
const build = await client.build.get(jobFullName, buildNumber);
const jobScmInfo = JenkinsApiImpl.extractScmDetailsFromJob(project);
return this.augmentBuild(build, jobScmInfo);
}
/**
* Trigger a build of a project
* @see ../../../jenkins/src/api/JenkinsApi.ts#retry
*/
async buildProject(jenkinsInfo: JenkinsInfo, jobFullName: string) {
const client = await JenkinsApiImpl.getClient(jenkinsInfo);
// 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)
// Note Jenkins itself has concepts of rebuild and replay on a job.
// The latter should be possible to trigger with a POST to /replay/rebuild
await client.job.build(jobFullName);
}
// private helper methods
private static async getClient(jenkinsInfo: JenkinsInfo) {
// The typings for the jenkins library are out of date so just cast to any
return jenkins({
baseUrl: jenkinsInfo.baseUrl,
headers: jenkinsInfo.headers,
promisify: true,
}) as any;
}
private augmentProject(project: JenkinsProject): BackstageProject {
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;
}
const jobScmInfo = JenkinsApiImpl.extractScmDetailsFromJob(project);
return {
...project,
lastBuild: this.augmentBuild(project.lastBuild, jobScmInfo),
status,
// actions: undefined,
};
}
private 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: this.getTestReport(build),
};
}
private static 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;
}
private 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();
}
}
@@ -0,0 +1,269 @@
/*
* 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 { DefaultJenkinsInfoProvider, JenkinsInfo } from './jenkinsInfoProvider';
import { CatalogClient } from '@backstage/catalog-client';
import { ConfigReader } from '@backstage/config';
import { Entity, EntityName } from '@backstage/catalog-model';
describe('DefaultJenkinsInfoProvider', () => {
const mockCatalog: jest.Mocked<CatalogClient> = ({
getEntityByName: jest.fn(),
} as any) as jest.Mocked<CatalogClient>;
const entityRef: EntityName = {
kind: 'Component',
namespace: 'foo',
name: 'bar',
};
function configureProvider(configData: any, entityData: any) {
const config = new ConfigReader(configData);
mockCatalog.getEntityByName.mockReturnValueOnce(
Promise.resolve(entityData as Entity),
);
return DefaultJenkinsInfoProvider.fromConfig({
config,
catalog: mockCatalog,
});
}
it('Handles entity not found', async () => {
const provider = configureProvider({ jenkins: {} }, undefined);
await expect(provider.getInstance({ entityRef })).rejects.toThrowError();
expect(mockCatalog.getEntityByName).toBeCalledWith(entityRef);
});
it('Reads simple config and annotation', async () => {
const provider = configureProvider(
{
jenkins: {
baseUrl: 'https://jenkins.example.com',
username: 'backstage - bot',
apiKey: '123456789abcdef0123456789abcedf012',
},
},
{
metadata: {
annotations: {
'jenkins.io/job-full-name': 'teamA/artistLookup-build',
},
},
},
);
const info: JenkinsInfo = await provider.getInstance({ entityRef });
expect(mockCatalog.getEntityByName).toBeCalledWith(entityRef);
expect(info).toStrictEqual({
baseUrl: 'https://jenkins.example.com',
headers: {
Authorization:
'Basic YmFja3N0YWdlIC0gYm90OjEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNlZGYwMTI=',
},
jobFullName: 'teamA/artistLookup-build',
});
});
it('Reads named default config and annotation', async () => {
const provider = configureProvider(
{
jenkins: {
instances: [
{
name: 'default',
baseUrl: 'https://jenkins.example.com',
username: 'backstage - bot',
apiKey: '123456789abcdef0123456789abcedf012',
},
],
},
},
{
metadata: {
annotations: {
'jenkins.io/job-full-name': 'teamA/artistLookup-build',
},
},
},
);
const info: JenkinsInfo = await provider.getInstance({ entityRef });
expect(mockCatalog.getEntityByName).toBeCalledWith(entityRef);
expect(info).toMatchObject({
baseUrl: 'https://jenkins.example.com',
jobFullName: 'teamA/artistLookup-build',
});
});
it('Reads named default config (amongst named other configs) and annotation', async () => {
const provider = configureProvider(
{
jenkins: {
instances: [
{
name: 'default',
baseUrl: 'https://jenkins.example.com',
username: 'backstage - bot',
apiKey: '123456789abcdef0123456789abcedf012',
},
{
name: 'other',
baseUrl: 'https://jenkins-other.example.com',
username: 'backstage - bot',
apiKey: '123456789abcdef0123456789abcedf012',
},
],
},
},
{
metadata: {
annotations: {
'jenkins.io/job-full-name': 'teamA/artistLookup-build',
},
},
},
);
const info: JenkinsInfo = await provider.getInstance({ entityRef });
expect(mockCatalog.getEntityByName).toBeCalledWith(entityRef);
expect(info).toMatchObject({
baseUrl: 'https://jenkins.example.com',
jobFullName: 'teamA/artistLookup-build',
});
});
it('Reads named other config and named annotation', async () => {
const provider = configureProvider(
{
jenkins: {
instances: [
{
name: 'default',
baseUrl: 'https://jenkins.example.com',
username: 'backstage - bot',
apiKey: '123456789abcdef0123456789abcedf012',
},
{
name: 'other',
baseUrl: 'https://jenkins-other.example.com',
username: 'backstage - bot',
apiKey: '123456789abcdef0123456789abcedf012',
},
],
},
},
{
metadata: {
annotations: {
'jenkins.io/job-full-name': 'other:teamA/artistLookup-build',
},
},
},
);
const info: JenkinsInfo = await provider.getInstance({ entityRef });
expect(mockCatalog.getEntityByName).toBeCalledWith(entityRef);
expect(info).toMatchObject({
baseUrl: 'https://jenkins-other.example.com',
jobFullName: 'teamA/artistLookup-build',
});
});
it('Reads simple config and default named annotation', async () => {
const provider = configureProvider(
{
jenkins: {
baseUrl: 'https://jenkins.example.com',
username: 'backstage - bot',
apiKey: '123456789abcdef0123456789abcedf012',
},
},
{
metadata: {
annotations: {
'jenkins.io/job-full-name': 'default:teamA/artistLookup-build',
},
},
},
);
const info: JenkinsInfo = await provider.getInstance({ entityRef });
expect(mockCatalog.getEntityByName).toBeCalledWith(entityRef);
expect(info).toMatchObject({
baseUrl: 'https://jenkins.example.com',
jobFullName: 'teamA/artistLookup-build',
});
});
it('Reads simple config and old annotation', async () => {
const provider = configureProvider(
{
jenkins: {
baseUrl: 'https://jenkins.example.com',
username: 'backstage - bot',
apiKey: '123456789abcdef0123456789abcedf012',
},
},
{
metadata: {
annotations: {
'jenkins.io/github-folder': 'teamA/artistLookup-build',
},
},
},
);
const info: JenkinsInfo = await provider.getInstance({ entityRef });
expect(mockCatalog.getEntityByName).toBeCalledWith(entityRef);
expect(info).toMatchObject({
baseUrl: 'https://jenkins.example.com',
jobFullName: 'teamA/artistLookup-build',
});
});
it('Reads named other config (with on default config) and named annotation', async () => {
const provider = configureProvider(
{
jenkins: {
instances: [
{
name: 'other',
baseUrl: 'https://jenkins-other.example.com',
username: 'backstage - bot',
apiKey: '123456789abcdef0123456789abcedf012',
},
],
},
},
{
metadata: {
annotations: {
'jenkins.io/job-full-name': 'other:teamA/artistLookup-build',
},
},
},
);
const info: JenkinsInfo = await provider.getInstance({ entityRef });
expect(mockCatalog.getEntityByName).toBeCalledWith(entityRef);
expect(info).toMatchObject({
baseUrl: 'https://jenkins-other.example.com',
jobFullName: 'teamA/artistLookup-build',
});
});
});
@@ -0,0 +1,232 @@
/*
* 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 {
Entity,
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.
*/
jobFullName?: string;
}): Promise<JenkinsInfo>;
}
export interface JenkinsInfo {
baseUrl: string;
headers?: Record<string, string | string[]>;
jobFullName: string; // TODO: make this an array
}
/**
* Use default config and annotations, build using fromConfig static function.
*
* This will fallback through various deprecated config and annotation schemes.
*/
export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider {
static readonly OLD_JENKINS_ANNOTATION = 'jenkins.io/github-folder';
static readonly NEW_JENKINS_ANNOTATION = 'jenkins.io/job-full-name';
private constructor(
private readonly config: {
name: string;
baseUrl: string;
username: string;
apiKey: string;
}[],
private readonly catalog: CatalogClient,
) {}
static fromConfig(options: {
config: Config;
catalog: CatalogClient;
}): DefaultJenkinsInfoProvider {
return new DefaultJenkinsInfoProvider(
this.loadConfig(options.config),
options.catalog,
);
}
async getInstance(opt: {
entityRef: EntityName;
jobFullName?: string;
}): Promise<JenkinsInfo> {
// load entity
const entity = await this.catalog.getEntityByName(opt.entityRef);
if (!entity) {
throw new Error(
`Couldn't find entity with name: ${stringifyEntityRef(opt.entityRef)}`,
);
}
// lookup `[jenkinsName#]jobFullName` from entity annotation
const jenkinsAndJobName = DefaultJenkinsInfoProvider.getEntityAnnotationValue(
entity,
);
if (!jenkinsAndJobName) {
throw new Error(
`Couldn't find jenkins annotation (${
DefaultJenkinsInfoProvider.NEW_JENKINS_ANNOTATION
}) on entity with name: ${stringifyEntityRef(opt.entityRef)}`,
);
}
let jobFullName;
let jenkinsName: string | undefined;
const splitIndex = jenkinsAndJobName.indexOf(':');
if (splitIndex === -1) {
// no jenkinsName specified, use default
jobFullName = jenkinsAndJobName;
} else {
// There is a jenkinsName specified
jenkinsName = jenkinsAndJobName.substring(0, splitIndex);
jobFullName = jenkinsAndJobName.substring(
splitIndex + 1,
jenkinsAndJobName.length,
);
}
// lookup baseURL + creds from config
const instanceConfig = DefaultJenkinsInfoProvider.getInstanceConfig(
jenkinsName,
this.config,
);
const creds = Buffer.from(
`${instanceConfig.username}:${instanceConfig.apiKey}`,
'binary',
).toString('base64');
return {
baseUrl: instanceConfig.baseUrl,
headers: {
Authorization: `Basic ${creds}`,
},
jobFullName,
};
}
private static getEntityAnnotationValue(entity: Entity) {
return (
entity.metadata.annotations?.[
DefaultJenkinsInfoProvider.OLD_JENKINS_ANNOTATION
] ||
entity.metadata.annotations?.[
DefaultJenkinsInfoProvider.NEW_JENKINS_ANNOTATION
]
);
}
private static getInstanceConfig(
jenkinsName: string | undefined,
config: {
name: string;
baseUrl: string;
username: string;
apiKey: string;
}[],
): { name: string; baseUrl: string; username: string; apiKey: string } {
const DEFAULT_JENKINS_NAME = 'default';
if (!jenkinsName || jenkinsName === DEFAULT_JENKINS_NAME) {
// no name provided, use default
const instanceConfig = config.find(c => c.name === DEFAULT_JENKINS_NAME);
if (!instanceConfig) {
throw new Error(
`Couldn't find a default jenkins instance in the config. Either configure an instance with name ${DEFAULT_JENKINS_NAME} or add a prefix to your annotation value.`,
);
}
return instanceConfig;
}
// A name is provided, look it up.
const instanceConfig = config.find(c => c.name === jenkinsName);
if (!instanceConfig) {
throw new Error(
`Couldn't find a jenkins instance in the config with name ${jenkinsName}`,
);
}
return instanceConfig;
}
private static loadConfig(
rootConfig: Config,
): { name: string; baseUrl: string; username: string; apiKey: string }[] {
const DEFAULT_JENKINS_NAME = 'default';
const jenkinsConfig = rootConfig.getConfig('jenkins');
// load all named instance config
const namedInstanceConfig =
jenkinsConfig.getOptionalConfigArray('instances')?.map(c => ({
name: c.getString('name'),
baseUrl: c.getString('baseUrl'),
username: c.getString('username'),
apiKey: c.getString('apiKey'),
})) || [];
// load unnamed default config
const hasNamedDefault = namedInstanceConfig.some(
x => x.name === DEFAULT_JENKINS_NAME,
);
// Get these as optional strings and check to give a better error message
const baseUrl = jenkinsConfig.getOptionalString('baseUrl');
const username = jenkinsConfig.getOptionalString('username');
const apiKey = jenkinsConfig.getOptionalString('apiKey');
if (hasNamedDefault && (baseUrl || username || apiKey)) {
throw new Error(
`Found both a named jenkins instance with name ${DEFAULT_JENKINS_NAME} and top level baseUrl, username or apiKey config. Use only one style of config.`,
);
}
const unnamedNonePresent = !baseUrl && !username && !apiKey;
const unnamedAllPresent = baseUrl && username && apiKey;
if (!(unnamedAllPresent || unnamedNonePresent)) {
throw new Error(
`Found partial default jenkins config. All (or none) of baseUrl, username ans apiKey must be provided.`,
);
}
if (unnamedAllPresent) {
const unnamedInstanceConfig = [
{ name: DEFAULT_JENKINS_NAME, baseUrl, username, apiKey },
] as {
name: string;
baseUrl: string;
username: string;
apiKey: string;
}[];
return [...namedInstanceConfig, ...unnamedInstanceConfig];
}
return namedInstanceConfig;
}
}
@@ -0,0 +1,128 @@
/*
* 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 { JenkinsInfoProvider } from './jenkinsInfoProvider';
import { JenkinsApiImpl } from './jenkinsApi';
export interface RouterOptions {
logger: Logger;
jenkinsInfoProvider: JenkinsInfoProvider;
}
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { jenkinsInfoProvider } = options;
const jenkinsApi = new JenkinsApiImpl();
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;
let branchStr: string | undefined;
if (branch === undefined) {
branchStr = undefined;
} else if (typeof branch === 'string') {
branchStr = branch;
} else {
// this was passed in as something weird -> 400
// https://evanhahn.com/gotchas-with-express-query-parsing-and-how-to-avoid-them/
response
.status(400)
.send('Something was unexpected about the branch queryString');
return;
}
const jenkinsInfo = await jenkinsInfoProvider.getInstance({
entityRef: {
kind,
namespace,
name,
},
});
const projects = await jenkinsApi.getProjects(jenkinsInfo, branchStr);
response.json({
projects: projects,
});
},
);
router.get(
'/v1/entity/:namespace/:kind/:name/job/:jobFullName/:buildNumber',
async (request, response) => {
const {
namespace,
kind,
name,
jobFullName,
buildNumber,
} = request.params;
const jenkinsInfo = await jenkinsInfoProvider.getInstance({
entityRef: {
kind,
namespace,
name,
},
jobFullName,
});
const build = await jenkinsApi.getBuild(
jenkinsInfo,
jobFullName,
parseInt(buildNumber, 10),
);
response.json({
build: build,
});
},
);
router.post(
'/v1/entity/:namespace/:kind/:name/job/:jobFullName/:buildNumber::rebuild',
async (request, response) => {
const { namespace, kind, name, jobFullName } = request.params;
const jenkinsInfo = await jenkinsInfoProvider.getInstance({
entityRef: {
kind,
namespace,
name,
},
jobFullName,
});
await jenkinsApi.buildProject(jenkinsInfo, jobFullName);
response.json({});
},
);
router.use(errorHandler());
return router;
}
@@ -0,0 +1,57 @@
/*
* 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 { 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/', jobFullName: 'build-foo' };
},
},
});
let service = createServiceBuilder(module)
.setPort(options.port)
.addRouter('/jenkins', router);
if (options.enableCors) {
service = service.enableCors({ origin: 'http://localhost:3000' });
}
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: boolean;
}
export interface JenkinsProject extends CommonProject {
// standard Jenkins
lastBuild: JenkinsBuild;
// read by us from jenkins but not passed to frontend
actions: object[];
}
export interface BackstageProject extends CommonProject {
// standard Jenkins
lastBuild: BackstageBuild;
// added by us
status: string; // == inQueue ? 'queued' : lastBuild.building ? 'running' : lastBuild.result,
}
+36 -42
View File
@@ -16,45 +16,54 @@ cd packages/app
yarn add @backstage/plugin-jenkins
```
2. Add the `EntityJenkinsContent` extension to the entity page in the app:
2. Add and configure the backend plugin according to it's instructions
3. Add the `EntityJenkinsContent` extension to the `CI/CD` page and `EntityLatestJenkinsRunCard` to the `overview` page in the app (or wherever you'd prefer):
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
import { EntityJenkinsContent } from '@backstage/plugin-jenkins';
import {
EntityJenkinsContent,
EntityLatestJenkinsRunCard,
isJenkinsAvailable,
} from '@backstage/plugin-jenkins';
// You can add the tab to any number of pages, the service page is shown as an
// example here
const serviceEntityPage = (
<EntityLayout>
{/* other tabs... */}
<EntityLayout.Route path="/jenkins" title="Jenkins">
<EntityJenkinsContent />
<EntityLayout.Route path="/" title="Overview">
{/* ... */}
<EntitySwitch>
<EntitySwitch.Case if={isJenkinsAvailable}>
<Grid item sm={6}>
<EntityLatestJenkinsRunCard branch="master" variant="gridItem" />
</Grid>
</EntitySwitch.Case>
{/* ... */}
</EntitySwitch>
</EntityLayout.Route>
{/* other tabs... */}
<EntityLayout.Route path="/ci-cd" title="CI/CD">
<EntitySwitch>
<EntitySwitch.Case if={isJenkinsAvailable}>
<EntityJenkinsContent />
</EntitySwitch.Case>
{/* ... */}
</EntitySwitch>
</EntityLayout.Route>
{/* ... */}
</EntityLayout>
);
```
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 +84,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
+58 -25
View File
@@ -9,6 +9,9 @@ import { ApiRef } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { DiscoveryApi } from '@backstage/core-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { EntityName } from '@backstage/catalog-model';
import { EntityRef } from '@backstage/catalog-model';
import { IdentityApi } from '@backstage/core-plugin-api';
import { InfoCardVariants } from '@backstage/core-components';
import { RouteRef } from '@backstage/core-plugin-api';
@@ -35,37 +38,67 @@ export { isJenkinsAvailable as isPluginApplicableToEntity };
export const JENKINS_ANNOTATION = 'jenkins.io/github-folder';
// @public (undocumented)
export class JenkinsApi {
constructor(options: Options);
export interface JenkinsApi {
getBuild(options: {
entity: EntityName;
jobFullName: string;
buildNumber: string;
}): Promise<Build>;
getProjects(options: {
entity: EntityRef;
filter: {
branch?: string;
};
}): Promise<Project[]>;
// (undocumented)
extractJobDetailsFromBuildName(
buildName: string,
): {
jobName: string;
buildNumber: number;
};
// (undocumented)
extractScmDetailsFromJob(jobDetails: any): any | undefined;
// (undocumented)
getBuild(buildName: string): Promise<any>;
// (undocumented)
getFolder(folderName: string): Promise<CITableBuildInfo[]>;
// (undocumented)
getJob(jobName: string): Promise<any>;
// (undocumented)
getLastBuild(jobName: string): Promise<any>;
// (undocumented)
mapJenkinsBuildToCITable(
jenkinsResult: any,
jobScmInfo?: any,
): CITableBuildInfo;
// (undocumented)
retry(buildName: string): Promise<any>;
retry(options: {
entity: EntityName;
jobFullName: string;
buildNumber: string;
}): Promise<void>;
}
// @public (undocumented)
export const jenkinsApiRef: ApiRef<JenkinsApi>;
// @public (undocumented)
export class JenkinsClient implements JenkinsApi {
constructor(options: {
discoveryApi: DiscoveryApi;
identityApi: IdentityApi;
});
// (undocumented)
getBuild({
entity,
jobFullName,
buildNumber,
}: {
entity: EntityName;
jobFullName: string;
buildNumber: string;
}): Promise<Build>;
// (undocumented)
getProjects({
entity,
filter,
}: {
entity: EntityName;
filter: {
branch?: string;
};
}): Promise<Project[]>;
// (undocumented)
retry({
entity,
jobFullName,
buildNumber,
}: {
entity: EntityName;
jobFullName: string;
buildNumber: string;
}): Promise<void>;
}
// @public (undocumented)
const jenkinsPlugin: BackstagePlugin<
{
-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",
+179 -220
View File
@@ -14,246 +14,205 @@
* 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: () => Promise<void>; // TODO rename to handle.* ? also, should this be on lastBuild?
}
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 options.entity the entity whose jobs should be retrieved.
* @param options.filter a filter on jobs. Currently this just takes a branch (and assumes certain structures in jenkins)
*/
getProjects(options: {
entity: EntityRef;
filter: { branch?: string };
}): Promise<Project[]>;
/**
* Get a single build.
*
* This takes an entity to support selecting between multiple jenkins instances.
*
* TODO: abstract jobFullName (so we could support differentiating between the same named job on multiple instances).
* @param options.entity
* @param options.jobFullName
* @param options.buildNumber
*/
getBuild(options: {
entity: EntityName;
jobFullName: string;
buildNumber: string;
}): Promise<Build>;
retry(options: {
entity: EntityName;
jobFullName: string;
buildNumber: string;
}): Promise<void>;
}
export class JenkinsClient 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,
filter,
}: {
entity: EntityName;
filter: { branch?: string };
}): Promise<Project[]> {
const url = new URL(
`${await this.discoveryApi.getBaseUrl(
'jenkins',
)}/v1/entity/${encodeURIComponent(entity.namespace)}/${encodeURIComponent(
entity.kind,
)}/${encodeURIComponent(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?.map((p: Project) => ({
...p,
onRestartClick: async () => {
await this.retry({
entity,
jobFullName: p.fullName,
buildNumber: String(p.lastBuild.number),
});
},
})) || []
);
}
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;
async getBuild({
entity,
jobFullName,
buildNumber,
}: {
entity: EntityName;
jobFullName: string;
buildNumber: string;
}): Promise<Build> {
const url = `${await this.discoveryApi.getBaseUrl(
'jenkins',
)}/v1/entity/${encodeURIComponent(entity.namespace)}/${encodeURIComponent(
entity.kind,
)}/${encodeURIComponent(entity.name)}/job/${encodeURIComponent(
jobFullName,
)}/${encodeURIComponent(buildNumber)}`;
const idToken = await this.identityApi.getIdToken();
const response = await fetch(url, {
method: 'GET',
headers: {
...(idToken && { Authorization: `Bearer ${idToken}` }),
},
});
return (await response.json()).build;
}
extractJobDetailsFromBuildName(buildName: string) {
const trimmedBuild = buildName.replace(/\/job/g, '').replace(/\/$/, '');
async retry({
entity,
jobFullName,
buildNumber,
}: {
entity: EntityName;
jobFullName: string;
buildNumber: string;
}): Promise<void> {
const url = `${await this.discoveryApi.getBaseUrl(
'jenkins',
)}/v1/entity/${encodeURIComponent(entity.namespace)}/${encodeURIComponent(
entity.kind,
)}/${encodeURIComponent(entity.name)}/job/${encodeURIComponent(
jobFullName,
)}/${encodeURIComponent(buildNumber)}:rebuild`;
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 {
jobName,
buildNumber,
};
const idToken = await this.identityApi.getIdToken();
await fetch(url, {
method: 'POST',
headers: {
...(idToken && { Authorization: `Bearer ${idToken}` }),
},
});
}
}
+3 -1
View File
@@ -14,4 +14,6 @@
* limitations under the License.
*/
export { JenkinsApi, jenkinsApiRef } from './JenkinsApi';
export { JenkinsClient, 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,16 @@ const useStyles = makeStyles(theme => ({
}));
const BuildWithStepsView = () => {
const projectName = useProjectSlugFromEntity();
const { branch, buildNumber } = useRouteRefParams(buildRouteRef);
// TODO: Add a test that react-router decodes this (even though `generatePath` doesn't encode it for you!)
const { jobFullName, buildNumber } = useRouteRefParams(buildRouteRef);
const classes = useStyles();
const buildPath = `${projectName}/${encodeURIComponent(
branch,
)}/${buildNumber}`;
const [{ value }] = useBuildWithSteps(buildPath);
const [{ value }] = useBuildWithSteps({ jobFullName, 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 +102,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 +110,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(),
jobFullName: encodeURIComponent(row.fullName),
buildNumber: String(row.lastBuild?.number),
})}
>
{row.buildName}
{row.fullDisplayName}
</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,34 +18,36 @@ 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 jobFullName 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({
jobFullName,
buildNumber,
}: {
jobFullName: string;
buildNumber: string;
}) {
const api = useApi(jenkinsApiRef);
const errorApi = useApi(errorApiRef);
const { entity } = useEntity();
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));
const entityName = await getEntityName(entity);
return api.getBuild({ entity: entityName, jobFullName, buildNumber });
} catch (e) {
errorApi.post(e);
return Promise.reject(e);
}
}, [buildName, api, errorApi]);
const restartBuild = async () => {
try {
await api.retry(buildName);
} catch (e) {
errorApi.post(e);
}
};
}, [buildNumber, jobFullName, entity, api, errorApi]);
const { loading, value, retry } = useAsyncRetry(() => getBuildWithSteps(), [
getBuildWithSteps,
@@ -59,7 +61,6 @@ export function useBuildWithSteps(buildName: string) {
return [
{ loading, value, retry },
{
restartBuild,
getBuildWithSteps,
startPolling,
stopPolling,
+23 -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 }: { branch?: string } = {}) {
const { entity } = useEntity();
const entityName = getEntityName(entity);
const api = useApi(jenkinsApiRef);
const errorApi = useApi(errorApiRef);
@@ -35,27 +45,24 @@ export function useBuilds(projectName: string, branch?: string) {
errorType: ErrorType;
}>();
const restartBuild = async (buildName: string) => {
const restartBuild = async (jobFullName: string, buildNumber: string) => {
try {
await api.retry(buildName);
await api.retry({ entity: entityName, jobFullName, 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({
entity: getEntityName(entity),
filter: { 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 +70,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 -6
View File
@@ -14,7 +14,6 @@
* limitations under the License.
*/
import { JenkinsApi, jenkinsApiRef } from './api';
import {
createApiFactory,
createComponentExtension,
@@ -22,7 +21,9 @@ import {
createRoutableExtension,
createRouteRef,
discoveryApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
import { JenkinsClient, jenkinsApiRef } from './api';
export const rootRouteRef = createRouteRef({
path: '',
@@ -30,9 +31,9 @@ export const rootRouteRef = createRouteRef({
});
export const buildRouteRef = createRouteRef({
path: 'run/:branch/:buildNumber',
params: ['branch', 'buildNumber'],
title: 'Jenkins run',
path: 'build/:jobFullName/:buildNumber',
params: ['jobFullName', 'buildNumber'],
title: 'Jenkins build',
});
export const jenkinsPlugin = createPlugin({
@@ -40,8 +41,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 JenkinsClient({ 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==