From 449b995261d822521920df7826cfc76aef1d6117 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Fri, 30 Apr 2021 12:33:37 +0100 Subject: [PATCH] Initial work on jenkins backend Signed-off-by: Andrew Shirley Signed-off-by: blam --- packages/backend/package.json | 1 + packages/backend/src/index.ts | 3 + packages/backend/src/plugins/jenkins.ts | 31 ++ plugins/jenkins-backend/.eslintrc.js | 3 + plugins/jenkins-backend/README.md | 160 ++++++++ plugins/jenkins-backend/package.json | 46 +++ .../src/index.ts} | 13 +- plugins/jenkins-backend/src/run.ts | 33 ++ .../src/service/jenkinsInfoProvider.ts | 310 +++++++++++++++ plugins/jenkins-backend/src/service/router.ts | 314 +++++++++++++++ .../src/service/standaloneServer.ts | 69 ++++ plugins/jenkins-backend/src/setupTests.ts | 17 + plugins/jenkins-backend/src/types.ts | 87 +++++ plugins/jenkins/README.md | 47 +-- plugins/jenkins/package.json | 1 - plugins/jenkins/src/api/JenkinsApi.ts | 358 +++++++----------- plugins/jenkins/src/api/index.ts | 4 +- .../BuildWithStepsPage/BuildWithStepsPage.tsx | 16 +- .../BuildsPage/lib/CITable/CITable.tsx | 91 ++--- .../BuildsPage/lib/CITable/index.ts | 1 - .../src/components/Cards/Cards.test.tsx | 10 +- .../jenkins/src/components/Cards/Cards.tsx | 23 +- .../src/components/useBuildWithSteps.ts | 24 +- plugins/jenkins/src/components/useBuilds.ts | 38 +- plugins/jenkins/src/plugin.ts | 13 +- yarn.lock | 9 +- 26 files changed, 1338 insertions(+), 384 deletions(-) create mode 100644 packages/backend/src/plugins/jenkins.ts create mode 100644 plugins/jenkins-backend/.eslintrc.js create mode 100644 plugins/jenkins-backend/README.md create mode 100644 plugins/jenkins-backend/package.json rename plugins/{jenkins/src/components/useProjectSlugFromEntity.ts => jenkins-backend/src/index.ts} (69%) create mode 100644 plugins/jenkins-backend/src/run.ts create mode 100644 plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts create mode 100644 plugins/jenkins-backend/src/service/router.ts create mode 100644 plugins/jenkins-backend/src/service/standaloneServer.ts create mode 100644 plugins/jenkins-backend/src/setupTests.ts create mode 100644 plugins/jenkins-backend/src/types.ts 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..ae6ede0272 --- /dev/null +++ b/packages/backend/src/plugins/jenkins.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createRouter, + DummyJenkinsInfoProvider, +} from '@backstage/plugin-jenkins-backend'; +import { Router } from 'express'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin({ + logger, +}: PluginEnvironment): Promise { + return await createRouter({ + logger, + jenkinsInfoProvider: new DummyJenkinsInfoProvider(), + }); +} 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..dfc8ef440c --- /dev/null +++ b/plugins/jenkins-backend/README.md @@ -0,0 +1,160 @@ +# Jenkins Plugin (Alpha) + +Welcome to the jenkins backend plugin! Website: [https://jenkins.io/](https://jenkins.io/) + +This is the backend half of the 2 jenkins plugins and is responsible for: + +- finding an appropriate instance of jenkins for an entity +- finding the appropriate job(s) on that instance for an entity +- connecting to jenkins and gathering data to present to the frontend + +## Integrating into a backstage instance + +This plugin needs to be added to an existing backstage instance. + +Typically, this means creating a `src/plugins/jenkins.ts` file and adding a reference to it to `src/index.ts` + +### jenkins.ts + +```typescript +import { + createRouter, + SingleJenkinsInfoProvider, +} from '@backstage/plugin-jenkins-backend'; +import { Router } from 'express'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin({ + logger, +}: PluginEnvironment): Promise { + return await createRouter({ + logger, + jenkinsInfoProvider: new SingleJenkinsInfoProvider(), + }); +} +``` + +This plugin must be provided with a JenkinsInfoProvider, this is a strategy object for finding the jenkins instance and job for an entity. + +There is a selection of standard ones provided, but the Integrator is free to build their own. + +### SingleJenkinsInfoProvider + +Allows configuration of a single global jenkins instance and annotating entities with the job name on that instance. + +The following will look for jobs for this entity at `https://jenkins.example.com/job/teamA/job/artistLookup-build` + +#### Config + +```yaml +jenkins: + baseUrl: https://jenkins.example.com + username: backstage-bot + apikey: 123456789abcdef0123456789abcedf012 +``` + +#### Catalog + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: artist-lookup + annotations: + 'jenkins.io/github-folder': teamA/artistLookup-build +``` + +### PrefixedJenkinsInfoProvider + +Allows configuration of multiple global jenkins instance and annotating entities with the name of the instance and job name on that instance. + +The following will look for jobs for this entity at `https://jenkins-foo.example.com/job/teamA/job/artistLookup-build` + +#### Config + +```yaml +jenkins: + instances: + - name: default + baseUrl: https://jenkins.example.com + username: backstage-bot + apikey: 123456789abcdef0123456789abcedf012 + - name: departmentFoo + baseUrl: https://jenkins-foo.example.com + username: backstage-bot + apikey: 123456789abcdef0123456789abcedf012 +``` + +#### Catalog + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: artist-lookup + annotations: + 'jenkins.io/github-folder': departmentFoo#teamA/artistLookup-build +``` + +If the `departmentFoo#` part is omitted, the default instance will be assumed. + +### DefaultJenkinsInfoProvider + +The default jenkins info provider makes it clear it is replaceable in the config but is otherwise the same as PrefixedJenkinsInfoProvider + +The following will look for jobs for this entity at `https://jenkins-foo.example.com/job/teamA/job/artistLookup-build` + +#### Config + +```yaml +jenkins: + DefaultJenkinsInfoProvider: + instances: + - name: default + baseUrl: https://jenkins.example.com + username: backstage-bot + apikey: 123456789abcdef0123456789abcedf012 + - name: departmentFoo + baseUrl: https://jenkins-foo.example.com + username: backstage-bot + apikey: 123456789abcdef0123456789abcedf012 +``` + +#### Catalog + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: artist-lookup + annotations: + 'jenkins.io/github-folder': departmentFoo#teamA/artistLookup-build +``` + +### AcmeJenkinsInfoProvider + +An example of a bespoke JenkinsInfoProvider which uses an organisation specific annotation to look up the jenkins info (including jobName) + +#### Config + +None needed + +#### Catalog + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: artist-lookup + annotations: + 'acme.example.com/paas-project-name': artistLookupService +``` + +The following will look for jobs for this entity at `https://jenkins-departmentFoo.example.com/job/teamA/job/artistLookupService` + +## Jenkins' terminology notes + +The domain model for Jenkins is not particularly clear but for the purposes of this plugin the following model has been assumed: + +Jenkins contains a tree of *job*s which have children of either; other *job*s (making it a _folder_) or *build*s (making it a _project_). +Concepts like _pipeline_ and *view*s are meaningless (pipelines are just jobs for our purposes, views are (as the name suggests) just views of subsets of jobs) diff --git a/plugins/jenkins-backend/package.json b/plugins/jenkins-backend/package.json new file mode 100644 index 0000000000..21421a80d8 --- /dev/null +++ b/plugins/jenkins-backend/package.json @@ -0,0 +1,46 @@ +{ + "name": "@backstage/plugin-jenkins-backend", + "version": "0.1.1", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": true, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "start": "backstage-cli backend:dev", + "build": "backstage-cli backend:build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/backend-common": "^0.8.2", + "@backstage/catalog-client": "^0.3.13", + "@backstage/catalog-model": "^0.8.3", + "@backstage/config": "^0.1.5", + "@backstage/core": "^0.7.13", + "@types/express": "^4.17.6", + "@types/jenkins": "^0.23.1", + "cross-fetch": "^3.0.6", + "express": "^4.17.1", + "express-promise-router": "^4.1.0", + "jenkins": "^0.28.1", + "winston": "^3.2.1", + "yn": "^4.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.7.0", + "@types/supertest": "^2.0.8", + "msw": "^0.21.2", + "supertest": "^4.0.2" + }, + "files": [ + "dist" + ] +} 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..1d8a13bde6 100644 --- a/plugins/jenkins/src/components/useProjectSlugFromEntity.ts +++ b/plugins/jenkins-backend/src/index.ts @@ -13,11 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useEntity } from '@backstage/plugin-catalog-react'; -import { JENKINS_ANNOTATION } from '../constants'; -export const useProjectSlugFromEntity = () => { - const { entity } = useEntity(); - - return entity.metadata.annotations?.[JENKINS_ANNOTATION] ?? ''; -}; +export * from './service/router'; +export type { + JenkinsInfo, + JenkinsInfoProvider, +} from './service/jenkinsInfoProvider'; +export { DummyJenkinsInfoProvider } from './service/jenkinsInfoProvider'; 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/jenkinsInfoProvider.ts b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts new file mode 100644 index 0000000000..5e481bb7f0 --- /dev/null +++ b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts @@ -0,0 +1,310 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { EntityName, stringifyEntityRef } from '@backstage/catalog-model'; +import { CatalogClient } from '@backstage/catalog-client'; +import { Config } from '@backstage/config'; + +export interface JenkinsInfoProvider { + getInstance(options: { + /** + * The entity to get the info about. + */ + entityRef: EntityName; + /** + * A specific job to get. This is only passed in when we know about a job name we are interested in. + */ + jobName?: string; + }): Promise; +} + +export interface JenkinsInfo { + baseUrl: string; + headers?: any; + jobName: string; // TODO: make this an array +} + +export class DummyJenkinsInfoProvider implements JenkinsInfoProvider { + async getInstance(_: { + entityRef: EntityName; + jobName?: string; + }): Promise { + return { + baseUrl: 'https://jenkins.internal.example.com/', + headers: { + Authorization: + 'Basic YWRtaW46MTFlYzI1NmU0Mzg1MDFjM2Y1Yzc2Yjc1MWE3ZTQ3YWY4Mw==', + }, + jobName: 'department-A/team-1/project-foo', + }; + } +} + +/** + * Use the original annotation scheme and a simple config + */ +export class SingleJenkinsInfoProvider implements JenkinsInfoProvider { + constructor( + private readonly catalog: CatalogClient, + private readonly config: Config, + ) {} + + async getInstance(opt: { + entityRef: EntityName; + jobName?: string; + }): Promise { + const JENKINS_ANNOTATION = 'jenkins.io/github-folder'; + + // lookup jobName from entity annotation + const entity = await this.catalog.getEntityByName(opt.entityRef); + if (!entity) { + throw new Error( + `Couldn't find entity with name: ${stringifyEntityRef(opt.entityRef)}`, + ); + } + + const jobName = entity.metadata.annotations?.[JENKINS_ANNOTATION]; + if (!jobName) { + throw new Error( + `Couldn't find jenkins annotation (${JENKINS_ANNOTATION}) on entity with name: ${stringifyEntityRef( + opt.entityRef, + )}`, + ); + } + + // lookup baseURL + creds from config + const baseUrl = this.config.getString('jenkins.baseUrl'); + const username = this.config.getString('jenkins.username'); + const apiKey = this.config.getString('jenkins.apiKey'); + const creds = btoa(`${username}:${apiKey}`); + + return { + baseUrl, + headers: { + Authorization: `Basic ${creds}`, + }, + jobName, + }; + } +} + +/** + * Use a prefixed version of the original annotation scheme and a multiple instance config + */ +export class PrefixedJenkinsInfoProvider implements JenkinsInfoProvider { + constructor( + private readonly catalog: CatalogClient, + private readonly config: Config, + ) {} + + async getInstance(opt: { + entityRef: EntityName; + jobName?: string; + }): Promise { + const JENKINS_ANNOTATION = 'jenkins.io/github-folder'; + const DEFAULT_JENKINS_NAME = 'default'; + + // lookup `[jenkinsName#]jobName` from entity annotation + const entity = await this.catalog.getEntityByName(opt.entityRef); + if (!entity) { + throw new Error( + `Couldn't find entity with name: ${stringifyEntityRef(opt.entityRef)}`, + ); + } + + const jenkinsAndJobName = entity.metadata.annotations?.[JENKINS_ANNOTATION]; + if (!jenkinsAndJobName) { + throw new Error( + `Couldn't find jenkins annotation (${JENKINS_ANNOTATION}) on entity with name: ${stringifyEntityRef( + opt.entityRef, + )}`, + ); + } + + let jobName; + let jenkinsName: string; + const splitIndex = jenkinsAndJobName.indexOf('#'); + if (splitIndex === -1) { + // no jenkinsName specified, use default + jenkinsName = DEFAULT_JENKINS_NAME; + jobName = jenkinsAndJobName; + } else { + // There is a jenkinsName specified + jenkinsName = jenkinsAndJobName.substring(0, splitIndex); + jobName = jenkinsAndJobName.substring( + splitIndex + 1, + jenkinsAndJobName.length, + ); + } + + // lookup baseURL + creds from config + const instanceConfig = this.config + .getConfigArray('jenkins.instances') + .filter(c => c.getString('name') === jenkinsName)[0]; + if (!instanceConfig) { + throw new Error( + `Couldn't find a jenkins instance in the config with name ${jenkinsName}`, + ); + } + + const baseUrl = instanceConfig.getString('baseUrl'); + const username = instanceConfig.getString('username'); + const apiKey = instanceConfig.getString('apiKey'); + const creds = btoa(`${username}:${apiKey}`); + + return { + baseUrl, + headers: { + Authorization: `Basic ${creds}`, + }, + jobName, + }; + } +} + +/** + * Use a prefixed version of the original annotation scheme and a multiple instance config with clear "default" name + */ +export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { + constructor( + private readonly catalog: CatalogClient, + private readonly config: Config, + ) {} + + async getInstance(opt: { + entityRef: EntityName; + jobName?: string; + }): Promise { + const JENKINS_ANNOTATION = 'jenkins.io/github-folder'; + const DEFAULT_JENKINS_NAME = 'default'; + + // lookup `[jenkinsName#]jobName` from entity annotation + const entity = await this.catalog.getEntityByName(opt.entityRef); + if (!entity) { + throw new Error( + `Couldn't find entity with name: ${stringifyEntityRef(opt.entityRef)}`, + ); + } + + const jenkinsAndJobName = entity.metadata.annotations?.[JENKINS_ANNOTATION]; + if (!jenkinsAndJobName) { + throw new Error( + `Couldn't find jenkins annotation (${JENKINS_ANNOTATION}) on entity with name: ${stringifyEntityRef( + opt.entityRef, + )}`, + ); + } + + let jobName; + let jenkinsName: string; + const splitIndex = jenkinsAndJobName.indexOf('#'); + if (splitIndex === -1) { + // no jenkinsName specified, use default + jenkinsName = DEFAULT_JENKINS_NAME; + jobName = jenkinsAndJobName; + } else { + // There is a jenkinsName specified + jenkinsName = jenkinsAndJobName.substring(0, splitIndex); + jobName = jenkinsAndJobName.substring( + splitIndex + 1, + jenkinsAndJobName.length, + ); + } + + // lookup baseURL + creds from config + const instanceConfig = this.config + .getConfigArray('jenkins.DefaultJenkinsInfoProvider.instances') + .filter(c => c.getString('name') === jenkinsName)[0]; + if (!instanceConfig) { + throw new Error( + `Couldn't find a jenkins instance in the config with name ${jenkinsName}`, + ); + } + + const baseUrl = instanceConfig.getString('baseUrl'); + const username = instanceConfig.getString('username'); + const apiKey = instanceConfig.getString('apiKey'); + const creds = btoa(`${username}:${apiKey}`); + + return { + baseUrl, + headers: { + Authorization: `Basic ${creds}`, + }, + jobName, + }; + } +} + +/** + * Use a bespoke annotation and no config + */ +export class AcmeJenkinsInfoProvider implements JenkinsInfoProvider { + constructor(private readonly catalog: CatalogClient) {} + + async getInstance(opt: { + entityRef: EntityName; + jobName?: string; + }): Promise { + const PAAS_ANNOTATION = 'acme.example.com/paas-project-name'; + + // lookup pass-project-name from entity annotation + const entity = await this.catalog.getEntityByName(opt.entityRef); + if (!entity) { + throw new Error( + `Couldn't find entity with name: ${stringifyEntityRef(opt.entityRef)}`, + ); + } + + const paasProjectName = entity.metadata.annotations?.[PAAS_ANNOTATION]; + if (!paasProjectName) { + throw new Error( + `Couldn't find paas annotation (${PAAS_ANNOTATION}) on entity with name: ${stringifyEntityRef( + opt.entityRef, + )}`, + ); + } + + // lookup department and team for paas project name + const { team, dept } = this.lookupPaasInfo(paasProjectName); + + const baseUrl = `https://jenkins-${dept}.example.com/`; + const jobName = `${team}/${paasProjectName}`; + const username = 'backstage-bot'; + const apiKey = this.getJenkinsApiKey(paasProjectName); + const creds = btoa(`${username}:${apiKey}`); + + return { + baseUrl, + headers: { + Authorization: `Basic ${creds}`, + }, + jobName, + }; + } + + private lookupPaasInfo(_: string): { team: string; dept: string } { + // Mock implementation, this would get info from the paas system somehow in reality. + return { + team: 'teamA', + dept: 'DepartmentA', + }; + } + + private getJenkinsApiKey(_: string): string { + // Mock implementation, this would get info from the paas system somehow in reality. + return '123456789abcdef0123456789abcedf012'; + } +} diff --git a/plugins/jenkins-backend/src/service/router.ts b/plugins/jenkins-backend/src/service/router.ts new file mode 100644 index 0000000000..19033daad0 --- /dev/null +++ b/plugins/jenkins-backend/src/service/router.ts @@ -0,0 +1,314 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { errorHandler } from '@backstage/backend-common'; +import express from 'express'; +import Router from 'express-promise-router'; +import { Logger } from 'winston'; +import jenkins from 'jenkins'; +import { + BackstageBuild, + BackstageProject, + JenkinsBuild, + JenkinsProject, + ScmDetails, +} from '../types'; +import { JenkinsInfo, JenkinsInfoProvider } from './jenkinsInfoProvider'; + +const lastBuildTreeSpec = `lastBuild[ + number, + url, + fullDisplayName, + displayName, + building, + result, + timestamp, + duration, + actions[ + *[ + *[ + *[ + * + ] + ] + ] + ] + ],`; + +const jobTreeSpec = `actions[*], + ${lastBuildTreeSpec} + jobs{0,1}, + name, + displayName, + fullDisplayName`; + +const jobsTreeSpec = `jobs[ + ${jobTreeSpec} + ]{0,50}`; + +export interface RouterOptions { + logger: Logger; + jenkinsInfoProvider: JenkinsInfoProvider; +} + +export async function createRouter( + options: RouterOptions, +): Promise { + // @ts-ignore keeping unused logger for future use + const { logger, jenkinsInfoProvider } = options; + + const router = Router(); + router.use(express.json()); + + router.get( + '/v1/entity/:namespace/:kind/:name/projects', + async (request, response) => { + const { namespace, kind, name } = request.params; + const branch = request.query.branch; + + const jenkinsInfo = await jenkinsInfoProvider.getInstance({ + entityRef: { + kind, + namespace, + name, + }, + }); + + const client = await getClient(jenkinsInfo); + const projects: BackstageProject[] = []; + + if (branch) { + // we have been asked to filter to a single branch. + // Assume jenkinsInfo.jobName is a folder which contains one job per branch. + // TODO: extract a strategy interface for this + // @ts-ignore + const job = await client.job.get({ + name: `${jenkinsInfo.jobName}/${branch}`, + tree: jobTreeSpec.replace(/\s/g, ''), + }); + projects.push(augmentProject(job)); + } else { + // We aren't filtering + // Assume jenkinsInfo.jobName is a folder which contains one job per branch. + // @ts-ignore + const folder = await client.job.get({ + name: jenkinsInfo.jobName, + // Filter only be the information we need, instead of loading all fields. + // Limit to only show the latest build for each job and only load 50 jobs + // at all. + // Whitespaces are only included for readablity here and stripped out + // before sending to Jenkins + tree: jobsTreeSpec.replace(/\s/g, ''), + }); + + // TODO: support this being a project itself. + for (const jobDetails of folder.jobs) { + // for each branch (we assume) + if (jobDetails?.jobs) { + // skipping folders inside folders for now + // TODO: recurse + } else { + projects.push(augmentProject(jobDetails)); + } + } + } + + response.send({ + projects: projects, + }); + }, + ); + + router.get( + '/v1/entity/:namespace/:kind/:name/job/:jobName/:buildNumber', + async (request, response) => { + const { + namespace, + kind, + name, + jobName: jobNameEnc, + buildNumber, + } = request.params; + + const jobName = decodeURIComponent(jobNameEnc); + + const jenkinsInfo = await jenkinsInfoProvider.getInstance({ + entityRef: { + kind, + namespace, + name, + }, + jobName, + }); + + const client = await getClient(jenkinsInfo); + + // @ts-ignore + const project = await client.job.get({ + name: jobName, + depth: 1, + }); + + const build = await client.build.get(jobName, parseInt(buildNumber, 10)); + + const jobScmInfo = extractScmDetailsFromJob(project); + + response.send({ + build: augmentBuild(build, jobScmInfo), + }); + }, + ); + + router.use(errorHandler()); + return router; +} + +function augmentProject(project: JenkinsProject): BackstageProject { + const jobScmInfo = extractScmDetailsFromJob(project); + + let status: string; + if (project.inQueue) { + status = 'queued'; + } else if (project.lastBuild.building) { + status = 'running'; + } else if (!project.lastBuild.result) { + status = 'unknown'; + } else { + status = project.lastBuild.result; + } + + return { + ...project, + lastBuild: augmentBuild(project.lastBuild, jobScmInfo), + status, + // actions: undefined, + }; +} + +function augmentBuild( + build: JenkinsBuild, + jobScmInfo: ScmDetails | undefined, +): BackstageBuild { + const source = + build.actions + .filter( + (action: any) => action._class === 'hudson.plugins.git.util.BuildData', + ) + .map((action: any) => { + const [first]: any = Object.values(action.buildsByBranchName); + const branch = first.revision.branch[0]; + return { + branchName: branch.name, + commit: { + hash: branch.SHA1.substring(0, 8), + }, + }; + }) + .pop() || {}; + + if (jobScmInfo) { + source.url = jobScmInfo.url; + source.displayName = jobScmInfo.displayName; + source.author = jobScmInfo.author; + } + + let status: string; + if (build.building) { + status = 'running'; + } else if (!build.result) { + status = 'unknown'; + } else { + status = build.result; + } + return { + ...build, + status, + source: source, + tests: getTestReport(build), + }; +} + +function extractScmDetailsFromJob( + project: JenkinsProject, +): ScmDetails | undefined { + const scmInfo: ScmDetails | undefined = project.actions + .filter( + (action: any) => + action._class === 'jenkins.scm.api.metadata.ObjectMetadataAction', + ) + .map((action: any) => { + return { + url: action?.objectUrl, + // https://javadoc.jenkins.io/plugin/scm-api/jenkins/scm/api/metadata/ObjectMetadataAction.html + // branch name for regular builds, pull request title on pull requests + displayName: action?.objectDisplayName, + }; + }) + .pop(); + + if (!scmInfo) { + return undefined; + } + + const author = project.actions + .filter( + (action: any) => + action._class === 'jenkins.scm.api.metadata.ContributorMetadataAction', + ) + .map((action: any) => { + return action.contributorDisplayName; + }) + .pop(); + + if (author) { + scmInfo.author = author; + } + + return scmInfo; +} + +function getTestReport( + build: JenkinsBuild, +): { + total: number; + passed: number; + skipped: number; + failed: number; + testUrl: string; +} { + return build.actions + .filter( + (action: any) => action._class === 'hudson.tasks.junit.TestResultAction', + ) + .map((action: any) => { + return { + total: action.totalCount, + passed: action.totalCount - action.failCount - action.skipCount, + skipped: action.skipCount, + failed: action.failCount, + testUrl: `${build.url}${action.urlName}/`, + }; + }) + .pop(); +} + +async function getClient(jenkinsInfo: JenkinsInfo) { + return jenkins({ + baseUrl: jenkinsInfo.baseUrl, + headers: jenkinsInfo.headers, + promisify: true, + }); +} diff --git a/plugins/jenkins-backend/src/service/standaloneServer.ts b/plugins/jenkins-backend/src/service/standaloneServer.ts new file mode 100644 index 0000000000..5b9de299da --- /dev/null +++ b/plugins/jenkins-backend/src/service/standaloneServer.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createServiceBuilder } from '@backstage/backend-common'; +import { Server } from 'http'; +import { Logger } from 'winston'; +import { createRouter } from './router'; +import { EntityRef } from '@backstage/catalog-model'; +import { JenkinsInfo } from './jenkinsInfoProvider'; + +export interface ServerOptions { + port: number; + enableCors: boolean; + logger: Logger; +} + +export async function startStandaloneServer( + options: ServerOptions, +): Promise { + 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/', jobName: 'build-foo' }; + }, + }, + }); + + const service = createServiceBuilder(module) + .enableCors({ origin: 'http://localhost:3000' }) + .addRouter('/jenkins', router); + + return await service.start().catch(err => { + logger.error(err); + process.exit(1); + }); +} + +module.hot?.accept(); 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..88b029bb26 --- /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: string; +} + +export interface JenkinsProject extends CommonProject { + // standard Jenkins + lastBuild: JenkinsBuild; + + // read by us from jenkins but not passed to frontend + actions: any; +} + +export interface BackstageProject extends CommonProject { + // standard Jenkins + lastBuild: BackstageBuild; + + // added by us + status: string; // == inQueue ? 'queued' : lastBuild.building ? 'running' : lastBuild.result, +} diff --git a/plugins/jenkins/README.md b/plugins/jenkins/README.md index 69304cb847..62bc60d443 100644 --- a/plugins/jenkins/README.md +++ b/plugins/jenkins/README.md @@ -16,7 +16,11 @@ cd packages/app yarn add @backstage/plugin-jenkins ``` -2. Add the `EntityJenkinsContent` extension to the entity page in the app: +2. Add and configure tha backend plugin according to it's instructions + +3. Add the `EntityJenkinsContent` extension to the cicd page and `EntityLatestJenkinsRunCard` to the entity page in the app: + +Note that if you configured a custom JenkinsInfoProvider in step 2, you may need a custom isJenkinsAvailable. ```tsx // In packages/app/src/components/catalog/EntityPage.tsx @@ -32,29 +36,11 @@ const serviceEntityPage = ( ``` -3. Add proxy configuration to `app-config.yaml` +4. Run app with `yarn start` +5. Add the Jenkins folder annotation to your `catalog-info.yaml`. -```yaml -proxy: - '/jenkins/api': - target: 'http://localhost:8080' # your Jenkins URL - changeOrigin: true - headers: - Authorization: Basic ${JENKINS_BASIC_AUTH_HEADER} -``` - -4. Add an environment variable which contains the Jenkins credentials (NOTE: - use an API token, not your password). Here `user` is the name of the user - created in Jenkins. - -```shell -export JENKINS_BASIC_AUTH_HEADER=$(echo -n user:api-token | base64) -``` - -5. Run the app with `yarn start` - -6. Add the Jenkins folder annotation to your `catalog-info.yaml`, (NOTE: - currently this plugin only supports folders and Git SCM) +Currently, this plugin only supports folders and Git SCM. +Note that if you configured a custom JenkinsInfoProvider in step 2, you may need to use a different annotation scheme here ```yaml apiVersion: backstage.io/v1alpha1 @@ -75,21 +61,6 @@ spec: 8. Click the component in the catalog. You should now see Jenkins builds, and a last build result for your master build. -Note: If you are not using environment variables, you can directly type the API -token into `app-config.yaml`. - -```yaml -proxy: - '/jenkins/api': - target: 'http://localhost:8080' # your Jenkins URL - changeOrigin: true - headers: - Authorization: Basic YWRtaW46MTFlYzI1NmU0Mzg1MDFjM2Y1Yzc2Yjc1MWE3ZTQ3YWY4Mw== -``` - -The string starting with `YWR...` is the base64 encoding of the user and their -API token, e.g. `admin:11ec256e438501c3f5c76b751a7e47af83`. - ## Features - View all runs inside a folder 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..a816cc3afd 100644 --- a/plugins/jenkins/src/api/JenkinsApi.ts +++ b/plugins/jenkins/src/api/JenkinsApi.ts @@ -14,246 +14,160 @@ * limitations under the License. */ -import { CITableBuildInfo } from '../components/BuildsPage/lib/CITable'; -import { createApiRef, DiscoveryApi } from '@backstage/core-plugin-api'; - -const jenkins = require('jenkins'); +import { createApiRef, DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; +import { EntityName, EntityRef } from '@backstage/catalog-model'; export const jenkinsApiRef = createApiRef({ - id: 'plugin.jenkins.service', + id: 'plugin.jenkins.service2', description: 'Used by the Jenkins plugin to make requests', }); -const DEFAULT_PROXY_PATH = '/jenkins/api'; +export interface Build { + // standard Jenkins + timestamp: number; + building: boolean; + duration: number; + result?: string; + fullDisplayName: string; + displayName: string; + url: string; + number: number; -type Options = { - discoveryApi: DiscoveryApi; - /** - * Path to use for requests via the proxy, defaults to /jenkins/api - */ - proxyPath?: string; -}; - -export class JenkinsApi { - private readonly discoveryApi: DiscoveryApi; - private readonly proxyPath: string; - - constructor(options: Options) { - this.discoveryApi = options.discoveryApi; - this.proxyPath = options.proxyPath ?? DEFAULT_PROXY_PATH; - } - - private async getClient() { - const proxyUrl = await this.discoveryApi.getBaseUrl('proxy'); - return jenkins({ baseUrl: proxyUrl + this.proxyPath, promisify: true }); - } - - async retry(buildName: string) { - const client = await this.getClient(); - // looks like the current SDK only supports triggering a new build - // can't see any support for replay (re-running the specific build with the same SCM info) - return await client.job.build(buildName); - } - - async getLastBuild(jobName: string) { - const client = await this.getClient(); - const job = await client.job.get(jobName); - - const lastBuild = await client.build.get(jobName, job.lastBuild.number); - return lastBuild; - } - - extractScmDetailsFromJob(jobDetails: any): any | undefined { - const scmInfo = jobDetails.actions - .filter( - (action: any) => - action._class === 'jenkins.scm.api.metadata.ObjectMetadataAction', - ) - .map((action: any) => { - return { - url: action?.objectUrl, - // https://javadoc.jenkins.io/plugin/scm-api/jenkins/scm/api/metadata/ObjectMetadataAction.html - // branch name for regular builds, pull request title on pull requests - displayName: action?.objectDisplayName, - }; - }) - .pop(); - - if (!scmInfo) { - return undefined; - } - - const author = jobDetails.actions - .filter( - (action: any) => - action._class === - 'jenkins.scm.api.metadata.ContributorMetadataAction', - ) - .map((action: any) => { - return action.contributorDisplayName; - }) - .pop(); - - if (author) { - scmInfo.author = author; - } - - return scmInfo; - } - - async getJob(jobName: string) { - const client = await this.getClient(); - return client.job.get({ - name: jobName, - depth: 1, - }); - } - - async getFolder(folderName: string) { - const client = await this.getClient(); - const folder = await client.job.get({ - name: folderName, - // Filter only be the information we need, instead of loading all fields. - // Limit to only show the latest build for each job and only load 50 jobs - // at all. - // Whitespaces are only included for readablity here and stripped out - // before sending to Jenkins - tree: `jobs[ - actions[*], - builds[ - number, - url, - fullDisplayName, - building, - result, - actions[ - *[ - *[ - *[ - * - ] - ] - ] - ] - ]{0,1}, - jobs{0,1}, - name - ]{0,50} - `.replace(/\s/g, ''), - }); - const results = []; - - for (const jobDetails of folder.jobs) { - const jobScmInfo = this.extractScmDetailsFromJob(jobDetails); - if (jobDetails?.jobs) { - // skipping folders inside folders for now - } else { - for (const buildDetails of jobDetails.builds) { - const ciTable = this.mapJenkinsBuildToCITable( - buildDetails, - jobScmInfo, - ); - results.push(ciTable); - } - } - } - return results; - } - - private getTestReport( - jenkinsResult: any, - ): { - total: number; + // added by us + source?: { + branchName: string; + displayName: string; + url: string; + commit: { + hash: string; + }; + author: string; + }; + tests: { passed: number; skipped: number; failed: number; + total: number; testUrl: string; - } { - return jenkinsResult.actions - .filter( - (action: any) => - action._class === 'hudson.tasks.junit.TestResultAction', - ) - .map((action: any) => { - return { - total: action.totalCount, - passed: action.totalCount - action.failCount - action.skipCount, - skipped: action.skipCount, - failed: action.failCount, - testUrl: `${jenkinsResult.url}${action.urlName}/`, - }; - }) - .pop(); + }; + status: string; // == building ? 'running' : result, +} + +export interface Project { + // standard Jenkins + lastBuild: Build; + displayName: string; + fullDisplayName: string; + fullName: string; + inQueue: string; + // added by us + status: string; // == inQueue ? 'queued' : lastBuild.building ? 'running' : lastBuild.result, + onRestartClick: () => void; // TODO handle.* ? +} + +export interface JenkinsApi { + /** + * Get the projects (jobs which have builds, not folders) including info about their lastBuild. + * + * Deciding what jobs are for an entity can be configured by the backstage _Integrator_ in the plugin-jenkins-backend setup + * and by the _Software Engineer_ using annotations agreed with the _Integrator_. + * + * Typically, a folder job will be identified and the backend plugin will recursively look for projects (jobs with builds) within that folder. + * + * @param entity the entity whose jobs should be retrieved. + * @param filter a filter on jobs. Currently this just takes a branch (and assumes certain structures in jenkins) + */ + getProjects( + entity: EntityRef, + filter: { branch?: string }, + ): Promise; + + /** + * Get a single build. + * + * This takes an entity to support selecting between multiple jenkins instances. + * + * TODO: abstract jobName (so we could support differentiating between the same named job on multiple instances). + * @param entity + * @param jobName + * @param buildNumber + */ + getBuild( + entity: EntityName, + jobName: string, + buildNumber: string, + ): Promise; + + retry(entity: EntityName, jobName: string, buildNumber: string): Promise; +} + +export class JenkinsApiImpl implements JenkinsApi { + private readonly discoveryApi: DiscoveryApi; + private readonly identityApi: IdentityApi; + + constructor(options: { + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }) { + this.discoveryApi = options.discoveryApi; + this.identityApi = options.identityApi; } - mapJenkinsBuildToCITable( - jenkinsResult: any, - jobScmInfo?: any, - ): CITableBuildInfo { - const source = - jenkinsResult.actions - .filter( - (action: any) => - action._class === 'hudson.plugins.git.util.BuildData', - ) - .map((action: any) => { - const [first]: any = Object.values(action.buildsByBranchName); - const branch = first.revision.branch[0]; - return { - branchName: branch.name, - commit: { - hash: branch.SHA1.substring(0, 8), - }, - }; - }) - .pop() || {}; + async getProjects( + entity: EntityName, + filter: { branch?: string }, + ): Promise { + const url = new URL( + `${await this.discoveryApi.getBaseUrl('jenkins')}/v1/entity/${ + entity.namespace + }/${entity.kind}/${entity.name}/projects`, + ); - if (jobScmInfo) { - source.url = jobScmInfo?.url; - source.displayName = jobScmInfo?.displayName; - source.author = jobScmInfo?.author; + if (filter.branch) { + url.searchParams.append('branch', filter.branch); } - const path = new URL(jenkinsResult.url).pathname; - - return { - id: path, - buildNumber: jenkinsResult.number, - buildUrl: jenkinsResult.url, - buildName: jenkinsResult.fullDisplayName, - status: jenkinsResult.building ? 'running' : jenkinsResult.result, - onRestartClick: () => { - // TODO: this won't handle non root context path, need a better way to get the job name - const { jobName } = this.extractJobDetailsFromBuildName(path); - return this.retry(jobName); + const idToken = await this.identityApi.getIdToken(); + const response = await fetch(url.href, { + method: 'GET', + headers: { + ...(idToken && { Authorization: `Bearer ${idToken}` }), }, - source: source, - tests: this.getTestReport(jenkinsResult), - }; + }); + + return (await response.json()).projects; } - async getBuild(buildName: string) { - const client = await this.getClient(); - const { jobName, buildNumber } = this.extractJobDetailsFromBuildName( - buildName, - ); - const buildResult = await client.build.get(jobName, buildNumber); - return buildResult; - } - - extractJobDetailsFromBuildName(buildName: string) { - const trimmedBuild = buildName.replace(/\/job/g, '').replace(/\/$/, ''); - - const split = trimmedBuild.split('/'); - const buildNumber = parseInt(split[split.length - 1], 10); - const jobName = trimmedBuild.slice( - 0, - trimmedBuild.length - buildNumber.toString(10).length - 1, - ); - - return { + async getBuild( + entity: EntityName, + jobName: string, + buildNumber: string, + ): Promise { + const url = `${await this.discoveryApi.getBaseUrl('jenkins')}/v1/entity/${ + entity.namespace + }/${entity.kind}/${entity.name}/job/${encodeURIComponent( jobName, - buildNumber, - }; + )}/${buildNumber}`; + + const idToken = await this.identityApi.getIdToken(); + const response = await fetch(url, { + method: 'GET', + headers: { + ...(idToken && { Authorization: `Bearer ${idToken}` }), + }, + }); + + return (await response.json()).build; + } + + retry( + // @ts-ignore unused because unimplemented + entity: EntityName, + // @ts-ignore unused because unimplemented + jobName: string, + // @ts-ignore unused because unimplemented + buildNumber: string, + ): Promise { + return Promise.resolve(undefined); } } diff --git a/plugins/jenkins/src/api/index.ts b/plugins/jenkins/src/api/index.ts index 7bf664fbed..e7e81d4faa 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 { JenkinsApiImpl, 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..0b466f1f2e 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,15 @@ const useStyles = makeStyles(theme => ({ })); const BuildWithStepsView = () => { - const projectName = useProjectSlugFromEntity(); - const { branch, buildNumber } = useRouteRefParams(buildRouteRef); + const { jobName, buildNumber } = useRouteRefParams(buildRouteRef); const classes = useStyles(); - const buildPath = `${projectName}/${encodeURIComponent( - branch, - )}/${buildNumber}`; - const [{ value }] = useBuildWithSteps(buildPath); + + const [{ value }] = useBuildWithSteps(jobName, buildNumber); return (
+ {/* TODO: don't hardcode this link */} Projects Run @@ -104,7 +101,7 @@ const BuildWithStepsView = () => { Jenkins - + View on Jenkins{' '} @@ -112,10 +109,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..f4c3e26e92 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.fullName} ); }, }, { 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..7f05a1ba3e 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..cf2719d1e8 100644 --- a/plugins/jenkins/src/components/useBuildWithSteps.ts +++ b/plugins/jenkins/src/components/useBuildWithSteps.ts @@ -18,30 +18,34 @@ import { useAsyncRetry } from 'react-use'; import { jenkinsApiRef } from '../api'; import { useAsyncPolling } from './useAsyncPolling'; import { errorApiRef, useApi } from '@backstage/core-plugin-api'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { getEntityName } from '@backstage/catalog-model'; const INTERVAL_AMOUNT = 1500; -export function useBuildWithSteps(buildName: string) { + +/** + * Hook to expose a specific build. + * @param jobName the full name of the project (job with builds, not a folder). e.g. "department-A/team-1/project-foo/master" + * @param buildNumber the number of the build. e.g. "13" + */ +export function useBuildWithSteps(jobName: string, buildNumber: string) { const api = useApi(jenkinsApiRef); const errorApi = useApi(errorApiRef); + const { entity } = useEntity(); + const entityName = getEntityName(entity); const getBuildWithSteps = useCallback(async () => { try { - const build = await api.getBuild(buildName); - - const { jobName } = api.extractJobDetailsFromBuildName(buildName); - const job = await api.getJob(jobName); - const jobInfo = api.extractScmDetailsFromJob(job); - - return Promise.resolve(api.mapJenkinsBuildToCITable(build, jobInfo)); + return api.getBuild(entityName, jobName, buildNumber); } catch (e) { errorApi.post(e); return Promise.reject(e); } - }, [buildName, api, errorApi]); + }, [buildNumber, jobName, entityName, api, errorApi]); const restartBuild = async () => { try { - await api.retry(buildName); + await api.retry(entityName, jobName, buildNumber); } catch (e) { errorApi.post(e); } diff --git a/plugins/jenkins/src/components/useBuilds.ts b/plugins/jenkins/src/components/useBuilds.ts index 49be4893a7..f3c8878047 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?: string) { + const { entity } = useEntity(); + const entityName = getEntityName(entity); const api = useApi(jenkinsApiRef); const errorApi = useApi(errorApiRef); @@ -35,27 +45,21 @@ export function useBuilds(projectName: string, branch?: string) { errorType: ErrorType; }>(); - const restartBuild = async (buildName: string) => { + const restartBuild = async (jobName: string, buildNumber: string) => { try { - await api.retry(buildName); + await api.retry(entityName, jobName, buildNumber); } catch (e) { errorApi.post(e); } }; - const { loading, value: builds, retry } = useAsyncRetry(async () => { + const { loading, value: projects, retry } = useAsyncRetry(async () => { try { - let build; - if (branch) { - build = await api.getLastBuild(`${projectName}/${branch}`); - } else { - build = await api.getFolder(`${projectName}`); - } + const build = await api.getProjects(getEntityName(entity), { branch }); - const size = Array.isArray(build) ? build?.[0].build_num! : 1; - setTotal(size); + setTotal(build.length); - return build || []; + return build; } catch (e) { const errorType = e.notFound ? ErrorType.NOT_FOUND @@ -63,24 +67,22 @@ export function useBuilds(projectName: string, branch?: string) { setError({ message: e.message, errorType }); throw e; } - }, [api, errorApi, projectName, branch]); + }, [api, errorApi, entity, branch]); return [ { page, pageSize, loading, - builds, - projectName, + projects, total, error, }, { - builds, setPage, setPageSize, restartBuild, - retry, + retry, // fetch data again }, ] as const; } diff --git a/plugins/jenkins/src/plugin.ts b/plugins/jenkins/src/plugin.ts index 2548612896..949e07e16c 100644 --- a/plugins/jenkins/src/plugin.ts +++ b/plugins/jenkins/src/plugin.ts @@ -22,7 +22,9 @@ import { createRoutableExtension, createRouteRef, discoveryApiRef, + identityApiRef, } from '@backstage/core-plugin-api'; +import { JenkinsApiImpl, jenkinsApiRef } from './api'; export const rootRouteRef = createRouteRef({ path: '', @@ -30,9 +32,9 @@ export const rootRouteRef = createRouteRef({ }); export const buildRouteRef = createRouteRef({ - path: 'run/:branch/:buildNumber', - params: ['branch', 'buildNumber'], - title: 'Jenkins run', + path: 'build/:jobName/:buildNumber', + params: ['jobName', 'buildNumber'], + title: 'Jenkins build', }); export const jenkinsPlugin = createPlugin({ @@ -40,8 +42,9 @@ export const jenkinsPlugin = createPlugin({ apis: [ createApiFactory({ api: jenkinsApiRef, - deps: { discoveryApi: discoveryApiRef }, - factory: ({ discoveryApi }) => new JenkinsApi({ discoveryApi }), + deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef }, + factory: ({ discoveryApi, identityApi }) => + new JenkinsApiImpl({ discoveryApi, identityApi }), }), ], routes: { 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==