From 449b995261d822521920df7826cfc76aef1d6117 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Fri, 30 Apr 2021 12:33:37 +0100 Subject: [PATCH 01/40] 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== From e3675685a238e561dbe8349d54ba3afe5131d6d8 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Tue, 1 Jun 2021 19:01:07 +0100 Subject: [PATCH 02/40] create a single DefaultJenkinsProvider Signed-off-by: Andrew Shirley Signed-off-by: blam --- plugins/jenkins-backend/README.md | 142 ++++++--- .../src/service/jenkinsInfoProvider.ts | 283 +++++------------- 2 files changed, 175 insertions(+), 250 deletions(-) diff --git a/plugins/jenkins-backend/README.md b/plugins/jenkins-backend/README.md index dfc8ef440c..8226e71772 100644 --- a/plugins/jenkins-backend/README.md +++ b/plugins/jenkins-backend/README.md @@ -19,32 +19,39 @@ Typically, this means creating a `src/plugins/jenkins.ts` file and adding a refe ```typescript import { createRouter, - SingleJenkinsInfoProvider, + 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 SingleJenkinsInfoProvider(), + jenkinsInfoProvider: new DefaultJenkinsInfoProvider(catalogClient, config), }); } ``` 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. +There is a standard one provided, but the Integrator is free to build their own. -### SingleJenkinsInfoProvider +### DefaultJenkinsInfoProvider -Allows configuration of a single global jenkins instance and annotating entities with the job name on that instance. +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 +Config ```yaml jenkins: @@ -53,7 +60,7 @@ jenkins: apikey: 123456789abcdef0123456789abcedf012 ``` -#### Catalog +Catalog ```yaml apiVersion: backstage.io/v1alpha1 @@ -61,16 +68,16 @@ kind: Component metadata: name: artist-lookup annotations: - 'jenkins.io/github-folder': teamA/artistLookup-build + 'jenkins.io/job-slug': teamA/artistLookup-build ``` -### PrefixedJenkinsInfoProvider +The old annotation name of `jenkins.io/github-folder` is equivalent to `jenkins.io/job-slug` -Allows configuration of multiple global jenkins instance and annotating entities with the name of the instance and job name on that instance. +#### 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 +Config ```yaml jenkins: @@ -85,7 +92,7 @@ jenkins: apikey: 123456789abcdef0123456789abcedf012 ``` -#### Catalog +Catalog ```yaml apiVersion: backstage.io/v1alpha1 @@ -93,53 +100,92 @@ kind: Component metadata: name: artist-lookup annotations: - 'jenkins.io/github-folder': departmentFoo#teamA/artistLookup-build + 'jenkins.io/job-slug': departmentFoo:teamA/artistLookup-build ``` -If the `departmentFoo#` part is omitted, the default instance will be assumed. +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 +The following config is an equivalent (but less clear) version of the above: ```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 + baseUrl: https://jenkins.example.com + username: backstage-bot + apikey: 123456789abcdef0123456789abcedf012 + instances: + - name: departmentFoo + baseUrl: https://jenkins-foo.example.com + username: backstage-bot + apikey: 123456789abcdef0123456789abcedf012 ``` -#### Catalog +### Custom JenkinsInfoProvider -```yaml -apiVersion: backstage.io/v1alpha1 -kind: Component -metadata: - name: artist-lookup - annotations: - 'jenkins.io/github-folder': departmentFoo#teamA/artistLookup-build +An example of a bespoke JenkinsInfoProvider which uses an organisation specific annotation to look up the jenkins info (including jobName): + +```typescript +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: 'DepartmentFoo', + }; + } + + private getJenkinsApiKey(_: string): string { + // Mock implementation, this would get info from the paas system somehow in reality. + return '123456789abcdef0123456789abcedf012'; + } +} ``` -### AcmeJenkinsInfoProvider +No config would be needed if using this JenkinsInfoProvider -An example of a bespoke JenkinsInfoProvider which uses an organisation specific annotation to look up the jenkins info (including jobName) - -#### Config - -None needed - -#### Catalog +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 @@ -150,8 +196,6 @@ metadata: '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: diff --git a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts index 5e481bb7f0..51c6c3e383 100644 --- a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts +++ b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts @@ -13,7 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { EntityName, stringifyEntityRef } from '@backstage/catalog-model'; +import { + Entity, + EntityName, + stringifyEntityRef, +} from '@backstage/catalog-model'; import { CatalogClient } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; @@ -53,131 +57,14 @@ export class DummyJenkinsInfoProvider implements JenkinsInfoProvider { } /** - * 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 + * Use default config and annotations. + * + * 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-slug'; + constructor( private readonly catalog: CatalogClient, private readonly config: Config, @@ -187,10 +74,7 @@ export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { entityRef: EntityName; jobName?: string; }): Promise { - const JENKINS_ANNOTATION = 'jenkins.io/github-folder'; - const DEFAULT_JENKINS_NAME = 'default'; - - // lookup `[jenkinsName#]jobName` from entity annotation + // load entity const entity = await this.catalog.getEntityByName(opt.entityRef); if (!entity) { throw new Error( @@ -198,21 +82,23 @@ export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { ); } - const jenkinsAndJobName = entity.metadata.annotations?.[JENKINS_ANNOTATION]; + // lookup `[jenkinsName#]jobName` from entity annotation + const jenkinsAndJobName = DefaultJenkinsInfoProvider.getEntityAnnotationValue( + entity, + ); if (!jenkinsAndJobName) { throw new Error( - `Couldn't find jenkins annotation (${JENKINS_ANNOTATION}) on entity with name: ${stringifyEntityRef( - opt.entityRef, - )}`, + `Couldn't find jenkins annotation (${ + DefaultJenkinsInfoProvider.NEW_JENKINS_ANNOTATION + }) on entity with name: ${stringifyEntityRef(opt.entityRef)}`, ); } let jobName; - let jenkinsName: string; - const splitIndex = jenkinsAndJobName.indexOf('#'); + let jenkinsName: string | undefined; + const splitIndex = jenkinsAndJobName.indexOf(':'); if (splitIndex === -1) { // no jenkinsName specified, use default - jenkinsName = DEFAULT_JENKINS_NAME; jobName = jenkinsAndJobName; } else { // There is a jenkinsName specified @@ -224,67 +110,17 @@ export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { } // 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 instanceConfig = DefaultJenkinsInfoProvider.getInstanceConfig( + jenkinsName, + this.config, + ); 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}`); + const creds = Buffer.from(`${username}:${apiKey}`, 'binary').toString( + 'base64', + ); return { baseUrl, @@ -295,16 +131,61 @@ export class AcmeJenkinsInfoProvider implements JenkinsInfoProvider { }; } - 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 static getEntityAnnotationValue(entity: Entity) { + return ( + entity.metadata.annotations?.[ + DefaultJenkinsInfoProvider.OLD_JENKINS_ANNOTATION + ] || + entity.metadata.annotations?.[ + DefaultJenkinsInfoProvider.NEW_JENKINS_ANNOTATION + ] + ); } - private getJenkinsApiKey(_: string): string { - // Mock implementation, this would get info from the paas system somehow in reality. - return '123456789abcdef0123456789abcedf012'; + private static getInstanceConfig( + jenkinsName: string | undefined, + rootConfig: Config, + ): Config { + const DEFAULT_JENKINS_NAME = 'default'; + + const jenkinsConfig = rootConfig.getConfig('jenkins'); + + if (!jenkinsName || jenkinsName === DEFAULT_JENKINS_NAME) { + // no name provided, this could be + // (jenkins.baseUrl, jenkins.username, jenkins.apiKey) or + // the entry with default name in jenkins.instances + const namedInstanceConfig = jenkinsConfig + .getConfigArray('instances') + .filter(c => c.getString('name') === DEFAULT_JENKINS_NAME)[0]; + if (namedInstanceConfig) { + return namedInstanceConfig; + } + + // 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 (!baseUrl || !username || !apiKey) { + 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 jenkinsConfig; + } + + // A name is provided, look it up. + + const namedInstanceConfig = jenkinsConfig + .getConfigArray('instances') + .filter(c => c.getString('name') === jenkinsName)[0]; + + if (!namedInstanceConfig) { + throw new Error( + `Couldn't find a jenkins instance in the config with name ${jenkinsName}`, + ); + } + return namedInstanceConfig; } } From af604845bb1364b8f601f5d8edea25d81cabafc1 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Wed, 2 Jun 2021 10:27:42 +0100 Subject: [PATCH 03/40] Remove some ts-ignores Signed-off-by: Andrew Shirley Signed-off-by: blam --- plugins/jenkins-backend/src/service/router.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/plugins/jenkins-backend/src/service/router.ts b/plugins/jenkins-backend/src/service/router.ts index 19033daad0..95bbde4bb3 100644 --- a/plugins/jenkins-backend/src/service/router.ts +++ b/plugins/jenkins-backend/src/service/router.ts @@ -94,7 +94,6 @@ export async function createRouter( // 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, ''), @@ -103,7 +102,6 @@ export async function createRouter( } 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. @@ -156,7 +154,6 @@ export async function createRouter( const client = await getClient(jenkinsInfo); - // @ts-ignore const project = await client.job.get({ name: jobName, depth: 1, @@ -306,9 +303,10 @@ function getTestReport( } async function 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; } From a6f65b728f1d6910becf15f0050cb5f2ddb8758e Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Wed, 2 Jun 2021 12:15:51 +0100 Subject: [PATCH 04/40] Make plugin-jenkins-backend public Signed-off-by: Andrew Shirley Signed-off-by: blam --- plugins/jenkins-backend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/jenkins-backend/package.json b/plugins/jenkins-backend/package.json index 21421a80d8..11dc25f971 100644 --- a/plugins/jenkins-backend/package.json +++ b/plugins/jenkins-backend/package.json @@ -4,7 +4,7 @@ "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", - "private": true, + "private": false, "publishConfig": { "access": "public", "main": "dist/index.cjs.js", From edc019d0032e81fcc2aa92a3c32ed1886c9b5e1c Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Wed, 2 Jun 2021 12:53:28 +0100 Subject: [PATCH 05/40] Use DefaultJenkinsInfoProvider Signed-off-by: Andrew Shirley Signed-off-by: blam --- app-config.yaml | 6 ++++++ packages/backend/src/plugins/jenkins.ts | 9 +++++++-- plugins/jenkins-backend/README.md | 10 +++++----- plugins/jenkins-backend/src/index.ts | 5 ++++- 4 files changed, 22 insertions(+), 8 deletions(-) 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/packages/backend/src/plugins/jenkins.ts b/packages/backend/src/plugins/jenkins.ts index ae6ede0272..4206ebb5e1 100644 --- a/packages/backend/src/plugins/jenkins.ts +++ b/packages/backend/src/plugins/jenkins.ts @@ -16,16 +16,21 @@ import { createRouter, - DummyJenkinsInfoProvider, + 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 catalogClient = new CatalogClient({ discoveryApi: discovery }); + return await createRouter({ logger, - jenkinsInfoProvider: new DummyJenkinsInfoProvider(), + jenkinsInfoProvider: new DefaultJenkinsInfoProvider(catalogClient, config), }); } diff --git a/plugins/jenkins-backend/README.md b/plugins/jenkins-backend/README.md index 8226e71772..7a1bd3b6fa 100644 --- a/plugins/jenkins-backend/README.md +++ b/plugins/jenkins-backend/README.md @@ -57,7 +57,7 @@ Config jenkins: baseUrl: https://jenkins.example.com username: backstage-bot - apikey: 123456789abcdef0123456789abcedf012 + apiKey: 123456789abcdef0123456789abcedf012 ``` Catalog @@ -85,11 +85,11 @@ jenkins: - name: default baseUrl: https://jenkins.example.com username: backstage-bot - apikey: 123456789abcdef0123456789abcedf012 + apiKey: 123456789abcdef0123456789abcedf012 - name: departmentFoo baseUrl: https://jenkins-foo.example.com username: backstage-bot - apikey: 123456789abcdef0123456789abcedf012 + apiKey: 123456789abcdef0123456789abcedf012 ``` Catalog @@ -111,12 +111,12 @@ The following config is an equivalent (but less clear) version of the above: jenkins: baseUrl: https://jenkins.example.com username: backstage-bot - apikey: 123456789abcdef0123456789abcedf012 + apiKey: 123456789abcdef0123456789abcedf012 instances: - name: departmentFoo baseUrl: https://jenkins-foo.example.com username: backstage-bot - apikey: 123456789abcdef0123456789abcedf012 + apiKey: 123456789abcdef0123456789abcedf012 ``` ### Custom JenkinsInfoProvider diff --git a/plugins/jenkins-backend/src/index.ts b/plugins/jenkins-backend/src/index.ts index 1d8a13bde6..4ebbb7eea5 100644 --- a/plugins/jenkins-backend/src/index.ts +++ b/plugins/jenkins-backend/src/index.ts @@ -19,4 +19,7 @@ export type { JenkinsInfo, JenkinsInfoProvider, } from './service/jenkinsInfoProvider'; -export { DummyJenkinsInfoProvider } from './service/jenkinsInfoProvider'; +export { + DummyJenkinsInfoProvider, + DefaultJenkinsInfoProvider, +} from './service/jenkinsInfoProvider'; From e5f69b082d1f1dd5f59783e0782c00fa610fa812 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Wed, 2 Jun 2021 14:25:28 +0100 Subject: [PATCH 06/40] Tidy documentation Signed-off-by: Andrew Shirley Signed-off-by: blam --- plugins/jenkins-backend/README.md | 14 ++++++------- plugins/jenkins/README.md | 35 +++++++++++++++++++++++++------ 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/plugins/jenkins-backend/README.md b/plugins/jenkins-backend/README.md index 7a1bd3b6fa..308b911257 100644 --- a/plugins/jenkins-backend/README.md +++ b/plugins/jenkins-backend/README.md @@ -1,12 +1,12 @@ # Jenkins Plugin (Alpha) -Welcome to the jenkins backend plugin! Website: [https://jenkins.io/](https://jenkins.io/) +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: +This is the backend half of the 2 Jenkins plugins and is responsible for: -- finding an appropriate instance of jenkins for an entity +- 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 +- connecting to Jenkins and gathering data to present to the frontend ## Integrating into a backstage instance @@ -39,13 +39,13 @@ export default async function createPlugin({ } ``` -This plugin must be provided with a JenkinsInfoProvider, this is a strategy object for finding the jenkins instance and job for an entity. +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). +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 @@ -121,7 +121,7 @@ jenkins: ### Custom JenkinsInfoProvider -An example of a bespoke JenkinsInfoProvider which uses an organisation specific annotation to look up the jenkins info (including jobName): +An example of a bespoke JenkinsInfoProvider which uses an organisation specific annotation to look up the Jenkins info (including jobName): ```typescript class AcmeJenkinsInfoProvider implements JenkinsInfoProvider { diff --git a/plugins/jenkins/README.md b/plugins/jenkins/README.md index 62bc60d443..1af511b16f 100644 --- a/plugins/jenkins/README.md +++ b/plugins/jenkins/README.md @@ -16,24 +16,47 @@ cd packages/app yarn add @backstage/plugin-jenkins ``` -2. Add and configure tha backend plugin according to it's instructions +2. Add and configure the 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: +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... */} + + + + + + {/* ... */} + + + {/* ... */} + +); ``` 4. Run app with `yarn start` From 64b279a55bcfa5d615ad7da765c99e5d21e1d7b4 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Wed, 2 Jun 2021 14:28:54 +0100 Subject: [PATCH 07/40] Move typings to devDependency Signed-off-by: Andrew Shirley Signed-off-by: blam --- plugins/jenkins-backend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/jenkins-backend/package.json b/plugins/jenkins-backend/package.json index 11dc25f971..77348bd37e 100644 --- a/plugins/jenkins-backend/package.json +++ b/plugins/jenkins-backend/package.json @@ -26,7 +26,6 @@ "@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", @@ -36,6 +35,7 @@ }, "devDependencies": { "@backstage/cli": "^0.7.0", + "@types/jenkins": "^0.23.1", "@types/supertest": "^2.0.8", "msw": "^0.21.2", "supertest": "^4.0.2" From 79593909c93407f04e2c609b17918d746c97d121 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Wed, 2 Jun 2021 18:55:25 +0100 Subject: [PATCH 08/40] Support rebuilding a build Note that like the previous implementation, this actually triggers a build of the project, not a re-build of a given build. Signed-off-by: Andrew Shirley Signed-off-by: blam --- plugins/jenkins-backend/src/service/router.ts | 35 +++++++++++++++-- plugins/jenkins/src/api/JenkinsApi.ts | 38 ++++++++++++++----- .../BuildWithStepsPage/BuildWithStepsPage.tsx | 1 + .../BuildsPage/lib/CITable/CITable.tsx | 6 +-- .../src/components/useBuildWithSteps.ts | 13 +------ 5 files changed, 67 insertions(+), 26 deletions(-) diff --git a/plugins/jenkins-backend/src/service/router.ts b/plugins/jenkins-backend/src/service/router.ts index 95bbde4bb3..6ba7f9e574 100644 --- a/plugins/jenkins-backend/src/service/router.ts +++ b/plugins/jenkins-backend/src/service/router.ts @@ -52,8 +52,10 @@ const jobTreeSpec = `actions[*], ${lastBuildTreeSpec} jobs{0,1}, name, + fullName, displayName, - fullDisplayName`; + fullDisplayName, + inQueue`; const jobsTreeSpec = `jobs[ ${jobTreeSpec} @@ -67,8 +69,7 @@ export interface RouterOptions { export async function createRouter( options: RouterOptions, ): Promise { - // @ts-ignore keeping unused logger for future use - const { logger, jenkinsInfoProvider } = options; + const { jenkinsInfoProvider } = options; const router = Router(); router.use(express.json()); @@ -169,6 +170,34 @@ export async function createRouter( }, ); + router.post( + '/v1/entity/:namespace/:kind/:name/job/:jobName/:buildNumber::rebuild', + async (request, response) => { + const { namespace, kind, name, jobName } = request.params; + + const jenkinsInfo = await jenkinsInfoProvider.getInstance({ + entityRef: { + kind, + namespace, + name, + }, + jobName, + }); + + const client = await 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(jobName); + + // TODO: return the buildNumber which was started. + response.send({}); + }, + ); + router.use(errorHandler()); return router; } diff --git a/plugins/jenkins/src/api/JenkinsApi.ts b/plugins/jenkins/src/api/JenkinsApi.ts index a816cc3afd..68f712b09b 100644 --- a/plugins/jenkins/src/api/JenkinsApi.ts +++ b/plugins/jenkins/src/api/JenkinsApi.ts @@ -62,7 +62,7 @@ export interface Project { inQueue: string; // added by us status: string; // == inQueue ? 'queued' : lastBuild.building ? 'running' : lastBuild.result, - onRestartClick: () => void; // TODO handle.* ? + onRestartClick: () => Promise; // TODO rename to handle.* ? also, should this be on lastBuild? } export interface JenkinsApi { @@ -98,7 +98,11 @@ export interface JenkinsApi { buildNumber: string, ): Promise; - retry(entity: EntityName, jobName: string, buildNumber: string): Promise; + retry( + entity: EntityName, + jobName: string, + buildNumber: string, + ): Promise; } export class JenkinsApiImpl implements JenkinsApi { @@ -135,7 +139,14 @@ export class JenkinsApiImpl implements JenkinsApi { }, }); - return (await response.json()).projects; + return ( + (await response.json()).projects?.map((p: Project) => ({ + ...p, + onRestartClick: async () => { + await this.retry(entity, p.fullName, String(p.lastBuild.number)); + }, + })) || [] + ); } async getBuild( @@ -160,14 +171,23 @@ export class JenkinsApiImpl implements JenkinsApi { return (await response.json()).build; } - retry( - // @ts-ignore unused because unimplemented + async retry( entity: EntityName, - // @ts-ignore unused because unimplemented jobName: string, - // @ts-ignore unused because unimplemented buildNumber: string, - ): Promise { - return Promise.resolve(undefined); + ): Promise { + const url = `${await this.discoveryApi.getBaseUrl('jenkins')}/v1/entity/${ + entity.namespace + }/${entity.kind}/${entity.name}/job/${encodeURIComponent( + jobName, + )}/${buildNumber}:rebuild`; + + const idToken = await this.identityApi.getIdToken(); + await fetch(url, { + method: 'POST', + headers: { + ...(idToken && { Authorization: `Bearer ${idToken}` }), + }, + }); } } diff --git a/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx b/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx index 0b466f1f2e..9d8a4c11e4 100644 --- a/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx +++ b/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx @@ -48,6 +48,7 @@ const useStyles = makeStyles(theme => ({ })); const BuildWithStepsView = () => { + // TODO: Add a test that react-router decodes this (even though `generatePath` doesn't encode it for you!) const { jobName, buildNumber } = useRouteRefParams(buildRouteRef); const classes = useStyles(); diff --git a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx index f4c3e26e92..7c1e7db8b1 100644 --- a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx +++ b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx @@ -100,11 +100,11 @@ const generatedColumns: TableColumn[] = [ - {row.fullName} + {row.fullDisplayName} ); }, diff --git a/plugins/jenkins/src/components/useBuildWithSteps.ts b/plugins/jenkins/src/components/useBuildWithSteps.ts index cf2719d1e8..82f3634941 100644 --- a/plugins/jenkins/src/components/useBuildWithSteps.ts +++ b/plugins/jenkins/src/components/useBuildWithSteps.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { useAsyncRetry } from 'react-use'; import { jenkinsApiRef } from '../api'; import { useAsyncPolling } from './useAsyncPolling'; @@ -32,7 +32,7 @@ export function useBuildWithSteps(jobName: string, buildNumber: string) { const api = useApi(jenkinsApiRef); const errorApi = useApi(errorApiRef); const { entity } = useEntity(); - const entityName = getEntityName(entity); + const entityName = useMemo(() => getEntityName(entity), [entity]); const getBuildWithSteps = useCallback(async () => { try { @@ -43,14 +43,6 @@ export function useBuildWithSteps(jobName: string, buildNumber: string) { } }, [buildNumber, jobName, entityName, api, errorApi]); - const restartBuild = async () => { - try { - await api.retry(entityName, jobName, buildNumber); - } catch (e) { - errorApi.post(e); - } - }; - const { loading, value, retry } = useAsyncRetry(() => getBuildWithSteps(), [ getBuildWithSteps, ]); @@ -63,7 +55,6 @@ export function useBuildWithSteps(jobName: string, buildNumber: string) { return [ { loading, value, retry }, { - restartBuild, getBuildWithSteps, startPolling, stopPolling, From 66ebdb34d1ad43af032ff2a59664dce323d1bf41 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Fri, 4 Jun 2021 11:57:40 +0100 Subject: [PATCH 09/40] Update site documentation Signed-off-by: Andrew Shirley Signed-off-by: blam --- .../software-catalog/well-known-annotations.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md index 67b92bd941..10c362ac07 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/folder-slug ```yaml # Example: metadata: annotations: - jenkins.io/github-folder: folder-name/job-name + jenkins.io/folder-slug: 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/folder-slug` instead. + ## Links - [Descriptor Format: annotations](descriptor-format.md#annotations-optional) From 8c1812de5f9a02f51e6962ab342657ff8c801648 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Fri, 4 Jun 2021 12:22:57 +0100 Subject: [PATCH 10/40] Add config schema Signed-off-by: Andrew Shirley Signed-off-by: blam --- plugins/jenkins-backend/config.d.ts | 51 ++++++++++++++++++++++++++++ plugins/jenkins-backend/package.json | 4 ++- 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 plugins/jenkins-backend/config.d.ts 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 index 77348bd37e..2b7232f1be 100644 --- a/plugins/jenkins-backend/package.json +++ b/plugins/jenkins-backend/package.json @@ -10,6 +10,7 @@ "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", @@ -41,6 +42,7 @@ "supertest": "^4.0.2" }, "files": [ - "dist" + "dist", + "config.d.ts" ] } From b470921b0898584112fa620b871467d11b4a7f62 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Fri, 11 Jun 2021 18:51:51 +0100 Subject: [PATCH 11/40] Remove duplicate copyright Signed-off-by: Andrew Shirley Signed-off-by: blam --- .../src/service/standaloneServer.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/plugins/jenkins-backend/src/service/standaloneServer.ts b/plugins/jenkins-backend/src/service/standaloneServer.ts index 5b9de299da..45e874891a 100644 --- a/plugins/jenkins-backend/src/service/standaloneServer.ts +++ b/plugins/jenkins-backend/src/service/standaloneServer.ts @@ -13,21 +13,6 @@ * 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'; From 8e9f1675829c97c02fada54d0f0368f22ccf1f10 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Fri, 11 Jun 2021 18:52:23 +0100 Subject: [PATCH 12/40] Add jenkinsInfoProvider tests (and fix bug :-) ) Signed-off-by: Andrew Shirley Signed-off-by: blam --- .../src/service/jenkinsInfoProvider.test.ts | 266 ++++++++++++++++++ .../src/service/jenkinsInfoProvider.ts | 4 +- 2 files changed, 268 insertions(+), 2 deletions(-) create mode 100644 plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts 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..e26809406e --- /dev/null +++ b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts @@ -0,0 +1,266 @@ +/* + * 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 new DefaultJenkinsInfoProvider(mockCatalog, config); + } + + it('Handles entity not found', async () => { + const provider = configureProvider({}, 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-slug': '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=', + }, + jobName: '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-slug': 'teamA/artistLookup-build', + }, + }, + }, + ); + const info: JenkinsInfo = await provider.getInstance({ entityRef }); + + expect(mockCatalog.getEntityByName).toBeCalledWith(entityRef); + expect(info).toMatchObject({ + baseUrl: 'https://jenkins.example.com', + jobName: '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-slug': 'teamA/artistLookup-build', + }, + }, + }, + ); + const info: JenkinsInfo = await provider.getInstance({ entityRef }); + + expect(mockCatalog.getEntityByName).toBeCalledWith(entityRef); + expect(info).toMatchObject({ + baseUrl: 'https://jenkins.example.com', + jobName: '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-slug': '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', + jobName: '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-slug': 'default:teamA/artistLookup-build', + }, + }, + }, + ); + const info: JenkinsInfo = await provider.getInstance({ entityRef }); + + expect(mockCatalog.getEntityByName).toBeCalledWith(entityRef); + expect(info).toMatchObject({ + baseUrl: 'https://jenkins.example.com', + jobName: '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', + jobName: '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-slug': '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', + jobName: 'teamA/artistLookup-build', + }); + }); +}); diff --git a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts index 51c6c3e383..0a71218739 100644 --- a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts +++ b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts @@ -155,8 +155,8 @@ export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { // (jenkins.baseUrl, jenkins.username, jenkins.apiKey) or // the entry with default name in jenkins.instances const namedInstanceConfig = jenkinsConfig - .getConfigArray('instances') - .filter(c => c.getString('name') === DEFAULT_JENKINS_NAME)[0]; + .getOptionalConfigArray('instances') + ?.filter(c => c.getString('name') === DEFAULT_JENKINS_NAME)[0]; if (namedInstanceConfig) { return namedInstanceConfig; } From fef7cd6cade991272e33230cdeb840dc31a4e490 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Wed, 16 Jun 2021 17:41:40 +0100 Subject: [PATCH 13/40] Extract Jenkins logic from router and test Signed-off-by: Andrew Shirley Signed-off-by: blam --- .../src/service/jenkinsApi.test.ts | 421 ++++++++++++++++++ .../jenkins-backend/src/service/jenkinsApi.ts | 283 ++++++++++++ plugins/jenkins-backend/src/service/router.ts | 263 ++--------- plugins/jenkins-backend/src/types.ts | 4 +- 4 files changed, 733 insertions(+), 238 deletions(-) create mode 100644 plugins/jenkins-backend/src/service/jenkinsApi.test.ts create mode 100644 plugins/jenkins-backend/src/service/jenkinsApi.ts 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..43902a40a4 --- /dev/null +++ b/plugins/jenkins-backend/src/service/jenkinsApi.test.ts @@ -0,0 +1,421 @@ +/* + * 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 jobName = 'example-jobName/foo'; +const buildNumber = 19; +const jenkinsInfo: JenkinsInfo = { + baseUrl: 'https://jenkins.example.com', + headers: { headerName: 'headerValue' }, + jobName: '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.jobName, + 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', + }); + }); + it.skip('3-layer nesting', async () => { + mockedJenkinsClient.job.get.mockResolvedValueOnce({ + jobs: [{ 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.jobName, + tree: expect.anything(), + }); + expect(result).toHaveLength(1); + expect(result[0].fullName).toEqual('example-jobName/exampleBuild'); + }); + it.skip('start at project', async () => { + mockedJenkinsClient.job.get.mockResolvedValueOnce(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.jobName, + tree: expect.anything(), + }); + expect(result).toHaveLength(1); + expect(result[0].fullName).toEqual('example-jobName/exampleBuild'); + }); + }); + 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.jobName}/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, jobName, buildNumber); + + expect(mockedJenkins).toHaveBeenCalledWith({ + baseUrl: jenkinsInfo.baseUrl, + headers: jenkinsInfo.headers, + promisify: true, + }); + expect(mockedJenkinsClient.job.get).toBeCalledWith({ + name: jobName, + depth: 1, + }); + expect(mockedJenkinsClient.build.get).toBeCalledWith(jobName, buildNumber); + }); + it('buildProject', () => {}); +}); diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.ts b/plugins/jenkins-backend/src/service/jenkinsApi.ts new file mode 100644 index 0000000000..f7476daab9 --- /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.jobName is a folder which contains one job per branch. + // TODO: extract a strategy interface for this + const job = await client.job.get({ + name: `${jenkinsInfo.jobName}/${branch}`, + tree: JenkinsApiImpl.jobTreeSpec.replace(/\s/g, ''), + }); + projects.push(this.augmentProject(job)); + } else { + // We aren't filtering + // Assume jenkinsInfo.jobName is a folder which contains one job per branch. + 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: 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, + jobName: string, + buildNumber: number, + ) { + const client = await JenkinsApiImpl.getClient(jenkinsInfo); + + const project = await client.job.get({ + name: jobName, + depth: 1, + }); + + const build = await client.build.get(jobName, 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, jobName: 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(jobName); + } + + // 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/router.ts b/plugins/jenkins-backend/src/service/router.ts index 6ba7f9e574..d8563cfbeb 100644 --- a/plugins/jenkins-backend/src/service/router.ts +++ b/plugins/jenkins-backend/src/service/router.ts @@ -18,48 +18,8 @@ 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, - fullName, - displayName, - fullDisplayName, - inQueue`; - -const jobsTreeSpec = `jobs[ - ${jobTreeSpec} - ]{0,50}`; +import { JenkinsInfoProvider } from './jenkinsInfoProvider'; +import { JenkinsApiImpl } from './jenkinsApi'; export interface RouterOptions { logger: Logger; @@ -71,6 +31,8 @@ export async function createRouter( ): Promise { const { jenkinsInfoProvider } = options; + const jenkinsApi = new JenkinsApiImpl(); + const router = Router(); router.use(express.json()); @@ -79,6 +41,21 @@ export async function createRouter( 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: { @@ -87,43 +64,7 @@ export async function createRouter( 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 - 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. - 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)); - } - } - } + const projects = await jenkinsApi.getProjects(jenkinsInfo, branchStr); response.send({ projects: projects, @@ -153,19 +94,14 @@ export async function createRouter( jobName, }); - const client = await getClient(jenkinsInfo); - - const project = await client.job.get({ - name: jobName, - depth: 1, - }); - - const build = await client.build.get(jobName, parseInt(buildNumber, 10)); - - const jobScmInfo = extractScmDetailsFromJob(project); + const build = await jenkinsApi.getBuild( + jenkinsInfo, + jobName, + parseInt(buildNumber, 10), + ); response.send({ - build: augmentBuild(build, jobScmInfo), + build: build, }); }, ); @@ -184,14 +120,7 @@ export async function createRouter( jobName, }); - const client = await 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(jobName); + await jenkinsApi.buildProject(jenkinsInfo, jobName); // TODO: return the buildNumber which was started. response.send({}); @@ -201,141 +130,3 @@ export async function createRouter( 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) { - // 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; -} diff --git a/plugins/jenkins-backend/src/types.ts b/plugins/jenkins-backend/src/types.ts index 88b029bb26..3fc1bf615c 100644 --- a/plugins/jenkins-backend/src/types.ts +++ b/plugins/jenkins-backend/src/types.ts @@ -67,7 +67,7 @@ export interface CommonProject { displayName: string; fullDisplayName: string; fullName: string; - inQueue: string; + inQueue: boolean; } export interface JenkinsProject extends CommonProject { @@ -75,7 +75,7 @@ export interface JenkinsProject extends CommonProject { lastBuild: JenkinsBuild; // read by us from jenkins but not passed to frontend - actions: any; + actions: object[]; } export interface BackstageProject extends CommonProject { From 6c7f00eb02a537d1c7582728e81b612b66a159a3 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Thu, 17 Jun 2021 19:44:29 +0100 Subject: [PATCH 14/40] Add changesets Signed-off-by: Andrew Shirley Signed-off-by: blam --- .changeset/good-hornets-hunt.md | 65 +++++++++++++++++++++++++++++++++ .changeset/light-frogs-prove.md | 9 +++++ 2 files changed, 74 insertions(+) create mode 100644 .changeset/good-hornets-hunt.md create mode 100644 .changeset/light-frogs-prove.md diff --git a/.changeset/good-hornets-hunt.md b/.changeset/good-hornets-hunt.md new file mode 100644 index 0000000000..847acda1ab --- /dev/null +++ b/.changeset/good-hornets-hunt.md @@ -0,0 +1,65 @@ +--- +'@backstage/plugin-jenkins': major +--- + +## 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 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/.changeset/light-frogs-prove.md b/.changeset/light-frogs-prove.md new file mode 100644 index 0000000000..09c2839853 --- /dev/null +++ b/.changeset/light-frogs-prove.md @@ -0,0 +1,9 @@ +--- +'example-backend': patch +'@backstage/plugin-jenkins-backend': patch +--- + +## 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. From d5cd58725e6c8921bb1dcdbe5734109de1366982 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Thu, 17 Jun 2021 20:01:16 +0100 Subject: [PATCH 15/40] Add tests for BuildProject and remove skipped ones Signed-off-by: Andrew Shirley Signed-off-by: blam --- .../src/service/jenkinsApi.test.ts | 47 ++++--------------- 1 file changed, 10 insertions(+), 37 deletions(-) diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.test.ts b/plugins/jenkins-backend/src/service/jenkinsApi.test.ts index 43902a40a4..fc2ccb3748 100644 --- a/plugins/jenkins-backend/src/service/jenkinsApi.test.ts +++ b/plugins/jenkins-backend/src/service/jenkinsApi.test.ts @@ -101,42 +101,6 @@ describe('JenkinsApi', () => { status: 'success', }); }); - it.skip('3-layer nesting', async () => { - mockedJenkinsClient.job.get.mockResolvedValueOnce({ - jobs: [{ 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.jobName, - tree: expect.anything(), - }); - expect(result).toHaveLength(1); - expect(result[0].fullName).toEqual('example-jobName/exampleBuild'); - }); - it.skip('start at project', async () => { - mockedJenkinsClient.job.get.mockResolvedValueOnce(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.jobName, - tree: expect.anything(), - }); - expect(result).toHaveLength(1); - expect(result[0].fullName).toEqual('example-jobName/exampleBuild'); - }); }); describe('filtered by branch', () => { it('standard github layout', async () => { @@ -417,5 +381,14 @@ describe('JenkinsApi', () => { }); expect(mockedJenkinsClient.build.get).toBeCalledWith(jobName, buildNumber); }); - it('buildProject', () => {}); + it('buildProject', async () => { + await jenkinsApi.buildProject(jenkinsInfo, jobName); + + expect(mockedJenkins).toHaveBeenCalledWith({ + baseUrl: jenkinsInfo.baseUrl, + headers: jenkinsInfo.headers, + promisify: true, + }); + expect(mockedJenkinsClient.job.build).toBeCalledWith(jobName); + }); }); From 217231596a4ba3419d037e6f5c405a56563fc1df Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Thu, 17 Jun 2021 20:45:24 +0100 Subject: [PATCH 16/40] Remove unneeded decodeURIComponent Signed-off-by: Andrew Shirley Signed-off-by: blam --- plugins/jenkins-backend/src/service/router.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/plugins/jenkins-backend/src/service/router.ts b/plugins/jenkins-backend/src/service/router.ts index d8563cfbeb..c097b338ef 100644 --- a/plugins/jenkins-backend/src/service/router.ts +++ b/plugins/jenkins-backend/src/service/router.ts @@ -75,15 +75,7 @@ export async function createRouter( 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 { namespace, kind, name, jobName, buildNumber } = request.params; const jenkinsInfo = await jenkinsInfoProvider.getInstance({ entityRef: { From 9c1a673cfaa167fe3edef67690ee5d0f276be3a7 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Thu, 17 Jun 2021 20:57:04 +0100 Subject: [PATCH 17/40] Capitalise 'Jenkins' Signed-off-by: Andrew Shirley Signed-off-by: blam --- .changeset/good-hornets-hunt.md | 6 +++--- .changeset/light-frogs-prove.md | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.changeset/good-hornets-hunt.md b/.changeset/good-hornets-hunt.md index 847acda1ab..7c92d7937c 100644 --- a/.changeset/good-hornets-hunt.md +++ b/.changeset/good-hornets-hunt.md @@ -4,10 +4,10 @@ ## 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. +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. +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 diff --git a/.changeset/light-frogs-prove.md b/.changeset/light-frogs-prove.md index 09c2839853..4684e887fa 100644 --- a/.changeset/light-frogs-prove.md +++ b/.changeset/light-frogs-prove.md @@ -5,5 +5,5 @@ ## 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. +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. From 3c7991423c154f94df8476318440209028e13e50 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Fri, 18 Jun 2021 10:22:23 +0100 Subject: [PATCH 18/40] Remove internal things from the jenkins public API Signed-off-by: Andrew Shirley Signed-off-by: blam --- plugins/jenkins-backend/api-report.md | 69 +++++++++++++++++++++++++++ plugins/jenkins/src/index.ts | 1 - 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 plugins/jenkins-backend/api-report.md diff --git a/plugins/jenkins-backend/api-report.md b/plugins/jenkins-backend/api-report.md new file mode 100644 index 0000000000..aa89e1c897 --- /dev/null +++ b/plugins/jenkins-backend/api-report.md @@ -0,0 +1,69 @@ +## 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 } from 'winston'; + +// @public (undocumented) +export function createRouter(options: RouterOptions): Promise; + +// @public +export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { + constructor(catalog: CatalogClient, config: Config); + // (undocumented) + getInstance(opt: { + entityRef: EntityName; + jobName?: string; + }): Promise; + // (undocumented) + static readonly NEW_JENKINS_ANNOTATION = "jenkins.io/job-slug"; + // (undocumented) + static readonly OLD_JENKINS_ANNOTATION = "jenkins.io/github-folder"; +} + +// @public (undocumented) +export class DummyJenkinsInfoProvider implements JenkinsInfoProvider { + // (undocumented) + getInstance(_: { + entityRef: EntityName; + jobName?: string; + }): Promise; +} + +// @public (undocumented) +export interface JenkinsInfo { + // (undocumented) + baseUrl: string; + // (undocumented) + headers?: any; + // (undocumented) + jobName: string; +} + +// @public (undocumented) +export interface JenkinsInfoProvider { + // (undocumented) + getInstance(options: { + entityRef: EntityName; + jobName?: string; + }): Promise; +} + +// @public (undocumented) +export interface RouterOptions { + // (undocumented) + jenkinsInfoProvider: JenkinsInfoProvider; + // (undocumented) + logger: Logger; +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/jenkins/src/index.ts b/plugins/jenkins/src/index.ts index fb0a27cb26..1a72b438ab 100644 --- a/plugins/jenkins/src/index.ts +++ b/plugins/jenkins/src/index.ts @@ -27,4 +27,3 @@ export { isJenkinsAvailable as isPluginApplicableToEntity, } from './components/Router'; export { JENKINS_ANNOTATION } from './constants'; -export * from './api'; From 44e53097dab55803672c15d5bcb994234e78d512 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Mon, 21 Jun 2021 14:20:04 +0100 Subject: [PATCH 19/40] Re-instate the API export After a discussion with blam I now understand the value of the SPI export so have added it back in (albeit with some significant changes from the original) https://discord.com/channels/687207715902193673/687207715902193679/855378432871759922 Signed-off-by: Andrew Shirley Signed-off-by: blam --- .changeset/good-hornets-hunt.md | 6 ++++++ plugins/jenkins/src/index.ts | 1 + 2 files changed, 7 insertions(+) diff --git a/.changeset/good-hornets-hunt.md b/.changeset/good-hornets-hunt.md index 7c92d7937c..2c618a955e 100644 --- a/.changeset/good-hornets-hunt.md +++ b/.changeset/good-hornets-hunt.md @@ -57,6 +57,12 @@ jenkins: 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 nedpoints 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 diff --git a/plugins/jenkins/src/index.ts b/plugins/jenkins/src/index.ts index 1a72b438ab..fb0a27cb26 100644 --- a/plugins/jenkins/src/index.ts +++ b/plugins/jenkins/src/index.ts @@ -27,3 +27,4 @@ export { isJenkinsAvailable as isPluginApplicableToEntity, } from './components/Router'; export { JENKINS_ANNOTATION } from './constants'; +export * from './api'; From 7991581e6268987734de66a2847430af884ba72c Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Mon, 21 Jun 2021 14:33:34 +0100 Subject: [PATCH 20/40] Use correct annotation name Signed-off-by: Andrew Shirley Signed-off-by: blam --- docs/features/software-catalog/well-known-annotations.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md index 10c362ac07..79c55eaa50 100644 --- a/docs/features/software-catalog/well-known-annotations.md +++ b/docs/features/software-catalog/well-known-annotations.md @@ -101,13 +101,13 @@ 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/folder-slug +### jenkins.io/job-slug ```yaml # Example: metadata: annotations: - jenkins.io/folder-slug: folder-name/job-name + jenkins.io/job-slug: folder-name/job-name ``` The value of this annotation is the path to a job on Jenkins, that builds this @@ -310,7 +310,7 @@ instead. ### jenkins.io/github-folder -Use the `jenkins.io/folder-slug` instead. +Use the `jenkins.io/job-slug` instead. ## Links From 2b0eb29bf9d08db6dbdc2327d3753429013a3f89 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Mon, 21 Jun 2021 14:38:33 +0100 Subject: [PATCH 21/40] Spelling Signed-off-by: Andrew Shirley Signed-off-by: blam --- .changeset/good-hornets-hunt.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/good-hornets-hunt.md b/.changeset/good-hornets-hunt.md index 2c618a955e..00b68e601a 100644 --- a/.changeset/good-hornets-hunt.md +++ b/.changeset/good-hornets-hunt.md @@ -57,10 +57,10 @@ jenkins: apiKey: ${JENKINS_PASSWORD} ``` -## Change javascript API +## 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 nedpoints exposed by +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 From 048f0a69ac4ea9563be5840e8ca44a6b8ccb9156 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Fri, 25 Jun 2021 14:31:19 +0100 Subject: [PATCH 22/40] Fix rebase error Signed-off-by: Andrew Shirley Signed-off-by: blam --- plugins/jenkins/src/plugin.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/jenkins/src/plugin.ts b/plugins/jenkins/src/plugin.ts index 949e07e16c..5099415ac3 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, From a1675b47a9549d920f37f59ebbe09d74054052bf Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Fri, 25 Jun 2021 14:31:52 +0100 Subject: [PATCH 23/40] Tweak changeset documentation Signed-off-by: Andrew Shirley Signed-off-by: blam --- .changeset/good-hornets-hunt.md | 2 +- .changeset/light-frogs-prove.md | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.changeset/good-hornets-hunt.md b/.changeset/good-hornets-hunt.md index 00b68e601a..b04a70b8be 100644 --- a/.changeset/good-hornets-hunt.md +++ b/.changeset/good-hornets-hunt.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-jenkins': major +'@backstage/plugin-jenkins': minor --- ## Extract an entity-oriented Jenkins Backend diff --git a/.changeset/light-frogs-prove.md b/.changeset/light-frogs-prove.md index 4684e887fa..bd9f84b701 100644 --- a/.changeset/light-frogs-prove.md +++ b/.changeset/light-frogs-prove.md @@ -1,5 +1,4 @@ --- -'example-backend': patch '@backstage/plugin-jenkins-backend': patch --- From 0581c1f5544a80ecc5ff3cbed2cf8aca65cba319 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Fri, 25 Jun 2021 14:32:18 +0100 Subject: [PATCH 24/40] Use a specific type for headers Signed-off-by: Andrew Shirley Signed-off-by: blam --- plugins/jenkins-backend/api-report.md | 2 +- plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/jenkins-backend/api-report.md b/plugins/jenkins-backend/api-report.md index aa89e1c897..9661e5dafc 100644 --- a/plugins/jenkins-backend/api-report.md +++ b/plugins/jenkins-backend/api-report.md @@ -41,7 +41,7 @@ export interface JenkinsInfo { // (undocumented) baseUrl: string; // (undocumented) - headers?: any; + headers?: Record; // (undocumented) jobName: string; } diff --git a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts index 0a71218739..340de001ea 100644 --- a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts +++ b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts @@ -36,7 +36,7 @@ export interface JenkinsInfoProvider { export interface JenkinsInfo { baseUrl: string; - headers?: any; + headers?: Record; jobName: string; // TODO: make this an array } From 17b493a3a59f1b10846da7d3453213fff379d685 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Fri, 25 Jun 2021 14:32:37 +0100 Subject: [PATCH 25/40] Use res.json not res.send Signed-off-by: Andrew Shirley Signed-off-by: blam --- plugins/jenkins-backend/src/service/router.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/jenkins-backend/src/service/router.ts b/plugins/jenkins-backend/src/service/router.ts index c097b338ef..f001fe4c1c 100644 --- a/plugins/jenkins-backend/src/service/router.ts +++ b/plugins/jenkins-backend/src/service/router.ts @@ -66,7 +66,7 @@ export async function createRouter( }); const projects = await jenkinsApi.getProjects(jenkinsInfo, branchStr); - response.send({ + response.json({ projects: projects, }); }, @@ -92,7 +92,7 @@ export async function createRouter( parseInt(buildNumber, 10), ); - response.send({ + response.json({ build: build, }); }, @@ -115,7 +115,7 @@ export async function createRouter( await jenkinsApi.buildProject(jenkinsInfo, jobName); // TODO: return the buildNumber which was started. - response.send({}); + response.json({}); }, ); From 41dab29598e9628520af9d71f0f44dc1c97e302c Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Fri, 25 Jun 2021 14:50:21 +0100 Subject: [PATCH 26/40] Remove DummyJenkinsInfoProvider Signed-off-by: Andrew Shirley Signed-off-by: blam --- plugins/jenkins-backend/api-report.md | 9 --------- plugins/jenkins-backend/src/index.ts | 5 +---- .../src/service/jenkinsInfoProvider.ts | 16 ---------------- 3 files changed, 1 insertion(+), 29 deletions(-) diff --git a/plugins/jenkins-backend/api-report.md b/plugins/jenkins-backend/api-report.md index 9661e5dafc..f42846f265 100644 --- a/plugins/jenkins-backend/api-report.md +++ b/plugins/jenkins-backend/api-report.md @@ -27,15 +27,6 @@ export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { static readonly OLD_JENKINS_ANNOTATION = "jenkins.io/github-folder"; } -// @public (undocumented) -export class DummyJenkinsInfoProvider implements JenkinsInfoProvider { - // (undocumented) - getInstance(_: { - entityRef: EntityName; - jobName?: string; - }): Promise; -} - // @public (undocumented) export interface JenkinsInfo { // (undocumented) diff --git a/plugins/jenkins-backend/src/index.ts b/plugins/jenkins-backend/src/index.ts index 4ebbb7eea5..fc84bd26a1 100644 --- a/plugins/jenkins-backend/src/index.ts +++ b/plugins/jenkins-backend/src/index.ts @@ -19,7 +19,4 @@ export type { JenkinsInfo, JenkinsInfoProvider, } from './service/jenkinsInfoProvider'; -export { - DummyJenkinsInfoProvider, - DefaultJenkinsInfoProvider, -} from './service/jenkinsInfoProvider'; +export { DefaultJenkinsInfoProvider } from './service/jenkinsInfoProvider'; diff --git a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts index 340de001ea..6c6d228685 100644 --- a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts +++ b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts @@ -40,22 +40,6 @@ export interface JenkinsInfo { 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 default config and annotations. * From 5b5d38194732f1a9e0cf5adb2ba5e789818f8d3b Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Fri, 25 Jun 2021 14:51:33 +0100 Subject: [PATCH 27/40] Apply port update from #6032 Signed-off-by: Andrew Shirley Signed-off-by: blam --- plugins/jenkins-backend/src/service/standaloneServer.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/jenkins-backend/src/service/standaloneServer.ts b/plugins/jenkins-backend/src/service/standaloneServer.ts index 45e874891a..3b201946bb 100644 --- a/plugins/jenkins-backend/src/service/standaloneServer.ts +++ b/plugins/jenkins-backend/src/service/standaloneServer.ts @@ -41,9 +41,12 @@ export async function startStandaloneServer( }, }); - const service = createServiceBuilder(module) - .enableCors({ origin: 'http://localhost:3000' }) + 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); From aaa12c8bdb771c68336f787c25b547b5a83a58d9 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Fri, 25 Jun 2021 15:04:22 +0100 Subject: [PATCH 28/40] Fix prettier from rebase Signed-off-by: Andrew Shirley Signed-off-by: blam --- plugins/jenkins/src/api/JenkinsApi.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/jenkins/src/api/JenkinsApi.ts b/plugins/jenkins/src/api/JenkinsApi.ts index 68f712b09b..9725c0009e 100644 --- a/plugins/jenkins/src/api/JenkinsApi.ts +++ b/plugins/jenkins/src/api/JenkinsApi.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { createApiRef, DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; +import { + createApiRef, + DiscoveryApi, + IdentityApi, +} from '@backstage/core-plugin-api'; import { EntityName, EntityRef } from '@backstage/catalog-model'; export const jenkinsApiRef = createApiRef({ From 5a3bd232a29b4e942f2a5ea9c67af045b8c4116e Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Fri, 2 Jul 2021 11:35:46 +0100 Subject: [PATCH 29/40] Better organise exports Signed-off-by: Andrew Shirley Signed-off-by: blam --- plugins/jenkins-backend/src/index.ts | 7 +------ plugins/jenkins-backend/src/service/index.ts | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 6 deletions(-) create mode 100644 plugins/jenkins-backend/src/service/index.ts diff --git a/plugins/jenkins-backend/src/index.ts b/plugins/jenkins-backend/src/index.ts index fc84bd26a1..c5c541db2b 100644 --- a/plugins/jenkins-backend/src/index.ts +++ b/plugins/jenkins-backend/src/index.ts @@ -14,9 +14,4 @@ * limitations under the License. */ -export * from './service/router'; -export type { - JenkinsInfo, - JenkinsInfoProvider, -} from './service/jenkinsInfoProvider'; -export { DefaultJenkinsInfoProvider } from './service/jenkinsInfoProvider'; +export * from './service'; 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'; From b56de238766c678d070eee77ff403bce8af8d119 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Fri, 2 Jul 2021 11:37:20 +0100 Subject: [PATCH 30/40] No need for changeset for new plugin Signed-off-by: Andrew Shirley Signed-off-by: blam --- .changeset/light-frogs-prove.md | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 .changeset/light-frogs-prove.md diff --git a/.changeset/light-frogs-prove.md b/.changeset/light-frogs-prove.md deleted file mode 100644 index bd9f84b701..0000000000 --- a/.changeset/light-frogs-prove.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@backstage/plugin-jenkins-backend': patch ---- - -## 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. From 9fde1b3313ff773f3fc415586fc34d8ec4a4c8c8 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Fri, 2 Jul 2021 13:13:15 +0100 Subject: [PATCH 31/40] Rename JenkinsApiImpl to JenkinsClient Signed-off-by: Andrew Shirley Signed-off-by: blam --- plugins/jenkins/src/api/JenkinsApi.ts | 2 +- plugins/jenkins/src/api/index.ts | 2 +- plugins/jenkins/src/plugin.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/jenkins/src/api/JenkinsApi.ts b/plugins/jenkins/src/api/JenkinsApi.ts index 9725c0009e..035e8aefa4 100644 --- a/plugins/jenkins/src/api/JenkinsApi.ts +++ b/plugins/jenkins/src/api/JenkinsApi.ts @@ -109,7 +109,7 @@ export interface JenkinsApi { ): Promise; } -export class JenkinsApiImpl implements JenkinsApi { +export class JenkinsClient implements JenkinsApi { private readonly discoveryApi: DiscoveryApi; private readonly identityApi: IdentityApi; diff --git a/plugins/jenkins/src/api/index.ts b/plugins/jenkins/src/api/index.ts index e7e81d4faa..af1829c0b4 100644 --- a/plugins/jenkins/src/api/index.ts +++ b/plugins/jenkins/src/api/index.ts @@ -14,6 +14,6 @@ * limitations under the License. */ -export { JenkinsApiImpl, jenkinsApiRef } from './JenkinsApi'; +export { JenkinsClient, jenkinsApiRef } from './JenkinsApi'; export type { JenkinsApi } from './JenkinsApi'; diff --git a/plugins/jenkins/src/plugin.ts b/plugins/jenkins/src/plugin.ts index 5099415ac3..fd6291618a 100644 --- a/plugins/jenkins/src/plugin.ts +++ b/plugins/jenkins/src/plugin.ts @@ -23,7 +23,7 @@ import { discoveryApiRef, identityApiRef, } from '@backstage/core-plugin-api'; -import { JenkinsApiImpl, jenkinsApiRef } from './api'; +import { JenkinsClient, jenkinsApiRef } from './api'; export const rootRouteRef = createRouteRef({ path: '', @@ -43,7 +43,7 @@ export const jenkinsPlugin = createPlugin({ api: jenkinsApiRef, deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef }, factory: ({ discoveryApi, identityApi }) => - new JenkinsApiImpl({ discoveryApi, identityApi }), + new JenkinsClient({ discoveryApi, identityApi }), }), ], routes: { From dc05501c086bf014ec7d08549d4c9c3b9ffc9c9a Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Fri, 2 Jul 2021 14:28:41 +0100 Subject: [PATCH 32/40] Tidy hook APIs Signed-off-by: Andrew Shirley Signed-off-by: blam --- .../BuildWithStepsPage/BuildWithStepsPage.tsx | 2 +- plugins/jenkins/src/components/Cards/Cards.tsx | 2 +- .../jenkins/src/components/useBuildWithSteps.ts | 14 ++++++++++---- plugins/jenkins/src/components/useBuilds.ts | 2 +- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx b/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx index 9d8a4c11e4..255fc949b2 100644 --- a/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx +++ b/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx @@ -52,7 +52,7 @@ const BuildWithStepsView = () => { const { jobName, buildNumber } = useRouteRefParams(buildRouteRef); const classes = useStyles(); - const [{ value }] = useBuildWithSteps(jobName, buildNumber); + const [{ value }] = useBuildWithSteps({ jobName, buildNumber }); return (
diff --git a/plugins/jenkins/src/components/Cards/Cards.tsx b/plugins/jenkins/src/components/Cards/Cards.tsx index 7f05a1ba3e..f0b1ff2bca 100644 --- a/plugins/jenkins/src/components/Cards/Cards.tsx +++ b/plugins/jenkins/src/components/Cards/Cards.tsx @@ -100,7 +100,7 @@ export const LatestRunCard = ({ branch: string; variant?: InfoCardVariants; }) => { - const [{ projects, loading, error }] = useBuilds(branch); + const [{ projects, loading, error }] = useBuilds({ branch }); const latestRun = projects?.[0]; return ( diff --git a/plugins/jenkins/src/components/useBuildWithSteps.ts b/plugins/jenkins/src/components/useBuildWithSteps.ts index 82f3634941..b109c9f964 100644 --- a/plugins/jenkins/src/components/useBuildWithSteps.ts +++ b/plugins/jenkins/src/components/useBuildWithSteps.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useCallback, useMemo } from 'react'; +import { useCallback } from 'react'; import { useAsyncRetry } from 'react-use'; import { jenkinsApiRef } from '../api'; import { useAsyncPolling } from './useAsyncPolling'; @@ -28,20 +28,26 @@ const INTERVAL_AMOUNT = 1500; * @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) { +export function useBuildWithSteps({ + jobName, + buildNumber, +}: { + jobName: string; + buildNumber: string; +}) { const api = useApi(jenkinsApiRef); const errorApi = useApi(errorApiRef); const { entity } = useEntity(); - const entityName = useMemo(() => getEntityName(entity), [entity]); const getBuildWithSteps = useCallback(async () => { try { + const entityName = await getEntityName(entity); return api.getBuild(entityName, jobName, buildNumber); } catch (e) { errorApi.post(e); return Promise.reject(e); } - }, [buildNumber, jobName, entityName, api, errorApi]); + }, [buildNumber, jobName, entity, api, errorApi]); const { loading, value, retry } = useAsyncRetry(() => getBuildWithSteps(), [ getBuildWithSteps, diff --git a/plugins/jenkins/src/components/useBuilds.ts b/plugins/jenkins/src/components/useBuilds.ts index f3c8878047..8a68e6f1cf 100644 --- a/plugins/jenkins/src/components/useBuilds.ts +++ b/plugins/jenkins/src/components/useBuilds.ts @@ -31,7 +31,7 @@ export enum ErrorType { * * TODO: deprecate branch and add a generic filter concept. */ -export function useBuilds(branch?: string) { +export function useBuilds({ branch }: { branch?: string } = {}) { const { entity } = useEntity(); const entityName = getEntityName(entity); const api = useApi(jenkinsApiRef); From 76fcd5ab40baed0c611ebf3c38b3f969ce086860 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Tue, 6 Jul 2021 17:18:50 +0100 Subject: [PATCH 33/40] Tidy types Signed-off-by: Andrew Shirley Signed-off-by: blam --- plugins/jenkins-backend/src/types.ts | 6 +++--- plugins/jenkins/src/api/JenkinsApi.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/jenkins-backend/src/types.ts b/plugins/jenkins-backend/src/types.ts index 3fc1bf615c..2a0c5ae7f9 100644 --- a/plugins/jenkins-backend/src/types.ts +++ b/plugins/jenkins-backend/src/types.ts @@ -15,9 +15,9 @@ */ export interface ScmDetails { - url?: String; - displayName?: String; - author?: String; + url?: string; + displayName?: string; + author?: string; } interface CommonBuild { diff --git a/plugins/jenkins/src/api/JenkinsApi.ts b/plugins/jenkins/src/api/JenkinsApi.ts index 035e8aefa4..5b6a07e38c 100644 --- a/plugins/jenkins/src/api/JenkinsApi.ts +++ b/plugins/jenkins/src/api/JenkinsApi.ts @@ -124,7 +124,7 @@ export class JenkinsClient implements JenkinsApi { async getProjects( entity: EntityName, filter: { branch?: string }, - ): Promise { + ): Promise { const url = new URL( `${await this.discoveryApi.getBaseUrl('jenkins')}/v1/entity/${ entity.namespace @@ -157,7 +157,7 @@ export class JenkinsClient implements JenkinsApi { entity: EntityName, jobName: string, buildNumber: string, - ): Promise { + ): Promise { const url = `${await this.discoveryApi.getBaseUrl('jenkins')}/v1/entity/${ entity.namespace }/${entity.kind}/${entity.name}/job/${encodeURIComponent( From b240ef73e2403fb0dd48adf4185e6410eed0ff75 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Tue, 6 Jul 2021 18:21:22 +0100 Subject: [PATCH 34/40] Parse as much of the config as possible at start time. Signed-off-by: Andrew Shirley Signed-off-by: blam --- packages/backend/src/plugins/jenkins.ts | 7 +- plugins/jenkins-backend/README.md | 7 +- plugins/jenkins-backend/api-report.md | 6 +- .../src/service/jenkinsInfoProvider.test.ts | 7 +- .../src/service/jenkinsInfoProvider.ts | 127 +++++++++++++----- 5 files changed, 112 insertions(+), 42 deletions(-) diff --git a/packages/backend/src/plugins/jenkins.ts b/packages/backend/src/plugins/jenkins.ts index 4206ebb5e1..5420eb0df6 100644 --- a/packages/backend/src/plugins/jenkins.ts +++ b/packages/backend/src/plugins/jenkins.ts @@ -27,10 +27,13 @@ export default async function createPlugin({ config, discovery, }: PluginEnvironment): Promise { - const catalogClient = new CatalogClient({ discoveryApi: discovery }); + const catalog = new CatalogClient({ discoveryApi: discovery }); return await createRouter({ logger, - jenkinsInfoProvider: new DefaultJenkinsInfoProvider(catalogClient, config), + jenkinsInfoProvider: DefaultJenkinsInfoProvider.fromConfig({ + catalog, + config, + }), }); } diff --git a/plugins/jenkins-backend/README.md b/plugins/jenkins-backend/README.md index 308b911257..37d188b4eb 100644 --- a/plugins/jenkins-backend/README.md +++ b/plugins/jenkins-backend/README.md @@ -30,11 +30,14 @@ export default async function createPlugin({ config, discovery, }: PluginEnvironment): Promise { - const catalogClient = new CatalogClient({ discoveryApi: discovery }); + const catalog = new CatalogClient({ discoveryApi: discovery }); return await createRouter({ logger, - jenkinsInfoProvider: new DefaultJenkinsInfoProvider(catalogClient, config), + jenkinsInfoProvider: DefaultJenkinsInfoProvider.fromConfig({ + config, + catalog, + }), }); } ``` diff --git a/plugins/jenkins-backend/api-report.md b/plugins/jenkins-backend/api-report.md index f42846f265..37c1674eff 100644 --- a/plugins/jenkins-backend/api-report.md +++ b/plugins/jenkins-backend/api-report.md @@ -15,7 +15,11 @@ export function createRouter(options: RouterOptions): Promise; // @public export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { - constructor(catalog: CatalogClient, config: Config); + // (undocumented) + static fromConfig(options: { + config: Config; + catalog: CatalogClient; + }): DefaultJenkinsInfoProvider; // (undocumented) getInstance(opt: { entityRef: EntityName; diff --git a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts index e26809406e..4651d7a489 100644 --- a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts +++ b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts @@ -36,11 +36,14 @@ describe('DefaultJenkinsInfoProvider', () => { Promise.resolve(entityData as Entity), ); - return new DefaultJenkinsInfoProvider(mockCatalog, config); + return DefaultJenkinsInfoProvider.fromConfig({ + config, + catalog: mockCatalog, + }); } it('Handles entity not found', async () => { - const provider = configureProvider({}, undefined); + const provider = configureProvider({ jenkins: {} }, undefined); await expect(provider.getInstance({ entityRef })).rejects.toThrowError(); expect(mockCatalog.getEntityByName).toBeCalledWith(entityRef); diff --git a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts index 6c6d228685..2314aa80af 100644 --- a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts +++ b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts @@ -41,7 +41,7 @@ export interface JenkinsInfo { } /** - * Use default config and annotations. + * Use default config and annotations, build using fromConfig static function. * * This will fallback through various deprecated config and annotation schemes. */ @@ -49,11 +49,26 @@ export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { static readonly OLD_JENKINS_ANNOTATION = 'jenkins.io/github-folder'; static readonly NEW_JENKINS_ANNOTATION = 'jenkins.io/job-slug'; - constructor( + private constructor( + private readonly config: { + name: string; + baseUrl: string; + username: string; + apiKey: string; + }[], private readonly catalog: CatalogClient, - private readonly config: Config, ) {} + static fromConfig(options: { + config: Config; + catalog: CatalogClient; + }): DefaultJenkinsInfoProvider { + return new DefaultJenkinsInfoProvider( + this.loadConfig(options.config), + options.catalog, + ); + } + async getInstance(opt: { entityRef: EntityName; jobName?: string; @@ -99,15 +114,13 @@ export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { this.config, ); - const baseUrl = instanceConfig.getString('baseUrl'); - const username = instanceConfig.getString('username'); - const apiKey = instanceConfig.getString('apiKey'); - const creds = Buffer.from(`${username}:${apiKey}`, 'binary').toString( - 'base64', - ); + const creds = Buffer.from( + `${instanceConfig.username}:${instanceConfig.apiKey}`, + 'binary', + ).toString('base64'); return { - baseUrl, + baseUrl: instanceConfig.baseUrl, headers: { Authorization: `Basic ${creds}`, }, @@ -128,48 +141,92 @@ export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { private static getInstanceConfig( jenkinsName: string | undefined, - rootConfig: Config, - ): Config { + config: { + name: string; + baseUrl: string; + username: string; + apiKey: string; + }[], + ): { name: string; baseUrl: string; username: string; apiKey: string } { const DEFAULT_JENKINS_NAME = 'default'; - const jenkinsConfig = rootConfig.getConfig('jenkins'); - if (!jenkinsName || jenkinsName === DEFAULT_JENKINS_NAME) { - // no name provided, this could be - // (jenkins.baseUrl, jenkins.username, jenkins.apiKey) or - // the entry with default name in jenkins.instances - const namedInstanceConfig = jenkinsConfig - .getOptionalConfigArray('instances') - ?.filter(c => c.getString('name') === DEFAULT_JENKINS_NAME)[0]; - if (namedInstanceConfig) { - return namedInstanceConfig; - } + // no name provided, use default + const instanceConfig = config.find(c => c.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 (!baseUrl || !username || !apiKey) { + 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 jenkinsConfig; + return instanceConfig; } // A name is provided, look it up. + const instanceConfig = config.find(c => c.name === jenkinsName); - const namedInstanceConfig = jenkinsConfig - .getConfigArray('instances') - .filter(c => c.getString('name') === jenkinsName)[0]; - - if (!namedInstanceConfig) { + 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; } } From 24d786e197b6af1bec649ab32ff4330dbbe34117 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Thu, 8 Jul 2021 17:34:25 +0100 Subject: [PATCH 35/40] Encode all components in the url Signed-off-by: Andrew Shirley Signed-off-by: blam --- plugins/jenkins/src/api/JenkinsApi.ts | 28 ++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/plugins/jenkins/src/api/JenkinsApi.ts b/plugins/jenkins/src/api/JenkinsApi.ts index 5b6a07e38c..e1e5c2df44 100644 --- a/plugins/jenkins/src/api/JenkinsApi.ts +++ b/plugins/jenkins/src/api/JenkinsApi.ts @@ -126,9 +126,11 @@ export class JenkinsClient implements JenkinsApi { filter: { branch?: string }, ): Promise { const url = new URL( - `${await this.discoveryApi.getBaseUrl('jenkins')}/v1/entity/${ - entity.namespace - }/${entity.kind}/${entity.name}/projects`, + `${await this.discoveryApi.getBaseUrl( + 'jenkins', + )}/v1/entity/${encodeURIComponent(entity.namespace)}/${encodeURIComponent( + entity.kind, + )}/${encodeURIComponent(entity.name)}/projects`, ); if (filter.branch) { @@ -158,11 +160,13 @@ export class JenkinsClient implements JenkinsApi { jobName: string, buildNumber: string, ): Promise { - const url = `${await this.discoveryApi.getBaseUrl('jenkins')}/v1/entity/${ - entity.namespace - }/${entity.kind}/${entity.name}/job/${encodeURIComponent( + const url = `${await this.discoveryApi.getBaseUrl( + 'jenkins', + )}/v1/entity/${encodeURIComponent(entity.namespace)}/${encodeURIComponent( + entity.kind, + )}/${encodeURIComponent(entity.name)}/job/${encodeURIComponent( jobName, - )}/${buildNumber}`; + )}/${encodeURIComponent(buildNumber)}`; const idToken = await this.identityApi.getIdToken(); const response = await fetch(url, { @@ -180,11 +184,13 @@ export class JenkinsClient implements JenkinsApi { jobName: string, buildNumber: string, ): Promise { - const url = `${await this.discoveryApi.getBaseUrl('jenkins')}/v1/entity/${ - entity.namespace - }/${entity.kind}/${entity.name}/job/${encodeURIComponent( + const url = `${await this.discoveryApi.getBaseUrl( + 'jenkins', + )}/v1/entity/${encodeURIComponent(entity.namespace)}/${encodeURIComponent( + entity.kind, + )}/${encodeURIComponent(entity.name)}/job/${encodeURIComponent( jobName, - )}/${buildNumber}:rebuild`; + )}/${encodeURIComponent(buildNumber)}:rebuild`; const idToken = await this.identityApi.getIdToken(); await fetch(url, { From 051c3b2c0c9237b537cedadd808128c62845ee6a Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Thu, 8 Jul 2021 17:44:45 +0100 Subject: [PATCH 36/40] Remove useless TODO Signed-off-by: Andrew Shirley Signed-off-by: blam --- plugins/jenkins-backend/src/service/router.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/plugins/jenkins-backend/src/service/router.ts b/plugins/jenkins-backend/src/service/router.ts index f001fe4c1c..360dfb004d 100644 --- a/plugins/jenkins-backend/src/service/router.ts +++ b/plugins/jenkins-backend/src/service/router.ts @@ -113,8 +113,6 @@ export async function createRouter( }); await jenkinsApi.buildProject(jenkinsInfo, jobName); - - // TODO: return the buildNumber which was started. response.json({}); }, ); From 37d0143e72ec47b81359e7011fdaef3a70db24c9 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Thu, 8 Jul 2021 18:08:10 +0100 Subject: [PATCH 37/40] Bump versions to match other plugins Signed-off-by: Andrew Shirley Signed-off-by: blam --- plugins/jenkins-backend/package.json | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/plugins/jenkins-backend/package.json b/plugins/jenkins-backend/package.json index 2b7232f1be..408d714aaa 100644 --- a/plugins/jenkins-backend/package.json +++ b/plugins/jenkins-backend/package.json @@ -21,11 +21,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.8.2", - "@backstage/catalog-client": "^0.3.13", - "@backstage/catalog-model": "^0.8.3", + "@backstage/backend-common": "^0.8.5s", + "@backstage/catalog-client": "^0.3.16", + "@backstage/catalog-model": "^0.9.0", "@backstage/config": "^0.1.5", - "@backstage/core": "^0.7.13", "@types/express": "^4.17.6", "cross-fetch": "^3.0.6", "express": "^4.17.1", @@ -35,10 +34,10 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.7.0", + "@backstage/cli": "^0.7.3", "@types/jenkins": "^0.23.1", "@types/supertest": "^2.0.8", - "msw": "^0.21.2", + "msw": "^0.29.0", "supertest": "^4.0.2" }, "files": [ From 91cc4913ab40277d6502f95b40fa537a93640106 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Sun, 11 Jul 2021 13:19:50 +0100 Subject: [PATCH 38/40] Use the options object pattern for frontend API Signed-off-by: Andrew Shirley Signed-off-by: blam --- plugins/jenkins-backend/api-report.md | 4 +- plugins/jenkins/src/api/JenkinsApi.ts | 83 +++++++++++-------- .../src/components/useBuildWithSteps.ts | 2 +- plugins/jenkins/src/components/useBuilds.ts | 7 +- 4 files changed, 57 insertions(+), 39 deletions(-) diff --git a/plugins/jenkins-backend/api-report.md b/plugins/jenkins-backend/api-report.md index 37c1674eff..f81c141f73 100644 --- a/plugins/jenkins-backend/api-report.md +++ b/plugins/jenkins-backend/api-report.md @@ -8,7 +8,7 @@ import { CatalogClient } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; import { EntityName } from '@backstage/catalog-model'; import express from 'express'; -import { Logger } from 'winston'; +import { Logger as Logger_2 } from 'winston'; // @public (undocumented) export function createRouter(options: RouterOptions): Promise; @@ -55,7 +55,7 @@ export interface RouterOptions { // (undocumented) jenkinsInfoProvider: JenkinsInfoProvider; // (undocumented) - logger: Logger; + logger: Logger_2; } diff --git a/plugins/jenkins/src/api/JenkinsApi.ts b/plugins/jenkins/src/api/JenkinsApi.ts index e1e5c2df44..d37fdd2032 100644 --- a/plugins/jenkins/src/api/JenkinsApi.ts +++ b/plugins/jenkins/src/api/JenkinsApi.ts @@ -78,13 +78,13 @@ export interface JenkinsApi { * * 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) + * @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( - entity: EntityRef, - filter: { branch?: string }, - ): Promise; + getProjects(options: { + entity: EntityRef; + filter: { branch?: string }; + }): Promise; /** * Get a single build. @@ -92,21 +92,21 @@ export interface JenkinsApi { * 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 + * @param options.entity + * @param options.jobName + * @param options.buildNumber */ - getBuild( - entity: EntityName, - jobName: string, - buildNumber: string, - ): Promise; + getBuild(options: { + entity: EntityName; + jobName: string; + buildNumber: string; + }): Promise; - retry( - entity: EntityName, - jobName: string, - buildNumber: string, - ): Promise; + retry(options: { + entity: EntityName; + jobName: string; + buildNumber: string; + }): Promise; } export class JenkinsClient implements JenkinsApi { @@ -121,10 +121,13 @@ export class JenkinsClient implements JenkinsApi { this.identityApi = options.identityApi; } - async getProjects( - entity: EntityName, - filter: { branch?: string }, - ): Promise { + async getProjects({ + entity, + filter, + }: { + entity: EntityName; + filter: { branch?: string }; + }): Promise { const url = new URL( `${await this.discoveryApi.getBaseUrl( 'jenkins', @@ -149,17 +152,25 @@ export class JenkinsClient implements JenkinsApi { (await response.json()).projects?.map((p: Project) => ({ ...p, onRestartClick: async () => { - await this.retry(entity, p.fullName, String(p.lastBuild.number)); + await this.retry({ + entity, + jobName: p.fullName, + buildNumber: String(p.lastBuild.number), + }); }, })) || [] ); } - async getBuild( - entity: EntityName, - jobName: string, - buildNumber: string, - ): Promise { + async getBuild({ + entity, + jobName, + buildNumber, + }: { + entity: EntityName; + jobName: string; + buildNumber: string; + }): Promise { const url = `${await this.discoveryApi.getBaseUrl( 'jenkins', )}/v1/entity/${encodeURIComponent(entity.namespace)}/${encodeURIComponent( @@ -179,11 +190,15 @@ export class JenkinsClient implements JenkinsApi { return (await response.json()).build; } - async retry( - entity: EntityName, - jobName: string, - buildNumber: string, - ): Promise { + async retry({ + entity, + jobName, + buildNumber, + }: { + entity: EntityName; + jobName: string; + buildNumber: string; + }): Promise { const url = `${await this.discoveryApi.getBaseUrl( 'jenkins', )}/v1/entity/${encodeURIComponent(entity.namespace)}/${encodeURIComponent( diff --git a/plugins/jenkins/src/components/useBuildWithSteps.ts b/plugins/jenkins/src/components/useBuildWithSteps.ts index b109c9f964..e82a4800d5 100644 --- a/plugins/jenkins/src/components/useBuildWithSteps.ts +++ b/plugins/jenkins/src/components/useBuildWithSteps.ts @@ -42,7 +42,7 @@ export function useBuildWithSteps({ const getBuildWithSteps = useCallback(async () => { try { const entityName = await getEntityName(entity); - return api.getBuild(entityName, jobName, buildNumber); + return api.getBuild({ entity: entityName, jobName, buildNumber }); } catch (e) { errorApi.post(e); return Promise.reject(e); diff --git a/plugins/jenkins/src/components/useBuilds.ts b/plugins/jenkins/src/components/useBuilds.ts index 8a68e6f1cf..ad068dd219 100644 --- a/plugins/jenkins/src/components/useBuilds.ts +++ b/plugins/jenkins/src/components/useBuilds.ts @@ -47,7 +47,7 @@ export function useBuilds({ branch }: { branch?: string } = {}) { const restartBuild = async (jobName: string, buildNumber: string) => { try { - await api.retry(entityName, jobName, buildNumber); + await api.retry({ entity: entityName, jobName, buildNumber }); } catch (e) { errorApi.post(e); } @@ -55,7 +55,10 @@ export function useBuilds({ branch }: { branch?: string } = {}) { const { loading, value: projects, retry } = useAsyncRetry(async () => { try { - const build = await api.getProjects(getEntityName(entity), { branch }); + const build = await api.getProjects({ + entity: getEntityName(entity), + filter: { branch }, + }); setTotal(build.length); From 8e38300d7cb3543435907834a94d335151de4cef Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Sun, 11 Jul 2021 14:14:58 +0100 Subject: [PATCH 39/40] Consistently use job-full-name not name or slug Signed-off-by: Andrew Shirley Signed-off-by: blam --- .../well-known-annotations.md | 6 ++--- plugins/jenkins-backend/README.md | 16 +++++++----- plugins/jenkins-backend/api-report.md | 8 +++--- .../src/service/jenkinsApi.test.ts | 21 ++++++++------- .../jenkins-backend/src/service/jenkinsApi.ts | 18 ++++++------- .../src/service/jenkinsInfoProvider.test.ts | 26 +++++++++---------- .../src/service/jenkinsInfoProvider.ts | 18 ++++++------- plugins/jenkins-backend/src/service/router.ts | 22 ++++++++++------ .../src/service/standaloneServer.ts | 2 +- plugins/jenkins/src/api/JenkinsApi.ts | 22 ++++++++-------- .../BuildWithStepsPage/BuildWithStepsPage.tsx | 4 +-- .../BuildsPage/lib/CITable/CITable.tsx | 2 +- .../src/components/useBuildWithSteps.ts | 10 +++---- plugins/jenkins/src/components/useBuilds.ts | 4 +-- plugins/jenkins/src/plugin.ts | 4 +-- 15 files changed, 97 insertions(+), 86 deletions(-) diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md index 79c55eaa50..5f474624f9 100644 --- a/docs/features/software-catalog/well-known-annotations.md +++ b/docs/features/software-catalog/well-known-annotations.md @@ -101,13 +101,13 @@ 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/job-slug +### jenkins.io/job-full-name ```yaml # Example: metadata: annotations: - jenkins.io/job-slug: 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 @@ -310,7 +310,7 @@ instead. ### jenkins.io/github-folder -Use the `jenkins.io/job-slug` instead. +Use the `jenkins.io/job-full-name` instead. ## Links diff --git a/plugins/jenkins-backend/README.md b/plugins/jenkins-backend/README.md index 37d188b4eb..bac56b6eaf 100644 --- a/plugins/jenkins-backend/README.md +++ b/plugins/jenkins-backend/README.md @@ -71,10 +71,10 @@ kind: Component metadata: name: artist-lookup annotations: - 'jenkins.io/job-slug': teamA/artistLookup-build + 'jenkins.io/job-full-name': teamA/artistLookup-build ``` -The old annotation name of `jenkins.io/github-folder` is equivalent to `jenkins.io/job-slug` +The old annotation name of `jenkins.io/github-folder` is equivalent to `jenkins.io/job-full-name` #### Example - Multiple global instances @@ -103,7 +103,7 @@ kind: Component metadata: name: artist-lookup annotations: - 'jenkins.io/job-slug': departmentFoo:teamA/artistLookup-build + 'jenkins.io/job-full-name': departmentFoo:teamA/artistLookup-build ``` If the `departmentFoo:` part is omitted, the default instance will be assumed. @@ -124,7 +124,7 @@ jenkins: ### Custom JenkinsInfoProvider -An example of a bespoke JenkinsInfoProvider which uses an organisation specific annotation to look up the Jenkins info (including jobName): +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 { @@ -132,7 +132,7 @@ class AcmeJenkinsInfoProvider implements JenkinsInfoProvider { async getInstance(opt: { entityRef: EntityName; - jobName?: string; + jobFullName?: string; }): Promise { const PAAS_ANNOTATION = 'acme.example.com/paas-project-name'; @@ -157,7 +157,7 @@ class AcmeJenkinsInfoProvider implements JenkinsInfoProvider { const { team, dept } = this.lookupPaasInfo(paasProjectName); const baseUrl = `https://jenkins-${dept}.example.com/`; - const jobName = `${team}/${paasProjectName}`; + const jobFullName = `${team}/${paasProjectName}`; const username = 'backstage-bot'; const apiKey = this.getJenkinsApiKey(paasProjectName); const creds = btoa(`${username}:${apiKey}`); @@ -167,7 +167,7 @@ class AcmeJenkinsInfoProvider implements JenkinsInfoProvider { headers: { Authorization: `Basic ${creds}`, }, - jobName, + jobFullName, }; } @@ -205,3 +205,5 @@ The domain model for Jenkins is not particularly clear but for the purposes of t 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 index f81c141f73..7d4ffe27c0 100644 --- a/plugins/jenkins-backend/api-report.md +++ b/plugins/jenkins-backend/api-report.md @@ -23,10 +23,10 @@ export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { // (undocumented) getInstance(opt: { entityRef: EntityName; - jobName?: string; + jobFullName?: string; }): Promise; // (undocumented) - static readonly NEW_JENKINS_ANNOTATION = "jenkins.io/job-slug"; + static readonly NEW_JENKINS_ANNOTATION = "jenkins.io/job-full-name"; // (undocumented) static readonly OLD_JENKINS_ANNOTATION = "jenkins.io/github-folder"; } @@ -38,7 +38,7 @@ export interface JenkinsInfo { // (undocumented) headers?: Record; // (undocumented) - jobName: string; + jobFullName: string; } // @public (undocumented) @@ -46,7 +46,7 @@ export interface JenkinsInfoProvider { // (undocumented) getInstance(options: { entityRef: EntityName; - jobName?: string; + jobFullName?: string; }): Promise; } diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.test.ts b/plugins/jenkins-backend/src/service/jenkinsApi.test.ts index fc2ccb3748..380a65f895 100644 --- a/plugins/jenkins-backend/src/service/jenkinsApi.test.ts +++ b/plugins/jenkins-backend/src/service/jenkinsApi.test.ts @@ -31,12 +31,12 @@ const mockedJenkinsClient = { const mockedJenkins = jenkins as jest.Mocked; mockedJenkins.mockReturnValue(mockedJenkinsClient); -const jobName = 'example-jobName/foo'; +const jobFullName = 'example-jobName/foo'; const buildNumber = 19; const jenkinsInfo: JenkinsInfo = { baseUrl: 'https://jenkins.example.com', headers: { headerName: 'headerValue' }, - jobName: 'example-jobName', + jobFullName: 'example-jobName', }; describe('JenkinsApi', () => { @@ -74,7 +74,7 @@ describe('JenkinsApi', () => { promisify: true, }); expect(mockedJenkinsClient.job.get).toBeCalledWith({ - name: jenkinsInfo.jobName, + name: jenkinsInfo.jobFullName, tree: expect.anything(), }); expect(result).toHaveLength(1); @@ -117,7 +117,7 @@ describe('JenkinsApi', () => { promisify: true, }); expect(mockedJenkinsClient.job.get).toBeCalledWith({ - name: `${jenkinsInfo.jobName}/testBranchName`, + name: `${jenkinsInfo.jobFullName}/testBranchName`, tree: expect.anything(), }); expect(result).toHaveLength(1); @@ -368,7 +368,7 @@ describe('JenkinsApi', () => { mockedJenkinsClient.job.get.mockResolvedValueOnce(project); mockedJenkinsClient.build.get.mockResolvedValueOnce(build); - await jenkinsApi.getBuild(jenkinsInfo, jobName, buildNumber); + await jenkinsApi.getBuild(jenkinsInfo, jobFullName, buildNumber); expect(mockedJenkins).toHaveBeenCalledWith({ baseUrl: jenkinsInfo.baseUrl, @@ -376,19 +376,22 @@ describe('JenkinsApi', () => { promisify: true, }); expect(mockedJenkinsClient.job.get).toBeCalledWith({ - name: jobName, + name: jobFullName, depth: 1, }); - expect(mockedJenkinsClient.build.get).toBeCalledWith(jobName, buildNumber); + expect(mockedJenkinsClient.build.get).toBeCalledWith( + jobFullName, + buildNumber, + ); }); it('buildProject', async () => { - await jenkinsApi.buildProject(jenkinsInfo, jobName); + await jenkinsApi.buildProject(jenkinsInfo, jobFullName); expect(mockedJenkins).toHaveBeenCalledWith({ baseUrl: jenkinsInfo.baseUrl, headers: jenkinsInfo.headers, promisify: true, }); - expect(mockedJenkinsClient.job.build).toBeCalledWith(jobName); + expect(mockedJenkinsClient.job.build).toBeCalledWith(jobFullName); }); }); diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.ts b/plugins/jenkins-backend/src/service/jenkinsApi.ts index f7476daab9..7df5c5db1d 100644 --- a/plugins/jenkins-backend/src/service/jenkinsApi.ts +++ b/plugins/jenkins-backend/src/service/jenkinsApi.ts @@ -67,18 +67,18 @@ export class JenkinsApiImpl { if (branch) { // we have been asked to filter to a single branch. - // Assume jenkinsInfo.jobName is a folder which contains one job per 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.jobName}/${branch}`, + name: `${jenkinsInfo.jobFullName}/${branch}`, tree: JenkinsApiImpl.jobTreeSpec.replace(/\s/g, ''), }); projects.push(this.augmentProject(job)); } else { // We aren't filtering - // Assume jenkinsInfo.jobName is a folder which contains one job per branch. + // Assume jenkinsInfo.jobFullName is a folder which contains one job per branch. const folder = await client.job.get({ - name: jenkinsInfo.jobName, + 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. @@ -107,17 +107,17 @@ export class JenkinsApiImpl { */ async getBuild( jenkinsInfo: JenkinsInfo, - jobName: string, + jobFullName: string, buildNumber: number, ) { const client = await JenkinsApiImpl.getClient(jenkinsInfo); const project = await client.job.get({ - name: jobName, + name: jobFullName, depth: 1, }); - const build = await client.build.get(jobName, buildNumber); + const build = await client.build.get(jobFullName, buildNumber); const jobScmInfo = JenkinsApiImpl.extractScmDetailsFromJob(project); return this.augmentBuild(build, jobScmInfo); @@ -127,7 +127,7 @@ export class JenkinsApiImpl { * Trigger a build of a project * @see ../../../jenkins/src/api/JenkinsApi.ts#retry */ - async buildProject(jenkinsInfo: JenkinsInfo, jobName: string) { + async buildProject(jenkinsInfo: JenkinsInfo, jobFullName: string) { const client = await JenkinsApiImpl.getClient(jenkinsInfo); // looks like the current SDK only supports triggering a new build @@ -135,7 +135,7 @@ export class JenkinsApiImpl { // 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(jobName); + await client.job.build(jobFullName); } // private helper methods diff --git a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts index 4651d7a489..7c4761a19a 100644 --- a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts +++ b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts @@ -61,7 +61,7 @@ describe('DefaultJenkinsInfoProvider', () => { { metadata: { annotations: { - 'jenkins.io/job-slug': 'teamA/artistLookup-build', + 'jenkins.io/job-full-name': 'teamA/artistLookup-build', }, }, }, @@ -75,7 +75,7 @@ describe('DefaultJenkinsInfoProvider', () => { Authorization: 'Basic YmFja3N0YWdlIC0gYm90OjEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNlZGYwMTI=', }, - jobName: 'teamA/artistLookup-build', + jobFullName: 'teamA/artistLookup-build', }); }); @@ -96,7 +96,7 @@ describe('DefaultJenkinsInfoProvider', () => { { metadata: { annotations: { - 'jenkins.io/job-slug': 'teamA/artistLookup-build', + 'jenkins.io/job-full-name': 'teamA/artistLookup-build', }, }, }, @@ -106,7 +106,7 @@ describe('DefaultJenkinsInfoProvider', () => { expect(mockCatalog.getEntityByName).toBeCalledWith(entityRef); expect(info).toMatchObject({ baseUrl: 'https://jenkins.example.com', - jobName: 'teamA/artistLookup-build', + jobFullName: 'teamA/artistLookup-build', }); }); @@ -133,7 +133,7 @@ describe('DefaultJenkinsInfoProvider', () => { { metadata: { annotations: { - 'jenkins.io/job-slug': 'teamA/artistLookup-build', + 'jenkins.io/job-full-name': 'teamA/artistLookup-build', }, }, }, @@ -143,7 +143,7 @@ describe('DefaultJenkinsInfoProvider', () => { expect(mockCatalog.getEntityByName).toBeCalledWith(entityRef); expect(info).toMatchObject({ baseUrl: 'https://jenkins.example.com', - jobName: 'teamA/artistLookup-build', + jobFullName: 'teamA/artistLookup-build', }); }); @@ -170,7 +170,7 @@ describe('DefaultJenkinsInfoProvider', () => { { metadata: { annotations: { - 'jenkins.io/job-slug': 'other:teamA/artistLookup-build', + 'jenkins.io/job-full-name': 'other:teamA/artistLookup-build', }, }, }, @@ -180,7 +180,7 @@ describe('DefaultJenkinsInfoProvider', () => { expect(mockCatalog.getEntityByName).toBeCalledWith(entityRef); expect(info).toMatchObject({ baseUrl: 'https://jenkins-other.example.com', - jobName: 'teamA/artistLookup-build', + jobFullName: 'teamA/artistLookup-build', }); }); @@ -196,7 +196,7 @@ describe('DefaultJenkinsInfoProvider', () => { { metadata: { annotations: { - 'jenkins.io/job-slug': 'default:teamA/artistLookup-build', + 'jenkins.io/job-full-name': 'default:teamA/artistLookup-build', }, }, }, @@ -206,7 +206,7 @@ describe('DefaultJenkinsInfoProvider', () => { expect(mockCatalog.getEntityByName).toBeCalledWith(entityRef); expect(info).toMatchObject({ baseUrl: 'https://jenkins.example.com', - jobName: 'teamA/artistLookup-build', + jobFullName: 'teamA/artistLookup-build', }); }); @@ -232,7 +232,7 @@ describe('DefaultJenkinsInfoProvider', () => { expect(mockCatalog.getEntityByName).toBeCalledWith(entityRef); expect(info).toMatchObject({ baseUrl: 'https://jenkins.example.com', - jobName: 'teamA/artistLookup-build', + jobFullName: 'teamA/artistLookup-build', }); }); @@ -253,7 +253,7 @@ describe('DefaultJenkinsInfoProvider', () => { { metadata: { annotations: { - 'jenkins.io/job-slug': 'other:teamA/artistLookup-build', + 'jenkins.io/job-full-name': 'other:teamA/artistLookup-build', }, }, }, @@ -263,7 +263,7 @@ describe('DefaultJenkinsInfoProvider', () => { expect(mockCatalog.getEntityByName).toBeCalledWith(entityRef); expect(info).toMatchObject({ baseUrl: 'https://jenkins-other.example.com', - jobName: 'teamA/artistLookup-build', + jobFullName: 'teamA/artistLookup-build', }); }); }); diff --git a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts index 2314aa80af..1f316aa6bc 100644 --- a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts +++ b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts @@ -30,14 +30,14 @@ export interface JenkinsInfoProvider { /** * A specific job to get. This is only passed in when we know about a job name we are interested in. */ - jobName?: string; + jobFullName?: string; }): Promise; } export interface JenkinsInfo { baseUrl: string; headers?: Record; - jobName: string; // TODO: make this an array + jobFullName: string; // TODO: make this an array } /** @@ -47,7 +47,7 @@ export interface JenkinsInfo { */ export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { static readonly OLD_JENKINS_ANNOTATION = 'jenkins.io/github-folder'; - static readonly NEW_JENKINS_ANNOTATION = 'jenkins.io/job-slug'; + static readonly NEW_JENKINS_ANNOTATION = 'jenkins.io/job-full-name'; private constructor( private readonly config: { @@ -71,7 +71,7 @@ export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { async getInstance(opt: { entityRef: EntityName; - jobName?: string; + jobFullName?: string; }): Promise { // load entity const entity = await this.catalog.getEntityByName(opt.entityRef); @@ -81,7 +81,7 @@ export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { ); } - // lookup `[jenkinsName#]jobName` from entity annotation + // lookup `[jenkinsName#]jobFullName` from entity annotation const jenkinsAndJobName = DefaultJenkinsInfoProvider.getEntityAnnotationValue( entity, ); @@ -93,16 +93,16 @@ export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { ); } - let jobName; + let jobFullName; let jenkinsName: string | undefined; const splitIndex = jenkinsAndJobName.indexOf(':'); if (splitIndex === -1) { // no jenkinsName specified, use default - jobName = jenkinsAndJobName; + jobFullName = jenkinsAndJobName; } else { // There is a jenkinsName specified jenkinsName = jenkinsAndJobName.substring(0, splitIndex); - jobName = jenkinsAndJobName.substring( + jobFullName = jenkinsAndJobName.substring( splitIndex + 1, jenkinsAndJobName.length, ); @@ -124,7 +124,7 @@ export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { headers: { Authorization: `Basic ${creds}`, }, - jobName, + jobFullName, }; } diff --git a/plugins/jenkins-backend/src/service/router.ts b/plugins/jenkins-backend/src/service/router.ts index 360dfb004d..cd250aea31 100644 --- a/plugins/jenkins-backend/src/service/router.ts +++ b/plugins/jenkins-backend/src/service/router.ts @@ -73,9 +73,15 @@ export async function createRouter( ); router.get( - '/v1/entity/:namespace/:kind/:name/job/:jobName/:buildNumber', + '/v1/entity/:namespace/:kind/:name/job/:jobFullName/:buildNumber', async (request, response) => { - const { namespace, kind, name, jobName, buildNumber } = request.params; + const { + namespace, + kind, + name, + jobFullName, + buildNumber, + } = request.params; const jenkinsInfo = await jenkinsInfoProvider.getInstance({ entityRef: { @@ -83,12 +89,12 @@ export async function createRouter( namespace, name, }, - jobName, + jobFullName, }); const build = await jenkinsApi.getBuild( jenkinsInfo, - jobName, + jobFullName, parseInt(buildNumber, 10), ); @@ -99,9 +105,9 @@ export async function createRouter( ); router.post( - '/v1/entity/:namespace/:kind/:name/job/:jobName/:buildNumber::rebuild', + '/v1/entity/:namespace/:kind/:name/job/:jobFullName/:buildNumber::rebuild', async (request, response) => { - const { namespace, kind, name, jobName } = request.params; + const { namespace, kind, name, jobFullName } = request.params; const jenkinsInfo = await jenkinsInfoProvider.getInstance({ entityRef: { @@ -109,10 +115,10 @@ export async function createRouter( namespace, name, }, - jobName, + jobFullName, }); - await jenkinsApi.buildProject(jenkinsInfo, jobName); + await jenkinsApi.buildProject(jenkinsInfo, jobFullName); response.json({}); }, ); diff --git a/plugins/jenkins-backend/src/service/standaloneServer.ts b/plugins/jenkins-backend/src/service/standaloneServer.ts index 3b201946bb..a1bc5dd256 100644 --- a/plugins/jenkins-backend/src/service/standaloneServer.ts +++ b/plugins/jenkins-backend/src/service/standaloneServer.ts @@ -36,7 +36,7 @@ export async function startStandaloneServer( logger, jenkinsInfoProvider: { async getInstance(_: { entityRef: EntityRef }): Promise { - return { baseUrl: 'https://example.com/', jobName: 'build-foo' }; + return { baseUrl: 'https://example.com/', jobFullName: 'build-foo' }; }, }, }); diff --git a/plugins/jenkins/src/api/JenkinsApi.ts b/plugins/jenkins/src/api/JenkinsApi.ts index d37fdd2032..4b23fb4292 100644 --- a/plugins/jenkins/src/api/JenkinsApi.ts +++ b/plugins/jenkins/src/api/JenkinsApi.ts @@ -91,20 +91,20 @@ export interface JenkinsApi { * * 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). + * TODO: abstract jobFullName (so we could support differentiating between the same named job on multiple instances). * @param options.entity - * @param options.jobName + * @param options.jobFullName * @param options.buildNumber */ getBuild(options: { entity: EntityName; - jobName: string; + jobFullName: string; buildNumber: string; }): Promise; retry(options: { entity: EntityName; - jobName: string; + jobFullName: string; buildNumber: string; }): Promise; } @@ -154,7 +154,7 @@ export class JenkinsClient implements JenkinsApi { onRestartClick: async () => { await this.retry({ entity, - jobName: p.fullName, + jobFullName: p.fullName, buildNumber: String(p.lastBuild.number), }); }, @@ -164,11 +164,11 @@ export class JenkinsClient implements JenkinsApi { async getBuild({ entity, - jobName, + jobFullName, buildNumber, }: { entity: EntityName; - jobName: string; + jobFullName: string; buildNumber: string; }): Promise { const url = `${await this.discoveryApi.getBaseUrl( @@ -176,7 +176,7 @@ export class JenkinsClient implements JenkinsApi { )}/v1/entity/${encodeURIComponent(entity.namespace)}/${encodeURIComponent( entity.kind, )}/${encodeURIComponent(entity.name)}/job/${encodeURIComponent( - jobName, + jobFullName, )}/${encodeURIComponent(buildNumber)}`; const idToken = await this.identityApi.getIdToken(); @@ -192,11 +192,11 @@ export class JenkinsClient implements JenkinsApi { async retry({ entity, - jobName, + jobFullName, buildNumber, }: { entity: EntityName; - jobName: string; + jobFullName: string; buildNumber: string; }): Promise { const url = `${await this.discoveryApi.getBaseUrl( @@ -204,7 +204,7 @@ export class JenkinsClient implements JenkinsApi { )}/v1/entity/${encodeURIComponent(entity.namespace)}/${encodeURIComponent( entity.kind, )}/${encodeURIComponent(entity.name)}/job/${encodeURIComponent( - jobName, + jobFullName, )}/${encodeURIComponent(buildNumber)}:rebuild`; const idToken = await this.identityApi.getIdToken(); diff --git a/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx b/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx index 255fc949b2..e06332f8f0 100644 --- a/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx +++ b/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx @@ -49,10 +49,10 @@ const useStyles = makeStyles(theme => ({ const BuildWithStepsView = () => { // TODO: Add a test that react-router decodes this (even though `generatePath` doesn't encode it for you!) - const { jobName, buildNumber } = useRouteRefParams(buildRouteRef); + const { jobFullName, buildNumber } = useRouteRefParams(buildRouteRef); const classes = useStyles(); - const [{ value }] = useBuildWithSteps({ jobName, buildNumber }); + const [{ value }] = useBuildWithSteps({ jobFullName, buildNumber }); return (
diff --git a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx index 7c1e7db8b1..3e5eeffdaa 100644 --- a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx +++ b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx @@ -100,7 +100,7 @@ const generatedColumns: TableColumn[] = [ diff --git a/plugins/jenkins/src/components/useBuildWithSteps.ts b/plugins/jenkins/src/components/useBuildWithSteps.ts index e82a4800d5..c73a116aae 100644 --- a/plugins/jenkins/src/components/useBuildWithSteps.ts +++ b/plugins/jenkins/src/components/useBuildWithSteps.ts @@ -25,14 +25,14 @@ const INTERVAL_AMOUNT = 1500; /** * 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 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({ - jobName, + jobFullName, buildNumber, }: { - jobName: string; + jobFullName: string; buildNumber: string; }) { const api = useApi(jenkinsApiRef); @@ -42,12 +42,12 @@ export function useBuildWithSteps({ const getBuildWithSteps = useCallback(async () => { try { const entityName = await getEntityName(entity); - return api.getBuild({ entity: entityName, jobName, buildNumber }); + return api.getBuild({ entity: entityName, jobFullName, buildNumber }); } catch (e) { errorApi.post(e); return Promise.reject(e); } - }, [buildNumber, jobName, entity, api, errorApi]); + }, [buildNumber, jobFullName, entity, api, errorApi]); const { loading, value, retry } = useAsyncRetry(() => getBuildWithSteps(), [ getBuildWithSteps, diff --git a/plugins/jenkins/src/components/useBuilds.ts b/plugins/jenkins/src/components/useBuilds.ts index ad068dd219..820cb099a1 100644 --- a/plugins/jenkins/src/components/useBuilds.ts +++ b/plugins/jenkins/src/components/useBuilds.ts @@ -45,9 +45,9 @@ export function useBuilds({ branch }: { branch?: string } = {}) { errorType: ErrorType; }>(); - const restartBuild = async (jobName: string, buildNumber: string) => { + const restartBuild = async (jobFullName: string, buildNumber: string) => { try { - await api.retry({ entity: entityName, jobName, buildNumber }); + await api.retry({ entity: entityName, jobFullName, buildNumber }); } catch (e) { errorApi.post(e); } diff --git a/plugins/jenkins/src/plugin.ts b/plugins/jenkins/src/plugin.ts index fd6291618a..e975361da2 100644 --- a/plugins/jenkins/src/plugin.ts +++ b/plugins/jenkins/src/plugin.ts @@ -31,8 +31,8 @@ export const rootRouteRef = createRouteRef({ }); export const buildRouteRef = createRouteRef({ - path: 'build/:jobName/:buildNumber', - params: ['jobName', 'buildNumber'], + path: 'build/:jobFullName/:buildNumber', + params: ['jobFullName', 'buildNumber'], title: 'Jenkins build', }); From cd43895668ec17b617c66ad6520400d68bd781f4 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 14 Jul 2021 20:44:48 +0200 Subject: [PATCH 40/40] chore: make the api-docs pretty again Signed-off-by: blam --- plugins/jenkins-backend/api-report.md | 61 ++++++++++---------- plugins/jenkins/api-report.md | 83 +++++++++++++++++++-------- 2 files changed, 87 insertions(+), 57 deletions(-) diff --git a/plugins/jenkins-backend/api-report.md b/plugins/jenkins-backend/api-report.md index 7d4ffe27c0..faa1d56c4e 100644 --- a/plugins/jenkins-backend/api-report.md +++ b/plugins/jenkins-backend/api-report.md @@ -3,7 +3,6 @@ > 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'; @@ -15,50 +14,48 @@ 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"; + // (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; + // (undocumented) + baseUrl: string; + // (undocumented) + headers?: Record; + // (undocumented) + jobFullName: string; } // @public (undocumented) export interface JenkinsInfoProvider { - // (undocumented) - getInstance(options: { - entityRef: EntityName; - jobFullName?: string; - }): Promise; + // (undocumented) + getInstance(options: { + entityRef: EntityName; + jobFullName?: string; + }): Promise; } // @public (undocumented) export interface RouterOptions { - // (undocumented) - jenkinsInfoProvider: JenkinsInfoProvider; - // (undocumented) - logger: Logger_2; + // (undocumented) + jenkinsInfoProvider: JenkinsInfoProvider; + // (undocumented) + logger: Logger_2; } - // (No @packageDocumentation comment for this package) - ``` 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< {