From efbb82dd3006d42258338487138e2566591f2f01 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Thu, 12 Aug 2021 14:23:50 +0200 Subject: [PATCH] Extract `JenkinsConfig` to make writing a custom `JenkinsInfoProvider` easier Signed-off-by: Oliver Sand --- .changeset/long-rats-flow.md | 5 + plugins/jenkins-backend/api-report.md | 25 ++ plugins/jenkins-backend/src/service/index.ts | 13 +- .../src/service/jenkinsInfoProvider.test.ts | 143 ++++++++- .../src/service/jenkinsInfoProvider.ts | 286 +++++++++--------- 5 files changed, 330 insertions(+), 142 deletions(-) create mode 100644 .changeset/long-rats-flow.md diff --git a/.changeset/long-rats-flow.md b/.changeset/long-rats-flow.md new file mode 100644 index 0000000000..61673f8bf9 --- /dev/null +++ b/.changeset/long-rats-flow.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-jenkins-backend': patch +--- + +Extract `JenkinsConfig` to make writing a custom `JenkinsInfoProvider` easier. diff --git a/plugins/jenkins-backend/api-report.md b/plugins/jenkins-backend/api-report.md index b94931e000..01114e74d6 100644 --- a/plugins/jenkins-backend/api-report.md +++ b/plugins/jenkins-backend/api-report.md @@ -34,6 +34,17 @@ export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { static readonly OLD_JENKINS_ANNOTATION = 'jenkins.io/github-folder'; } +// Warning: (ae-missing-release-tag) "JenkinsConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export class JenkinsConfig { + constructor(instances: JenkinsInstanceConfig[]); + static fromConfig(config: Config): JenkinsConfig; + getInstanceConfig(jenkinsName?: string): JenkinsInstanceConfig; + // (undocumented) + readonly instances: JenkinsInstanceConfig[]; +} + // Warning: (ae-missing-release-tag) "JenkinsInfo" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -57,6 +68,20 @@ export interface JenkinsInfoProvider { }): Promise; } +// Warning: (ae-missing-release-tag) "JenkinsInstanceConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface JenkinsInstanceConfig { + // (undocumented) + apiKey: string; + // (undocumented) + baseUrl: string; + // (undocumented) + name: string; + // (undocumented) + username: string; +} + // Warning: (ae-missing-release-tag) "RouterOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/plugins/jenkins-backend/src/service/index.ts b/plugins/jenkins-backend/src/service/index.ts index 329092e881..af9986c492 100644 --- a/plugins/jenkins-backend/src/service/index.ts +++ b/plugins/jenkins-backend/src/service/index.ts @@ -14,7 +14,14 @@ * limitations under the License. */ -export type { RouterOptions } from './router'; +export { + DefaultJenkinsInfoProvider, + JenkinsConfig, +} from './jenkinsInfoProvider'; +export type { + JenkinsInfo, + JenkinsInfoProvider, + JenkinsInstanceConfig, +} from './jenkinsInfoProvider'; export { createRouter } from './router'; -export type { JenkinsInfo, JenkinsInfoProvider } from './jenkinsInfoProvider'; -export { DefaultJenkinsInfoProvider } from './jenkinsInfoProvider'; +export type { RouterOptions } from './router'; diff --git a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts index 7d1615ac27..08439297a7 100644 --- a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts +++ b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts @@ -14,10 +14,149 @@ * 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'; +import { ConfigReader } from '@backstage/config'; +import { + DefaultJenkinsInfoProvider, + JenkinsConfig, + JenkinsInfo, +} from './jenkinsInfoProvider'; + +describe('JenkinsConfig', () => { + it('Reads simple config and annotation', async () => { + const config = JenkinsConfig.fromConfig( + new ConfigReader({ + jenkins: { + baseUrl: 'https://jenkins.example.com', + username: 'backstage - bot', + apiKey: '123456789abcdef0123456789abcedf012', + }, + }), + ); + + expect(config.instances).toEqual([ + { + name: 'default', + baseUrl: 'https://jenkins.example.com', + username: 'backstage - bot', + apiKey: '123456789abcdef0123456789abcedf012', + }, + ]); + }); + + it('Reads named default config and annotation', async () => { + const config = JenkinsConfig.fromConfig( + new ConfigReader({ + jenkins: { + instances: [ + { + name: 'default', + baseUrl: 'https://jenkins.example.com', + username: 'backstage - bot', + apiKey: '123456789abcdef0123456789abcedf012', + }, + ], + }, + }), + ); + + expect(config.instances).toEqual([ + { + name: 'default', + baseUrl: 'https://jenkins.example.com', + username: 'backstage - bot', + apiKey: '123456789abcdef0123456789abcedf012', + }, + ]); + }); + + it('Parses named default config (amongst named other configs)', async () => { + const config = JenkinsConfig.fromConfig( + new ConfigReader({ + 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', + }, + ], + }, + }), + ); + + expect(config.instances).toEqual([ + { + 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', + }, + ]); + }); + + it('Gets default Jenkins instance', async () => { + const config = new JenkinsConfig([ + { + 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', + }, + ]); + + expect(config.getInstanceConfig()).toEqual({ + name: 'default', + baseUrl: 'https://jenkins.example.com', + username: 'backstage - bot', + apiKey: '123456789abcdef0123456789abcedf012', + }); + }); + + it('Gets named Jenkins instance', async () => { + const config = new JenkinsConfig([ + { + 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', + }, + ]); + + expect(config.getInstanceConfig('other')).toEqual({ + name: 'other', + baseUrl: 'https://jenkins-other.example.com', + username: 'backstage - bot', + apiKey: '123456789abcdef0123456789abcedf012', + }); + }); +}); describe('DefaultJenkinsInfoProvider', () => { const mockCatalog: jest.Mocked = { diff --git a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts index 50875cde99..1a992e73f1 100644 --- a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts +++ b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { CatalogClient } from '@backstage/catalog-client'; import { Entity, EntityName, stringifyEntityRef, } from '@backstage/catalog-model'; -import { CatalogClient } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; export interface JenkinsInfoProvider { @@ -40,145 +40,28 @@ export interface JenkinsInfo { jobFullName: string; // TODO: make this an array } +export interface JenkinsInstanceConfig { + name: string; + baseUrl: string; + username: string; + apiKey: string; +} + /** - * Use default config and annotations, build using fromConfig static function. - * - * This will fallback through various deprecated config and annotation schemes. + * Holds multiple Jenkins configurations. */ -export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { - static readonly OLD_JENKINS_ANNOTATION = 'jenkins.io/github-folder'; - static readonly NEW_JENKINS_ANNOTATION = 'jenkins.io/job-full-name'; +export class JenkinsConfig { + constructor(public readonly instances: JenkinsInstanceConfig[]) {} - private constructor( - private readonly config: { - name: string; - baseUrl: string; - username: string; - apiKey: string; - }[], - private readonly catalog: CatalogClient, - ) {} - - static fromConfig(options: { - config: Config; - catalog: CatalogClient; - }): DefaultJenkinsInfoProvider { - return new DefaultJenkinsInfoProvider( - this.loadConfig(options.config), - options.catalog, - ); - } - - async getInstance(opt: { - entityRef: EntityName; - jobFullName?: string; - }): Promise { - // load entity - const entity = await this.catalog.getEntityByName(opt.entityRef); - if (!entity) { - throw new Error( - `Couldn't find entity with name: ${stringifyEntityRef(opt.entityRef)}`, - ); - } - - // lookup `[jenkinsName#]jobFullName` from entity annotation - const jenkinsAndJobName = - DefaultJenkinsInfoProvider.getEntityAnnotationValue(entity); - if (!jenkinsAndJobName) { - throw new Error( - `Couldn't find jenkins annotation (${ - DefaultJenkinsInfoProvider.NEW_JENKINS_ANNOTATION - }) on entity with name: ${stringifyEntityRef(opt.entityRef)}`, - ); - } - - let jobFullName; - let jenkinsName: string | undefined; - const splitIndex = jenkinsAndJobName.indexOf(':'); - if (splitIndex === -1) { - // no jenkinsName specified, use default - jobFullName = jenkinsAndJobName; - } else { - // There is a jenkinsName specified - jenkinsName = jenkinsAndJobName.substring(0, splitIndex); - jobFullName = jenkinsAndJobName.substring( - splitIndex + 1, - jenkinsAndJobName.length, - ); - } - - // lookup baseURL + creds from config - const instanceConfig = DefaultJenkinsInfoProvider.getInstanceConfig( - jenkinsName, - this.config, - ); - - const creds = Buffer.from( - `${instanceConfig.username}:${instanceConfig.apiKey}`, - 'binary', - ).toString('base64'); - - return { - baseUrl: instanceConfig.baseUrl, - headers: { - Authorization: `Basic ${creds}`, - }, - jobFullName, - }; - } - - private static getEntityAnnotationValue(entity: Entity) { - return ( - entity.metadata.annotations?.[ - DefaultJenkinsInfoProvider.OLD_JENKINS_ANNOTATION - ] || - entity.metadata.annotations?.[ - DefaultJenkinsInfoProvider.NEW_JENKINS_ANNOTATION - ] - ); - } - - private static getInstanceConfig( - jenkinsName: string | undefined, - config: { - name: string; - baseUrl: string; - username: string; - apiKey: string; - }[], - ): { name: string; baseUrl: string; username: string; apiKey: string } { + /** + * Read all Jenkins instance configurations. + * @param config - Root configuration + * @returns A JenkinsConfig that contains all configured Jenkins instances. + */ + static fromConfig(config: Config): JenkinsConfig { const DEFAULT_JENKINS_NAME = 'default'; - if (!jenkinsName || jenkinsName === DEFAULT_JENKINS_NAME) { - // no name provided, use default - const instanceConfig = config.find(c => c.name === DEFAULT_JENKINS_NAME); - - if (!instanceConfig) { - throw new Error( - `Couldn't find a default jenkins instance in the config. Either configure an instance with name ${DEFAULT_JENKINS_NAME} or add a prefix to your annotation value.`, - ); - } - - return instanceConfig; - } - - // A name is provided, look it up. - const instanceConfig = config.find(c => c.name === jenkinsName); - - if (!instanceConfig) { - throw new Error( - `Couldn't find a jenkins instance in the config with name ${jenkinsName}`, - ); - } - return instanceConfig; - } - - private static loadConfig( - rootConfig: Config, - ): { name: string; baseUrl: string; username: string; apiKey: string }[] { - const DEFAULT_JENKINS_NAME = 'default'; - - const jenkinsConfig = rootConfig.getConfig('jenkins'); + const jenkinsConfig = config.getConfig('jenkins'); // load all named instance config const namedInstanceConfig = @@ -223,9 +106,138 @@ export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { apiKey: string; }[]; - return [...namedInstanceConfig, ...unnamedInstanceConfig]; + return new JenkinsConfig([ + ...namedInstanceConfig, + ...unnamedInstanceConfig, + ]); } - return namedInstanceConfig; + return new JenkinsConfig(namedInstanceConfig); + } + + /** + * Gets a Jenkins instance configuration by name, or the default one if no + * name is provided. + * @param jenkinsName - Optional name of the Jenkins instance. + * @returns The requested Jenkins instance. + */ + getInstanceConfig(jenkinsName?: string): JenkinsInstanceConfig { + const DEFAULT_JENKINS_NAME = 'default'; + + if (!jenkinsName || jenkinsName === DEFAULT_JENKINS_NAME) { + // no name provided, use default + const instanceConfig = this.instances.find( + c => c.name === DEFAULT_JENKINS_NAME, + ); + + if (!instanceConfig) { + throw new Error( + `Couldn't find a default jenkins instance in the config. Either configure an instance with name ${DEFAULT_JENKINS_NAME} or add a prefix to your annotation value.`, + ); + } + + return instanceConfig; + } + + // A name is provided, look it up. + const instanceConfig = this.instances.find(c => c.name === jenkinsName); + + if (!instanceConfig) { + throw new Error( + `Couldn't find a jenkins instance in the config with name ${jenkinsName}`, + ); + } + return instanceConfig; + } +} + +/** + * Use default config and annotations, build using fromConfig static function. + * + * This will fallback through various deprecated config and annotation schemes. + */ +export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { + static readonly OLD_JENKINS_ANNOTATION = 'jenkins.io/github-folder'; + static readonly NEW_JENKINS_ANNOTATION = 'jenkins.io/job-full-name'; + + private constructor( + private readonly config: JenkinsConfig, + private readonly catalog: CatalogClient, + ) {} + + static fromConfig(options: { + config: Config; + catalog: CatalogClient; + }): DefaultJenkinsInfoProvider { + return new DefaultJenkinsInfoProvider( + JenkinsConfig.fromConfig(options.config), + options.catalog, + ); + } + + async getInstance(opt: { + entityRef: EntityName; + jobFullName?: string; + }): Promise { + // load entity + const entity = await this.catalog.getEntityByName(opt.entityRef); + if (!entity) { + throw new Error( + `Couldn't find entity with name: ${stringifyEntityRef(opt.entityRef)}`, + ); + } + + // lookup `[jenkinsName#]jobFullName` from entity annotation + const jenkinsAndJobName = + DefaultJenkinsInfoProvider.getEntityAnnotationValue(entity); + if (!jenkinsAndJobName) { + throw new Error( + `Couldn't find jenkins annotation (${ + DefaultJenkinsInfoProvider.NEW_JENKINS_ANNOTATION + }) on entity with name: ${stringifyEntityRef(opt.entityRef)}`, + ); + } + + let jobFullName; + let jenkinsName: string | undefined; + const splitIndex = jenkinsAndJobName.indexOf(':'); + if (splitIndex === -1) { + // no jenkinsName specified, use default + jobFullName = jenkinsAndJobName; + } else { + // There is a jenkinsName specified + jenkinsName = jenkinsAndJobName.substring(0, splitIndex); + jobFullName = jenkinsAndJobName.substring( + splitIndex + 1, + jenkinsAndJobName.length, + ); + } + + // lookup baseURL + creds from config + const instanceConfig = this.config.getInstanceConfig(jenkinsName); + + const creds = Buffer.from( + `${instanceConfig.username}:${instanceConfig.apiKey}`, + 'binary', + ).toString('base64'); + + return { + baseUrl: instanceConfig.baseUrl, + headers: { + Authorization: `Basic ${creds}`, + }, + jobFullName, + }; + } + + private static getEntityAnnotationValue(entity: Entity) { + return ( + entity.metadata.annotations?.[ + DefaultJenkinsInfoProvider.OLD_JENKINS_ANNOTATION + ] || + entity.metadata.annotations?.[ + DefaultJenkinsInfoProvider.NEW_JENKINS_ANNOTATION + ] + ); } }