diff --git a/.changeset/good-hornets-hunt.md b/.changeset/good-hornets-hunt.md new file mode 100644 index 0000000000..b04a70b8be --- /dev/null +++ b/.changeset/good-hornets-hunt.md @@ -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 { + 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` diff --git a/app-config.yaml b/app-config.yaml index b50de963ed..9d588eb0a0 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -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 diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md index 67b92bd941..5f474624f9 100644 --- a/docs/features/software-catalog/well-known-annotations.md +++ b/docs/features/software-catalog/well-known-annotations.md @@ -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) diff --git a/packages/backend/package.json b/packages/backend/package.json index 4338f3e2b7..d966631a35 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -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", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 168f6aa2d9..aecb233581 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -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) diff --git a/packages/backend/src/plugins/jenkins.ts b/packages/backend/src/plugins/jenkins.ts new file mode 100644 index 0000000000..5420eb0df6 --- /dev/null +++ b/packages/backend/src/plugins/jenkins.ts @@ -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 { + const catalog = new CatalogClient({ discoveryApi: discovery }); + + return await createRouter({ + logger, + jenkinsInfoProvider: DefaultJenkinsInfoProvider.fromConfig({ + catalog, + config, + }), + }); +} diff --git a/plugins/jenkins-backend/.eslintrc.js b/plugins/jenkins-backend/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/plugins/jenkins-backend/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/plugins/jenkins-backend/README.md b/plugins/jenkins-backend/README.md new file mode 100644 index 0000000000..bac56b6eaf --- /dev/null +++ b/plugins/jenkins-backend/README.md @@ -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 { + 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 { + 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. diff --git a/plugins/jenkins-backend/api-report.md b/plugins/jenkins-backend/api-report.md new file mode 100644 index 0000000000..faa1d56c4e --- /dev/null +++ b/plugins/jenkins-backend/api-report.md @@ -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; + +// @public +export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { + // (undocumented) + static fromConfig(options: { + config: Config; + catalog: CatalogClient; + }): DefaultJenkinsInfoProvider; + // (undocumented) + getInstance(opt: { + entityRef: EntityName; + jobFullName?: string; + }): Promise; + // (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; + // (undocumented) + jobFullName: string; +} + +// @public (undocumented) +export interface JenkinsInfoProvider { + // (undocumented) + getInstance(options: { + entityRef: EntityName; + jobFullName?: string; + }): Promise; +} + +// @public (undocumented) +export interface RouterOptions { + // (undocumented) + jenkinsInfoProvider: JenkinsInfoProvider; + // (undocumented) + logger: Logger_2; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/jenkins-backend/config.d.ts b/plugins/jenkins-backend/config.d.ts new file mode 100644 index 0000000000..bfb519bb58 --- /dev/null +++ b/plugins/jenkins-backend/config.d.ts @@ -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; + }[]; + }; +} diff --git a/plugins/jenkins-backend/package.json b/plugins/jenkins-backend/package.json new file mode 100644 index 0000000000..408d714aaa --- /dev/null +++ b/plugins/jenkins-backend/package.json @@ -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" + ] +} diff --git a/plugins/jenkins/src/components/useProjectSlugFromEntity.ts b/plugins/jenkins-backend/src/index.ts similarity index 69% rename from plugins/jenkins/src/components/useProjectSlugFromEntity.ts rename to plugins/jenkins-backend/src/index.ts index 10789b383a..c5c541db2b 100644 --- a/plugins/jenkins/src/components/useProjectSlugFromEntity.ts +++ b/plugins/jenkins-backend/src/index.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'; diff --git a/plugins/jenkins-backend/src/run.ts b/plugins/jenkins-backend/src/run.ts new file mode 100644 index 0000000000..b96989e4b8 --- /dev/null +++ b/plugins/jenkins-backend/src/run.ts @@ -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); +}); diff --git a/plugins/jenkins-backend/src/service/index.ts b/plugins/jenkins-backend/src/service/index.ts new file mode 100644 index 0000000000..329092e881 --- /dev/null +++ b/plugins/jenkins-backend/src/service/index.ts @@ -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'; diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.test.ts b/plugins/jenkins-backend/src/service/jenkinsApi.test.ts new file mode 100644 index 0000000000..380a65f895 --- /dev/null +++ b/plugins/jenkins-backend/src/service/jenkinsApi.test.ts @@ -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; +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); + }); +}); diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.ts b/plugins/jenkins-backend/src/service/jenkinsApi.ts new file mode 100644 index 0000000000..7df5c5db1d --- /dev/null +++ b/plugins/jenkins-backend/src/service/jenkinsApi.ts @@ -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(); + } +} diff --git a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts new file mode 100644 index 0000000000..7c4761a19a --- /dev/null +++ b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts @@ -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 = ({ + getEntityByName: jest.fn(), + } as any) as jest.Mocked; + + 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', + }); + }); +}); diff --git a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts new file mode 100644 index 0000000000..1f316aa6bc --- /dev/null +++ b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts @@ -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; +} + +export interface JenkinsInfo { + baseUrl: string; + headers?: Record; + 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 { + // 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; + } +} diff --git a/plugins/jenkins-backend/src/service/router.ts b/plugins/jenkins-backend/src/service/router.ts new file mode 100644 index 0000000000..cd250aea31 --- /dev/null +++ b/plugins/jenkins-backend/src/service/router.ts @@ -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 { + 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; +} diff --git a/plugins/jenkins-backend/src/service/standaloneServer.ts b/plugins/jenkins-backend/src/service/standaloneServer.ts new file mode 100644 index 0000000000..a1bc5dd256 --- /dev/null +++ b/plugins/jenkins-backend/src/service/standaloneServer.ts @@ -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 { + const logger = options.logger.child({ service: 'jenkins-backend' }); + logger.debug('Starting application server...'); + const router = await createRouter({ + logger, + jenkinsInfoProvider: { + async getInstance(_: { entityRef: EntityRef }): Promise { + 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(); diff --git a/plugins/jenkins-backend/src/setupTests.ts b/plugins/jenkins-backend/src/setupTests.ts new file mode 100644 index 0000000000..ba33cf996b --- /dev/null +++ b/plugins/jenkins-backend/src/setupTests.ts @@ -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 {}; diff --git a/plugins/jenkins-backend/src/types.ts b/plugins/jenkins-backend/src/types.ts new file mode 100644 index 0000000000..2a0c5ae7f9 --- /dev/null +++ b/plugins/jenkins-backend/src/types.ts @@ -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, +} diff --git a/plugins/jenkins/README.md b/plugins/jenkins/README.md index 69304cb847..1af511b16f 100644 --- a/plugins/jenkins/README.md +++ b/plugins/jenkins/README.md @@ -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 = ( - {/* other tabs... */} - - + + {/* ... */} + + + + + + + {/* ... */} + + {/* other tabs... */} + + + + + + {/* ... */} + + + {/* ... */} + +); ``` -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 diff --git a/plugins/jenkins/api-report.md b/plugins/jenkins/api-report.md index 47ec8d655f..3a51f348b9 100644 --- a/plugins/jenkins/api-report.md +++ b/plugins/jenkins/api-report.md @@ -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; + getProjects(options: { + entity: EntityRef; + filter: { + branch?: string; + }; + }): Promise; // (undocumented) - extractJobDetailsFromBuildName( - buildName: string, - ): { - jobName: string; - buildNumber: number; - }; - // (undocumented) - extractScmDetailsFromJob(jobDetails: any): any | undefined; - // (undocumented) - getBuild(buildName: string): Promise; - // (undocumented) - getFolder(folderName: string): Promise; - // (undocumented) - getJob(jobName: string): Promise; - // (undocumented) - getLastBuild(jobName: string): Promise; - // (undocumented) - mapJenkinsBuildToCITable( - jenkinsResult: any, - jobScmInfo?: any, - ): CITableBuildInfo; - // (undocumented) - retry(buildName: string): Promise; + retry(options: { + entity: EntityName; + jobFullName: string; + buildNumber: string; + }): Promise; } // @public (undocumented) export const jenkinsApiRef: ApiRef; +// @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; + // (undocumented) + getProjects({ + entity, + filter, + }: { + entity: EntityName; + filter: { + branch?: string; + }; + }): Promise; + // (undocumented) + retry({ + entity, + jobFullName, + buildNumber, + }: { + entity: EntityName; + jobFullName: string; + buildNumber: string; + }): Promise; +} + // @public (undocumented) const jenkinsPlugin: BackstagePlugin< { diff --git a/plugins/jenkins/package.json b/plugins/jenkins/package.json index 6cc5436574..1e2458d92e 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -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", diff --git a/plugins/jenkins/src/api/JenkinsApi.ts b/plugins/jenkins/src/api/JenkinsApi.ts index c378e15693..4b23fb4292 100644 --- a/plugins/jenkins/src/api/JenkinsApi.ts +++ b/plugins/jenkins/src/api/JenkinsApi.ts @@ -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({ - 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; // 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; + + /** + * 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; + + retry(options: { + entity: EntityName; + jobFullName: string; + buildNumber: string; + }): Promise; +} + +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 { + 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 { + 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 { + 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}` }), + }, + }); } } diff --git a/plugins/jenkins/src/api/index.ts b/plugins/jenkins/src/api/index.ts index 7bf664fbed..af1829c0b4 100644 --- a/plugins/jenkins/src/api/index.ts +++ b/plugins/jenkins/src/api/index.ts @@ -14,4 +14,6 @@ * limitations under the License. */ -export { JenkinsApi, jenkinsApiRef } from './JenkinsApi'; +export { JenkinsClient, jenkinsApiRef } from './JenkinsApi'; + +export type { JenkinsApi } from './JenkinsApi'; diff --git a/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx b/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx index 0bfd2bb5d8..e06332f8f0 100644 --- a/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx +++ b/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx @@ -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 (
+ {/* TODO: don't hardcode this link */} Projects Run @@ -104,7 +102,7 @@ const BuildWithStepsView = () => { Jenkins - + View on Jenkins{' '} @@ -112,10 +110,11 @@ const BuildWithStepsView = () => { + {/* TODO: be SCM agnostic */} GitHub - + View on GitHub{' '} diff --git a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx index 6e456bd281..3e5eeffdaa 100644 --- a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx +++ b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx @@ -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) => { - if (!row.source?.branchName || !row.buildNumber) { - return <>{row.buildName}; + render: (row: Partial) => { + if (!row.fullName || !row.lastBuild?.number) { + return ( + <> + {row.fullName || + row.fullDisplayName || + row.displayName || + 'Unknown'} + + ); } return ( - {row.buildName} + {row.fullDisplayName} ); }, }, { title: 'Source', - field: 'source.branchName', - render: (row: Partial) => ( + field: 'lastBuild.source.branchName', + render: (row: Partial) => ( <>

- - {row.source?.branchName} + + {row.lastBuild?.source?.branchName}

-

{row.source?.commit?.hash}

+

{row.lastBuild?.source?.commit?.hash}

), }, { title: 'Status', field: 'status', - render: (row: Partial) => { + render: (row: Partial) => { return ( @@ -155,21 +137,22 @@ const generatedColumns: TableColumn[] = [ { title: 'Tests', sorting: false, - render: (row: Partial) => { + render: (row: Partial) => { return ( <>

- {row.tests && ( - - {row.tests.passed} / {row.tests.total} passed + {row.lastBuild?.tests && ( + + {row.lastBuild?.tests.passed} / {row.lastBuild?.tests.total}{' '} + passed )} - {!row.tests && 'n/a'} + {!row.lastBuild?.tests && 'n/a'}

); @@ -178,7 +161,7 @@ const generatedColumns: TableColumn[] = [ { title: 'Actions', sorting: false, - render: (row: Partial) => ( + render: (row: Partial) => ( @@ -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={ Jenkins logo - Project: {projectName} + Projects } 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 ( ', () => { const entity = { @@ -33,7 +34,10 @@ describe('', () => { }; const jenkinsApi: Partial = { - 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('', () => { it('should show the appropriate error in case of a connection error', async () => { const jenkinsApiWithError: Partial = { - getLastBuild: () => Promise.reject(new Error('Unauthorized')), + getProjects: () => Promise.reject(new Error('Unauthorized')), }; const apis = ApiRegistry.from([[jenkinsApiRef, jenkinsApiWithError]]); @@ -71,7 +75,7 @@ describe('', () => { it('should show the appropriate error in case Jenkins project is not found', async () => { const jenkinsApiWithError: Partial = { - getLastBuild: () => + getProjects: () => Promise.reject({ notFound: true, message: 'jenkins-project not found', diff --git a/plugins/jenkins/src/components/Cards/Cards.tsx b/plugins/jenkins/src/components/Cards/Cards.tsx index 21044a194d..f0b1ff2bca 100644 --- a/plugins/jenkins/src/components/Cards/Cards.tsx +++ b/plugins/jenkins/src/components/Cards/Cards.tsx @@ -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({ externalLinkIcon: { @@ -39,16 +39,18 @@ const WidgetContent = ({ latestRun, }: { loading?: boolean; - latestRun: any; + latestRun?: Project; branch: string; }) => { const classes = useStyles(); if (loading || !latestRun) return ; - 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: ( <> - + ), build: latestRun.fullDisplayName, 'latest run': displayDate, duration: displayDuration, link: ( - + See more on Jenkins{' '} @@ -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 ( {!error ? ( diff --git a/plugins/jenkins/src/components/useBuildWithSteps.ts b/plugins/jenkins/src/components/useBuildWithSteps.ts index 9625d8abbb..c73a116aae 100644 --- a/plugins/jenkins/src/components/useBuildWithSteps.ts +++ b/plugins/jenkins/src/components/useBuildWithSteps.ts @@ -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, diff --git a/plugins/jenkins/src/components/useBuilds.ts b/plugins/jenkins/src/components/useBuilds.ts index 49be4893a7..820cb099a1 100644 --- a/plugins/jenkins/src/components/useBuilds.ts +++ b/plugins/jenkins/src/components/useBuilds.ts @@ -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; } diff --git a/plugins/jenkins/src/plugin.ts b/plugins/jenkins/src/plugin.ts index 2548612896..e975361da2 100644 --- a/plugins/jenkins/src/plugin.ts +++ b/plugins/jenkins/src/plugin.ts @@ -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: { diff --git a/yarn.lock b/yarn.lock index a8e6f4a167..9f263eae28 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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==