Merge pull request #5642 from ashirley/jenkinsBackend
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
---
|
||||
'@backstage/plugin-jenkins': minor
|
||||
---
|
||||
|
||||
## 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<Router> {
|
||||
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 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 endpoints 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
|
||||
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`
|
||||
@@ -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
|
||||
|
||||
@@ -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/job-full-name
|
||||
|
||||
```yaml
|
||||
# Example:
|
||||
metadata:
|
||||
annotations:
|
||||
jenkins.io/github-folder: 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
|
||||
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/job-full-name` instead.
|
||||
|
||||
## Links
|
||||
|
||||
- [Descriptor Format: annotations](descriptor-format.md#annotations-optional)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* 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,
|
||||
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<Router> {
|
||||
const catalog = new CatalogClient({ discoveryApi: discovery });
|
||||
|
||||
return await createRouter({
|
||||
logger,
|
||||
jenkinsInfoProvider: DefaultJenkinsInfoProvider.fromConfig({
|
||||
catalog,
|
||||
config,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
# 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,
|
||||
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 catalog = new CatalogClient({ discoveryApi: discovery });
|
||||
|
||||
return await createRouter({
|
||||
logger,
|
||||
jenkinsInfoProvider: DefaultJenkinsInfoProvider.fromConfig({
|
||||
config,
|
||||
catalog,
|
||||
}),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
#### Example - Single global 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/job-full-name': teamA/artistLookup-build
|
||||
```
|
||||
|
||||
The old annotation name of `jenkins.io/github-folder` is equivalent to `jenkins.io/job-full-name`
|
||||
|
||||
#### 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
|
||||
|
||||
```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/job-full-name': departmentFoo:teamA/artistLookup-build
|
||||
```
|
||||
|
||||
If the `departmentFoo:` part is omitted, the default instance will be assumed.
|
||||
|
||||
The following config is an equivalent (but less clear) version of the above:
|
||||
|
||||
```yaml
|
||||
jenkins:
|
||||
baseUrl: https://jenkins.example.com
|
||||
username: backstage-bot
|
||||
apiKey: 123456789abcdef0123456789abcedf012
|
||||
instances:
|
||||
- name: departmentFoo
|
||||
baseUrl: https://jenkins-foo.example.com
|
||||
username: backstage-bot
|
||||
apiKey: 123456789abcdef0123456789abcedf012
|
||||
```
|
||||
|
||||
### Custom JenkinsInfoProvider
|
||||
|
||||
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 {
|
||||
constructor(private readonly catalog: CatalogClient) {}
|
||||
|
||||
async getInstance(opt: {
|
||||
entityRef: EntityName;
|
||||
jobFullName?: 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 jobFullName = `${team}/${paasProjectName}`;
|
||||
const username = 'backstage-bot';
|
||||
const apiKey = this.getJenkinsApiKey(paasProjectName);
|
||||
const creds = btoa(`${username}:${apiKey}`);
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
headers: {
|
||||
Authorization: `Basic ${creds}`,
|
||||
},
|
||||
jobFullName,
|
||||
};
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
No config would be needed if using this JenkinsInfoProvider
|
||||
|
||||
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
|
||||
kind: Component
|
||||
metadata:
|
||||
name: artist-lookup
|
||||
annotations:
|
||||
'acme.example.com/paas-project-name': 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)
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,61 @@
|
||||
## 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 as Logger_2 } from 'winston';
|
||||
|
||||
// @public (undocumented)
|
||||
export function createRouter(options: RouterOptions): Promise<express.Router>;
|
||||
|
||||
// @public
|
||||
export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider {
|
||||
// (undocumented)
|
||||
static fromConfig(options: {
|
||||
config: Config;
|
||||
catalog: CatalogClient;
|
||||
}): DefaultJenkinsInfoProvider;
|
||||
// (undocumented)
|
||||
getInstance(opt: {
|
||||
entityRef: EntityName;
|
||||
jobFullName?: string;
|
||||
}): Promise<JenkinsInfo>;
|
||||
// (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<string, string | string[]>;
|
||||
// (undocumented)
|
||||
jobFullName: string;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface JenkinsInfoProvider {
|
||||
// (undocumented)
|
||||
getInstance(options: {
|
||||
entityRef: EntityName;
|
||||
jobFullName?: string;
|
||||
}): Promise<JenkinsInfo>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface RouterOptions {
|
||||
// (undocumented)
|
||||
jenkinsInfoProvider: JenkinsInfoProvider;
|
||||
// (undocumented)
|
||||
logger: Logger_2;
|
||||
}
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
Vendored
+51
@@ -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;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@backstage/plugin-jenkins-backend",
|
||||
"version": "0.1.1",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"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",
|
||||
"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.5s",
|
||||
"@backstage/catalog-client": "^0.3.16",
|
||||
"@backstage/catalog-model": "^0.9.0",
|
||||
"@backstage/config": "^0.1.5",
|
||||
"@types/express": "^4.17.6",
|
||||
"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.3",
|
||||
"@types/jenkins": "^0.23.1",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"msw": "^0.29.0",
|
||||
"supertest": "^4.0.2"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"config.d.ts"
|
||||
]
|
||||
}
|
||||
+1
-7
@@ -13,11 +13,5 @@
|
||||
* 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';
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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';
|
||||
@@ -0,0 +1,397 @@
|
||||
/*
|
||||
* 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<any>;
|
||||
mockedJenkins.mockReturnValue(mockedJenkinsClient);
|
||||
|
||||
const jobFullName = 'example-jobName/foo';
|
||||
const buildNumber = 19;
|
||||
const jenkinsInfo: JenkinsInfo = {
|
||||
baseUrl: 'https://jenkins.example.com',
|
||||
headers: { headerName: 'headerValue' },
|
||||
jobFullName: '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.jobFullName,
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
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.jobFullName}/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, jobFullName, buildNumber);
|
||||
|
||||
expect(mockedJenkins).toHaveBeenCalledWith({
|
||||
baseUrl: jenkinsInfo.baseUrl,
|
||||
headers: jenkinsInfo.headers,
|
||||
promisify: true,
|
||||
});
|
||||
expect(mockedJenkinsClient.job.get).toBeCalledWith({
|
||||
name: jobFullName,
|
||||
depth: 1,
|
||||
});
|
||||
expect(mockedJenkinsClient.build.get).toBeCalledWith(
|
||||
jobFullName,
|
||||
buildNumber,
|
||||
);
|
||||
});
|
||||
it('buildProject', async () => {
|
||||
await jenkinsApi.buildProject(jenkinsInfo, jobFullName);
|
||||
|
||||
expect(mockedJenkins).toHaveBeenCalledWith({
|
||||
baseUrl: jenkinsInfo.baseUrl,
|
||||
headers: jenkinsInfo.headers,
|
||||
promisify: true,
|
||||
});
|
||||
expect(mockedJenkinsClient.job.build).toBeCalledWith(jobFullName);
|
||||
});
|
||||
});
|
||||
@@ -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.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.jobFullName}/${branch}`,
|
||||
tree: JenkinsApiImpl.jobTreeSpec.replace(/\s/g, ''),
|
||||
});
|
||||
projects.push(this.augmentProject(job));
|
||||
} else {
|
||||
// We aren't filtering
|
||||
// Assume jenkinsInfo.jobFullName is a folder which contains one job per branch.
|
||||
const folder = await client.job.get({
|
||||
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.
|
||||
// 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,
|
||||
jobFullName: string,
|
||||
buildNumber: number,
|
||||
) {
|
||||
const client = await JenkinsApiImpl.getClient(jenkinsInfo);
|
||||
|
||||
const project = await client.job.get({
|
||||
name: jobFullName,
|
||||
depth: 1,
|
||||
});
|
||||
|
||||
const build = await client.build.get(jobFullName, 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, jobFullName: 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(jobFullName);
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/*
|
||||
* 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<CatalogClient> = ({
|
||||
getEntityByName: jest.fn(),
|
||||
} as any) as jest.Mocked<CatalogClient>;
|
||||
|
||||
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 DefaultJenkinsInfoProvider.fromConfig({
|
||||
config,
|
||||
catalog: mockCatalog,
|
||||
});
|
||||
}
|
||||
|
||||
it('Handles entity not found', async () => {
|
||||
const provider = configureProvider({ jenkins: {} }, 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-full-name': '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=',
|
||||
},
|
||||
jobFullName: '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-full-name': 'teamA/artistLookup-build',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
const info: JenkinsInfo = await provider.getInstance({ entityRef });
|
||||
|
||||
expect(mockCatalog.getEntityByName).toBeCalledWith(entityRef);
|
||||
expect(info).toMatchObject({
|
||||
baseUrl: 'https://jenkins.example.com',
|
||||
jobFullName: '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-full-name': 'teamA/artistLookup-build',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
const info: JenkinsInfo = await provider.getInstance({ entityRef });
|
||||
|
||||
expect(mockCatalog.getEntityByName).toBeCalledWith(entityRef);
|
||||
expect(info).toMatchObject({
|
||||
baseUrl: 'https://jenkins.example.com',
|
||||
jobFullName: '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-full-name': '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',
|
||||
jobFullName: '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-full-name': 'default:teamA/artistLookup-build',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
const info: JenkinsInfo = await provider.getInstance({ entityRef });
|
||||
|
||||
expect(mockCatalog.getEntityByName).toBeCalledWith(entityRef);
|
||||
expect(info).toMatchObject({
|
||||
baseUrl: 'https://jenkins.example.com',
|
||||
jobFullName: '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',
|
||||
jobFullName: '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-full-name': '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',
|
||||
jobFullName: 'teamA/artistLookup-build',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
/*
|
||||
* 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 {
|
||||
Entity,
|
||||
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.
|
||||
*/
|
||||
jobFullName?: string;
|
||||
}): Promise<JenkinsInfo>;
|
||||
}
|
||||
|
||||
export interface JenkinsInfo {
|
||||
baseUrl: string;
|
||||
headers?: Record<string, string | string[]>;
|
||||
jobFullName: string; // TODO: make this an array
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: {
|
||||
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<JenkinsInfo> {
|
||||
// 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 } {
|
||||
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');
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* 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 { JenkinsInfoProvider } from './jenkinsInfoProvider';
|
||||
import { JenkinsApiImpl } from './jenkinsApi';
|
||||
|
||||
export interface RouterOptions {
|
||||
logger: Logger;
|
||||
jenkinsInfoProvider: JenkinsInfoProvider;
|
||||
}
|
||||
|
||||
export async function createRouter(
|
||||
options: RouterOptions,
|
||||
): Promise<express.Router> {
|
||||
const { jenkinsInfoProvider } = options;
|
||||
|
||||
const jenkinsApi = new JenkinsApiImpl();
|
||||
|
||||
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;
|
||||
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: {
|
||||
kind,
|
||||
namespace,
|
||||
name,
|
||||
},
|
||||
});
|
||||
const projects = await jenkinsApi.getProjects(jenkinsInfo, branchStr);
|
||||
|
||||
response.json({
|
||||
projects: projects,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/v1/entity/:namespace/:kind/:name/job/:jobFullName/:buildNumber',
|
||||
async (request, response) => {
|
||||
const {
|
||||
namespace,
|
||||
kind,
|
||||
name,
|
||||
jobFullName,
|
||||
buildNumber,
|
||||
} = request.params;
|
||||
|
||||
const jenkinsInfo = await jenkinsInfoProvider.getInstance({
|
||||
entityRef: {
|
||||
kind,
|
||||
namespace,
|
||||
name,
|
||||
},
|
||||
jobFullName,
|
||||
});
|
||||
|
||||
const build = await jenkinsApi.getBuild(
|
||||
jenkinsInfo,
|
||||
jobFullName,
|
||||
parseInt(buildNumber, 10),
|
||||
);
|
||||
|
||||
response.json({
|
||||
build: build,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/v1/entity/:namespace/:kind/:name/job/:jobFullName/:buildNumber::rebuild',
|
||||
async (request, response) => {
|
||||
const { namespace, kind, name, jobFullName } = request.params;
|
||||
|
||||
const jenkinsInfo = await jenkinsInfoProvider.getInstance({
|
||||
entityRef: {
|
||||
kind,
|
||||
namespace,
|
||||
name,
|
||||
},
|
||||
jobFullName,
|
||||
});
|
||||
|
||||
await jenkinsApi.buildProject(jenkinsInfo, jobFullName);
|
||||
response.json({});
|
||||
},
|
||||
);
|
||||
|
||||
router.use(errorHandler());
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* 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 { 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<Server> {
|
||||
const logger = options.logger.child({ service: 'jenkins-backend' });
|
||||
logger.debug('Starting application server...');
|
||||
const router = await createRouter({
|
||||
logger,
|
||||
jenkinsInfoProvider: {
|
||||
async getInstance(_: { entityRef: EntityRef }): Promise<JenkinsInfo> {
|
||||
return { baseUrl: 'https://example.com/', jobFullName: 'build-foo' };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.hot?.accept();
|
||||
@@ -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 {};
|
||||
@@ -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: boolean;
|
||||
}
|
||||
|
||||
export interface JenkinsProject extends CommonProject {
|
||||
// standard Jenkins
|
||||
lastBuild: JenkinsBuild;
|
||||
|
||||
// read by us from jenkins but not passed to frontend
|
||||
actions: object[];
|
||||
}
|
||||
|
||||
export interface BackstageProject extends CommonProject {
|
||||
// standard Jenkins
|
||||
lastBuild: BackstageBuild;
|
||||
|
||||
// added by us
|
||||
status: string; // == inQueue ? 'queued' : lastBuild.building ? 'running' : lastBuild.result,
|
||||
}
|
||||
+36
-42
@@ -16,45 +16,54 @@ cd packages/app
|
||||
yarn add @backstage/plugin-jenkins
|
||||
```
|
||||
|
||||
2. Add the `EntityJenkinsContent` extension to the entity page in the app:
|
||||
2. Add and configure the backend plugin according to it's instructions
|
||||
|
||||
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 = (
|
||||
<EntityLayout>
|
||||
{/* other tabs... */}
|
||||
<EntityLayout.Route path="/jenkins" title="Jenkins">
|
||||
<EntityJenkinsContent />
|
||||
<EntityLayout.Route path="/" title="Overview">
|
||||
{/* ... */}
|
||||
<EntitySwitch>
|
||||
<EntitySwitch.Case if={isJenkinsAvailable}>
|
||||
<Grid item sm={6}>
|
||||
<EntityLatestJenkinsRunCard branch="master" variant="gridItem" />
|
||||
</Grid>
|
||||
</EntitySwitch.Case>
|
||||
{/* ... */}
|
||||
</EntitySwitch>
|
||||
</EntityLayout.Route>
|
||||
{/* other tabs... */}
|
||||
<EntityLayout.Route path="/ci-cd" title="CI/CD">
|
||||
<EntitySwitch>
|
||||
<EntitySwitch.Case if={isJenkinsAvailable}>
|
||||
<EntityJenkinsContent />
|
||||
</EntitySwitch.Case>
|
||||
{/* ... */}
|
||||
</EntitySwitch>
|
||||
</EntityLayout.Route>
|
||||
{/* ... */}
|
||||
</EntityLayout>
|
||||
);
|
||||
```
|
||||
|
||||
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 +84,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
|
||||
|
||||
@@ -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<Build>;
|
||||
getProjects(options: {
|
||||
entity: EntityRef;
|
||||
filter: {
|
||||
branch?: string;
|
||||
};
|
||||
}): Promise<Project[]>;
|
||||
// (undocumented)
|
||||
extractJobDetailsFromBuildName(
|
||||
buildName: string,
|
||||
): {
|
||||
jobName: string;
|
||||
buildNumber: number;
|
||||
};
|
||||
// (undocumented)
|
||||
extractScmDetailsFromJob(jobDetails: any): any | undefined;
|
||||
// (undocumented)
|
||||
getBuild(buildName: string): Promise<any>;
|
||||
// (undocumented)
|
||||
getFolder(folderName: string): Promise<CITableBuildInfo[]>;
|
||||
// (undocumented)
|
||||
getJob(jobName: string): Promise<any>;
|
||||
// (undocumented)
|
||||
getLastBuild(jobName: string): Promise<any>;
|
||||
// (undocumented)
|
||||
mapJenkinsBuildToCITable(
|
||||
jenkinsResult: any,
|
||||
jobScmInfo?: any,
|
||||
): CITableBuildInfo;
|
||||
// (undocumented)
|
||||
retry(buildName: string): Promise<any>;
|
||||
retry(options: {
|
||||
entity: EntityName;
|
||||
jobFullName: string;
|
||||
buildNumber: string;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const jenkinsApiRef: ApiRef<JenkinsApi>;
|
||||
|
||||
// @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<Build>;
|
||||
// (undocumented)
|
||||
getProjects({
|
||||
entity,
|
||||
filter,
|
||||
}: {
|
||||
entity: EntityName;
|
||||
filter: {
|
||||
branch?: string;
|
||||
};
|
||||
}): Promise<Project[]>;
|
||||
// (undocumented)
|
||||
retry({
|
||||
entity,
|
||||
jobFullName,
|
||||
buildNumber,
|
||||
}: {
|
||||
entity: EntityName;
|
||||
jobFullName: string;
|
||||
buildNumber: string;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
const jenkinsPlugin: BackstagePlugin<
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -14,246 +14,205 @@
|
||||
* 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<JenkinsApi>({
|
||||
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: () => Promise<void>; // TODO rename to handle.* ? also, should this be on lastBuild?
|
||||
}
|
||||
|
||||
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 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(options: {
|
||||
entity: EntityRef;
|
||||
filter: { branch?: string };
|
||||
}): Promise<Project[]>;
|
||||
|
||||
/**
|
||||
* Get a single build.
|
||||
*
|
||||
* This takes an entity to support selecting between multiple jenkins instances.
|
||||
*
|
||||
* TODO: abstract jobFullName (so we could support differentiating between the same named job on multiple instances).
|
||||
* @param options.entity
|
||||
* @param options.jobFullName
|
||||
* @param options.buildNumber
|
||||
*/
|
||||
getBuild(options: {
|
||||
entity: EntityName;
|
||||
jobFullName: string;
|
||||
buildNumber: string;
|
||||
}): Promise<Build>;
|
||||
|
||||
retry(options: {
|
||||
entity: EntityName;
|
||||
jobFullName: string;
|
||||
buildNumber: string;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
export class JenkinsClient 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,
|
||||
filter,
|
||||
}: {
|
||||
entity: EntityName;
|
||||
filter: { branch?: string };
|
||||
}): Promise<Project[]> {
|
||||
const url = new URL(
|
||||
`${await this.discoveryApi.getBaseUrl(
|
||||
'jenkins',
|
||||
)}/v1/entity/${encodeURIComponent(entity.namespace)}/${encodeURIComponent(
|
||||
entity.kind,
|
||||
)}/${encodeURIComponent(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?.map((p: Project) => ({
|
||||
...p,
|
||||
onRestartClick: async () => {
|
||||
await this.retry({
|
||||
entity,
|
||||
jobFullName: p.fullName,
|
||||
buildNumber: String(p.lastBuild.number),
|
||||
});
|
||||
},
|
||||
})) || []
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
async getBuild({
|
||||
entity,
|
||||
jobFullName,
|
||||
buildNumber,
|
||||
}: {
|
||||
entity: EntityName;
|
||||
jobFullName: string;
|
||||
buildNumber: string;
|
||||
}): Promise<Build> {
|
||||
const url = `${await this.discoveryApi.getBaseUrl(
|
||||
'jenkins',
|
||||
)}/v1/entity/${encodeURIComponent(entity.namespace)}/${encodeURIComponent(
|
||||
entity.kind,
|
||||
)}/${encodeURIComponent(entity.name)}/job/${encodeURIComponent(
|
||||
jobFullName,
|
||||
)}/${encodeURIComponent(buildNumber)}`;
|
||||
|
||||
const idToken = await this.identityApi.getIdToken();
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...(idToken && { Authorization: `Bearer ${idToken}` }),
|
||||
},
|
||||
});
|
||||
|
||||
return (await response.json()).build;
|
||||
}
|
||||
|
||||
extractJobDetailsFromBuildName(buildName: string) {
|
||||
const trimmedBuild = buildName.replace(/\/job/g, '').replace(/\/$/, '');
|
||||
async retry({
|
||||
entity,
|
||||
jobFullName,
|
||||
buildNumber,
|
||||
}: {
|
||||
entity: EntityName;
|
||||
jobFullName: string;
|
||||
buildNumber: string;
|
||||
}): Promise<void> {
|
||||
const url = `${await this.discoveryApi.getBaseUrl(
|
||||
'jenkins',
|
||||
)}/v1/entity/${encodeURIComponent(entity.namespace)}/${encodeURIComponent(
|
||||
entity.kind,
|
||||
)}/${encodeURIComponent(entity.name)}/job/${encodeURIComponent(
|
||||
jobFullName,
|
||||
)}/${encodeURIComponent(buildNumber)}:rebuild`;
|
||||
|
||||
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 {
|
||||
jobName,
|
||||
buildNumber,
|
||||
};
|
||||
const idToken = await this.identityApi.getIdToken();
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(idToken && { Authorization: `Bearer ${idToken}` }),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,4 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { JenkinsApi, jenkinsApiRef } from './JenkinsApi';
|
||||
export { JenkinsClient, jenkinsApiRef } from './JenkinsApi';
|
||||
|
||||
export type { JenkinsApi } from './JenkinsApi';
|
||||
|
||||
@@ -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,16 @@ const useStyles = makeStyles(theme => ({
|
||||
}));
|
||||
|
||||
const BuildWithStepsView = () => {
|
||||
const projectName = useProjectSlugFromEntity();
|
||||
const { branch, buildNumber } = useRouteRefParams(buildRouteRef);
|
||||
// TODO: Add a test that react-router decodes this (even though `generatePath` doesn't encode it for you!)
|
||||
const { jobFullName, buildNumber } = useRouteRefParams(buildRouteRef);
|
||||
const classes = useStyles();
|
||||
const buildPath = `${projectName}/${encodeURIComponent(
|
||||
branch,
|
||||
)}/${buildNumber}`;
|
||||
const [{ value }] = useBuildWithSteps(buildPath);
|
||||
|
||||
const [{ value }] = useBuildWithSteps({ jobFullName, buildNumber });
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Breadcrumbs aria-label="breadcrumb">
|
||||
{/* TODO: don't hardcode this link */}
|
||||
<Link to="../../..">Projects</Link>
|
||||
<Typography>Run</Typography>
|
||||
</Breadcrumbs>
|
||||
@@ -104,7 +102,7 @@ const BuildWithStepsView = () => {
|
||||
<Typography noWrap>Jenkins</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MaterialLink target="_blank" href={value?.buildUrl}>
|
||||
<MaterialLink target="_blank" href={value?.url}>
|
||||
View on Jenkins{' '}
|
||||
<ExternalLinkIcon className={classes.externalLinkIcon} />
|
||||
</MaterialLink>
|
||||
@@ -112,10 +110,11 @@ const BuildWithStepsView = () => {
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
{/* TODO: be SCM agnostic */}
|
||||
<Typography noWrap>GitHub</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MaterialLink target="_blank" href={value?.source.url}>
|
||||
<MaterialLink target="_blank" href={value?.source?.url}>
|
||||
View on GitHub{' '}
|
||||
<ExternalLinkIcon className={classes.externalLinkIcon} />
|
||||
</MaterialLink>
|
||||
|
||||
@@ -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<CITableBuildInfo>) => {
|
||||
if (!row.source?.branchName || !row.buildNumber) {
|
||||
return <>{row.buildName}</>;
|
||||
render: (row: Partial<Project>) => {
|
||||
if (!row.fullName || !row.lastBuild?.number) {
|
||||
return (
|
||||
<>
|
||||
{row.fullName ||
|
||||
row.fullDisplayName ||
|
||||
row.displayName ||
|
||||
'Unknown'}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={generatePath(buildRouteRef.path, {
|
||||
branch: encodeURIComponent(row.source.branchName),
|
||||
buildNumber: row.buildNumber.toString(),
|
||||
jobFullName: encodeURIComponent(row.fullName),
|
||||
buildNumber: String(row.lastBuild?.number),
|
||||
})}
|
||||
>
|
||||
{row.buildName}
|
||||
{row.fullDisplayName}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Source',
|
||||
field: 'source.branchName',
|
||||
render: (row: Partial<CITableBuildInfo>) => (
|
||||
field: 'lastBuild.source.branchName',
|
||||
render: (row: Partial<Project>) => (
|
||||
<>
|
||||
<p>
|
||||
<Link href={row.source?.url || ''} target="_blank">
|
||||
{row.source?.branchName}
|
||||
<Link href={row.lastBuild?.source?.url || ''} target="_blank">
|
||||
{row.lastBuild?.source?.branchName}
|
||||
</Link>
|
||||
</p>
|
||||
<p>{row.source?.commit?.hash}</p>
|
||||
<p>{row.lastBuild?.source?.commit?.hash}</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
field: 'status',
|
||||
render: (row: Partial<CITableBuildInfo>) => {
|
||||
render: (row: Partial<Project>) => {
|
||||
return (
|
||||
<Box display="flex" alignItems="center">
|
||||
<JenkinsRunStatus status={row.status} />
|
||||
@@ -155,21 +137,22 @@ const generatedColumns: TableColumn[] = [
|
||||
{
|
||||
title: 'Tests',
|
||||
sorting: false,
|
||||
render: (row: Partial<CITableBuildInfo>) => {
|
||||
render: (row: Partial<Project>) => {
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
{row.tests && (
|
||||
<Link href={row.tests.testUrl || ''} target="_blank">
|
||||
{row.tests.passed} / {row.tests.total} passed
|
||||
{row.lastBuild?.tests && (
|
||||
<Link href={row.lastBuild?.tests.testUrl || ''} target="_blank">
|
||||
{row.lastBuild?.tests.passed} / {row.lastBuild?.tests.total}{' '}
|
||||
passed
|
||||
<FailSkippedWidget
|
||||
skipped={row.tests.skipped}
|
||||
failed={row.tests.failed}
|
||||
skipped={row.lastBuild?.tests.skipped}
|
||||
failed={row.lastBuild?.tests.failed}
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{!row.tests && 'n/a'}
|
||||
{!row.lastBuild?.tests && 'n/a'}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
@@ -178,7 +161,7 @@ const generatedColumns: TableColumn[] = [
|
||||
{
|
||||
title: 'Actions',
|
||||
sorting: false,
|
||||
render: (row: Partial<CITableBuildInfo>) => (
|
||||
render: (row: Partial<Project>) => (
|
||||
<Tooltip title="Rerun build">
|
||||
<IconButton onClick={row.onRestartClick}>
|
||||
<RetryIcon />
|
||||
@@ -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={
|
||||
<Box display="flex" alignItems="center">
|
||||
<img src={JenkinsLogo} alt="Jenkins logo" height="50px" />
|
||||
<Box mr={2} />
|
||||
<Typography variant="h6">Project: {projectName}</Typography>
|
||||
<Typography variant="h6">Projects</Typography>
|
||||
</Box>
|
||||
}
|
||||
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 (
|
||||
<CITableView
|
||||
|
||||
@@ -14,4 +14,3 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { CITable } from './CITable';
|
||||
export type { CITableBuildInfo } from './CITable';
|
||||
|
||||
@@ -20,6 +20,7 @@ import { LatestRunCard } from './Cards';
|
||||
import { EntityProvider } from '@backstage/plugin-catalog-react';
|
||||
import { JenkinsApi, jenkinsApiRef } from '../../api';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
|
||||
import { Project } from '../../api/JenkinsApi';
|
||||
|
||||
describe('<LatestRunCard />', () => {
|
||||
const entity = {
|
||||
@@ -33,7 +34,10 @@ describe('<LatestRunCard />', () => {
|
||||
};
|
||||
|
||||
const jenkinsApi: Partial<JenkinsApi> = {
|
||||
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('<LatestRunCard />', () => {
|
||||
|
||||
it('should show the appropriate error in case of a connection error', async () => {
|
||||
const jenkinsApiWithError: Partial<JenkinsApi> = {
|
||||
getLastBuild: () => Promise.reject(new Error('Unauthorized')),
|
||||
getProjects: () => Promise.reject(new Error('Unauthorized')),
|
||||
};
|
||||
|
||||
const apis = ApiRegistry.from([[jenkinsApiRef, jenkinsApiWithError]]);
|
||||
@@ -71,7 +75,7 @@ describe('<LatestRunCard />', () => {
|
||||
|
||||
it('should show the appropriate error in case Jenkins project is not found', async () => {
|
||||
const jenkinsApiWithError: Partial<JenkinsApi> = {
|
||||
getLastBuild: () =>
|
||||
getProjects: () =>
|
||||
Promise.reject({
|
||||
notFound: true,
|
||||
message: 'jenkins-project not found',
|
||||
|
||||
@@ -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<Theme>({
|
||||
externalLinkIcon: {
|
||||
@@ -39,16 +39,18 @@ const WidgetContent = ({
|
||||
latestRun,
|
||||
}: {
|
||||
loading?: boolean;
|
||||
latestRun: any;
|
||||
latestRun?: Project;
|
||||
branch: string;
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
if (loading || !latestRun) return <LinearProgress />;
|
||||
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: (
|
||||
<>
|
||||
<JenkinsRunStatus
|
||||
status={latestRun.building ? 'running' : latestRun.result}
|
||||
/>
|
||||
<JenkinsRunStatus status={latestRun.lastBuild.status} />
|
||||
</>
|
||||
),
|
||||
build: latestRun.fullDisplayName,
|
||||
'latest run': displayDate,
|
||||
duration: displayDuration,
|
||||
link: (
|
||||
<Link href={latestRun.url} target="_blank">
|
||||
<Link href={latestRun.lastBuild.url} target="_blank">
|
||||
See more on Jenkins{' '}
|
||||
<ExternalLinkIcon className={classes.externalLinkIcon} />
|
||||
</Link>
|
||||
@@ -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 (
|
||||
<InfoCard title={`Latest ${branch} build`} variant={variant}>
|
||||
{!error ? (
|
||||
|
||||
@@ -18,34 +18,36 @@ 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 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({
|
||||
jobFullName,
|
||||
buildNumber,
|
||||
}: {
|
||||
jobFullName: string;
|
||||
buildNumber: string;
|
||||
}) {
|
||||
const api = useApi(jenkinsApiRef);
|
||||
const errorApi = useApi(errorApiRef);
|
||||
const { entity } = useEntity();
|
||||
|
||||
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));
|
||||
const entityName = await getEntityName(entity);
|
||||
return api.getBuild({ entity: entityName, jobFullName, buildNumber });
|
||||
} catch (e) {
|
||||
errorApi.post(e);
|
||||
return Promise.reject(e);
|
||||
}
|
||||
}, [buildName, api, errorApi]);
|
||||
|
||||
const restartBuild = async () => {
|
||||
try {
|
||||
await api.retry(buildName);
|
||||
} catch (e) {
|
||||
errorApi.post(e);
|
||||
}
|
||||
};
|
||||
}, [buildNumber, jobFullName, entity, api, errorApi]);
|
||||
|
||||
const { loading, value, retry } = useAsyncRetry(() => getBuildWithSteps(), [
|
||||
getBuildWithSteps,
|
||||
@@ -59,7 +61,6 @@ export function useBuildWithSteps(buildName: string) {
|
||||
return [
|
||||
{ loading, value, retry },
|
||||
{
|
||||
restartBuild,
|
||||
getBuildWithSteps,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
|
||||
@@ -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 }: { branch?: string } = {}) {
|
||||
const { entity } = useEntity();
|
||||
const entityName = getEntityName(entity);
|
||||
const api = useApi(jenkinsApiRef);
|
||||
const errorApi = useApi(errorApiRef);
|
||||
|
||||
@@ -35,27 +45,24 @@ export function useBuilds(projectName: string, branch?: string) {
|
||||
errorType: ErrorType;
|
||||
}>();
|
||||
|
||||
const restartBuild = async (buildName: string) => {
|
||||
const restartBuild = async (jobFullName: string, buildNumber: string) => {
|
||||
try {
|
||||
await api.retry(buildName);
|
||||
await api.retry({ entity: entityName, jobFullName, 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({
|
||||
entity: getEntityName(entity),
|
||||
filter: { 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 +70,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;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { JenkinsApi, jenkinsApiRef } from './api';
|
||||
import {
|
||||
createApiFactory,
|
||||
createComponentExtension,
|
||||
@@ -22,7 +21,9 @@ import {
|
||||
createRoutableExtension,
|
||||
createRouteRef,
|
||||
discoveryApiRef,
|
||||
identityApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { JenkinsClient, jenkinsApiRef } from './api';
|
||||
|
||||
export const rootRouteRef = createRouteRef({
|
||||
path: '',
|
||||
@@ -30,9 +31,9 @@ export const rootRouteRef = createRouteRef({
|
||||
});
|
||||
|
||||
export const buildRouteRef = createRouteRef({
|
||||
path: 'run/:branch/:buildNumber',
|
||||
params: ['branch', 'buildNumber'],
|
||||
title: 'Jenkins run',
|
||||
path: 'build/:jobFullName/:buildNumber',
|
||||
params: ['jobFullName', 'buildNumber'],
|
||||
title: 'Jenkins build',
|
||||
});
|
||||
|
||||
export const jenkinsPlugin = createPlugin({
|
||||
@@ -40,8 +41,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 JenkinsClient({ discoveryApi, identityApi }),
|
||||
}),
|
||||
],
|
||||
routes: {
|
||||
|
||||
@@ -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==
|
||||
|
||||
Reference in New Issue
Block a user