Merge branch 'master' into multi-cluster
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-sonarqube': patch
|
||||
---
|
||||
|
||||
Add support for the security hotspots that are provided by SonarQube and SonarCloud.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-kubernetes': patch
|
||||
---
|
||||
|
||||
Show Kubernetes Service manifests.
|
||||
Show Kubernetes Ingress manifests.
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
'@backstage/create-app': minor
|
||||
'@backstage/integration': minor
|
||||
'@backstage/plugin-scaffolder': minor
|
||||
'@backstage/plugin-scaffolder-backend': minor
|
||||
---
|
||||
|
||||
- Deprecating the `scaffolder.${provider}.token` auth duplication and favoring `integrations.${provider}` instead. If you receive deprecation warnings your config should change like the following:
|
||||
|
||||
```yaml
|
||||
scaffolder:
|
||||
github:
|
||||
token:
|
||||
$env: GITHUB_TOKEN
|
||||
visibility: public
|
||||
```
|
||||
|
||||
To something that looks like this:
|
||||
|
||||
```yaml
|
||||
integration:
|
||||
github:
|
||||
- host: github.com
|
||||
token:
|
||||
$env: GITHUB_TOKEN
|
||||
scaffolder:
|
||||
github:
|
||||
visibility: public
|
||||
```
|
||||
|
||||
You can also configure multiple different hosts under the `integration` config like the following:
|
||||
|
||||
```yaml
|
||||
integration:
|
||||
github:
|
||||
- host: github.com
|
||||
token:
|
||||
$env: GITHUB_TOKEN
|
||||
- host: ghe.mycompany.com
|
||||
token:
|
||||
$env: GITHUB_ENTERPRISE_TOKEN
|
||||
```
|
||||
|
||||
This of course is the case for all the providers respectively.
|
||||
|
||||
- Adding support for cross provider scaffolding, you can now create repositories in for example Bitbucket using a template residing in GitHub.
|
||||
|
||||
- Fix GitLab scaffolding so that it returns a `catalogInfoUrl` which automatically imports the project into the catalog.
|
||||
|
||||
- The `Store Path` field on the `scaffolder` frontend has now changed so that you require the full URL to the desired destination repository.
|
||||
|
||||
`backstage/new-repository` would become `https://github.com/backstage/new-repository` if provider was GitHub for example.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/create-app': patch
|
||||
---
|
||||
|
||||
Ensured that versions bumps of packages used in the app template trigger a release of this package when needed.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
'@backstage/techdocs-common': patch
|
||||
'@backstage/plugin-techdocs': patch
|
||||
'@backstage/plugin-techdocs-backend': patch
|
||||
---
|
||||
|
||||
Create type for TechDocsMetadata (#3716)
|
||||
|
||||
This change introduces a new type (TechDocsMetadata) in packages/techdocs-common. This type is then introduced in the endpoint response in techdocs-backend and in the api interface in techdocs (frontend).
|
||||
@@ -93,6 +93,7 @@ Henneke
|
||||
Heroku
|
||||
horizontalpodautoscalers
|
||||
Hostname
|
||||
hotspots
|
||||
html
|
||||
http
|
||||
https
|
||||
|
||||
@@ -372,3 +372,8 @@ homepage:
|
||||
timezone: 'Asia/Tokyo'
|
||||
pagerduty:
|
||||
eventsBaseUrl: 'https://events.pagerduty.com/v2'
|
||||
|
||||
kafka:
|
||||
clientId: backstage
|
||||
brokers:
|
||||
- localhost:9092
|
||||
|
||||
@@ -34,6 +34,7 @@ export default async function createPlugin({
|
||||
const cookiecutterTemplater = new CookieCutter();
|
||||
const craTemplater = new CreateReactAppTemplater();
|
||||
const templaters = new Templaters();
|
||||
|
||||
templaters.register('cookiecutter', cookiecutterTemplater);
|
||||
templaters.register('cra', craTemplater);
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"d3-zoom": "^2.0.0",
|
||||
"dagre": "^0.8.5",
|
||||
"qs": "^6.9.4",
|
||||
"immer": "^7.0.9",
|
||||
"immer": "^8.0.1",
|
||||
"lodash": "^4.17.15",
|
||||
"material-table": "^1.69.1",
|
||||
"prop-types": "^15.7.2",
|
||||
|
||||
@@ -37,6 +37,13 @@
|
||||
"recursive-readdir": "^2.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "^9.0.1",
|
||||
"@types/inquirer": "^7.3.1",
|
||||
"@types/react-dev-utils": "^9.0.4",
|
||||
"@types/recursive-readdir": "^2.2.0",
|
||||
"ts-node": "^8.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@backstage/backend-common": "^0.4.3",
|
||||
"@backstage/catalog-model": "^0.6.1",
|
||||
"@backstage/cli": "^0.4.6",
|
||||
@@ -62,12 +69,7 @@
|
||||
"@backstage/plugin-techdocs-backend": "^0.5.3",
|
||||
"@backstage/plugin-user-settings": "^0.2.3",
|
||||
"@backstage/test-utils": "^0.1.6",
|
||||
"@backstage/theme": "^0.2.2",
|
||||
"@types/fs-extra": "^9.0.1",
|
||||
"@types/inquirer": "^7.3.1",
|
||||
"@types/react-dev-utils": "^9.0.4",
|
||||
"@types/recursive-readdir": "^2.2.0",
|
||||
"ts-node": "^8.6.2"
|
||||
"@backstage/theme": "^0.2.2"
|
||||
},
|
||||
"nodemonConfig": {
|
||||
"watch": "./src",
|
||||
|
||||
@@ -18,6 +18,7 @@ export default async function createPlugin({
|
||||
const cookiecutterTemplater = new CookieCutter();
|
||||
const craTemplater = new CreateReactAppTemplater();
|
||||
const templaters = new Templaters();
|
||||
|
||||
templaters.register('cookiecutter', cookiecutterTemplater);
|
||||
templaters.register('cra', craTemplater);
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "node .",
|
||||
"start": "nodemon --",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test",
|
||||
"test:e2e": "yarn start"
|
||||
@@ -36,5 +36,10 @@
|
||||
"tree-kill": "^1.2.2",
|
||||
"ts-node": "^8.6.2",
|
||||
"zombie": "^6.1.4"
|
||||
},
|
||||
"nodemonConfig": {
|
||||
"watch": "./src",
|
||||
"exec": "bin/e2e-test",
|
||||
"ext": "ts"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,10 +80,22 @@ async function buildDistWorkspace(workspaceName: string, rootDir: string) {
|
||||
|
||||
// We grab the needed dependencies from the create app template
|
||||
const createAppDeps = new Set<string>();
|
||||
|
||||
function appendDeps(pkg: any) {
|
||||
Array<string>()
|
||||
.concat(
|
||||
Object.keys(pkg.dependencies ?? {}),
|
||||
Object.keys(pkg.devDependencies ?? {}),
|
||||
Object.keys(pkg.peerDependencies ?? {}),
|
||||
)
|
||||
.filter(name => name.startsWith('@backstage/'))
|
||||
.forEach(dep => createAppDeps.add(dep));
|
||||
}
|
||||
|
||||
for (const pkgJsonPath of templatePackagePaths) {
|
||||
const path = paths.resolveOwnRoot(pkgJsonPath);
|
||||
const pkgTemplate = await fs.readFile(path, 'utf8');
|
||||
const { dependencies = {}, devDependencies = {} } = JSON.parse(
|
||||
const pkg = JSON.parse(
|
||||
handlebars.compile(pkgTemplate)(
|
||||
{
|
||||
privatePackage: true,
|
||||
@@ -102,13 +114,12 @@ async function buildDistWorkspace(workspaceName: string, rootDir: string) {
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Array<string>()
|
||||
.concat(Object.keys(dependencies), Object.keys(devDependencies))
|
||||
.filter(name => name.startsWith('@backstage/'))
|
||||
.forEach(dep => createAppDeps.add(dep));
|
||||
appendDeps(pkg);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
appendDeps(require('@backstage/create-app/package.json'));
|
||||
|
||||
print(`Preparing workspace`);
|
||||
await runPlain([
|
||||
'yarn',
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import gitUrlParse from 'git-url-parse';
|
||||
import parseGitUrl from 'git-url-parse';
|
||||
import { GithubAppConfig, GitHubIntegrationConfig } from './config';
|
||||
import { createAppAuth } from '@octokit/auth-app';
|
||||
import { Octokit, RestEndpointMethodTypes } from '@octokit/rest';
|
||||
@@ -221,7 +221,7 @@ export class GithubCredentialsProvider {
|
||||
* const { token, headers } = await getCredentials({url: 'github.com/backstage/foobar'})
|
||||
*/
|
||||
async getCredentials(opts: { url: string }): Promise<GithubCredentials> {
|
||||
const parsed = gitUrlParse(opts.url);
|
||||
const parsed = parseGitUrl(opts.url);
|
||||
|
||||
const owner = parsed.owner || parsed.name;
|
||||
const repo = parsed.owner ? parsed.name : undefined;
|
||||
|
||||
@@ -31,11 +31,14 @@ describe('readGitLabIntegrationConfig', () => {
|
||||
buildConfig({
|
||||
host: 'a.com',
|
||||
token: 't',
|
||||
baseUrl: 'https://baseurl.for.me/gitlab',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(output).toEqual({
|
||||
host: 'a.com',
|
||||
token: 't',
|
||||
baseUrl: 'https://baseurl.for.me/gitlab',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +47,7 @@ describe('readGitLabIntegrationConfig', () => {
|
||||
expect(output).toEqual({
|
||||
host: 'gitlab.com',
|
||||
apiBaseUrl: 'https://gitlab.com/api/v4',
|
||||
baseUrl: 'https://gitlab.com',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,6 +58,7 @@ describe('readGitLabIntegrationConfig', () => {
|
||||
|
||||
expect(output).toEqual({
|
||||
host: 'gitlab.com',
|
||||
baseUrl: 'https://gitlab.com',
|
||||
apiBaseUrl: 'https://gitlab.com/api/v4',
|
||||
});
|
||||
});
|
||||
@@ -89,6 +94,7 @@ describe('readGitLabIntegrationConfigs', () => {
|
||||
expect(output).toContainEqual({
|
||||
host: 'a.com',
|
||||
token: 't',
|
||||
baseUrl: 'https://a.com',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -46,6 +46,14 @@ export type GitLabIntegrationConfig = {
|
||||
* If no token is specified, anonymous access is used.
|
||||
*/
|
||||
token?: string;
|
||||
|
||||
/**
|
||||
* The baseUrl of this provider, e.g "https://gitlab.com",
|
||||
* which is passed into the gitlab client.
|
||||
*
|
||||
* If no baseUrl is provided, it will default to https://${host}
|
||||
*/
|
||||
baseUrl?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -59,6 +67,7 @@ export function readGitLabIntegrationConfig(
|
||||
const host = config.getOptionalString('host') ?? GITLAB_HOST;
|
||||
let apiBaseUrl = config.getOptionalString('apiBaseUrl');
|
||||
const token = config.getOptionalString('token');
|
||||
const baseUrl = config.getOptionalString('baseUrl') ?? `https://${host}`;
|
||||
|
||||
if (!isValidHost(host)) {
|
||||
throw new Error(
|
||||
@@ -71,7 +80,8 @@ export function readGitLabIntegrationConfig(
|
||||
} else if (host === GITLAB_HOST) {
|
||||
apiBaseUrl = GITLAB_API_BASE_URL;
|
||||
}
|
||||
return { host, token, apiBaseUrl };
|
||||
|
||||
return { host, token, apiBaseUrl, baseUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"fs-extra": "^9.0.1",
|
||||
"git-url-parse": "^11.4.3",
|
||||
"js-yaml": "^4.0.0",
|
||||
"json5": "^2.1.3",
|
||||
"mime-types": "^2.1.27",
|
||||
"mock-fs": "^4.13.0",
|
||||
"recursive-readdir": "^2.2.2",
|
||||
|
||||
@@ -18,7 +18,7 @@ import path from 'path';
|
||||
import * as winston from 'winston';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { AwsS3Publish } from './awsS3';
|
||||
import { PublisherBase } from './types';
|
||||
import { PublisherBase, TechDocsMetadata } from './types';
|
||||
import type { Entity, EntityName } from '@backstage/catalog-model';
|
||||
|
||||
const createMockEntity = (annotations = {}): Entity => {
|
||||
@@ -159,13 +159,39 @@ describe('AwsS3Publish', () => {
|
||||
|
||||
mockFs({
|
||||
[entityRootDir]: {
|
||||
'techdocs_metadata.json': 'file-content',
|
||||
'techdocs_metadata.json':
|
||||
'{"site_name": "backstage", "site_description": "site_content"}',
|
||||
},
|
||||
});
|
||||
|
||||
expect(await publisher.fetchTechDocsMetadata(entityNameMock)).toBe(
|
||||
'file-content',
|
||||
);
|
||||
const expectedMetadata: TechDocsMetadata = {
|
||||
site_name: 'backstage',
|
||||
site_description: 'site_content',
|
||||
};
|
||||
expect(
|
||||
await publisher.fetchTechDocsMetadata(entityNameMock),
|
||||
).toStrictEqual(expectedMetadata);
|
||||
mockFs.restore();
|
||||
});
|
||||
|
||||
it('should return tech docs metadata when json encoded with single quotes', async () => {
|
||||
const entityNameMock = createMockEntityName();
|
||||
const entity = createMockEntity();
|
||||
const entityRootDir = getEntityRootDir(entity);
|
||||
|
||||
mockFs({
|
||||
[entityRootDir]: {
|
||||
'techdocs_metadata.json': `{'site_name': 'backstage', 'site_description': 'site_content'}`,
|
||||
},
|
||||
});
|
||||
|
||||
const expectedMetadata: TechDocsMetadata = {
|
||||
site_name: 'backstage',
|
||||
site_description: 'site_content',
|
||||
};
|
||||
expect(
|
||||
await publisher.fetchTechDocsMetadata(entityNameMock),
|
||||
).toStrictEqual(expectedMetadata);
|
||||
mockFs.restore();
|
||||
});
|
||||
|
||||
|
||||
@@ -20,9 +20,10 @@ import { Logger } from 'winston';
|
||||
import { Entity, EntityName } from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import { getHeadersForFileExtension, getFileTreeRecursively } from './helpers';
|
||||
import { PublisherBase, PublishRequest } from './types';
|
||||
import { PublisherBase, PublishRequest, TechDocsMetadata } from './types';
|
||||
import fs from 'fs-extra';
|
||||
import { Readable } from 'stream';
|
||||
import JSON5 from 'json5';
|
||||
|
||||
const streamToBuffer = (stream: Readable): Promise<Buffer> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -32,7 +33,7 @@ const streamToBuffer = (stream: Readable): Promise<Buffer> => {
|
||||
stream.on('error', reject);
|
||||
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
} catch (e) {
|
||||
throw new Error(`Unable to parse the response data, ${e.message}`);
|
||||
throw new Error(`Unable to parse the response data ${e.message}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -162,9 +163,11 @@ export class AwsS3Publish implements PublisherBase {
|
||||
}
|
||||
}
|
||||
|
||||
async fetchTechDocsMetadata(entityName: EntityName): Promise<string> {
|
||||
async fetchTechDocsMetadata(
|
||||
entityName: EntityName,
|
||||
): Promise<TechDocsMetadata> {
|
||||
try {
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
return await new Promise<TechDocsMetadata>((resolve, reject) => {
|
||||
const entityRootDir = `${entityName.namespace}/${entityName.kind}/${entityName.name}`;
|
||||
|
||||
this.storageClient
|
||||
@@ -182,8 +185,11 @@ export class AwsS3Publish implements PublisherBase {
|
||||
`Unable to parse the techdocs metadata file ${entityRootDir}/techdocs_metadata.json.`,
|
||||
);
|
||||
}
|
||||
const techdocsMetadata = JSON5.parse(
|
||||
techdocsMetadataJson.toString('utf-8'),
|
||||
);
|
||||
|
||||
resolve(techdocsMetadataJson.toString('utf-8'));
|
||||
resolve(techdocsMetadata);
|
||||
})
|
||||
.catch(err => {
|
||||
this.logger.error(err.message);
|
||||
|
||||
@@ -24,7 +24,8 @@ import { Logger } from 'winston';
|
||||
import { Entity, EntityName } from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import { getHeadersForFileExtension, getFileTreeRecursively } from './helpers';
|
||||
import { PublisherBase, PublishRequest } from './types';
|
||||
import { PublisherBase, PublishRequest, TechDocsMetadata } from './types';
|
||||
import JSON5 from 'json5';
|
||||
|
||||
export class GoogleGCSPublish implements PublisherBase {
|
||||
static async fromConfig(
|
||||
@@ -132,7 +133,7 @@ export class GoogleGCSPublish implements PublisherBase {
|
||||
});
|
||||
}
|
||||
|
||||
fetchTechDocsMetadata(entityName: EntityName): Promise<string> {
|
||||
fetchTechDocsMetadata(entityName: EntityName): Promise<TechDocsMetadata> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const entityRootDir = `${entityName.namespace}/${entityName.kind}/${entityName.name}`;
|
||||
|
||||
@@ -152,7 +153,7 @@ export class GoogleGCSPublish implements PublisherBase {
|
||||
const techdocsMetadataJson = Buffer.concat(
|
||||
fileStreamChunks,
|
||||
).toString();
|
||||
resolve(techdocsMetadataJson);
|
||||
resolve(JSON5.parse(techdocsMetadataJson));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,4 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { Publisher } from './publish';
|
||||
export type { PublisherBase, PublisherType } from './types';
|
||||
export type { PublisherBase, PublisherType, TechDocsMetadata } from './types';
|
||||
|
||||
@@ -25,7 +25,12 @@ import {
|
||||
PluginEndpointDiscovery,
|
||||
} from '@backstage/backend-common';
|
||||
import { Config } from '@backstage/config';
|
||||
import { PublisherBase, PublishRequest, PublishResponse } from './types';
|
||||
import {
|
||||
PublisherBase,
|
||||
PublishRequest,
|
||||
PublishResponse,
|
||||
TechDocsMetadata,
|
||||
} from './types';
|
||||
|
||||
// TODO: Use a more persistent storage than node_modules or /tmp directory.
|
||||
// Make it configurable with techdocs.publisher.local.publishDirectory
|
||||
@@ -102,7 +107,7 @@ export class LocalPublish implements PublisherBase {
|
||||
});
|
||||
}
|
||||
|
||||
fetchTechDocsMetadata(entityName: EntityName): Promise<string> {
|
||||
fetchTechDocsMetadata(entityName: EntityName): Promise<TechDocsMetadata> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.discovery.getBaseUrl('techdocs').then(techdocsApiUrl => {
|
||||
const storageUrl = new URL(
|
||||
@@ -116,7 +121,7 @@ export class LocalPublish implements PublisherBase {
|
||||
.then(response =>
|
||||
response
|
||||
.json()
|
||||
.then(techdocsMetadataJson => resolve(techdocsMetadataJson))
|
||||
.then(techdocsMetadata => resolve(techdocsMetadata))
|
||||
.catch(err => {
|
||||
reject(
|
||||
`Unable to parse metadata JSON for ${entityRootDir}. Error: ${err}`,
|
||||
|
||||
@@ -32,6 +32,14 @@ export type PublishResponse = {
|
||||
remoteUrl?: string;
|
||||
} | void;
|
||||
|
||||
/**
|
||||
* Type to hold metadata found in techdocs_metadata.json and associated with each site
|
||||
*/
|
||||
export type TechDocsMetadata = {
|
||||
site_name: string;
|
||||
site_description: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Base class for a TechDocs publisher (e.g. Local, Google GCS Bucket, AWS S3, etc.)
|
||||
* The publisher handles publishing of the generated static files after the prepare and generate steps of TechDocs.
|
||||
@@ -50,7 +58,7 @@ export interface PublisherBase {
|
||||
* Retrieve TechDocs Metadata about a site e.g. name, contributors, last updated, etc.
|
||||
* This API uses the techdocs_metadata.json file that co-exists along with the generated docs.
|
||||
*/
|
||||
fetchTechDocsMetadata(entityName: EntityName): Promise<string>;
|
||||
fetchTechDocsMetadata(entityName: EntityName): Promise<TechDocsMetadata>;
|
||||
|
||||
/**
|
||||
* Route middleware to serve static documentation files for an entity.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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 React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import * as ingresses from './__fixtures__/2-ingresses.json';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { IngressDrawer } from './IngressDrawer';
|
||||
|
||||
describe('IngressDrawer', () => {
|
||||
it('should render ingress drawer', async () => {
|
||||
const { getByText, getAllByText } = render(
|
||||
wrapInTestApp(
|
||||
<IngressDrawer ingress={(ingresses as any).ingresses[0]} expanded />,
|
||||
),
|
||||
);
|
||||
|
||||
expect(getAllByText('awesome-service')).toHaveLength(2);
|
||||
expect(getByText('YAML')).toBeInTheDocument();
|
||||
expect(getByText('Rules')).toBeInTheDocument();
|
||||
expect(getByText('Host: api.awesome-host.io')).toBeInTheDocument();
|
||||
expect(getAllByText('Service Port: 80')).toHaveLength(2);
|
||||
expect(getAllByText('Service Name: awesome-service')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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 React from 'react';
|
||||
import { ExtensionsV1beta1Ingress } from '@kubernetes/client-node';
|
||||
import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer';
|
||||
import { Typography, Grid } from '@material-ui/core';
|
||||
|
||||
export const IngressDrawer = ({
|
||||
ingress,
|
||||
expanded,
|
||||
}: {
|
||||
ingress: ExtensionsV1beta1Ingress;
|
||||
expanded?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<KubernetesDrawer
|
||||
object={ingress}
|
||||
expanded={expanded}
|
||||
kind="Ingress"
|
||||
renderObject={(ingress: ExtensionsV1beta1Ingress) => {
|
||||
return ingress.spec || {};
|
||||
}}
|
||||
>
|
||||
<Grid
|
||||
container
|
||||
direction="column"
|
||||
justify="flex-start"
|
||||
alignItems="flex-start"
|
||||
spacing={0}
|
||||
>
|
||||
<Grid item>
|
||||
<Typography variant="h5">
|
||||
{ingress.metadata?.name ?? 'unknown object'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Typography color="textSecondary" variant="body1">
|
||||
Ingress
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</KubernetesDrawer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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 React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import * as oneIngressFixture from './__fixtures__/2-ingresses.json';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { IngressesAccordions } from './IngressesAccordions';
|
||||
|
||||
describe('IngressesAccordions', () => {
|
||||
it('should render 1 ingress', async () => {
|
||||
const { getByText } = render(
|
||||
wrapInTestApp(
|
||||
<IngressesAccordions deploymentResources={oneIngressFixture as any} />,
|
||||
),
|
||||
);
|
||||
|
||||
expect(getByText('awesome-service')).toBeInTheDocument();
|
||||
expect(getByText('Ingress')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* 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 { GroupedResponses } from '../../types/types';
|
||||
import React from 'react';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Divider,
|
||||
Grid,
|
||||
} from '@material-ui/core';
|
||||
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
|
||||
import { ExtensionsV1beta1Ingress } from '@kubernetes/client-node';
|
||||
import { StructuredMetadataTable } from '@backstage/core';
|
||||
import { IngressDrawer } from './IngressDrawer';
|
||||
|
||||
type IngressesAccordionsProps = {
|
||||
deploymentResources: GroupedResponses;
|
||||
};
|
||||
|
||||
export const IngressesAccordions = ({
|
||||
deploymentResources,
|
||||
}: IngressesAccordionsProps) => {
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
direction="row"
|
||||
justify="flex-start"
|
||||
alignItems="flex-start"
|
||||
>
|
||||
{deploymentResources.ingresses.map((ingress, i) => (
|
||||
<Grid item key={i} xs>
|
||||
<IngressAccordion ingress={ingress} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
type IngressAccordionProps = {
|
||||
ingress: ExtensionsV1beta1Ingress;
|
||||
};
|
||||
|
||||
const IngressAccordion = ({ ingress }: IngressAccordionProps) => {
|
||||
return (
|
||||
<Accordion TransitionProps={{ unmountOnExit: true }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<IngressSummary ingress={ingress} />
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<IngressCard ingress={ingress} />
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
||||
type IngressSummaryProps = {
|
||||
ingress: ExtensionsV1beta1Ingress;
|
||||
};
|
||||
|
||||
const IngressSummary = ({ ingress }: IngressSummaryProps) => {
|
||||
return (
|
||||
<Grid container direction="row" justify="flex-start" alignItems="center">
|
||||
<Grid xs={3} item>
|
||||
<IngressDrawer ingress={ingress} />
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={1}>
|
||||
<Divider style={{ height: '5em' }} orientation="vertical" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
type IngressCardProps = {
|
||||
ingress: ExtensionsV1beta1Ingress;
|
||||
};
|
||||
|
||||
const IngressCard = ({ ingress }: IngressCardProps) => {
|
||||
return (
|
||||
<StructuredMetadataTable
|
||||
metadata={{
|
||||
...ingress.spec,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"ingresses": [
|
||||
{
|
||||
"metadata": {
|
||||
"annotations": {
|
||||
"artifact.spinnaker.io/location": "default",
|
||||
"artifact.spinnaker.io/name": "awesome-service",
|
||||
"artifact.spinnaker.io/type": "kubernetes/ingress",
|
||||
"kubernetes.io/ingress.class": "traefik",
|
||||
"kubernetes.io/ingress.global-static-ip-name": "traefik-tcp-lb",
|
||||
"moniker.spinnaker.io/application": "awesome-service",
|
||||
"moniker.spinnaker.io/cluster": "ingress awesome-service"
|
||||
},
|
||||
"creationTimestamp": "2018-11-16T14:00:13.000Z",
|
||||
"generation": 11,
|
||||
"labels": {
|
||||
"app": "awesome-service",
|
||||
"app.kubernetes.io/managed-by": "spinnaker",
|
||||
"app.kubernetes.io/name": "awesome-service"
|
||||
},
|
||||
"name": "awesome-service",
|
||||
"namespace": "default",
|
||||
"resourceVersion": "564824116",
|
||||
"selfLink": "/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/awesome-service",
|
||||
"uid": "f072e0b4-e9a7-11e8-af65-42010a9c0022"
|
||||
},
|
||||
"spec": {
|
||||
"rules": [
|
||||
{
|
||||
"host": "api.awesome-host.io",
|
||||
"http": {
|
||||
"paths": [
|
||||
{
|
||||
"backend": {
|
||||
"serviceName": "awesome-service",
|
||||
"servicePort": 80
|
||||
},
|
||||
"path": "/v1/awesome-service"
|
||||
},
|
||||
{
|
||||
"backend": {
|
||||
"serviceName": "awesome-service",
|
||||
"servicePort": 80
|
||||
},
|
||||
"path": "/v1/awesome-services"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"loadBalancer": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* 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 { IngressesAccordions } from './IngressesAccordions';
|
||||
@@ -47,6 +47,8 @@ import { DeploymentsAccordions } from '../DeploymentsAccordions';
|
||||
import { ErrorReporting } from '../ErrorReporting';
|
||||
import { groupResponses } from '../../utils/response';
|
||||
import { DetectedError, detectErrors } from '../../error-detection';
|
||||
import { IngressesAccordions } from '../IngressesAccordions';
|
||||
import { ServicesAccordions } from '../ServicesAccordions';
|
||||
|
||||
type KubernetesContentProps = { entity: Entity; children?: React.ReactNode };
|
||||
|
||||
@@ -187,10 +189,22 @@ const Cluster = ({ clusterObjects, detectedErrors }: ClusterProps) => {
|
||||
/>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<DeploymentsAccordions
|
||||
deploymentResources={groupedResponses}
|
||||
clusterPodNamesWithErrors={podsWithErrors}
|
||||
/>
|
||||
<Grid container direction="column">
|
||||
<Grid item>
|
||||
<DeploymentsAccordions
|
||||
deploymentResources={groupedResponses}
|
||||
clusterPodNamesWithErrors={podsWithErrors}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item>
|
||||
<IngressesAccordions deploymentResources={groupedResponses} />
|
||||
</Grid>
|
||||
|
||||
<Grid item>
|
||||
<ServicesAccordions deploymentResources={groupedResponses} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</>
|
||||
|
||||
@@ -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 React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import * as services from './__fixtures__/2-services.json';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { ServiceDrawer } from './ServiceDrawer';
|
||||
|
||||
describe('ServiceDrawer', () => {
|
||||
it('should render deployment drawer', async () => {
|
||||
const { getByText, getAllByText } = render(
|
||||
wrapInTestApp(
|
||||
<ServiceDrawer service={(services as any).services[0]} expanded />,
|
||||
),
|
||||
);
|
||||
|
||||
expect(getAllByText('awesome-service-grpc')).toHaveLength(2);
|
||||
expect(getAllByText('Service')).toHaveLength(2);
|
||||
expect(getByText('YAML')).toBeInTheDocument();
|
||||
expect(getByText('Cluster IP')).toBeInTheDocument();
|
||||
expect(getByText('Ports')).toBeInTheDocument();
|
||||
expect(getByText('Target Port: 1997')).toBeInTheDocument();
|
||||
expect(getByText('App: awesome-service')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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 React from 'react';
|
||||
import { V1Service } from '@kubernetes/client-node';
|
||||
import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer';
|
||||
import { Typography, Grid } from '@material-ui/core';
|
||||
|
||||
export const ServiceDrawer = ({
|
||||
service,
|
||||
expanded,
|
||||
}: {
|
||||
service: V1Service;
|
||||
expanded?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<KubernetesDrawer
|
||||
object={service}
|
||||
expanded={expanded}
|
||||
kind="Service"
|
||||
renderObject={(service: V1Service) => {
|
||||
return service.spec || {};
|
||||
}}
|
||||
>
|
||||
<Grid
|
||||
container
|
||||
direction="column"
|
||||
justify="flex-start"
|
||||
alignItems="flex-start"
|
||||
spacing={0}
|
||||
>
|
||||
<Grid item>
|
||||
<Typography variant="h5">
|
||||
{service.metadata?.name ?? 'unknown object'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Typography color="textSecondary" variant="body1">
|
||||
Service
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</KubernetesDrawer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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 React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import * as twoDeployFixture from './__fixtures__/2-services.json';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { ServicesAccordions } from './ServicesAccordions';
|
||||
|
||||
describe('ServicesAccordions', () => {
|
||||
it('should render 2 services', async () => {
|
||||
const { getByText } = render(
|
||||
wrapInTestApp(
|
||||
<ServicesAccordions deploymentResources={twoDeployFixture as any} />,
|
||||
),
|
||||
);
|
||||
|
||||
expect(getByText('awesome-service-grpc')).toBeInTheDocument();
|
||||
expect(getByText('Type: ClusterIP')).toBeInTheDocument();
|
||||
|
||||
expect(getByText('awesome-service-pg')).toBeInTheDocument();
|
||||
expect(getByText('Type: ExternalName')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* 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 { GroupedResponses } from '../../types/types';
|
||||
import React from 'react';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Divider,
|
||||
Grid,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
|
||||
import { V1Service } from '@kubernetes/client-node';
|
||||
import { StructuredMetadataTable } from '@backstage/core';
|
||||
import { ServiceDrawer } from './ServiceDrawer';
|
||||
|
||||
type ServicesAccordionsProps = {
|
||||
deploymentResources: GroupedResponses;
|
||||
};
|
||||
|
||||
export const ServicesAccordions = ({
|
||||
deploymentResources,
|
||||
}: ServicesAccordionsProps) => {
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
direction="row"
|
||||
justify="flex-start"
|
||||
alignItems="flex-start"
|
||||
>
|
||||
{deploymentResources.services.map((service, i) => (
|
||||
<Grid item key={i} xs>
|
||||
<ServiceAccordion service={service} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
type ServiceAccordionProps = {
|
||||
service: V1Service;
|
||||
};
|
||||
|
||||
const ServiceAccordion = ({ service }: ServiceAccordionProps) => {
|
||||
return (
|
||||
<Accordion TransitionProps={{ unmountOnExit: true }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<ServiceSummary service={service} />
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<ServiceCard service={service} />
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
||||
type ServiceSummaryProps = {
|
||||
service: V1Service;
|
||||
};
|
||||
|
||||
const ServiceSummary = ({ service }: ServiceSummaryProps) => {
|
||||
return (
|
||||
<Grid container direction="row" justify="flex-start" alignItems="center">
|
||||
<Grid xs={3} item>
|
||||
<ServiceDrawer service={service} />
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={1}>
|
||||
<Divider style={{ height: '5em' }} orientation="vertical" />
|
||||
</Grid>
|
||||
|
||||
<Grid item>
|
||||
<Typography variant="subtitle2">
|
||||
Type: {service.spec?.type ?? '?'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
type ServiceCardProps = {
|
||||
service: V1Service;
|
||||
};
|
||||
|
||||
const ServiceCard = ({ service }: ServiceCardProps) => {
|
||||
const metadata: any = {};
|
||||
|
||||
if (service.status?.loadBalancer?.ingress?.length ?? -1 > 0) {
|
||||
metadata.loadbalancer = service.status?.loadBalancer;
|
||||
}
|
||||
|
||||
if (service.spec?.type === 'ClusterIP') {
|
||||
metadata.clusterIP = service.spec.clusterIP;
|
||||
}
|
||||
if (service.spec?.type === 'ExternalName') {
|
||||
metadata.externalName = service.spec.externalName;
|
||||
}
|
||||
|
||||
return (
|
||||
<StructuredMetadataTable
|
||||
metadata={{
|
||||
type: service.spec?.type,
|
||||
ports: service.spec?.ports,
|
||||
...metadata,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"services": [
|
||||
{
|
||||
"metadata": {
|
||||
"annotations": {
|
||||
"artifact.spinnaker.io/location": "default",
|
||||
"artifact.spinnaker.io/name": "awesome-service-grpc",
|
||||
"artifact.spinnaker.io/type": "kubernetes/service",
|
||||
"moniker.spinnaker.io/application": "awesome-service",
|
||||
"moniker.spinnaker.io/cluster": "service awesome-service-grpc"
|
||||
},
|
||||
"creationTimestamp": "2021-01-04T16:35:04.000Z",
|
||||
"labels": {
|
||||
"app": "awesome-service",
|
||||
"app.kubernetes.io/managed-by": "spinnaker",
|
||||
"app.kubernetes.io/name": "awesome-service"
|
||||
},
|
||||
"name": "awesome-service-grpc",
|
||||
"namespace": "default",
|
||||
"resourceVersion": "548901649",
|
||||
"selfLink": "/api/v1/namespaces/default/services/awesome-service-grpc",
|
||||
"uid": "461cdcd7-8c61-4125-91f9-e03d745f2f2c"
|
||||
},
|
||||
"spec": {
|
||||
"clusterIP": "None",
|
||||
"ports": [
|
||||
{
|
||||
"name": "grpc",
|
||||
"port": 1997,
|
||||
"protocol": "TCP",
|
||||
"targetPort": 1997
|
||||
}
|
||||
],
|
||||
"selector": {
|
||||
"app": "awesome-service"
|
||||
},
|
||||
"sessionAffinity": "None",
|
||||
"type": "ClusterIP"
|
||||
},
|
||||
"status": {
|
||||
"loadBalancer": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"annotations": {
|
||||
"artifact.spinnaker.io/location": "default",
|
||||
"artifact.spinnaker.io/name": "awesome-service-pg",
|
||||
"artifact.spinnaker.io/type": "kubernetes/service",
|
||||
"moniker.spinnaker.io/application": "awesome-service",
|
||||
"moniker.spinnaker.io/cluster": "service awesome-service-pg"
|
||||
},
|
||||
"creationTimestamp": "2021-01-04T16:35:02.000Z",
|
||||
"labels": {
|
||||
"app": "awesome-service",
|
||||
"app.kubernetes.io/managed-by": "spinnaker",
|
||||
"app.kubernetes.io/name": "awesome-service"
|
||||
},
|
||||
"name": "awesome-service-pg",
|
||||
"namespace": "default",
|
||||
"resourceVersion": "548901625",
|
||||
"selfLink": "/api/v1/namespaces/default/services/awesome-service-pg",
|
||||
"uid": "7d7ff8f2-6caa-4888-ae55-b6d41833ab92"
|
||||
},
|
||||
"spec": {
|
||||
"externalName": "10.244.0.5",
|
||||
"ports": [
|
||||
{
|
||||
"name": "pg",
|
||||
"port": 5432,
|
||||
"protocol": "TCP",
|
||||
"targetPort": 5432
|
||||
}
|
||||
],
|
||||
"sessionAffinity": "None",
|
||||
"type": "ExternalName"
|
||||
},
|
||||
"status": {
|
||||
"loadBalancer": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* 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 { ServicesAccordions } from './ServicesAccordions';
|
||||
@@ -120,7 +120,7 @@ export class JobProcessor implements Processor {
|
||||
// Log to the current stage the error that occurred and fail the stage.
|
||||
stage.status = 'FAILED';
|
||||
logger.error(`Stage failed with error: ${error.message}`);
|
||||
|
||||
logger.debug(error.stack);
|
||||
// Throw the error so the job can be failed too.
|
||||
throw error;
|
||||
} finally {
|
||||
|
||||
@@ -13,15 +13,11 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import {
|
||||
makeDeprecatedLocationTypeDetector,
|
||||
parseLocationAnnotation,
|
||||
} from './helpers';
|
||||
import { parseLocationAnnotation } from './helpers';
|
||||
import {
|
||||
TemplateEntityV1alpha1,
|
||||
LOCATION_ANNOTATION,
|
||||
} from '@backstage/catalog-model';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
|
||||
describe('Helpers', () => {
|
||||
describe('parseLocationAnnotation', () => {
|
||||
@@ -30,10 +26,7 @@ describe('Helpers', () => {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
annotations: {
|
||||
// [LOCATION_ANNOTATION]:
|
||||
// 'github:https://github.com/benjdlambert/backstage-graphql-template/blob/master/template.yaml',
|
||||
},
|
||||
annotations: {},
|
||||
name: 'graphql-starter',
|
||||
title: 'GraphQL Service',
|
||||
description:
|
||||
@@ -257,29 +250,4 @@ describe('Helpers', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeDeprecatedLocationTypeDetector', () => {
|
||||
it('detects deprecated location types', () => {
|
||||
const detector = makeDeprecatedLocationTypeDetector(
|
||||
new ConfigReader({
|
||||
integrations: {
|
||||
github: [{ host: 'derp.com' }, { host: 'foo.com' }],
|
||||
gitlab: [{ host: 'derp.org' }, { host: 'foo.org' }],
|
||||
azure: [{ host: 'derp.net' }, { host: 'foo.net' }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(detector('http://lol:wut@derp.com/wat')).toBe('github');
|
||||
expect(detector('https://foo.com/wat')).toBe('github');
|
||||
expect(detector('http://derp.org:80/wat')).toBe('gitlab');
|
||||
expect(detector('https://foo.org/wat')).toBe('gitlab');
|
||||
expect(detector('http://not.derp.net')).toBe(undefined);
|
||||
expect(detector('http://derp.net')).toBe('azure/api');
|
||||
expect(detector('http://derp.net:8080/wat')).toBe('azure/api');
|
||||
expect(detector('http://github.com')).toBe('github');
|
||||
expect(detector('http://gitlab.com')).toBe('gitlab');
|
||||
expect(detector('http://dev.azure.com')).toBe('azure/api');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,12 +17,10 @@ import {
|
||||
TemplateEntityV1alpha1,
|
||||
LOCATION_ANNOTATION,
|
||||
} from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import { InputError } from '@backstage/backend-common';
|
||||
import { RemoteProtocol } from './types';
|
||||
|
||||
export type ParsedLocationAnnotation = {
|
||||
protocol: RemoteProtocol;
|
||||
protocol: 'file' | 'url';
|
||||
location: string;
|
||||
};
|
||||
|
||||
@@ -40,7 +38,7 @@ export const parseLocationAnnotation = (
|
||||
// split on the first colon for the protocol and the rest after the first split
|
||||
// is the location.
|
||||
const [protocol, location] = annotation.split(/:(.+)/) as [
|
||||
RemoteProtocol?,
|
||||
('file' | 'url')?,
|
||||
string?,
|
||||
];
|
||||
|
||||
@@ -55,41 +53,3 @@ export const parseLocationAnnotation = (
|
||||
location,
|
||||
};
|
||||
};
|
||||
|
||||
export type DeprecatedLocationTypeDetector = (
|
||||
url: string,
|
||||
) => string | undefined;
|
||||
|
||||
// The reason for the existence of this is to help in migration to using mostly locations
|
||||
// of type 'url'. This allows us to detect the deprecated location type based on the host,
|
||||
// which we in turn can use to select out preparer or publisher.
|
||||
//
|
||||
// TODO(Rugvip): This should be removed in the future once we fully migrate to using
|
||||
// integrations configuration for the scaffolder.
|
||||
export function makeDeprecatedLocationTypeDetector(
|
||||
config: Config,
|
||||
): DeprecatedLocationTypeDetector {
|
||||
const hostMap = new Map();
|
||||
|
||||
// These are installed by default by the integrations
|
||||
hostMap.set('github.com', 'github');
|
||||
hostMap.set('gitlab.com', 'gitlab');
|
||||
hostMap.set('dev.azure.com', 'azure/api');
|
||||
|
||||
config.getOptionalConfigArray('integrations.github')?.forEach(sub => {
|
||||
hostMap.set(sub.getString('host'), 'github');
|
||||
});
|
||||
config.getOptionalConfigArray('integrations.gitlab')?.forEach(sub => {
|
||||
hostMap.set(sub.getString('host'), 'gitlab');
|
||||
});
|
||||
config.getOptionalConfigArray('integrations.azure')?.forEach(sub => {
|
||||
hostMap.set(sub.getString('host'), 'azure/api');
|
||||
});
|
||||
config.getOptionalConfigArray('integrations.bitbucket')?.forEach(sub => {
|
||||
hostMap.set(sub.getString('host'), 'bitbucket');
|
||||
});
|
||||
return (url: string): string | undefined => {
|
||||
const parsed = new URL(url);
|
||||
return hostMap.get(parsed.hostname);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,13 +26,14 @@ import {
|
||||
LOCATION_ANNOTATION,
|
||||
} from '@backstage/catalog-model';
|
||||
import { getVoidLogger, Git } from '@backstage/backend-common';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
|
||||
describe('AzurePreparer', () => {
|
||||
const mockGitClient = {
|
||||
clone: jest.fn(),
|
||||
};
|
||||
|
||||
const logger = getVoidLogger();
|
||||
|
||||
jest.spyOn(Git, 'fromAuth').mockReturnValue(mockGitClient as any);
|
||||
|
||||
let mockEntity: TemplateEntityV1alpha1;
|
||||
@@ -44,7 +45,7 @@ describe('AzurePreparer', () => {
|
||||
metadata: {
|
||||
annotations: {
|
||||
[LOCATION_ANNOTATION]:
|
||||
'azure/api:https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=%2Ftemplate.yaml',
|
||||
'url:https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=%2Ftemplate.yaml',
|
||||
},
|
||||
name: 'graphql-starter',
|
||||
title: 'GraphQL Service',
|
||||
@@ -78,30 +79,33 @@ describe('AzurePreparer', () => {
|
||||
};
|
||||
});
|
||||
|
||||
it('initializes git client with the correct arguments if an access token is provided for a repository', async () => {
|
||||
const preparer = new AzurePreparer(
|
||||
new ConfigReader({
|
||||
scaffolder: {
|
||||
azure: {
|
||||
api: {
|
||||
token: 'fake-token',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const logger = getVoidLogger();
|
||||
const preparer = AzurePreparer.fromConfig({
|
||||
host: 'dev.azure.com',
|
||||
token: 'fake-azure-token',
|
||||
});
|
||||
|
||||
// TODO(blam): Here's a test that will fail when the deprecation is complete
|
||||
it('calls the clone command with deprecated token', async () => {
|
||||
await preparer.prepare(mockEntity, { logger });
|
||||
|
||||
expect(Git.fromAuth).toHaveBeenCalledWith({
|
||||
username: 'notempty',
|
||||
password: 'fake-token',
|
||||
logger,
|
||||
password: 'fake-azure-token',
|
||||
username: 'notempty',
|
||||
});
|
||||
});
|
||||
it('calls the clone command with the correct arguments for a repository', async () => {
|
||||
const preparer = new AzurePreparer(new ConfigReader({}));
|
||||
|
||||
it('calls the clone command with token from integrations config', async () => {
|
||||
await preparer.prepare(mockEntity, { logger });
|
||||
|
||||
expect(Git.fromAuth).toHaveBeenCalledWith({
|
||||
logger,
|
||||
password: 'fake-azure-token',
|
||||
username: 'notempty',
|
||||
});
|
||||
});
|
||||
|
||||
it('calls the clone command with the correct arguments for a repository', async () => {
|
||||
await preparer.prepare(mockEntity, { logger: getVoidLogger() });
|
||||
|
||||
expect(mockGitClient.clone).toHaveBeenCalledWith({
|
||||
@@ -112,7 +116,6 @@ describe('AzurePreparer', () => {
|
||||
});
|
||||
|
||||
it('calls the clone command with the correct arguments for a repository when no path is provided', async () => {
|
||||
const preparer = new AzurePreparer(new ConfigReader({}));
|
||||
delete mockEntity.spec.path;
|
||||
|
||||
await preparer.prepare(mockEntity, { logger: getVoidLogger() });
|
||||
@@ -125,7 +128,6 @@ describe('AzurePreparer', () => {
|
||||
});
|
||||
|
||||
it('return the temp directory with the path to the folder if it is specified', async () => {
|
||||
const preparer = new AzurePreparer(new ConfigReader({}));
|
||||
mockEntity.spec.path = './template/test/1/2/3';
|
||||
|
||||
const response = await preparer.prepare(mockEntity, {
|
||||
@@ -138,12 +140,11 @@ describe('AzurePreparer', () => {
|
||||
});
|
||||
|
||||
it('return the working directory with the path to the folder if it is specified', async () => {
|
||||
const preparer = new AzurePreparer(new ConfigReader({}));
|
||||
mockEntity.spec.path = './template/test/1/2/3';
|
||||
|
||||
const response = await preparer.prepare(mockEntity, {
|
||||
logger: getVoidLogger(),
|
||||
workingDirectory: '/workDir',
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
expect(response.split('\\').join('/')).toMatch(
|
||||
|
||||
@@ -18,32 +18,26 @@ import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
|
||||
import { parseLocationAnnotation } from '../helpers';
|
||||
import { InputError, Git } from '@backstage/backend-common';
|
||||
import { Git } from '@backstage/backend-common';
|
||||
import { PreparerBase, PreparerOptions } from './types';
|
||||
import parseGitUrl from 'git-url-parse';
|
||||
import { Config } from '@backstage/config';
|
||||
import { AzureIntegrationConfig } from '@backstage/integration';
|
||||
|
||||
export class AzurePreparer implements PreparerBase {
|
||||
private readonly privateToken: string;
|
||||
|
||||
constructor(config: Config) {
|
||||
this.privateToken =
|
||||
config.getOptionalString('scaffolder.azure.api.token') ?? '';
|
||||
static fromConfig(config: AzureIntegrationConfig) {
|
||||
return new AzurePreparer({ token: config.token });
|
||||
}
|
||||
|
||||
constructor(private readonly config: { token?: string }) {}
|
||||
|
||||
async prepare(
|
||||
template: TemplateEntityV1alpha1,
|
||||
opts: PreparerOptions,
|
||||
): Promise<string> {
|
||||
const { protocol, location } = parseLocationAnnotation(template);
|
||||
const workingDirectory = opts?.workingDirectory ?? os.tmpdir();
|
||||
const { logger } = opts;
|
||||
const { location } = parseLocationAnnotation(template);
|
||||
const workingDirectory = opts.workingDirectory ?? os.tmpdir();
|
||||
const logger = opts.logger;
|
||||
|
||||
if (!['azure/api', 'url'].includes(protocol)) {
|
||||
throw new InputError(
|
||||
`Wrong location protocol: ${protocol}, should be 'url'`,
|
||||
);
|
||||
}
|
||||
const templateId = template.metadata.name;
|
||||
|
||||
const parsedGitLocation = parseGitUrl(location);
|
||||
@@ -59,9 +53,9 @@ export class AzurePreparer implements PreparerBase {
|
||||
|
||||
// Username can be anything but the empty string according to:
|
||||
// https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=preview-page#use-a-pat
|
||||
const git = this.privateToken
|
||||
const git = this.config.token
|
||||
? Git.fromAuth({
|
||||
password: this.privateToken,
|
||||
password: this.config.token,
|
||||
username: 'notempty',
|
||||
logger,
|
||||
})
|
||||
|
||||
@@ -26,10 +26,10 @@ import {
|
||||
LOCATION_ANNOTATION,
|
||||
} from '@backstage/catalog-model';
|
||||
import { getVoidLogger, Git } from '@backstage/backend-common';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
|
||||
describe('BitbucketPreparer', () => {
|
||||
let mockEntity: TemplateEntityV1alpha1;
|
||||
const logger = getVoidLogger();
|
||||
const mockGitClient = {
|
||||
clone: jest.fn(),
|
||||
};
|
||||
@@ -78,8 +78,13 @@ describe('BitbucketPreparer', () => {
|
||||
};
|
||||
});
|
||||
|
||||
const preparer = BitbucketPreparer.fromConfig({
|
||||
host: 'bitbucket.org',
|
||||
username: 'fake-user',
|
||||
appPassword: 'fake-password',
|
||||
});
|
||||
|
||||
it('calls the clone command with the correct arguments for a repository', async () => {
|
||||
const preparer = new BitbucketPreparer(new ConfigReader({}));
|
||||
await preparer.prepare(mockEntity, { logger: getVoidLogger() });
|
||||
expect(mockGitClient.clone).toHaveBeenCalledWith({
|
||||
url: 'https://bitbucket.org/backstage-project/backstage-repo',
|
||||
@@ -88,28 +93,21 @@ describe('BitbucketPreparer', () => {
|
||||
});
|
||||
|
||||
it('calls the clone command with the correct arguments if an app password is provided for a repository', async () => {
|
||||
const preparer = new BitbucketPreparer(
|
||||
new ConfigReader({
|
||||
integrations: {
|
||||
bitbucket: [
|
||||
{
|
||||
host: 'bitbucket.org',
|
||||
username: 'fake-user',
|
||||
appPassword: 'fake-password',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await preparer.prepare(mockEntity, { logger: getVoidLogger() });
|
||||
expect(mockGitClient.clone).toHaveBeenCalledWith({
|
||||
url: 'https://bitbucket.org/backstage-project/backstage-repo',
|
||||
dir: expect.any(String),
|
||||
const preparer = BitbucketPreparer.fromConfig({
|
||||
host: 'bitbucket.org',
|
||||
username: 'fake-user',
|
||||
appPassword: 'fake-password',
|
||||
});
|
||||
await preparer.prepare(mockEntity, { logger });
|
||||
|
||||
expect(Git.fromAuth).toHaveBeenCalledWith({
|
||||
logger,
|
||||
username: 'fake-user',
|
||||
password: 'fake-password',
|
||||
});
|
||||
});
|
||||
|
||||
it('calls the clone command with the correct arguments for a repository when no path is provided', async () => {
|
||||
const preparer = new BitbucketPreparer(new ConfigReader({}));
|
||||
delete mockEntity.spec.path;
|
||||
await preparer.prepare(mockEntity, { logger: getVoidLogger() });
|
||||
expect(mockGitClient.clone).toHaveBeenCalledWith({
|
||||
@@ -119,7 +117,6 @@ describe('BitbucketPreparer', () => {
|
||||
});
|
||||
|
||||
it('return the temp directory with the path to the folder if it is specified', async () => {
|
||||
const preparer = new BitbucketPreparer(new ConfigReader({}));
|
||||
mockEntity.spec.path = './template/test/1/2/3';
|
||||
const response = await preparer.prepare(mockEntity, {
|
||||
logger: getVoidLogger(),
|
||||
@@ -130,12 +127,26 @@ describe('BitbucketPreparer', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('calls the clone command with with token for auth method', async () => {
|
||||
const preparer = BitbucketPreparer.fromConfig({
|
||||
host: 'bitbucket.org',
|
||||
token: 'fake-token',
|
||||
});
|
||||
|
||||
await preparer.prepare(mockEntity, { logger });
|
||||
|
||||
expect(Git.fromAuth).toHaveBeenCalledWith({
|
||||
logger,
|
||||
username: 'x-token-auth',
|
||||
password: 'fake-token',
|
||||
});
|
||||
});
|
||||
|
||||
it('return the working directory with the path to the folder if it is specified', async () => {
|
||||
const preparer = new BitbucketPreparer(new ConfigReader({}));
|
||||
mockEntity.spec.path = './template/test/1/2/3';
|
||||
const response = await preparer.prepare(mockEntity, {
|
||||
logger: getVoidLogger(),
|
||||
workingDirectory: '/workDir',
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
expect(response.split('\\').join('/')).toMatch(
|
||||
|
||||
@@ -18,35 +18,35 @@ import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
|
||||
import { parseLocationAnnotation } from '../helpers';
|
||||
import { InputError, Git } from '@backstage/backend-common';
|
||||
import { Git } from '@backstage/backend-common';
|
||||
import { PreparerBase, PreparerOptions } from './types';
|
||||
import { BitbucketIntegrationConfig } from '@backstage/integration';
|
||||
import parseGitUrl from 'git-url-parse';
|
||||
import { Config } from '@backstage/config';
|
||||
|
||||
export class BitbucketPreparer implements PreparerBase {
|
||||
private readonly privateToken: string;
|
||||
private readonly username: string;
|
||||
|
||||
constructor(config: Config) {
|
||||
this.username =
|
||||
config.getOptionalString('scaffolder.bitbucket.api.username') ?? '';
|
||||
this.privateToken =
|
||||
config.getOptionalString('scaffolder.bitbucket.api.token') ?? '';
|
||||
static fromConfig(config: BitbucketIntegrationConfig) {
|
||||
return new BitbucketPreparer({
|
||||
username: config.username,
|
||||
token: config.token,
|
||||
appPassword: config.appPassword,
|
||||
});
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly config: {
|
||||
username?: string;
|
||||
token?: string;
|
||||
appPassword?: string;
|
||||
},
|
||||
) {}
|
||||
|
||||
async prepare(
|
||||
template: TemplateEntityV1alpha1,
|
||||
opts: PreparerOptions,
|
||||
): Promise<string> {
|
||||
const { protocol, location } = parseLocationAnnotation(template);
|
||||
const workingDirectory = opts?.workingDirectory ?? os.tmpdir();
|
||||
const { logger } = opts;
|
||||
|
||||
if (!['bitbucket', 'url'].includes(protocol)) {
|
||||
throw new InputError(
|
||||
`Wrong location protocol: ${protocol}, should be 'url'`,
|
||||
);
|
||||
}
|
||||
const { location } = parseLocationAnnotation(template);
|
||||
const workingDirectory = opts.workingDirectory ?? os.tmpdir();
|
||||
const logger = opts.logger;
|
||||
const templateId = template.metadata.name;
|
||||
|
||||
const repo = parseGitUrl(location);
|
||||
@@ -63,10 +63,10 @@ export class BitbucketPreparer implements PreparerBase {
|
||||
|
||||
const checkoutLocation = path.resolve(tempDir, templateDirectory);
|
||||
|
||||
const git = this.privateToken
|
||||
const auth = this.getAuth();
|
||||
const git = auth
|
||||
? Git.fromAuth({
|
||||
username: this.username,
|
||||
password: this.privateToken,
|
||||
...auth,
|
||||
logger,
|
||||
})
|
||||
: Git.fromAuth({ logger });
|
||||
@@ -78,4 +78,21 @@ export class BitbucketPreparer implements PreparerBase {
|
||||
|
||||
return checkoutLocation;
|
||||
}
|
||||
|
||||
private getAuth(): { username: string; password: string } | undefined {
|
||||
const { username, token, appPassword } = this.config;
|
||||
|
||||
if (username && appPassword) {
|
||||
return { username: username, password: appPassword };
|
||||
}
|
||||
|
||||
if (token) {
|
||||
return {
|
||||
username: 'x-token-auth',
|
||||
password: token! || appPassword!,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ describe('GitHubPreparer', () => {
|
||||
const mockGitClient = {
|
||||
clone: jest.fn(),
|
||||
};
|
||||
const logger = getVoidLogger();
|
||||
|
||||
jest.spyOn(Git, 'fromAuth').mockReturnValue(mockGitClient as any);
|
||||
|
||||
@@ -76,9 +77,13 @@ describe('GitHubPreparer', () => {
|
||||
},
|
||||
};
|
||||
});
|
||||
it('calls the clone command with the correct arguments for a repository', async () => {
|
||||
const preparer = new GithubPreparer();
|
||||
|
||||
const preparer = GithubPreparer.fromConfig({
|
||||
host: 'github.com',
|
||||
token: 'fake-token',
|
||||
});
|
||||
|
||||
it('calls the clone command with the correct arguments for a repository', async () => {
|
||||
await preparer.prepare(mockEntity, { logger: getVoidLogger() });
|
||||
|
||||
expect(mockGitClient.clone).toHaveBeenCalledWith({
|
||||
@@ -86,8 +91,8 @@ describe('GitHubPreparer', () => {
|
||||
dir: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it('calls the clone command with the correct arguments for a repository when no path is provided', async () => {
|
||||
const preparer = new GithubPreparer();
|
||||
delete mockEntity.spec.path;
|
||||
|
||||
await preparer.prepare(mockEntity, { logger: getVoidLogger() });
|
||||
@@ -99,23 +104,29 @@ describe('GitHubPreparer', () => {
|
||||
});
|
||||
|
||||
it('return the temp directory with the path to the folder if it is specified', async () => {
|
||||
const preparer = new GithubPreparer();
|
||||
const preparer = GithubPreparer.fromConfig({
|
||||
host: 'github.com',
|
||||
token: 'fake-token',
|
||||
});
|
||||
mockEntity.spec.path = './template/test/1/2/3';
|
||||
const response = await preparer.prepare(mockEntity, {
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
expect(response.split('\\').join('/')).toMatch(
|
||||
/\/template\/test\/1\/2\/3$/,
|
||||
);
|
||||
});
|
||||
|
||||
it('return the working directory with the path to the folder if it is specified', async () => {
|
||||
const preparer = new GithubPreparer();
|
||||
const preparer = GithubPreparer.fromConfig({
|
||||
host: 'github.com',
|
||||
token: 'fake-token',
|
||||
});
|
||||
|
||||
mockEntity.spec.path = './template/test/1/2/3';
|
||||
const response = await preparer.prepare(mockEntity, {
|
||||
logger: getVoidLogger(),
|
||||
workingDirectory: '/workDir',
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
expect(response.split('\\').join('/')).toMatch(
|
||||
@@ -123,15 +134,12 @@ describe('GitHubPreparer', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('calls the clone command with the token when provided', async () => {
|
||||
const preparer = new GithubPreparer({ token: 'abc' });
|
||||
const logger = getVoidLogger();
|
||||
|
||||
it('calls the clone command with token', async () => {
|
||||
await preparer.prepare(mockEntity, { logger });
|
||||
|
||||
expect(Git.fromAuth).toHaveBeenCalledWith({
|
||||
logger,
|
||||
username: 'abc',
|
||||
username: 'fake-token',
|
||||
password: 'x-oauth-basic',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,30 +18,26 @@ import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
|
||||
import { parseLocationAnnotation } from '../helpers';
|
||||
import { InputError, Git } from '@backstage/backend-common';
|
||||
import { Git } from '@backstage/backend-common';
|
||||
import { PreparerBase, PreparerOptions } from './types';
|
||||
import parseGitUrl from 'git-url-parse';
|
||||
import { GitHubIntegrationConfig } from '@backstage/integration';
|
||||
|
||||
export class GithubPreparer implements PreparerBase {
|
||||
token?: string;
|
||||
|
||||
constructor(params: { token?: string } = {}) {
|
||||
this.token = params.token;
|
||||
static fromConfig(config: GitHubIntegrationConfig) {
|
||||
return new GithubPreparer({ token: config.token });
|
||||
}
|
||||
|
||||
constructor(private readonly config: { token?: string }) {}
|
||||
|
||||
async prepare(
|
||||
template: TemplateEntityV1alpha1,
|
||||
opts: PreparerOptions,
|
||||
): Promise<string> {
|
||||
const { protocol, location } = parseLocationAnnotation(template);
|
||||
const workingDirectory = opts?.workingDirectory ?? os.tmpdir();
|
||||
const { logger } = opts;
|
||||
const { location } = parseLocationAnnotation(template);
|
||||
const workingDirectory = opts.workingDirectory ?? os.tmpdir();
|
||||
const logger = opts.logger;
|
||||
|
||||
if (!['github', 'url'].includes(protocol)) {
|
||||
throw new InputError(
|
||||
`Wrong location protocol: ${protocol}, should be 'url'`,
|
||||
);
|
||||
}
|
||||
const templateId = template.metadata.name;
|
||||
|
||||
const parsedGitLocation = parseGitUrl(location);
|
||||
@@ -57,9 +53,9 @@ export class GithubPreparer implements PreparerBase {
|
||||
|
||||
const checkoutLocation = path.resolve(tempDir, templateDirectory);
|
||||
|
||||
const git = this.token
|
||||
const git = this.config.token
|
||||
? Git.fromAuth({
|
||||
username: this.token,
|
||||
username: this.config.token,
|
||||
password: 'x-oauth-basic',
|
||||
logger,
|
||||
})
|
||||
|
||||
@@ -24,15 +24,15 @@ import {
|
||||
TemplateEntityV1alpha1,
|
||||
LOCATION_ANNOTATION,
|
||||
} from '@backstage/catalog-model';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { getVoidLogger, Git } from '@backstage/backend-common';
|
||||
|
||||
const mockEntityWithProtocol = (protocol: string): TemplateEntityV1alpha1 => ({
|
||||
const mockTemplate = (): TemplateEntityV1alpha1 => ({
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
annotations: {
|
||||
[LOCATION_ANNOTATION]: `${protocol}:https://gitlab.com/benjdlambert/backstage-graphql-template/-/blob/master/template.yaml`,
|
||||
[LOCATION_ANNOTATION]:
|
||||
'url:https://gitlab.com/benjdlambert/backstage-graphql-template/-/blob/master/template.yaml',
|
||||
},
|
||||
name: 'graphql-starter',
|
||||
title: 'GraphQL Service',
|
||||
@@ -70,108 +70,72 @@ describe('GitLabPreparer', () => {
|
||||
const mockGitClient = {
|
||||
clone: jest.fn(),
|
||||
};
|
||||
const logger = getVoidLogger();
|
||||
|
||||
jest.spyOn(Git, 'fromAuth').mockReturnValue(mockGitClient as any);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
const preparer = GitlabPreparer.fromConfig({
|
||||
host: 'gitlab.com',
|
||||
token: 'fake-token',
|
||||
});
|
||||
it(`calls the clone command with the correct arguments for a repository`, async () => {
|
||||
mockEntity = mockTemplate();
|
||||
|
||||
['gitlab', 'gitlab/api'].forEach(protocol => {
|
||||
it(`calls the clone command with the correct arguments for a repository using the ${protocol} protocol`, async () => {
|
||||
const preparer = new GitlabPreparer(new ConfigReader({}));
|
||||
mockEntity = mockEntityWithProtocol(protocol);
|
||||
await preparer.prepare(mockEntity, { logger: getVoidLogger() });
|
||||
|
||||
await preparer.prepare(mockEntity, { logger: getVoidLogger() });
|
||||
|
||||
expect(mockGitClient.clone).toHaveBeenCalledWith({
|
||||
url: 'https://gitlab.com/benjdlambert/backstage-graphql-template',
|
||||
dir: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it(`calls the clone command with the correct arguments if an access token is provided in integrations for a repository using the ${protocol} protocol`, async () => {
|
||||
const preparer = new GitlabPreparer(
|
||||
new ConfigReader({
|
||||
integrations: {
|
||||
gitlab: [
|
||||
{
|
||||
host: 'gitlab.com',
|
||||
token: 'fake-token',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
mockEntity = mockEntityWithProtocol(protocol);
|
||||
const logger = getVoidLogger();
|
||||
|
||||
await preparer.prepare(mockEntity, { logger });
|
||||
|
||||
expect(Git.fromAuth).toHaveBeenCalledWith({
|
||||
logger,
|
||||
username: 'oauth2',
|
||||
password: 'fake-token',
|
||||
});
|
||||
});
|
||||
|
||||
it(`calls the clone command with the correct arguments if an access token is provided in scaffolder for a repository using the ${protocol} protocol`, async () => {
|
||||
const preparer = new GitlabPreparer(
|
||||
new ConfigReader({
|
||||
scaffolder: {
|
||||
gitlab: { api: { token: 'fake-token' } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
mockEntity = mockEntityWithProtocol(protocol);
|
||||
const logger = getVoidLogger();
|
||||
|
||||
await preparer.prepare(mockEntity, { logger });
|
||||
|
||||
expect(Git.fromAuth).toHaveBeenCalledWith({
|
||||
logger,
|
||||
username: 'oauth2',
|
||||
password: 'fake-token',
|
||||
});
|
||||
});
|
||||
|
||||
it(`calls the clone command with the correct arguments for a repository when no path is provided using the ${protocol} protocol`, async () => {
|
||||
const preparer = new GitlabPreparer(new ConfigReader({}));
|
||||
mockEntity = mockEntityWithProtocol(protocol);
|
||||
delete mockEntity.spec.path;
|
||||
|
||||
await preparer.prepare(mockEntity, { logger: getVoidLogger() });
|
||||
|
||||
expect(mockGitClient.clone).toHaveBeenCalledWith({
|
||||
url: 'https://gitlab.com/benjdlambert/backstage-graphql-template',
|
||||
dir: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it(`return the temp directory with the path to the folder if it is specified using the ${protocol} protocol`, async () => {
|
||||
const preparer = new GitlabPreparer(new ConfigReader({}));
|
||||
mockEntity = mockEntityWithProtocol(protocol);
|
||||
mockEntity.spec.path = './template/test/1/2/3';
|
||||
const response = await preparer.prepare(mockEntity, {
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
expect(response.split('\\').join('/')).toMatch(
|
||||
/\/template\/test\/1\/2\/3$/,
|
||||
);
|
||||
});
|
||||
|
||||
it('return the working directory with the path to the folder if it is specified', async () => {
|
||||
const preparer = new GitlabPreparer(new ConfigReader({}));
|
||||
mockEntity.spec.path = './template/test/1/2/3';
|
||||
const response = await preparer.prepare(mockEntity, {
|
||||
logger: getVoidLogger(),
|
||||
workingDirectory: '/workDir',
|
||||
});
|
||||
|
||||
expect(response.split('\\').join('/')).toMatch(
|
||||
/\/workDir\/graphql-starter-static\/template\/test\/1\/2\/3$/,
|
||||
);
|
||||
expect(mockGitClient.clone).toHaveBeenCalledWith({
|
||||
url: 'https://gitlab.com/benjdlambert/backstage-graphql-template',
|
||||
dir: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it(`calls the clone command with the correct arguments if an access token is provided in integrations for a repository`, async () => {
|
||||
mockEntity = mockTemplate();
|
||||
|
||||
await preparer.prepare(mockEntity, { logger });
|
||||
|
||||
expect(Git.fromAuth).toHaveBeenCalledWith({
|
||||
logger,
|
||||
username: 'oauth2',
|
||||
password: 'fake-token',
|
||||
});
|
||||
});
|
||||
|
||||
it(`calls the clone command with the correct arguments for a repository when no path is provided`, async () => {
|
||||
mockEntity = mockTemplate();
|
||||
delete mockEntity.spec.path;
|
||||
|
||||
await preparer.prepare(mockEntity, { logger: getVoidLogger() });
|
||||
|
||||
expect(mockGitClient.clone).toHaveBeenCalledWith({
|
||||
url: 'https://gitlab.com/benjdlambert/backstage-graphql-template',
|
||||
dir: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it(`return the temp directory with the path to the folder if it is specified`, async () => {
|
||||
mockEntity = mockTemplate();
|
||||
mockEntity.spec.path = './template/test/1/2/3';
|
||||
const response = await preparer.prepare(mockEntity, {
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
expect(response.split('\\').join('/')).toMatch(
|
||||
/\/template\/test\/1\/2\/3$/,
|
||||
);
|
||||
});
|
||||
|
||||
it('return the working directory with the path to the folder if it is specified', async () => {
|
||||
mockEntity.spec.path = './template/test/1/2/3';
|
||||
const response = await preparer.prepare(mockEntity, {
|
||||
workingDirectory: '/workDir',
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
expect(response.split('\\').join('/')).toMatch(
|
||||
/\/workDir\/graphql-starter-static\/template\/test\/1\/2\/3$/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,13 +13,9 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { InputError, Git } from '@backstage/backend-common';
|
||||
import { Git } from '@backstage/backend-common';
|
||||
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import {
|
||||
GitLabIntegrationConfig,
|
||||
readGitLabIntegrationConfigs,
|
||||
} from '@backstage/integration';
|
||||
import { GitLabIntegrationConfig } from '@backstage/integration';
|
||||
import fs from 'fs-extra';
|
||||
import parseGitUrl from 'git-url-parse';
|
||||
import os from 'os';
|
||||
@@ -28,31 +24,20 @@ import { parseLocationAnnotation } from '../helpers';
|
||||
import { PreparerBase, PreparerOptions } from './types';
|
||||
|
||||
export class GitlabPreparer implements PreparerBase {
|
||||
private readonly integrations: GitLabIntegrationConfig[];
|
||||
private readonly scaffolderToken: string | undefined;
|
||||
|
||||
constructor(config: Config) {
|
||||
this.integrations = readGitLabIntegrationConfigs(
|
||||
config.getOptionalConfigArray('integrations.gitlab') ?? [],
|
||||
);
|
||||
this.scaffolderToken = config.getOptionalString(
|
||||
'scaffolder.gitlab.api.token',
|
||||
);
|
||||
static fromConfig(config: GitLabIntegrationConfig) {
|
||||
return new GitlabPreparer({ token: config.token });
|
||||
}
|
||||
|
||||
constructor(private readonly config: { token?: string }) {}
|
||||
|
||||
async prepare(
|
||||
template: TemplateEntityV1alpha1,
|
||||
opts: PreparerOptions,
|
||||
): Promise<string> {
|
||||
const { protocol, location } = parseLocationAnnotation(template);
|
||||
const { logger } = opts;
|
||||
const workingDirectory = opts?.workingDirectory ?? os.tmpdir();
|
||||
const { location } = parseLocationAnnotation(template);
|
||||
const logger = opts.logger;
|
||||
const workingDirectory = opts.workingDirectory ?? os.tmpdir();
|
||||
|
||||
if (!['gitlab', 'gitlab/api', 'url'].includes(protocol)) {
|
||||
throw new InputError(
|
||||
`Wrong location protocol: ${protocol}, should be 'url'`,
|
||||
);
|
||||
}
|
||||
const templateId = template.metadata.name;
|
||||
|
||||
const parsedGitLocation = parseGitUrl(location);
|
||||
@@ -66,10 +51,9 @@ export class GitlabPreparer implements PreparerBase {
|
||||
template.spec.path ?? '.',
|
||||
);
|
||||
|
||||
const token = this.getToken(parsedGitLocation.resource);
|
||||
const git = token
|
||||
const git = this.config.token
|
||||
? Git.fromAuth({
|
||||
password: token,
|
||||
password: this.config.token,
|
||||
username: 'oauth2',
|
||||
logger,
|
||||
})
|
||||
@@ -82,11 +66,4 @@ export class GitlabPreparer implements PreparerBase {
|
||||
|
||||
return path.resolve(tempDir, templateDirectory);
|
||||
}
|
||||
|
||||
private getToken(host: string): string | undefined {
|
||||
return (
|
||||
this.scaffolderToken ||
|
||||
this.integrations.find(c => c.host === host)?.token
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,109 +14,36 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { Preparers } from '.';
|
||||
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
|
||||
import { FilePreparer } from './file';
|
||||
import { GithubPreparer } from './github';
|
||||
|
||||
describe('Preparers', () => {
|
||||
const mockTemplate: TemplateEntityV1alpha1 = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
annotations: {
|
||||
'backstage.io/managed-by-location':
|
||||
'file:/Users/bingo/backstage/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml',
|
||||
},
|
||||
name: 'react-ssr-template',
|
||||
title: 'React SSR Template',
|
||||
description:
|
||||
'Next.js application skeleton for creating isomorphic web applications.',
|
||||
uid: '7357f4c5-aa58-4a1e-9670-18931eef771f',
|
||||
etag: 'YWUxZWQyY2EtZDkxMC00MDM0LWI0ODAtMDgwMWY0YzdlMWIw',
|
||||
generation: 1,
|
||||
},
|
||||
spec: {
|
||||
templater: 'cookiecutter',
|
||||
path: '.',
|
||||
type: 'website',
|
||||
schema: {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
required: ['storePath', 'owner'],
|
||||
properties: {
|
||||
owner: {
|
||||
type: 'string',
|
||||
title: 'Owner',
|
||||
description: 'Who is going to own this component',
|
||||
},
|
||||
storePath: {
|
||||
type: 'string',
|
||||
title: 'Store path',
|
||||
description: 'GitHub store path in org/repo format',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
it('should throw an error when the preparer for the source location is not registered', () => {
|
||||
it('should return the correct preparer based on the hostname', async () => {
|
||||
const preparer = await GithubPreparer.fromConfig({
|
||||
host: 'github.com',
|
||||
apiBaseUrl: 'lols',
|
||||
token: 'something else yo',
|
||||
});
|
||||
|
||||
const preparers = new Preparers();
|
||||
preparers.register('github.com', preparer);
|
||||
|
||||
expect(() => preparers.get(mockTemplate)).toThrow(
|
||||
expect.objectContaining({
|
||||
message: 'No preparer registered for type: "file"',
|
||||
}),
|
||||
);
|
||||
});
|
||||
it('should return the correct preparer when the source matches', () => {
|
||||
const preparers = new Preparers();
|
||||
const preparer = new FilePreparer();
|
||||
|
||||
preparers.register('file', preparer);
|
||||
|
||||
expect(preparers.get(mockTemplate)).toBe(preparer);
|
||||
expect(
|
||||
preparers.get('https://github.com/please/find/me/something/from/github'),
|
||||
).toBe(preparer);
|
||||
});
|
||||
|
||||
it('should throw an error if the metadata tag does not exist in the entity', () => {
|
||||
const brokenTemplate: TemplateEntityV1alpha1 = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
annotations: {},
|
||||
name: 'react-ssr-template',
|
||||
title: 'React SSR Template',
|
||||
description:
|
||||
'Next.js application skeleton for creating isomorphic web applications.',
|
||||
uid: '7357f4c5-aa58-4a1e-9670-18931eef771f',
|
||||
etag: 'YWUxZWQyY2EtZDkxMC00MDM0LWI0ODAtMDgwMWY0YzdlMWIw',
|
||||
generation: 1,
|
||||
},
|
||||
spec: {
|
||||
type: 'website',
|
||||
templater: 'cookiecutter',
|
||||
path: '.',
|
||||
schema: {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
required: ['storePath', 'owner'],
|
||||
properties: {
|
||||
owner: {
|
||||
type: 'string',
|
||||
title: 'Owner',
|
||||
description: 'Who is going to own this component',
|
||||
},
|
||||
storePath: {
|
||||
type: 'string',
|
||||
title: 'Store path',
|
||||
description: 'GitHub store path in org/repo format',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
it('should throw an error if there is nothing that will match the url provided', async () => {
|
||||
const preparer = await GithubPreparer.fromConfig({
|
||||
host: 'github.com',
|
||||
apiBaseUrl: 'lols',
|
||||
token: 'something else yo',
|
||||
});
|
||||
|
||||
const preparers = new Preparers();
|
||||
preparers.register('github.com', preparer);
|
||||
|
||||
expect(() => preparers.get(brokenTemplate)).toThrow(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('No location annotation provided'),
|
||||
}),
|
||||
expect(() => preparers.get('https://404.com')).toThrow(
|
||||
`Unable to find a preparer for URL: https://404.com. Please make sure to register this host under an integration in app-config`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,95 +15,65 @@
|
||||
*/
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import { Logger } from 'winston';
|
||||
import { PreparerBase, PreparerBuilder } from './types';
|
||||
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
|
||||
import {
|
||||
DeprecatedLocationTypeDetector,
|
||||
makeDeprecatedLocationTypeDetector,
|
||||
parseLocationAnnotation,
|
||||
} from '../helpers';
|
||||
import { RemoteProtocol } from '../types';
|
||||
import { FilePreparer } from './file';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
import { GitlabPreparer } from './gitlab';
|
||||
import { AzurePreparer } from './azure';
|
||||
import { GithubPreparer } from './github';
|
||||
import { BitbucketPreparer } from './bitbucket';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
|
||||
export class Preparers implements PreparerBuilder {
|
||||
private preparerMap = new Map<RemoteProtocol, PreparerBase>();
|
||||
private preparerMap = new Map<string, PreparerBase>();
|
||||
|
||||
constructor(private readonly typeDetector?: DeprecatedLocationTypeDetector) {}
|
||||
|
||||
register(protocol: RemoteProtocol, preparer: PreparerBase) {
|
||||
this.preparerMap.set(protocol, preparer);
|
||||
register(host: string, preparer: PreparerBase) {
|
||||
this.preparerMap.set(host, preparer);
|
||||
}
|
||||
|
||||
get(template: TemplateEntityV1alpha1): PreparerBase {
|
||||
const { protocol, location } = parseLocationAnnotation(template);
|
||||
|
||||
const preparer = this.preparerMap.get(protocol);
|
||||
|
||||
get(url: string): PreparerBase {
|
||||
const preparer = this.preparerMap.get(new URL(url).host);
|
||||
if (!preparer) {
|
||||
if ((protocol as string) === 'url') {
|
||||
const type = this.typeDetector?.(location);
|
||||
const detected = type && this.preparerMap.get(type as RemoteProtocol);
|
||||
if (detected) {
|
||||
return detected;
|
||||
}
|
||||
if (type) {
|
||||
throw new Error(
|
||||
`No preparer configuration available for type '${type}' with url "${location}". ` +
|
||||
"Make sure you've added appropriate configuration in the 'scaffolder' configuration section",
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Failed to detect preparer type. Unable to determine integration type for location "${location}". ` +
|
||||
"Please add appropriate configuration to the 'integrations' configuration section",
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error(`No preparer registered for type: "${protocol}"`);
|
||||
throw new Error(
|
||||
`Unable to find a preparer for URL: ${url}. Please make sure to register this host under an integration in app-config`,
|
||||
);
|
||||
}
|
||||
|
||||
return preparer;
|
||||
}
|
||||
|
||||
static async fromConfig(
|
||||
config: Config,
|
||||
{ logger }: { logger: Logger },
|
||||
// eslint-disable-next-line
|
||||
_: { logger: Logger },
|
||||
): Promise<PreparerBuilder> {
|
||||
const typeDetector = makeDeprecatedLocationTypeDetector(config);
|
||||
const preparers = new Preparers();
|
||||
const scm = ScmIntegrations.fromConfig(config);
|
||||
for (const integration of scm.azure.list()) {
|
||||
preparers.register(
|
||||
integration.config.host,
|
||||
AzurePreparer.fromConfig(integration.config),
|
||||
);
|
||||
}
|
||||
|
||||
const preparers = new Preparers(typeDetector);
|
||||
for (const integration of scm.github.list()) {
|
||||
preparers.register(
|
||||
integration.config.host,
|
||||
GithubPreparer.fromConfig(integration.config),
|
||||
);
|
||||
}
|
||||
|
||||
const filePreparer = new FilePreparer();
|
||||
const gitlabPreparer = new GitlabPreparer(config);
|
||||
const azurePreparer = new AzurePreparer(config);
|
||||
const bitbucketPreparer = new BitbucketPreparer(config);
|
||||
for (const integration of scm.gitlab.list()) {
|
||||
preparers.register(
|
||||
integration.config.host,
|
||||
GitlabPreparer.fromConfig(integration.config),
|
||||
);
|
||||
}
|
||||
|
||||
preparers.register('file', filePreparer);
|
||||
preparers.register('gitlab', gitlabPreparer);
|
||||
preparers.register('gitlab/api', gitlabPreparer);
|
||||
preparers.register('azure/api', azurePreparer);
|
||||
preparers.register('bitbucket', bitbucketPreparer);
|
||||
|
||||
const githubConfig = config.getOptionalConfig('scaffolder.github');
|
||||
if (githubConfig) {
|
||||
try {
|
||||
const githubToken = githubConfig.getString('token');
|
||||
const githubPreparer = new GithubPreparer({ token: githubToken });
|
||||
|
||||
preparers.register('github', githubPreparer);
|
||||
} catch (e) {
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
throw new Error(
|
||||
`Failed to initialize github scaffolding provider, ${e.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
logger.warn(`Skipping github scaffolding provider, ${e.message}`);
|
||||
}
|
||||
for (const integration of scm.bitbucket.list()) {
|
||||
preparers.register(
|
||||
integration.config.host,
|
||||
BitbucketPreparer.fromConfig(integration.config),
|
||||
);
|
||||
}
|
||||
|
||||
return preparers;
|
||||
|
||||
@@ -15,14 +15,13 @@
|
||||
*/
|
||||
import type { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
|
||||
import { Logger } from 'winston';
|
||||
import { RemoteProtocol } from '../types';
|
||||
|
||||
export type PreparerOptions = {
|
||||
logger: Logger;
|
||||
workingDirectory?: string;
|
||||
logger: Logger;
|
||||
};
|
||||
|
||||
export type PreparerBase = {
|
||||
export interface PreparerBase {
|
||||
/**
|
||||
* Given an Entity definition from the Service Catalog, go and prepare a directory
|
||||
* with contents from the remote location in temporary storage and return the path
|
||||
@@ -30,11 +29,11 @@ export type PreparerBase = {
|
||||
*/
|
||||
prepare(
|
||||
template: TemplateEntityV1alpha1,
|
||||
opts: PreparerOptions,
|
||||
opts?: PreparerOptions,
|
||||
): Promise<string>;
|
||||
};
|
||||
}
|
||||
|
||||
export type PreparerBuilder = {
|
||||
register(protocol: RemoteProtocol, preparer: PreparerBase): void;
|
||||
get(template: TemplateEntityV1alpha1): PreparerBase;
|
||||
register(host: string, preparer: PreparerBase): void;
|
||||
get(url: string): PreparerBase;
|
||||
};
|
||||
|
||||
@@ -15,32 +15,40 @@
|
||||
*/
|
||||
jest.mock('./helpers');
|
||||
|
||||
jest.mock('azure-devops-node-api', () => ({
|
||||
WebApi: jest.fn(),
|
||||
getPersonalAccessTokenHandler: jest.fn(),
|
||||
}));
|
||||
|
||||
import { AzurePublisher } from './azure';
|
||||
import { GitApi } from 'azure-devops-node-api/GitApi';
|
||||
import { WebApi } from 'azure-devops-node-api';
|
||||
import * as helpers from './helpers';
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
|
||||
const { mockGitApi } = require('azure-devops-node-api/GitApi') as {
|
||||
mockGitApi: {
|
||||
createRepository: jest.MockedFunction<GitApi['createRepository']>;
|
||||
};
|
||||
};
|
||||
|
||||
describe('Azure Publisher', () => {
|
||||
const publisher = new AzurePublisher(new GitApi('', []), 'fake-token');
|
||||
const logger = getVoidLogger();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('publish: createRemoteInAzure', () => {
|
||||
it('should use azure-devops-node-api to create a repo in the given project', async () => {
|
||||
mockGitApi.createRepository.mockResolvedValue({
|
||||
const mockGitClient = {
|
||||
createRepository: jest.fn(),
|
||||
};
|
||||
const mockGitApi = {
|
||||
getGitApi: jest.fn().mockReturnValue(mockGitClient),
|
||||
};
|
||||
|
||||
((WebApi as unknown) as jest.Mock).mockImplementation(() => mockGitApi);
|
||||
|
||||
const publisher = await AzurePublisher.fromConfig({
|
||||
host: 'dev.azure.com',
|
||||
token: 'fake-azure-token',
|
||||
});
|
||||
|
||||
mockGitClient.createRepository.mockResolvedValue({
|
||||
remoteUrl: 'https://dev.azure.com/organization/project/_git/repo',
|
||||
} as { remoteUrl: string });
|
||||
|
||||
const result = await publisher.publish({
|
||||
const result = await publisher!.publish({
|
||||
values: {
|
||||
storePath: 'project/repo',
|
||||
owner: 'bob',
|
||||
@@ -54,7 +62,7 @@ describe('Azure Publisher', () => {
|
||||
catalogInfoUrl:
|
||||
'https://dev.azure.com/organization/project/_git/repo?path=%2Fcatalog-info.yaml',
|
||||
});
|
||||
expect(mockGitApi.createRepository).toHaveBeenCalledWith(
|
||||
expect(mockGitClient.createRepository).toHaveBeenCalledWith(
|
||||
{
|
||||
name: 'repo',
|
||||
},
|
||||
@@ -63,7 +71,7 @@ describe('Azure Publisher', () => {
|
||||
expect(helpers.initRepoAndPush).toHaveBeenCalledWith({
|
||||
dir: '/tmp/test',
|
||||
remoteUrl: 'https://dev.azure.com/organization/project/_git/repo',
|
||||
auth: { username: 'notempty', password: 'fake-token' },
|
||||
auth: { username: 'notempty', password: 'fake-azure-token' },
|
||||
logger,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,27 +15,38 @@
|
||||
*/
|
||||
|
||||
import { PublisherBase, PublisherOptions, PublisherResult } from './types';
|
||||
import { GitApi } from 'azure-devops-node-api/GitApi';
|
||||
import { IGitApi } from 'azure-devops-node-api/GitApi';
|
||||
import { GitRepositoryCreateOptions } from 'azure-devops-node-api/interfaces/GitInterfaces';
|
||||
import { JsonValue } from '@backstage/config';
|
||||
import { RequiredTemplateValues } from '../templater';
|
||||
import { initRepoAndPush } from './helpers';
|
||||
import { AzureIntegrationConfig } from '@backstage/integration';
|
||||
import parseGitUrl from 'git-url-parse';
|
||||
import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api';
|
||||
|
||||
export class AzurePublisher implements PublisherBase {
|
||||
private readonly client: GitApi;
|
||||
private readonly token: string;
|
||||
|
||||
constructor(client: GitApi, token: string) {
|
||||
this.client = client;
|
||||
this.token = token;
|
||||
static async fromConfig(config: AzureIntegrationConfig) {
|
||||
if (!config.token) {
|
||||
return undefined;
|
||||
}
|
||||
const authHandler = getPersonalAccessTokenHandler(config.token);
|
||||
const webApi = new WebApi(config.host, authHandler);
|
||||
const azureClient = await webApi.getGitApi();
|
||||
return new AzurePublisher({ token: config.token, client: azureClient });
|
||||
}
|
||||
|
||||
constructor(private readonly config: { token: string; client: IGitApi }) {}
|
||||
|
||||
async publish({
|
||||
values,
|
||||
directory,
|
||||
logger,
|
||||
}: PublisherOptions): Promise<PublisherResult> {
|
||||
const remoteUrl = await this.createRemote(values);
|
||||
const { owner, name } = parseGitUrl(values.storePath);
|
||||
|
||||
const remoteUrl = await this.createRemote({
|
||||
project: owner,
|
||||
name,
|
||||
});
|
||||
|
||||
const catalogInfoUrl = `${remoteUrl}?path=%2Fcatalog-info.yaml`;
|
||||
|
||||
await initRepoAndPush({
|
||||
@@ -43,7 +54,7 @@ export class AzurePublisher implements PublisherBase {
|
||||
remoteUrl,
|
||||
auth: {
|
||||
username: 'notempty',
|
||||
password: this.token,
|
||||
password: this.config.token,
|
||||
},
|
||||
logger,
|
||||
});
|
||||
@@ -51,13 +62,13 @@ export class AzurePublisher implements PublisherBase {
|
||||
return { remoteUrl, catalogInfoUrl };
|
||||
}
|
||||
|
||||
private async createRemote(
|
||||
values: RequiredTemplateValues & Record<string, JsonValue>,
|
||||
) {
|
||||
const [project, name] = values.storePath.split('/');
|
||||
|
||||
private async createRemote(opts: { name: string; project: string }) {
|
||||
const { name, project } = opts;
|
||||
const createOptions: GitRepositoryCreateOptions = { name };
|
||||
const repo = await this.client.createRepository(createOptions, project);
|
||||
const repo = await this.config.client.createRepository(
|
||||
createOptions,
|
||||
project,
|
||||
);
|
||||
|
||||
return repo.remoteUrl || '';
|
||||
}
|
||||
|
||||
@@ -58,15 +58,15 @@ describe('Bitbucket Publisher', () => {
|
||||
),
|
||||
);
|
||||
|
||||
const publisher = new BitbucketPublisher(
|
||||
'https://bitbucket.org',
|
||||
'fake-user',
|
||||
'fake-token',
|
||||
);
|
||||
const publisher = await BitbucketPublisher.fromConfig({
|
||||
host: 'bitbucket.org',
|
||||
username: 'fake-user',
|
||||
appPassword: 'fake-token',
|
||||
});
|
||||
|
||||
const result = await publisher.publish({
|
||||
values: {
|
||||
storePath: 'project/repo',
|
||||
storePath: 'https://bitbucket.org/project/repo',
|
||||
owner: 'bob',
|
||||
},
|
||||
directory: '/tmp/test',
|
||||
@@ -87,6 +87,7 @@ describe('Bitbucket Publisher', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('publish: createRemoteInBitbucketServer', () => {
|
||||
it('should create repo in bitbucket server', async () => {
|
||||
server.use(
|
||||
@@ -116,15 +117,14 @@ describe('Bitbucket Publisher', () => {
|
||||
),
|
||||
);
|
||||
|
||||
const publisher = new BitbucketPublisher(
|
||||
'https://bitbucket.mycompany.com',
|
||||
'fake-user',
|
||||
'fake-token',
|
||||
);
|
||||
const publisher = await BitbucketPublisher.fromConfig({
|
||||
host: 'bitbucket.mycompany.com',
|
||||
token: 'fake-token',
|
||||
});
|
||||
|
||||
const result = await publisher.publish({
|
||||
values: {
|
||||
storePath: 'project/repo',
|
||||
storePath: 'https://bitbucket.mycompany.com/project/repo',
|
||||
owner: 'bob',
|
||||
},
|
||||
directory: '/tmp/test',
|
||||
@@ -140,7 +140,7 @@ describe('Bitbucket Publisher', () => {
|
||||
expect(initRepoAndPush).toHaveBeenCalledWith({
|
||||
dir: '/tmp/test',
|
||||
remoteUrl: 'https://bitbucket.mycompany.com/scm/project/repo',
|
||||
auth: { username: 'fake-user', password: 'fake-token' },
|
||||
auth: { username: 'x-token-auth', password: 'fake-token' },
|
||||
logger: logger,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,62 +16,90 @@
|
||||
|
||||
import { PublisherBase, PublisherOptions, PublisherResult } from './types';
|
||||
import { initRepoAndPush } from './helpers';
|
||||
import { RequiredTemplateValues } from '../templater';
|
||||
import { JsonValue } from '../../../../../../packages/config/src';
|
||||
import fetch from 'cross-fetch';
|
||||
import { BitbucketIntegrationConfig } from '@backstage/integration';
|
||||
import parseGitUrl from 'git-url-parse';
|
||||
|
||||
// TODO(blam): We should probably start to use a bitbucket client here that we can change
|
||||
// the baseURL to point at on-prem or public bitbucket versions like we do for
|
||||
// github and ghe. There's to much logic and not enough types here for us to say that this way is better than using
|
||||
// a supported bitbucket client if one exists.
|
||||
export class BitbucketPublisher implements PublisherBase {
|
||||
private readonly host: string;
|
||||
private readonly username: string;
|
||||
private readonly token: string;
|
||||
|
||||
constructor(host: string, username: string, token: string) {
|
||||
this.host = host;
|
||||
this.username = username;
|
||||
this.token = token;
|
||||
static async fromConfig(config: BitbucketIntegrationConfig) {
|
||||
return new BitbucketPublisher({
|
||||
host: config.host,
|
||||
token: config.token,
|
||||
appPassword: config.appPassword,
|
||||
username: config.username,
|
||||
});
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly config: {
|
||||
host: string;
|
||||
token?: string;
|
||||
appPassword?: string;
|
||||
username?: string;
|
||||
},
|
||||
) {}
|
||||
|
||||
async publish({
|
||||
values,
|
||||
directory,
|
||||
logger,
|
||||
}: PublisherOptions): Promise<PublisherResult> {
|
||||
const result = await this.createRemote(values);
|
||||
const { owner: project, name } = parseGitUrl(values.storePath);
|
||||
|
||||
const description = values.description as string;
|
||||
const result = await this.createRemote({
|
||||
project,
|
||||
name,
|
||||
description,
|
||||
});
|
||||
|
||||
await initRepoAndPush({
|
||||
dir: directory,
|
||||
remoteUrl: result.remoteUrl,
|
||||
auth: {
|
||||
username: this.username,
|
||||
password: this.token,
|
||||
username: this.config.username ? this.config.username : 'x-token-auth',
|
||||
password: this.config.appPassword
|
||||
? this.config.appPassword
|
||||
: this.config.token ?? '',
|
||||
},
|
||||
logger,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private async createRemote(
|
||||
values: RequiredTemplateValues & Record<string, JsonValue>,
|
||||
): Promise<PublisherResult> {
|
||||
if (this.host === 'https://bitbucket.org') {
|
||||
return this.createBitbucketCloudRepository(values);
|
||||
private async createRemote(opts: {
|
||||
project: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}): Promise<PublisherResult> {
|
||||
if (this.config.host === 'bitbucket.org') {
|
||||
return this.createBitbucketCloudRepository(opts);
|
||||
}
|
||||
return this.createBitbucketServerRepository(values);
|
||||
return this.createBitbucketServerRepository(opts);
|
||||
}
|
||||
|
||||
private async createBitbucketCloudRepository(
|
||||
values: RequiredTemplateValues & Record<string, JsonValue>,
|
||||
): Promise<PublisherResult> {
|
||||
const [project, name] = values.storePath.split('/');
|
||||
private async createBitbucketCloudRepository(opts: {
|
||||
project: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}): Promise<PublisherResult> {
|
||||
const { project, name, description } = opts;
|
||||
|
||||
let response: Response;
|
||||
const buffer = Buffer.from(`${this.username}:${this.token}`, 'utf8');
|
||||
const buffer = Buffer.from(
|
||||
`${this.config.username}:${this.config.appPassword}`,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const options: RequestInit = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
scm: 'git',
|
||||
description: values.description,
|
||||
description: description,
|
||||
}),
|
||||
headers: {
|
||||
Authorization: `Basic ${buffer.toString('base64')}`,
|
||||
@@ -102,26 +130,28 @@ export class BitbucketPublisher implements PublisherBase {
|
||||
throw new Error(`Not a valid response code ${await response.text()}`);
|
||||
}
|
||||
|
||||
private async createBitbucketServerRepository(
|
||||
values: RequiredTemplateValues & Record<string, JsonValue>,
|
||||
): Promise<PublisherResult> {
|
||||
const [project, name] = values.storePath.split('/');
|
||||
private async createBitbucketServerRepository(opts: {
|
||||
project: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}): Promise<PublisherResult> {
|
||||
const { project, name, description } = opts;
|
||||
|
||||
let response: Response;
|
||||
const options: RequestInit = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
description: values.description,
|
||||
description: description,
|
||||
}),
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
Authorization: `Bearer ${this.config.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
try {
|
||||
response = await fetch(
|
||||
`${this.host}/rest/api/1.0/projects/${project}/repos`,
|
||||
`https://${this.config.host}/rest/api/1.0/projects/${project}/repos`,
|
||||
options,
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -37,14 +37,16 @@ describe('GitHub Publisher', () => {
|
||||
});
|
||||
|
||||
describe('with public repo visibility', () => {
|
||||
const publisher = new GithubPublisher({
|
||||
client: new Octokit(),
|
||||
token: 'abc',
|
||||
repoVisibility: 'public',
|
||||
});
|
||||
|
||||
describe('publish: createRemoteInGithub', () => {
|
||||
it('should use octokit to create a repo in an organisation if the organisation property is set', async () => {
|
||||
const publisher = await GithubPublisher.fromConfig(
|
||||
{
|
||||
token: 'fake-token',
|
||||
host: 'github.com',
|
||||
},
|
||||
{ repoVisibility: 'public' },
|
||||
);
|
||||
|
||||
mockGithubClient.repos.createInOrg.mockResolvedValue({
|
||||
data: {
|
||||
clone_url: 'https://github.com/backstage/backstage.git',
|
||||
@@ -56,9 +58,9 @@ describe('GitHub Publisher', () => {
|
||||
},
|
||||
} as RestEndpointMethodTypes['users']['getByUsername']['response']);
|
||||
|
||||
const result = await publisher.publish({
|
||||
const result = await publisher!.publish({
|
||||
values: {
|
||||
storePath: 'blam/test',
|
||||
storePath: 'https://github.com/blam/test',
|
||||
owner: 'bob',
|
||||
access: 'blam/team',
|
||||
},
|
||||
@@ -89,12 +91,20 @@ describe('GitHub Publisher', () => {
|
||||
expect(initRepoAndPush).toHaveBeenCalledWith({
|
||||
dir: '/tmp/test',
|
||||
remoteUrl: 'https://github.com/backstage/backstage.git',
|
||||
auth: { username: 'abc', password: 'x-oauth-basic' },
|
||||
auth: { username: 'fake-token', password: 'x-oauth-basic' },
|
||||
logger,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use octokit to create a repo in the authed user if the organisation property is not set', async () => {
|
||||
const publisher = await GithubPublisher.fromConfig(
|
||||
{
|
||||
token: 'fake-token',
|
||||
host: 'github.com',
|
||||
},
|
||||
{ repoVisibility: 'public' },
|
||||
);
|
||||
|
||||
mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({
|
||||
data: {
|
||||
clone_url: 'https://github.com/backstage/backstage.git',
|
||||
@@ -106,9 +116,9 @@ describe('GitHub Publisher', () => {
|
||||
},
|
||||
} as RestEndpointMethodTypes['users']['getByUsername']['response']);
|
||||
|
||||
const result = await publisher.publish({
|
||||
const result = await publisher!.publish({
|
||||
values: {
|
||||
storePath: 'blam/test',
|
||||
storePath: 'https://github.com/blam/test',
|
||||
owner: 'bob',
|
||||
access: 'blam',
|
||||
},
|
||||
@@ -132,13 +142,21 @@ describe('GitHub Publisher', () => {
|
||||
expect(initRepoAndPush).toHaveBeenCalledWith({
|
||||
dir: '/tmp/test',
|
||||
remoteUrl: 'https://github.com/backstage/backstage.git',
|
||||
auth: { username: 'abc', password: 'x-oauth-basic' },
|
||||
auth: { username: 'fake-token', password: 'x-oauth-basic' },
|
||||
logger,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should invite other user in the authed user', async () => {
|
||||
const publisher = await GithubPublisher.fromConfig(
|
||||
{
|
||||
token: 'fake-token',
|
||||
host: 'github.com',
|
||||
},
|
||||
{ repoVisibility: 'public' },
|
||||
);
|
||||
|
||||
mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({
|
||||
data: {
|
||||
clone_url: 'https://github.com/backstage/backstage.git',
|
||||
@@ -150,9 +168,9 @@ describe('GitHub Publisher', () => {
|
||||
},
|
||||
} as RestEndpointMethodTypes['users']['getByUsername']['response']);
|
||||
|
||||
const result = await publisher.publish({
|
||||
const result = await publisher!.publish({
|
||||
values: {
|
||||
storePath: 'blam/test',
|
||||
storePath: 'https://github.com/blam/test',
|
||||
owner: 'bob',
|
||||
access: 'bob',
|
||||
description: 'description',
|
||||
@@ -182,20 +200,22 @@ describe('GitHub Publisher', () => {
|
||||
expect(initRepoAndPush).toHaveBeenCalledWith({
|
||||
dir: '/tmp/test',
|
||||
remoteUrl: 'https://github.com/backstage/backstage.git',
|
||||
auth: { username: 'abc', password: 'x-oauth-basic' },
|
||||
auth: { username: 'fake-token', password: 'x-oauth-basic' },
|
||||
logger,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with internal repo visibility', () => {
|
||||
const publisher = new GithubPublisher({
|
||||
client: new Octokit(),
|
||||
token: 'abc',
|
||||
repoVisibility: 'internal',
|
||||
});
|
||||
|
||||
it('creates a private repository in the organization with visibility set to internal', async () => {
|
||||
const publisher = await GithubPublisher.fromConfig(
|
||||
{
|
||||
token: 'fake-token',
|
||||
host: 'github.com',
|
||||
},
|
||||
{ repoVisibility: 'internal' },
|
||||
);
|
||||
|
||||
mockGithubClient.repos.createInOrg.mockResolvedValue({
|
||||
data: {
|
||||
clone_url: 'https://github.com/backstage/backstage.git',
|
||||
@@ -207,10 +227,10 @@ describe('GitHub Publisher', () => {
|
||||
},
|
||||
} as RestEndpointMethodTypes['users']['getByUsername']['response']);
|
||||
|
||||
const result = await publisher.publish({
|
||||
const result = await publisher!.publish({
|
||||
values: {
|
||||
isOrg: true,
|
||||
storePath: 'blam/test',
|
||||
storePath: 'https://github.com/blam/test',
|
||||
owner: 'bob',
|
||||
},
|
||||
directory: '/tmp/test',
|
||||
@@ -231,20 +251,22 @@ describe('GitHub Publisher', () => {
|
||||
expect(initRepoAndPush).toHaveBeenCalledWith({
|
||||
dir: '/tmp/test',
|
||||
remoteUrl: 'https://github.com/backstage/backstage.git',
|
||||
auth: { username: 'abc', password: 'x-oauth-basic' },
|
||||
auth: { username: 'fake-token', password: 'x-oauth-basic' },
|
||||
logger,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('private visibility in a user account', () => {
|
||||
const publisher = new GithubPublisher({
|
||||
client: new Octokit(),
|
||||
token: 'abc',
|
||||
repoVisibility: 'private',
|
||||
});
|
||||
|
||||
it('creates a private repository', async () => {
|
||||
const publisher = await GithubPublisher.fromConfig(
|
||||
{
|
||||
token: 'fake-token',
|
||||
host: 'github.com',
|
||||
},
|
||||
{ repoVisibility: 'private' },
|
||||
);
|
||||
|
||||
mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({
|
||||
data: {
|
||||
clone_url: 'https://github.com/backstage/backstage.git',
|
||||
@@ -256,9 +278,9 @@ describe('GitHub Publisher', () => {
|
||||
},
|
||||
} as RestEndpointMethodTypes['users']['getByUsername']['response']);
|
||||
|
||||
const result = await publisher.publish({
|
||||
const result = await publisher!.publish({
|
||||
values: {
|
||||
storePath: 'blam/test',
|
||||
storePath: 'https://github.com/blam/test',
|
||||
owner: 'bob',
|
||||
},
|
||||
directory: '/tmp/test',
|
||||
@@ -279,7 +301,7 @@ describe('GitHub Publisher', () => {
|
||||
expect(initRepoAndPush).toHaveBeenCalledWith({
|
||||
dir: '/tmp/test',
|
||||
remoteUrl: 'https://github.com/backstage/backstage.git',
|
||||
auth: { username: 'abc', password: 'x-oauth-basic' },
|
||||
auth: { username: 'fake-token', password: 'x-oauth-basic' },
|
||||
logger,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,46 +15,61 @@
|
||||
*/
|
||||
|
||||
import { PublisherBase, PublisherOptions, PublisherResult } from './types';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import { initRepoAndPush } from './helpers';
|
||||
import { JsonValue } from '@backstage/config';
|
||||
import { RequiredTemplateValues } from '../templater';
|
||||
import { GitHubIntegrationConfig } from '@backstage/integration';
|
||||
import parseGitUrl from 'git-url-parse';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
|
||||
export type RepoVisibilityOptions = 'private' | 'internal' | 'public';
|
||||
|
||||
interface GithubPublisherParams {
|
||||
client: Octokit;
|
||||
token: string;
|
||||
repoVisibility: RepoVisibilityOptions;
|
||||
}
|
||||
|
||||
export class GithubPublisher implements PublisherBase {
|
||||
private client: Octokit;
|
||||
private token: string;
|
||||
private repoVisibility: RepoVisibilityOptions;
|
||||
static async fromConfig(
|
||||
config: GitHubIntegrationConfig,
|
||||
{ repoVisibility }: { repoVisibility: RepoVisibilityOptions },
|
||||
) {
|
||||
if (!config.token) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
constructor({
|
||||
client,
|
||||
token,
|
||||
repoVisibility = 'public',
|
||||
}: GithubPublisherParams) {
|
||||
this.client = client;
|
||||
this.token = token;
|
||||
this.repoVisibility = repoVisibility;
|
||||
const githubClient = new Octokit({
|
||||
auth: config.token,
|
||||
baseUrl: config.apiBaseUrl,
|
||||
});
|
||||
|
||||
return new GithubPublisher({
|
||||
token: config.token,
|
||||
client: githubClient,
|
||||
repoVisibility,
|
||||
});
|
||||
}
|
||||
constructor(
|
||||
private readonly config: {
|
||||
token: string;
|
||||
client: Octokit;
|
||||
repoVisibility: RepoVisibilityOptions;
|
||||
},
|
||||
) {}
|
||||
|
||||
async publish({
|
||||
values,
|
||||
directory,
|
||||
logger,
|
||||
}: PublisherOptions): Promise<PublisherResult> {
|
||||
const remoteUrl = await this.createRemote(values);
|
||||
const { owner, name } = parseGitUrl(values.storePath);
|
||||
|
||||
const description = values.description as string;
|
||||
const access = values.access as string;
|
||||
const remoteUrl = await this.createRemote({
|
||||
description,
|
||||
access,
|
||||
name,
|
||||
owner,
|
||||
});
|
||||
|
||||
await initRepoAndPush({
|
||||
dir: directory,
|
||||
remoteUrl,
|
||||
auth: {
|
||||
username: this.token,
|
||||
username: this.config.token,
|
||||
password: 'x-oauth-basic',
|
||||
},
|
||||
logger,
|
||||
@@ -68,35 +83,38 @@ export class GithubPublisher implements PublisherBase {
|
||||
return { remoteUrl, catalogInfoUrl };
|
||||
}
|
||||
|
||||
private async createRemote(
|
||||
values: RequiredTemplateValues & Record<string, JsonValue>,
|
||||
) {
|
||||
const [owner, name] = values.storePath.split('/');
|
||||
const description = values.description as string;
|
||||
private async createRemote(opts: {
|
||||
access: string;
|
||||
name: string;
|
||||
owner: string;
|
||||
description: string;
|
||||
}) {
|
||||
const { access, description, owner, name } = opts;
|
||||
|
||||
const user = await this.client.users.getByUsername({ username: owner });
|
||||
const user = await this.config.client.users.getByUsername({
|
||||
username: owner,
|
||||
});
|
||||
|
||||
const repoCreationPromise =
|
||||
user.data.type === 'Organization'
|
||||
? this.client.repos.createInOrg({
|
||||
? this.config.client.repos.createInOrg({
|
||||
name,
|
||||
org: owner,
|
||||
private: this.repoVisibility !== 'public',
|
||||
visibility: this.repoVisibility,
|
||||
private: this.config.repoVisibility !== 'public',
|
||||
visibility: this.config.repoVisibility,
|
||||
description,
|
||||
})
|
||||
: this.client.repos.createForAuthenticatedUser({
|
||||
: this.config.client.repos.createForAuthenticatedUser({
|
||||
name,
|
||||
private: this.repoVisibility === 'private',
|
||||
private: this.config.repoVisibility === 'private',
|
||||
description,
|
||||
});
|
||||
|
||||
const { data } = await repoCreationPromise;
|
||||
|
||||
const access = values.access as string;
|
||||
if (access?.startsWith(`${owner}/`)) {
|
||||
const [, team] = access.split('/');
|
||||
await this.client.teams.addOrUpdateRepoPermissionsInOrg({
|
||||
await this.config.client.teams.addOrUpdateRepoPermissionsInOrg({
|
||||
org: owner,
|
||||
team_slug: team,
|
||||
owner,
|
||||
@@ -105,7 +123,7 @@ export class GithubPublisher implements PublisherBase {
|
||||
});
|
||||
// no need to add access if it's the person who own's the personal account
|
||||
} else if (access && access !== owner) {
|
||||
await this.client.repos.addCollaborator({
|
||||
await this.config.client.repos.addCollaborator({
|
||||
owner,
|
||||
repo: name,
|
||||
username: access,
|
||||
|
||||
@@ -14,33 +14,47 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
jest.mock('@gitbeaker/node');
|
||||
jest.mock('@gitbeaker/node', () => ({
|
||||
Gitlab: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./helpers');
|
||||
|
||||
import { GitlabPublisher } from './gitlab';
|
||||
import { Gitlab as GitlabAPI } from '@gitbeaker/core';
|
||||
import { Gitlab } from '@gitbeaker/node';
|
||||
import { initRepoAndPush } from './helpers';
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
|
||||
const { mockGitlabClient } = require('@gitbeaker/node') as {
|
||||
mockGitlabClient: {
|
||||
Namespaces: jest.Mocked<GitlabAPI['Namespaces']>;
|
||||
Projects: jest.Mocked<GitlabAPI['Projects']>;
|
||||
Users: jest.Mocked<GitlabAPI['Users']>;
|
||||
};
|
||||
};
|
||||
|
||||
describe('GitLab Publisher', () => {
|
||||
const logger = getVoidLogger();
|
||||
const publisher = new GitlabPublisher(new Gitlab({}), 'fake-token');
|
||||
const mockGitlabClient = {
|
||||
Namespaces: {
|
||||
show: jest.fn(),
|
||||
},
|
||||
Projects: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
Users: {
|
||||
current: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
((Gitlab as unknown) as jest.Mock).mockImplementation(
|
||||
() => mockGitlabClient,
|
||||
);
|
||||
});
|
||||
|
||||
describe('publish: createRemoteInGitLab', () => {
|
||||
it('should use gitbeaker to create a repo in a namespace if the namespace property is set', async () => {
|
||||
const publisher = await GitlabPublisher.fromConfig({
|
||||
host: 'gitlab.com',
|
||||
token: 'fake-token',
|
||||
baseUrl: 'https://gitlab.hosted.com',
|
||||
});
|
||||
|
||||
mockGitlabClient.Namespaces.show.mockResolvedValue({
|
||||
id: 42,
|
||||
} as { id: number });
|
||||
@@ -48,17 +62,24 @@ describe('GitLab Publisher', () => {
|
||||
http_url_to_repo: 'mockclone',
|
||||
} as { http_url_to_repo: string });
|
||||
|
||||
const result = await publisher.publish({
|
||||
const result = await publisher!.publish({
|
||||
values: {
|
||||
isOrg: true,
|
||||
storePath: 'bloum/blam/test',
|
||||
storePath: 'https://gitlab.com/blam/test',
|
||||
owner: 'bob',
|
||||
},
|
||||
directory: '/tmp/test',
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ remoteUrl: 'mockclone' });
|
||||
expect(Gitlab).toHaveBeenCalledWith({
|
||||
token: 'fake-token',
|
||||
host: 'https://gitlab.hosted.com',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
remoteUrl: 'mockclone',
|
||||
catalogInfoUrl: 'mockclone',
|
||||
});
|
||||
expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({
|
||||
namespace_id: 42,
|
||||
name: 'test',
|
||||
@@ -72,6 +93,11 @@ describe('GitLab Publisher', () => {
|
||||
});
|
||||
|
||||
it('should use gitbeaker to create a repo in the authed user if the namespace property is not set', async () => {
|
||||
const publisher = await GitlabPublisher.fromConfig({
|
||||
host: 'gitlab.com',
|
||||
token: 'fake-token',
|
||||
});
|
||||
|
||||
mockGitlabClient.Namespaces.show.mockResolvedValue({});
|
||||
mockGitlabClient.Users.current.mockResolvedValue({
|
||||
id: 21,
|
||||
@@ -80,16 +106,19 @@ describe('GitLab Publisher', () => {
|
||||
http_url_to_repo: 'mockclone',
|
||||
} as { http_url_to_repo: string });
|
||||
|
||||
const result = await publisher.publish({
|
||||
const result = await publisher!.publish({
|
||||
values: {
|
||||
storePath: 'bloum/blam/test',
|
||||
storePath: 'https://gitlab.com/blam/test',
|
||||
owner: 'bob',
|
||||
},
|
||||
directory: '/tmp/test',
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ remoteUrl: 'mockclone' });
|
||||
expect(result).toEqual({
|
||||
remoteUrl: 'mockclone',
|
||||
catalogInfoUrl: 'mockclone',
|
||||
});
|
||||
expect(mockGitlabClient.Users.current).toHaveBeenCalled();
|
||||
expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({
|
||||
namespace_id: 21,
|
||||
|
||||
@@ -15,57 +15,74 @@
|
||||
*/
|
||||
|
||||
import { PublisherBase, PublisherOptions, PublisherResult } from './types';
|
||||
import { Gitlab } from '@gitbeaker/core';
|
||||
import { JsonValue } from '@backstage/config';
|
||||
import { Gitlab } from '@gitbeaker/node';
|
||||
import { Gitlab as GitlabClient } from '@gitbeaker/core';
|
||||
import { initRepoAndPush } from './helpers';
|
||||
import { RequiredTemplateValues } from '../templater';
|
||||
import parseGitUrl from 'git-url-parse';
|
||||
|
||||
import { GitLabIntegrationConfig } from '@backstage/integration';
|
||||
|
||||
export class GitlabPublisher implements PublisherBase {
|
||||
private readonly client: Gitlab;
|
||||
private readonly token: string;
|
||||
static async fromConfig(config: GitLabIntegrationConfig) {
|
||||
if (!config.token) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
constructor(client: Gitlab, token: string) {
|
||||
this.client = client;
|
||||
this.token = token;
|
||||
const client = new Gitlab({ host: config.baseUrl, token: config.token });
|
||||
return new GitlabPublisher({ token: config.token, client });
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly config: { token: string; client: GitlabClient },
|
||||
) {}
|
||||
|
||||
async publish({
|
||||
values,
|
||||
directory,
|
||||
logger,
|
||||
}: PublisherOptions): Promise<PublisherResult> {
|
||||
const remoteUrl = await this.createRemote(values);
|
||||
const { owner, name } = parseGitUrl(values.storePath);
|
||||
|
||||
const remoteUrl = await this.createRemote({
|
||||
owner,
|
||||
name,
|
||||
});
|
||||
|
||||
await initRepoAndPush({
|
||||
dir: directory,
|
||||
remoteUrl,
|
||||
auth: {
|
||||
username: 'oauth2',
|
||||
password: this.token,
|
||||
password: this.config.token,
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
return { remoteUrl };
|
||||
const catalogInfoUrl = remoteUrl.replace(
|
||||
/\.git$/,
|
||||
'/-/blob/master/catalog-info.yaml',
|
||||
);
|
||||
return { remoteUrl, catalogInfoUrl };
|
||||
}
|
||||
|
||||
private async createRemote(
|
||||
values: RequiredTemplateValues & Record<string, JsonValue>,
|
||||
) {
|
||||
const pathElements = values.storePath.split('/');
|
||||
const name = pathElements[pathElements.length - 1];
|
||||
pathElements.pop();
|
||||
const owner = pathElements.join('/');
|
||||
private async createRemote(opts: { name: string; owner: string }) {
|
||||
const { owner, name } = opts;
|
||||
|
||||
let targetNamespace = ((await this.client.Namespaces.show(owner)) as {
|
||||
// TODO(blam): this needs cleaning up to be nicer. The amount of brackets is too damn high!
|
||||
// Shouldn't have to cast things now
|
||||
let targetNamespace = ((await this.config.client.Namespaces.show(
|
||||
owner,
|
||||
)) as {
|
||||
id: number;
|
||||
}).id;
|
||||
|
||||
if (!targetNamespace) {
|
||||
targetNamespace = ((await this.client.Users.current()) as { id: number })
|
||||
.id;
|
||||
targetNamespace = ((await this.config.client.Users.current()) as {
|
||||
id: number;
|
||||
}).id;
|
||||
}
|
||||
|
||||
const project = (await this.client.Projects.create({
|
||||
const project = (await this.config.client.Projects.create({
|
||||
namespace_id: targetNamespace,
|
||||
name: name,
|
||||
})) as { http_url_to_repo: string };
|
||||
|
||||
@@ -14,120 +14,112 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { Publishers } from './publishers';
|
||||
import {
|
||||
LOCATION_ANNOTATION,
|
||||
TemplateEntityV1alpha1,
|
||||
} from '@backstage/catalog-model';
|
||||
import { GithubPublisher } from './github';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { AzurePublisher } from './azure';
|
||||
import { GitlabPublisher } from './gitlab';
|
||||
import { BitbucketPublisher } from './bitbucket';
|
||||
|
||||
jest.mock('@octokit/rest');
|
||||
jest.mock('azure-devops-node-api');
|
||||
|
||||
describe('Publishers', () => {
|
||||
const mockTemplate: TemplateEntityV1alpha1 = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
annotations: {
|
||||
[LOCATION_ANNOTATION]:
|
||||
'github:https://github.com/benjdlambert/backstage-graphql-template/blob/master/template.yaml',
|
||||
},
|
||||
name: 'graphql-starter',
|
||||
title: 'GraphQL Service',
|
||||
description:
|
||||
'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n',
|
||||
uid: '9cf16bad-16e0-4213-b314-c4eec773c50b',
|
||||
etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2',
|
||||
generation: 1,
|
||||
},
|
||||
spec: {
|
||||
type: 'website',
|
||||
templater: 'cookiecutter',
|
||||
path: './template',
|
||||
schema: {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
required: ['storePath', 'owner'],
|
||||
properties: {
|
||||
owner: {
|
||||
type: 'string',
|
||||
title: 'Owner',
|
||||
description: 'Who is going to own this component',
|
||||
},
|
||||
storePath: {
|
||||
type: 'string',
|
||||
title: 'Store path',
|
||||
description: 'GitHub store path in org/repo format',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const logger = getVoidLogger();
|
||||
|
||||
it('should throw an error when the publisher for the source location is not registered', () => {
|
||||
const publishers = new Publishers();
|
||||
|
||||
expect(() => publishers.get(mockTemplate)).toThrow(
|
||||
expect(() => publishers.get('https://github.com/org/repo')).toThrow(
|
||||
expect.objectContaining({
|
||||
message: 'No publisher registered for type: "github"',
|
||||
message:
|
||||
'Unable to find a publisher for URL: https://github.com/org/repo. Please make sure to register this host under an integration in app-config',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the correct preparer when the source matches', () => {
|
||||
const publishers = new Publishers();
|
||||
const publisher = new GithubPublisher({
|
||||
client: new Octokit(),
|
||||
token: 'fake',
|
||||
repoVisibility: 'public',
|
||||
});
|
||||
publishers.register('github', publisher);
|
||||
|
||||
expect(publishers.get(mockTemplate)).toBe(publisher);
|
||||
});
|
||||
|
||||
it('should throw an error if the metadata tag does not exist in the entity', () => {
|
||||
const brokenTemplate: TemplateEntityV1alpha1 = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
annotations: {},
|
||||
name: 'react-ssr-template',
|
||||
title: 'React SSR Template',
|
||||
description:
|
||||
'Next.js application skeleton for creating isomorphic web applications.',
|
||||
uid: '7357f4c5-aa58-4a1e-9670-18931eef771f',
|
||||
etag: 'YWUxZWQyY2EtZDkxMC00MDM0LWI0ODAtMDgwMWY0YzdlMWIw',
|
||||
generation: 1,
|
||||
},
|
||||
spec: {
|
||||
type: 'website',
|
||||
templater: 'cookiecutter',
|
||||
path: '.',
|
||||
schema: {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
required: ['storePath', 'owner'],
|
||||
properties: {
|
||||
owner: {
|
||||
type: 'string',
|
||||
title: 'Owner',
|
||||
description: 'Who is going to own this component',
|
||||
},
|
||||
storePath: {
|
||||
type: 'string',
|
||||
title: 'Store path',
|
||||
description: 'GitHub store path in org/repo format',
|
||||
},
|
||||
},
|
||||
it('should return the correct preparer when the source matches for github', async () => {
|
||||
const publishers = await Publishers.fromConfig(
|
||||
new ConfigReader({
|
||||
integrations: {
|
||||
github: [{ host: 'github.com', token: 'blob' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const publishers = new Publishers();
|
||||
|
||||
expect(() => publishers.get(brokenTemplate)).toThrow(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('No location annotation provided'),
|
||||
}),
|
||||
{
|
||||
logger,
|
||||
},
|
||||
);
|
||||
|
||||
expect(publishers.get('https://github.com/org/repo')).toBeInstanceOf(
|
||||
GithubPublisher,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the correct preparer when the source matches for azure', async () => {
|
||||
const publishers = await Publishers.fromConfig(
|
||||
new ConfigReader({
|
||||
integrations: {
|
||||
azure: [{ host: 'dev.azure.com', token: 'blob' }],
|
||||
},
|
||||
}),
|
||||
{
|
||||
logger,
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
publishers.get('https://dev.azure.com/org/project/_git/repo'),
|
||||
).toBeInstanceOf(AzurePublisher);
|
||||
});
|
||||
|
||||
it('should return the correct preparer when the source matches for bitbucket', async () => {
|
||||
const publishers = await Publishers.fromConfig(
|
||||
new ConfigReader({
|
||||
integrations: {
|
||||
bitbucket: [{ host: 'bitbucket.com', token: 'blob' }],
|
||||
},
|
||||
}),
|
||||
{
|
||||
logger,
|
||||
},
|
||||
);
|
||||
expect(publishers.get('https://bitbucket.org/owner/repo')).toBeInstanceOf(
|
||||
BitbucketPublisher,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the correct preparer when the source matches for gitlab', async () => {
|
||||
const publishers = await Publishers.fromConfig(
|
||||
new ConfigReader({
|
||||
integrations: {
|
||||
gitlab: [{ host: 'gitlab.com', token: 'blob' }],
|
||||
},
|
||||
}),
|
||||
{
|
||||
logger,
|
||||
},
|
||||
);
|
||||
expect(publishers.get('https://gitlab.com/owner/repo')).toBeInstanceOf(
|
||||
GitlabPublisher,
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect registrations for custom URLs for providers using the integrations config', async () => {
|
||||
const publishers = await Publishers.fromConfig(
|
||||
new ConfigReader({
|
||||
integrations: {
|
||||
github: [
|
||||
{ host: 'my.special.github.enterprise.thing', token: 'lolghe' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
{
|
||||
logger,
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
publishers.get('https://my.special.github.enterprise.thing/org/repo'),
|
||||
).toBeInstanceOf(GithubPublisher);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,184 +14,130 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Logger } from 'winston';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import { Gitlab } from '@gitbeaker/node';
|
||||
import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api';
|
||||
import { Config } from '@backstage/config';
|
||||
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
|
||||
import {
|
||||
DeprecatedLocationTypeDetector,
|
||||
makeDeprecatedLocationTypeDetector,
|
||||
parseLocationAnnotation,
|
||||
} from '../helpers';
|
||||
import { PublisherBase, PublisherBuilder } from './types';
|
||||
import { RemoteProtocol } from '../types';
|
||||
import { GithubPublisher, RepoVisibilityOptions } from './github';
|
||||
import { GitlabPublisher } from './gitlab';
|
||||
import { AzurePublisher } from './azure';
|
||||
import { BitbucketPublisher } from './bitbucket';
|
||||
import { Logger } from 'winston';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
|
||||
export class Publishers implements PublisherBuilder {
|
||||
private publisherMap = new Map<RemoteProtocol, PublisherBase>();
|
||||
private publisherMap = new Map<string, PublisherBase | undefined>();
|
||||
|
||||
constructor(private readonly typeDetector?: DeprecatedLocationTypeDetector) {}
|
||||
|
||||
register(protocol: RemoteProtocol, publisher: PublisherBase) {
|
||||
this.publisherMap.set(protocol, publisher);
|
||||
register(host: string, preparer: PublisherBase | undefined) {
|
||||
this.publisherMap.set(host, preparer);
|
||||
}
|
||||
|
||||
get(template: TemplateEntityV1alpha1): PublisherBase {
|
||||
const { protocol, location } = parseLocationAnnotation(template);
|
||||
const publisher = this.publisherMap.get(protocol);
|
||||
|
||||
if (!publisher) {
|
||||
if ((protocol as string) === 'url') {
|
||||
const type = this.typeDetector?.(location);
|
||||
const detected = type && this.publisherMap.get(type as RemoteProtocol);
|
||||
if (detected) {
|
||||
return detected;
|
||||
}
|
||||
if (type) {
|
||||
throw new Error(
|
||||
`No publisher configuration available for type '${type}' with url "${location}". ` +
|
||||
"Make sure you've added appropriate configuration in the 'scaffolder' configuration section",
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Failed to detect publisher type. Unable to determine integration type for location "${location}". ` +
|
||||
"Please add appropriate configuration to the 'integrations' configuration section",
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error(`No publisher registered for type: "${protocol}"`);
|
||||
get(url: string): PublisherBase {
|
||||
const preparer = this.publisherMap.get(new URL(url).host);
|
||||
if (!preparer) {
|
||||
throw new Error(
|
||||
`Unable to find a publisher for URL: ${url}. Please make sure to register this host under an integration in app-config`,
|
||||
);
|
||||
}
|
||||
|
||||
return publisher;
|
||||
return preparer;
|
||||
}
|
||||
|
||||
static async fromConfig(
|
||||
config: Config,
|
||||
{ logger }: { logger: Logger },
|
||||
): Promise<PublisherBuilder> {
|
||||
const typeDetector = makeDeprecatedLocationTypeDetector(config);
|
||||
const publishers = new Publishers(typeDetector);
|
||||
const publishers = new Publishers();
|
||||
|
||||
const githubConfig = config.getOptionalConfig('scaffolder.github');
|
||||
if (githubConfig) {
|
||||
try {
|
||||
const repoVisibility = githubConfig.getString(
|
||||
'visibility',
|
||||
) as RepoVisibilityOptions;
|
||||
const scm = ScmIntegrations.fromConfig(config);
|
||||
|
||||
const githubToken = githubConfig.getString('token');
|
||||
const githubHost =
|
||||
githubConfig.getOptionalString('host') ?? 'https://api.github.com';
|
||||
const githubClient = new Octokit({
|
||||
auth: githubToken,
|
||||
baseUrl: githubHost,
|
||||
});
|
||||
const githubPublisher = new GithubPublisher({
|
||||
client: githubClient,
|
||||
token: githubToken,
|
||||
repoVisibility,
|
||||
});
|
||||
const deprecationWarning = (name: string) => {
|
||||
logger.warn(
|
||||
`'Specifying credentials for ${name} in the Scaffolder configuration is deprecated. This will cause errors in a future release. Please migrate to using integrations config and specifying tokens under hostnames'`,
|
||||
);
|
||||
};
|
||||
|
||||
publishers.register('file', githubPublisher);
|
||||
publishers.register('github', githubPublisher);
|
||||
} catch (e) {
|
||||
const providerName = 'github';
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
throw new Error(
|
||||
`Failed to initialize ${providerName} scaffolding provider, ${e.message}`,
|
||||
);
|
||||
}
|
||||
for (const integration of scm.azure.list()) {
|
||||
const publisher = await AzurePublisher.fromConfig(integration.config);
|
||||
if (publisher) {
|
||||
publishers.register(integration.config.host, publisher);
|
||||
} else {
|
||||
deprecationWarning('Azure');
|
||||
|
||||
logger.warn(
|
||||
`Skipping ${providerName} scaffolding provider, ${e.message}`,
|
||||
publishers.register(
|
||||
integration.config.host,
|
||||
await AzurePublisher.fromConfig({
|
||||
token: config.getOptionalString('scaffolder.azure.token'),
|
||||
host: integration.config.host,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const gitLabConfig = config.getOptionalConfig('scaffolder.gitlab');
|
||||
if (gitLabConfig) {
|
||||
try {
|
||||
const gitLabToken = gitLabConfig.getConfig('api').getString('token');
|
||||
const gitLabClient = new Gitlab({
|
||||
host: gitLabConfig.getConfig('api').getOptionalString('baseUrl'),
|
||||
token: gitLabToken,
|
||||
});
|
||||
const gitLabPublisher = new GitlabPublisher(gitLabClient, gitLabToken);
|
||||
publishers.register('gitlab', gitLabPublisher);
|
||||
publishers.register('gitlab/api', gitLabPublisher);
|
||||
} catch (e) {
|
||||
const providerName = 'gitlab';
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
throw new Error(
|
||||
`Failed to initialize ${providerName} scaffolding provider, ${e.message}`,
|
||||
);
|
||||
}
|
||||
for (const integration of scm.github.list()) {
|
||||
const repoVisibility = (config.getOptionalString(
|
||||
'scaffolder.github.visibility',
|
||||
) ?? 'public') as RepoVisibilityOptions;
|
||||
|
||||
logger.warn(
|
||||
`Skipping ${providerName} scaffolding provider, ${e.message}`,
|
||||
const publisher = await GithubPublisher.fromConfig(integration.config, {
|
||||
repoVisibility,
|
||||
});
|
||||
if (publisher) {
|
||||
publishers.register(integration.config.host, publisher);
|
||||
} else {
|
||||
deprecationWarning('GitHub');
|
||||
|
||||
publishers.register(
|
||||
integration.config.host,
|
||||
await GithubPublisher.fromConfig(
|
||||
{
|
||||
token: config.getOptionalString('scaffolder.github.token') ?? '',
|
||||
host: integration.config.host,
|
||||
},
|
||||
{ repoVisibility },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const azureConfig = config.getOptionalConfig('scaffolder.azure');
|
||||
if (azureConfig) {
|
||||
try {
|
||||
const baseUrl = azureConfig.getString('baseUrl');
|
||||
const azureToken = azureConfig.getConfig('api').getString('token');
|
||||
for (const integration of scm.gitlab.list()) {
|
||||
const publisher = await GitlabPublisher.fromConfig(integration.config);
|
||||
|
||||
const authHandler = getPersonalAccessTokenHandler(azureToken);
|
||||
const webApi = new WebApi(baseUrl, authHandler);
|
||||
const azureClient = await webApi.getGitApi();
|
||||
if (publisher) {
|
||||
publishers.register(integration.config.host, publisher);
|
||||
} else {
|
||||
deprecationWarning('Gitlab');
|
||||
|
||||
const azurePublisher = new AzurePublisher(azureClient, azureToken);
|
||||
publishers.register('azure/api', azurePublisher);
|
||||
} catch (e) {
|
||||
const providerName = 'azure';
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
throw new Error(
|
||||
`Failed to initialize ${providerName} scaffolding provider, ${e.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`Skipping ${providerName} scaffolding provider, ${e.message}`,
|
||||
publishers.register(
|
||||
integration.config.host,
|
||||
await GitlabPublisher.fromConfig({
|
||||
token: config.getOptionalString('scaffolder.gitlab.token') ?? '',
|
||||
host: integration.config.host,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const bitbucketConfig = config.getOptionalConfig(
|
||||
'scaffolder.bitbucket.api',
|
||||
);
|
||||
if (bitbucketConfig) {
|
||||
try {
|
||||
const baseUrl = bitbucketConfig.getString('host');
|
||||
const bitbucketUsername = bitbucketConfig.getString('username');
|
||||
const bitbucketToken = bitbucketConfig.getString('token');
|
||||
for (const integration of scm.bitbucket.list()) {
|
||||
const publisher = await BitbucketPublisher.fromConfig(integration.config);
|
||||
|
||||
const bitbucketPublisher = new BitbucketPublisher(
|
||||
baseUrl,
|
||||
bitbucketUsername,
|
||||
bitbucketToken,
|
||||
);
|
||||
publishers.register('bitbucket', bitbucketPublisher);
|
||||
} catch (e) {
|
||||
const providerName = 'bitbucket';
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
throw new Error(
|
||||
`Failed to initialize ${providerName} scaffolding provider, ${e.message}`,
|
||||
);
|
||||
}
|
||||
if (publisher) {
|
||||
publishers.register(integration.config.host, publisher);
|
||||
} else {
|
||||
deprecationWarning('Bitbucket');
|
||||
|
||||
logger.warn(
|
||||
`Skipping ${providerName} scaffolding provider, ${e.message}`,
|
||||
publishers.register(
|
||||
integration.config.host,
|
||||
await BitbucketPublisher.fromConfig({
|
||||
token: config.getOptionalString('scaffolder.bitbucket.token') ?? '',
|
||||
username:
|
||||
config.getOptionalString('scaffolder.bitbucket.username') ?? '',
|
||||
appPassword:
|
||||
config.getOptionalString('scaffolder.bitbucket.appPassword') ??
|
||||
'',
|
||||
host: integration.config.host,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return publishers;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,8 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
|
||||
import { RequiredTemplateValues } from '../templater';
|
||||
import { JsonValue } from '@backstage/config';
|
||||
import { RemoteProtocol } from '../types';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
/**
|
||||
@@ -35,8 +33,8 @@ export type PublisherBase = {
|
||||
|
||||
export type PublisherOptions = {
|
||||
values: RequiredTemplateValues & Record<string, JsonValue>;
|
||||
logger: Logger;
|
||||
directory: string;
|
||||
logger: Logger;
|
||||
};
|
||||
|
||||
export type PublisherResult = {
|
||||
@@ -45,6 +43,6 @@ export type PublisherResult = {
|
||||
};
|
||||
|
||||
export type PublisherBuilder = {
|
||||
register(protocol: RemoteProtocol, publisher: PublisherBase): void;
|
||||
get(template: TemplateEntityV1alpha1): PublisherBase;
|
||||
register(host: string, publisher: PublisherBase): void;
|
||||
get(storePath: string): PublisherBase;
|
||||
};
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('createRouter - working directory', () => {
|
||||
const mockPreparer = {
|
||||
prepare: mockPrepare,
|
||||
};
|
||||
mockPreparers.register('azure/api', mockPreparer);
|
||||
mockPreparers.register('dev.azure.com', mockPreparer);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -65,7 +65,7 @@ describe('createRouter - working directory', () => {
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
annotations: {
|
||||
'backstage.io/managed-by-location': 'azure/api:dev.azure.com',
|
||||
'backstage.io/managed-by-location': 'url:https://dev.azure.com',
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
StageContext,
|
||||
TemplaterBuilder,
|
||||
PublisherBuilder,
|
||||
parseLocationAnnotation,
|
||||
FilePreparer,
|
||||
} from '../scaffolder';
|
||||
import { CatalogEntityClient } from '../lib/catalog';
|
||||
import { validate, ValidatorResult } from 'jsonschema';
|
||||
@@ -129,7 +131,15 @@ export async function createRouter(
|
||||
{
|
||||
name: 'Prepare the skeleton',
|
||||
handler: async ctx => {
|
||||
const preparer = preparers.get(ctx.entity);
|
||||
const { protocol, location: pullPath } = parseLocationAnnotation(
|
||||
ctx.entity,
|
||||
);
|
||||
|
||||
const preparer =
|
||||
protocol === 'file'
|
||||
? new FilePreparer()
|
||||
: preparers.get(pullPath);
|
||||
|
||||
const skeletonDir = await preparer.prepare(ctx.entity, {
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
@@ -154,7 +164,7 @@ export async function createRouter(
|
||||
{
|
||||
name: 'Publish template',
|
||||
handler: async (ctx: StageContext<{ resultDir: string }>) => {
|
||||
const publisher = publishers.get(ctx.entity);
|
||||
const publisher = publishers.get(ctx.values.storePath);
|
||||
ctx.logger.info('Will now store the template');
|
||||
const result = await publisher.publish({
|
||||
values: ctx.values,
|
||||
@@ -167,9 +177,9 @@ export async function createRouter(
|
||||
],
|
||||
});
|
||||
|
||||
res.status(201).json({ id: job.id });
|
||||
|
||||
jobProcessor.run(job);
|
||||
|
||||
res.status(201).json({ id: job.id });
|
||||
});
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"@rjsf/core": "^2.4.0",
|
||||
"@rjsf/material-ui": "^2.4.0",
|
||||
"classnames": "^2.2.6",
|
||||
"git-url-parse": "^11.4.3",
|
||||
"moment": "^2.26.0",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
|
||||
@@ -39,6 +39,7 @@ import { rootRoute } from '../../routes';
|
||||
import { JobStatusModal } from '../JobStatusModal';
|
||||
import { MultistepJsonForm } from '../MultistepJsonForm';
|
||||
import { useJobPolling } from '../hooks/useJobPolling';
|
||||
import parseGitUrl from 'git-url-parse';
|
||||
|
||||
const useTemplate = (
|
||||
templateName: string,
|
||||
@@ -63,10 +64,10 @@ const OWNER_REPO_SCHEMA = {
|
||||
description: 'Who is going to own this component',
|
||||
},
|
||||
storePath: {
|
||||
format: 'GitHub user or org / Repo name',
|
||||
type: 'string' as const,
|
||||
title: 'Store path',
|
||||
description: 'GitHub store path in org/repo format',
|
||||
description:
|
||||
'A full URL to the repository that should be created. e.g https://github.com/backstage/new-repo',
|
||||
},
|
||||
access: {
|
||||
type: 'string' as const,
|
||||
@@ -75,11 +76,6 @@ const OWNER_REPO_SCHEMA = {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const REPO_FORMAT = {
|
||||
'GitHub user or org / Repo name': /[^\/]*\/[^\/]*/,
|
||||
};
|
||||
|
||||
export const TemplatePage = () => {
|
||||
const errorApi = useApi(errorApiRef);
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
@@ -182,7 +178,34 @@ export const TemplatePage = () => {
|
||||
{
|
||||
label: 'Choose owner and repo',
|
||||
schema: OWNER_REPO_SCHEMA,
|
||||
customFormats: REPO_FORMAT,
|
||||
validate: (formData, errors) => {
|
||||
const { storePath } = formData;
|
||||
try {
|
||||
const parsedUrl = parseGitUrl(storePath);
|
||||
|
||||
if (
|
||||
!parsedUrl.resource ||
|
||||
!parsedUrl.owner ||
|
||||
!parsedUrl.name
|
||||
) {
|
||||
if (parsedUrl.resource === 'dev.azure.com') {
|
||||
errors.storePath.addError(
|
||||
"The store path should be formatted like https://dev.azure.com/{org}/{project}/_git/{repo} for Azure URL's",
|
||||
);
|
||||
} else {
|
||||
errors.storePath.addError(
|
||||
'The store path should be a complete Git URL to the new repository location. For example: https://github.com/{owner}/{repo}',
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (ex) {
|
||||
errors.storePath.addError(
|
||||
`Failed validation of the store pathn with message ${ex.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
return errors;
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -59,6 +59,8 @@ createDevApp()
|
||||
projectUrl: `/#${componentKey}`,
|
||||
getIssuesUrl: i => `/#${componentKey}/issues/${i}`,
|
||||
getComponentMeasuresUrl: i => `/#${componentKey}/measures/${i}`,
|
||||
getSecurityHotspotsUrl: () =>
|
||||
`#${componentKey}/security_hotspots`,
|
||||
} as FindingSummary;
|
||||
|
||||
case 'failed':
|
||||
@@ -70,6 +72,7 @@ createDevApp()
|
||||
reliability_rating: '2.0',
|
||||
vulnerabilities: '18',
|
||||
security_rating: '3.0',
|
||||
security_review_rating: '3.0',
|
||||
code_smells: '22',
|
||||
sqale_rating: '5.0',
|
||||
coverage: '15.7',
|
||||
@@ -78,6 +81,8 @@ createDevApp()
|
||||
projectUrl: `/#${componentKey}`,
|
||||
getIssuesUrl: i => `/#${componentKey}/issues/${i}`,
|
||||
getComponentMeasuresUrl: i => `/#${componentKey}/measures/${i}`,
|
||||
getSecurityHotspotsUrl: () =>
|
||||
`#${componentKey}/security_hotspots`,
|
||||
} as FindingSummary;
|
||||
|
||||
case 'passed':
|
||||
@@ -89,6 +94,8 @@ createDevApp()
|
||||
reliability_rating: '1.0',
|
||||
vulnerabilities: '0',
|
||||
security_rating: '1.0',
|
||||
security_hotspots_reviewed: '100.0',
|
||||
security_review_rating: '1.0',
|
||||
code_smells: '0',
|
||||
sqale_rating: '1.0',
|
||||
coverage: '100.0',
|
||||
@@ -97,6 +104,8 @@ createDevApp()
|
||||
projectUrl: `/#${componentKey}`,
|
||||
getIssuesUrl: i => `/#${componentKey}/issues/${i}`,
|
||||
getComponentMeasuresUrl: i => `/#${componentKey}/measures/${i}`,
|
||||
getSecurityHotspotsUrl: () =>
|
||||
`#${componentKey}/security_hotspots`,
|
||||
} as FindingSummary;
|
||||
|
||||
default:
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface FindingSummary {
|
||||
projectUrl: string;
|
||||
getIssuesUrl: SonarUrlProcessorFunc;
|
||||
getComponentMeasuresUrl: SonarUrlProcessorFunc;
|
||||
getSecurityHotspotsUrl: () => string;
|
||||
}
|
||||
|
||||
export const sonarQubeApiRef = createApiRef<SonarQubeApi>({
|
||||
|
||||
@@ -46,7 +46,7 @@ describe('SonarQubeClient', () => {
|
||||
server.use(
|
||||
rest.get(`${mockBaseUrl}/sonarqube/measures/search`, (req, res, ctx) => {
|
||||
expect(req.url.searchParams.toString()).toBe(
|
||||
'projectKeys=our-service&metricKeys=alert_status%2Cbugs%2Creliability_rating%2Cvulnerabilities%2Csecurity_rating%2Ccode_smells%2Csqale_rating%2Ccoverage%2Cduplicated_lines_density',
|
||||
'projectKeys=our-service&metricKeys=alert_status%2Cbugs%2Creliability_rating%2Cvulnerabilities%2Csecurity_rating%2Csecurity_hotspots_reviewed%2Csecurity_review_rating%2Ccode_smells%2Csqale_rating%2Ccoverage%2Cduplicated_lines_density',
|
||||
);
|
||||
return res(
|
||||
ctx.json({
|
||||
@@ -81,6 +81,16 @@ describe('SonarQubeClient', () => {
|
||||
value: '1.0',
|
||||
component: 'our-service',
|
||||
},
|
||||
{
|
||||
metric: 'security_hotspots_reviewed',
|
||||
value: '100',
|
||||
component: 'our-service',
|
||||
},
|
||||
{
|
||||
metric: 'security_review_rating',
|
||||
value: '1.0',
|
||||
component: 'our-service',
|
||||
},
|
||||
{
|
||||
metric: 'code_smells',
|
||||
value: '100',
|
||||
@@ -123,6 +133,8 @@ describe('SonarQubeClient', () => {
|
||||
reliability_rating: '3.0',
|
||||
vulnerabilities: '4',
|
||||
security_rating: '1.0',
|
||||
security_hotspots_reviewed: '100',
|
||||
security_review_rating: '1.0',
|
||||
code_smells: '100',
|
||||
sqale_rating: '2.0',
|
||||
coverage: '55.5',
|
||||
@@ -158,6 +170,8 @@ describe('SonarQubeClient', () => {
|
||||
reliability_rating: '3.0',
|
||||
vulnerabilities: '4',
|
||||
security_rating: '1.0',
|
||||
security_hotspots_reviewed: '100',
|
||||
security_review_rating: '1.0',
|
||||
code_smells: '100',
|
||||
sqale_rating: '2.0',
|
||||
coverage: '55.5',
|
||||
|
||||
@@ -63,6 +63,8 @@ export class SonarQubeClient implements SonarQubeApi {
|
||||
reliability_rating: undefined,
|
||||
vulnerabilities: undefined,
|
||||
security_rating: undefined,
|
||||
security_hotspots_reviewed: undefined,
|
||||
security_review_rating: undefined,
|
||||
code_smells: undefined,
|
||||
sqale_rating: undefined,
|
||||
coverage: undefined,
|
||||
@@ -92,10 +94,12 @@ export class SonarQubeClient implements SonarQubeApi {
|
||||
`${
|
||||
this.baseUrl
|
||||
}project/issues?id=${componentKey}&types=${identifier.toUpperCase()}&resolved=false`,
|
||||
getComponentMeasuresUrl: (identifier: string) =>
|
||||
getComponentMeasuresUrl: identifier =>
|
||||
`${
|
||||
this.baseUrl
|
||||
}component_measures?id=${componentKey}&metric=${identifier.toLowerCase()}&resolved=false&view=list`,
|
||||
getSecurityHotspotsUrl: () =>
|
||||
`${this.baseUrl}project/security_hotspots?id=${componentKey}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,10 @@ export type MetricKey =
|
||||
| 'code_smells'
|
||||
| 'sqale_rating'
|
||||
|
||||
// security hotspots
|
||||
| 'security_hotspots_reviewed'
|
||||
| 'security_review_rating'
|
||||
|
||||
// coverage
|
||||
| 'coverage'
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ const useStyles = makeStyles(theme => {
|
||||
return {
|
||||
root: {
|
||||
margin: theme.spacing(1, 0),
|
||||
minWidth: '140px',
|
||||
},
|
||||
upper: {
|
||||
display: 'flex',
|
||||
|
||||
@@ -26,6 +26,7 @@ import { Chip, Grid } from '@material-ui/core';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import BugReport from '@material-ui/icons/BugReport';
|
||||
import LockOpen from '@material-ui/icons/LockOpen';
|
||||
import Security from '@material-ui/icons/Security';
|
||||
import SentimentVeryDissatisfied from '@material-ui/icons/SentimentVeryDissatisfied';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
@@ -205,6 +206,25 @@ export const SonarQubeCard = ({
|
||||
leftSlot={<Value value={value.metrics.code_smells} />}
|
||||
rightSlot={<Rating rating={value.metrics.sqale_rating} />}
|
||||
/>
|
||||
{value.metrics.security_review_rating && (
|
||||
<RatingCard
|
||||
titleIcon={<Security />}
|
||||
title="Hotspots Reviewed"
|
||||
link={value.getSecurityHotspotsUrl()}
|
||||
leftSlot={
|
||||
<Value
|
||||
value={
|
||||
value.metrics.security_hotspots_reviewed
|
||||
? `${value.metrics.security_hotspots_reviewed}%`
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
}
|
||||
rightSlot={
|
||||
<Rating rating={value.metrics.security_review_rating} />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div style={{ width: '100%' }} />
|
||||
<RatingCard
|
||||
link={value.getComponentMeasuresUrl('COVERAGE')}
|
||||
|
||||
@@ -58,14 +58,22 @@ export async function createRouter({
|
||||
const { '0': path } = req.params;
|
||||
const entityName = getEntityNameFromUrlPath(path);
|
||||
|
||||
publisher
|
||||
.fetchTechDocsMetadata(entityName)
|
||||
.then(techdocsMetadataJson => {
|
||||
res.send(techdocsMetadataJson);
|
||||
})
|
||||
.catch(reason => {
|
||||
res.status(500).send(`Unable to get Metadata. Reason: ${reason}`);
|
||||
});
|
||||
try {
|
||||
const techdocsMetadata = await publisher.fetchTechDocsMetadata(
|
||||
entityName,
|
||||
);
|
||||
|
||||
res.send(techdocsMetadata);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Unable to get metadata for ${entityName.namespace}/${entityName.name} with error ${err}`,
|
||||
);
|
||||
res
|
||||
.status(500)
|
||||
.send(
|
||||
`Unable to get metadata for $${entityName.namespace}/${entityName.name}, reason: ${err}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/metadata/entity/:namespace/:kind/:name', async (req, res) => {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import { createApiRef } from '@backstage/core';
|
||||
import { EntityName } from '@backstage/catalog-model';
|
||||
import { TechDocsMetadata } from './types';
|
||||
|
||||
export const techdocsStorageApiRef = createApiRef<TechDocsStorageApi>({
|
||||
id: 'plugin.techdocs.storageservice',
|
||||
@@ -33,7 +34,7 @@ export interface TechDocsStorage {
|
||||
}
|
||||
|
||||
export interface TechDocs {
|
||||
getTechDocsMetadata(entityId: EntityName): Promise<string>;
|
||||
getTechDocsMetadata(entityId: EntityName): Promise<TechDocsMetadata>;
|
||||
getEntityMetadata(entityId: EntityName): Promise<string>;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,12 +19,13 @@ import { AsyncState } from 'react-use/lib/useAsync';
|
||||
import CodeIcon from '@material-ui/icons/Code';
|
||||
import { EntityName } from '@backstage/catalog-model';
|
||||
import { Header, HeaderLabel, Link } from '@backstage/core';
|
||||
import { TechDocsMetadata } from '../../types';
|
||||
|
||||
type TechDocsPageHeaderProps = {
|
||||
entityId: EntityName;
|
||||
metadataRequest: {
|
||||
entity: AsyncState<any>;
|
||||
techdocs: AsyncState<any>;
|
||||
techdocs: AsyncState<TechDocsMetadata>;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+6
-8
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
* 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.
|
||||
@@ -13,10 +13,8 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export type RemoteProtocol =
|
||||
| 'file'
|
||||
| 'github'
|
||||
| 'gitlab'
|
||||
| 'gitlab/api'
|
||||
| 'azure/api'
|
||||
| 'bitbucket';
|
||||
|
||||
export type TechDocsMetadata = {
|
||||
site_name: string;
|
||||
site_description: string;
|
||||
};
|
||||
@@ -2453,7 +2453,7 @@
|
||||
d3-shape "^2.0.0"
|
||||
d3-zoom "^2.0.0"
|
||||
dagre "^0.8.5"
|
||||
immer "^7.0.9"
|
||||
immer "^8.0.1"
|
||||
lodash "^4.17.15"
|
||||
material-table "^1.69.1"
|
||||
prop-types "^15.7.2"
|
||||
@@ -15280,10 +15280,10 @@ immer@1.10.0:
|
||||
resolved "https://registry.npmjs.org/immer/-/immer-1.10.0.tgz#bad67605ba9c810275d91e1c2a47d4582e98286d"
|
||||
integrity sha512-O3sR1/opvCDGLEVcvrGTMtLac8GJ5IwZC4puPrLuRj3l7ICKvkmA0vGuU9OW8mV9WIBRnaxp5GJh9IEAaNOoYg==
|
||||
|
||||
immer@^7.0.9:
|
||||
version "7.0.9"
|
||||
resolved "https://registry.npmjs.org/immer/-/immer-7.0.9.tgz#28e7552c21d39dd76feccd2b800b7bc86ee4a62e"
|
||||
integrity sha512-Vs/gxoM4DqNAYR7pugIxi0Xc8XAun/uy7AQu4fLLqaTBHxjOP9pJ266Q9MWA/ly4z6rAFZbvViOtihxUZ7O28A==
|
||||
immer@^8.0.1:
|
||||
version "8.0.1"
|
||||
resolved "https://registry.npmjs.org/immer/-/immer-8.0.1.tgz#9c73db683e2b3975c424fb0572af5889877ae656"
|
||||
integrity sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA==
|
||||
|
||||
immutable@>=3.8.2, immutable@^3.8.1, immutable@^3.8.2, immutable@^3.x.x:
|
||||
version "3.8.2"
|
||||
@@ -16957,7 +16957,7 @@ json3@^3.3.2:
|
||||
resolved "https://registry.npmjs.org/json3/-/json3-3.3.3.tgz#7fc10e375fc5ae42c4705a5cc0aa6f62be305b81"
|
||||
integrity sha512-c7/8mbUsKigAbLkD5B010BK4D9LZm7A1pNItkEwiUZRpIN66exu/e7YQWysGun+TRKaJp8MhemM+VkfWv42aCA==
|
||||
|
||||
json5@2.x, json5@^2.1.1, json5@^2.1.2:
|
||||
json5@2.x, json5@^2.1.1, json5@^2.1.2, json5@^2.1.3:
|
||||
version "2.1.3"
|
||||
resolved "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz#c9b0f7fa9233bfe5807fe66fcf3a5617ed597d43"
|
||||
integrity sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==
|
||||
|
||||
Reference in New Issue
Block a user