create a single DefaultJenkinsProvider
Signed-off-by: Andrew Shirley <andrew.shirley@sainsburys.co.uk> Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
@@ -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<Router> {
|
||||
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<JenkinsInfo> {
|
||||
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:
|
||||
|
||||
@@ -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<JenkinsInfo> {
|
||||
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<JenkinsInfo> {
|
||||
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<JenkinsInfo> {
|
||||
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<JenkinsInfo> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user