diff --git a/.changeset/real-baboons-vanish.md b/.changeset/real-baboons-vanish.md new file mode 100644 index 0000000000..ca09cc9bf3 --- /dev/null +++ b/.changeset/real-baboons-vanish.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-jenkins-backend': minor +'@backstage/plugin-jenkins': minor +--- + +Updated rebuild to use Jenkins API replay build, which works for Jenkins jobs that have required parameters. Jenkins SDK could not be used for this request because it does not have support for replay. + +Added link to view build in Jenkins CI/CD table action column. diff --git a/plugins/jenkins-backend/package.json b/plugins/jenkins-backend/package.json index 4f58776df7..07624593c3 100644 --- a/plugins/jenkins-backend/package.json +++ b/plugins/jenkins-backend/package.json @@ -36,6 +36,7 @@ "express": "^4.17.1", "express-promise-router": "^4.1.0", "jenkins": "^1.0.0", + "node-fetch": "^2.6.7", "winston": "^3.2.1", "yn": "^4.0.0" }, diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.test.ts b/plugins/jenkins-backend/src/service/jenkinsApi.test.ts index c89a3866c7..8e80de34a3 100644 --- a/plugins/jenkins-backend/src/service/jenkinsApi.test.ts +++ b/plugins/jenkins-backend/src/service/jenkinsApi.test.ts @@ -19,9 +19,10 @@ import jenkins from 'jenkins'; import { JenkinsInfo } from './jenkinsInfoProvider'; import { JenkinsBuild, JenkinsProject } from '../types'; import { AuthorizeResult } from '@backstage/plugin-permission-common'; -import { NotAllowedError } from '@backstage/errors'; +import fetch, { Response } from 'node-fetch'; jest.mock('jenkins'); +jest.mock('node-fetch'); const mockedJenkinsClient = { job: { get: jest.fn(), @@ -54,6 +55,11 @@ const fakePermissionApi = { describe('JenkinsApi', () => { const jenkinsApi = new JenkinsApiImpl(fakePermissionApi); + const mockFetch = fetch as jest.MockedFunction; + + afterEach(() => { + jest.clearAllMocks(); + }); describe('getProjects', () => { const project: JenkinsProject = { @@ -689,55 +695,63 @@ describe('JenkinsApi', () => { buildNumber, ); }); - it('buildProject', async () => { - await jenkinsApi.buildProject(jenkinsInfo, jobFullName, resourceRef); + it('getBuildUrl', async () => { + const jenkinsApiProto = Object.getPrototypeOf(jenkinsApi); + const buildUrl = jenkinsApiProto.getBuildUrl( + jenkinsInfo, + jobFullName, + buildNumber, + ); + expect(buildUrl).toEqual( + 'https://jenkins.example.com/job/example-jobName/job/foo/19', + ); - expect(mockedJenkins).toHaveBeenCalledWith({ - baseUrl: jenkinsInfo.baseUrl, - headers: jenkinsInfo.headers, - promisify: true, - }); - expect(mockedJenkinsClient.job.build).toHaveBeenCalledWith(jobFullName); + const buildUrlTriple = jenkinsApiProto.getBuildUrl( + jenkinsInfo, + 'example-jobName/foo/bar', + buildNumber, + ); + expect(buildUrlTriple).toEqual( + 'https://jenkins.example.com/job/example-jobName/job/foo/job/bar/19', + ); }); - - it('buildProject should fail if it does not have required permissions', async () => { - fakePermissionApi.authorize.mockResolvedValueOnce([ - { - result: AuthorizeResult.DENY, - }, - ]); - - await expect(() => - jenkinsApi.buildProject(jenkinsInfo, jobFullName, resourceRef), - ).rejects.toThrow(NotAllowedError); - }); - - it('buildProject should succeed if it have required permissions', async () => { - fakePermissionApi.authorize.mockResolvedValueOnce([ - { - result: AuthorizeResult.ALLOW, - }, - ]); - - await jenkinsApi.buildProject(jenkinsInfo, jobFullName, resourceRef); - expect(mockedJenkins).toHaveBeenCalledWith({ - baseUrl: jenkinsInfo.baseUrl, - headers: jenkinsInfo.headers, - promisify: true, + describe('rebuildProject', () => { + it('successfully rebuilds', async () => { + mockFetch.mockResolvedValueOnce({ status: 200 } as Response); + const status = await jenkinsApi.rebuildProject( + jenkinsInfo, + jobFullName, + buildNumber, + resourceRef, + ); + expect(status).toEqual(200); }); - expect(mockedJenkinsClient.job.build).toHaveBeenCalledWith(jobFullName); - }); - - it('buildProject with crumbIssuer option', async () => { - const info: JenkinsInfo = { ...jenkinsInfo, crumbIssuer: true }; - await jenkinsApi.buildProject(info, jobFullName, resourceRef); - - expect(mockedJenkins).toHaveBeenCalledWith({ - baseUrl: jenkinsInfo.baseUrl, - headers: jenkinsInfo.headers, - promisify: true, - crumbIssuer: true, + it('fails to rebuild', async () => { + mockFetch.mockResolvedValueOnce({ status: 401 } as Response); + const status = await jenkinsApi.rebuildProject( + jenkinsInfo, + jobFullName, + buildNumber, + resourceRef, + ); + expect(status).toEqual(401); + }); + + it('should fail if it does not have required permissions', async () => { + fakePermissionApi.authorize.mockResolvedValueOnce([ + { + result: AuthorizeResult.DENY, + }, + ]); + + mockFetch.mockResolvedValueOnce({ status: 200 } as Response); + const status = await jenkinsApi.rebuildProject( + jenkinsInfo, + jobFullName, + buildNumber, + resourceRef, + ); + expect(status).toEqual(401); }); - expect(mockedJenkinsClient.job.build).toHaveBeenCalledWith(jobFullName); }); }); diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.ts b/plugins/jenkins-backend/src/service/jenkinsApi.ts index e14437fa68..85271462eb 100644 --- a/plugins/jenkins-backend/src/service/jenkinsApi.ts +++ b/plugins/jenkins-backend/src/service/jenkinsApi.ts @@ -28,7 +28,7 @@ import { PermissionEvaluator, } from '@backstage/plugin-permission-common'; import { jenkinsExecutePermission } from '@backstage/plugin-jenkins-common'; -import { NotAllowedError } from '@backstage/errors'; +import fetch, { HeaderInit } from 'node-fetch'; export class JenkinsApiImpl { private static readonly lastBuildTreeSpec = `lastBuild[ @@ -144,14 +144,13 @@ export class JenkinsApiImpl { * Trigger a build of a project * @see ../../../jenkins/src/api/JenkinsApi.ts#retry */ - async buildProject( + async rebuildProject( jenkinsInfo: JenkinsInfo, jobFullName: string, + buildNumber: number, resourceRef: string, options?: { token?: string }, - ) { - const client = await JenkinsApiImpl.getClient(jenkinsInfo); - + ): Promise { if (this.permissionApi) { const response = await this.permissionApi.authorize( [{ permission: jenkinsExecutePermission, resourceRef }], @@ -160,16 +159,19 @@ export class JenkinsApiImpl { // permission api returns always at least one item, we need to check only one result since we do not expect any additional results const { result } = response[0]; if (result === AuthorizeResult.DENY) { - throw new NotAllowedError(); + return 401; } } - // 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) + const buildUrl = this.getBuildUrl(jenkinsInfo, jobFullName, buildNumber); - // 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); + // the current SDK only supports triggering a new build + // replay the job by triggering request directly from Jenkins api + const response = await fetch(`${buildUrl}/replay/rebuild`, { + method: 'post', + headers: jenkinsInfo.headers as HeaderInit, + }); + return response.status; } // private helper methods @@ -318,4 +320,13 @@ export class JenkinsApiImpl { }) .pop(); } + + private getBuildUrl( + jenkinsInfo: JenkinsInfo, + jobFullName: string, + buildId: number, + ): string { + const jobs = jobFullName.split('/'); + return `${jenkinsInfo.baseUrl}/job/${jobs.join('/job/')}/${buildId}`; + } } diff --git a/plugins/jenkins-backend/src/service/router.ts b/plugins/jenkins-backend/src/service/router.ts index df4c3b5a11..484bfa273f 100644 --- a/plugins/jenkins-backend/src/service/router.ts +++ b/plugins/jenkins-backend/src/service/router.ts @@ -145,9 +145,10 @@ export async function createRouter( ); router.post( - '/v1/entity/:namespace/:kind/:name/job/:jobFullName/:buildNumber::rebuild', + '/v1/entity/:namespace/:kind/:name/job/:jobFullName/:buildNumber', async (request, response) => { - const { namespace, kind, name, jobFullName } = request.params; + const { namespace, kind, name, jobFullName, buildNumber } = + request.params; const token = getBearerTokenFromAuthorizationHeader( request.header('authorization'), ); @@ -162,10 +163,16 @@ export async function createRouter( }); const resourceRef = stringifyEntityRef({ kind, namespace, name }); - await jenkinsApi.buildProject(jenkinsInfo, jobFullName, resourceRef, { - token, - }); - response.json({}); + const status = await jenkinsApi.rebuildProject( + jenkinsInfo, + jobFullName, + parseInt(buildNumber, 10), + resourceRef, + { + token, + }, + ); + response.json({}).status(status); }, ); router.use(errorHandler()); diff --git a/plugins/jenkins/src/assets/folder-results.png b/plugins/jenkins/src/assets/folder-results.png index 2c14d5d927..340b03549d 100644 Binary files a/plugins/jenkins/src/assets/folder-results.png and b/plugins/jenkins/src/assets/folder-results.png differ diff --git a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx index 1bb6dbaefc..f2d108eaf7 100644 --- a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx +++ b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx @@ -18,6 +18,7 @@ import { alertApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api'; import { useEntityPermission } from '@backstage/plugin-catalog-react/alpha'; import { Box, IconButton, Tooltip, Typography } from '@material-ui/core'; import RetryIcon from '@material-ui/icons/Replay'; +import VisibilityIcon from '@material-ui/icons/Visibility'; import { default as React, useState } from 'react'; import { Project } from '../../../../api/JenkinsApi'; import JenkinsLogo from '../../../../assets/JenkinsLogo.svg'; @@ -203,16 +204,23 @@ const generatedColumns: TableColumn[] = [ }; return ( - - <> - {isLoadingRebuild && } - {!isLoadingRebuild && ( +
+ {row.lastBuild?.url && ( + + + + + + )} + {isLoadingRebuild && } + {!isLoadingRebuild && ( + - )} - - + + )} +
); }; return ; diff --git a/yarn.lock b/yarn.lock index deac1514d8..82626c846e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7294,6 +7294,7 @@ __metadata: express-promise-router: ^4.1.0 jenkins: ^1.0.0 msw: ^1.0.0 + node-fetch: ^2.6.7 supertest: ^6.1.6 winston: ^3.2.1 yn: ^4.0.0