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; } }