Merge branch 'master' into rugvip/bapp

This commit is contained in:
Patrik Oldsberg
2020-08-31 13:19:02 +02:00
committed by GitHub
153 changed files with 2004 additions and 1759 deletions
+1 -1
View File
@@ -29,7 +29,6 @@
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@types/react": "^16.9",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "6.0.0-beta.0",
@@ -45,6 +44,7 @@
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"@types/react": "^16.9",
"@types/swagger-ui-react": "^3.23.3",
"jest-fetch-mock": "^3.0.3"
},
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { Table, TableColumn } from '@backstage/core';
import { Link } from '@material-ui/core';
@@ -16,13 +16,15 @@
import { ApiEntityV1alpha1 } from '@backstage/catalog-model';
import { InfoCard } from '@backstage/core';
import React, { FC } from 'react';
import React from 'react';
import { ApiDefinitionWidget } from '../ApiDefinitionWidget/ApiDefinitionWidget';
export const ApiDefinitionCard: FC<{
type Props = {
title?: string;
apiEntity: ApiEntityV1alpha1;
}> = ({ title, apiEntity }) => {
};
export const ApiDefinitionCard = ({ title, apiEntity }: Props) => {
const type = apiEntity?.spec?.type || '';
const definition = apiEntity?.spec?.definition || '';
@@ -14,15 +14,17 @@
* limitations under the License.
*/
import React, { FC } from 'react';
import React from 'react';
import { AsyncApiDefinitionWidget } from '../AsyncApiDefinitionWidget/AsyncApiDefinitionWidget';
import { OpenApiDefinitionWidget } from '../OpenApiDefinitionWidget/OpenApiDefinitionWidget';
import { PlainApiDefinitionWidget } from '../PlainApiDefinitionWidget/PlainApiDefinitionWidget';
export const ApiDefinitionWidget: FC<{
type Props = {
type: string;
definition: string;
}> = ({ type, definition }) => {
};
export const ApiDefinitionWidget = ({ type, definition }: Props) => {
switch (type) {
case 'openapi':
return <OpenApiDefinitionWidget definition={definition} />;
@@ -31,6 +33,8 @@ export const ApiDefinitionWidget: FC<{
return <AsyncApiDefinitionWidget definition={definition} />;
default:
return <PlainApiDefinitionWidget definition={definition} />;
return (
<PlainApiDefinitionWidget definition={definition} language={type} />
);
}
};
@@ -29,7 +29,7 @@ import {
import { catalogApiRef } from '@backstage/plugin-catalog';
import { Box } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React, { FC, useEffect } from 'react';
import React, { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useAsync } from 'react-use';
import { ApiDefinitionCard } from '../ApiDefinitionCard/ApiDefinitionCard';
@@ -59,15 +59,18 @@ export const getPageTheme = (entity?: Entity): PageTheme => {
return pageTheme[themeKey] ?? pageTheme.home;
};
const EntityPageTitle: FC<{ title: string; entity: Entity | undefined }> = ({
title,
}) => (
type EntityPageTitleProps = {
title: string;
entity: Entity | undefined;
};
const EntityPageTitle = ({ title }: EntityPageTitleProps) => (
<Box display="inline-flex" alignItems="center" height="1em">
{title}
</Box>
);
export const ApiEntityPage: FC<{}> = () => {
export const ApiEntityPage = () => {
const { optionalNamespaceAndName } = useParams() as {
optionalNamespaceAndName: string;
};
@@ -15,7 +15,7 @@
*/
import AsyncApi from '@kyma-project/asyncapi-react';
import React, { FC } from 'react';
import React from 'react';
import { makeStyles, fade } from '@material-ui/core/styles';
import '@kyma-project/asyncapi-react/lib/styles/fiori.css';
@@ -135,9 +135,11 @@ const useStyles = makeStyles(theme => ({
},
}));
export const AsyncApiDefinitionWidget: FC<{
type Props = {
definition: any;
}> = ({ definition }) => {
};
export const AsyncApiDefinitionWidget = ({ definition }: Props) => {
const classes = useStyles();
return (
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React, { FC, useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { makeStyles } from '@material-ui/core/styles';
import SwaggerUI from 'swagger-ui-react';
import 'swagger-ui-react/swagger-ui.css';
@@ -37,7 +37,7 @@ const useStyles = makeStyles(theme => ({
'& section.models, section.models.is-open h4': {
'border-color': theme.palette.divider,
},
'& .opblock .opblock-summary-description, .parameter__type, table.headers td, .model-title, .model .property.primitive': {
'& .opblock .opblock-summary-description, .parameter__type, table.headers td, .model-title, .model .property.primitive, section h3': {
color: theme.palette.text.secondary,
},
'& .opblock .opblock-summary-operation-id, .opblock .opblock-summary-path, .opblock .opblock-summary-path__deprecated, .opblock .opblock-section-header h4, .parameter__name, .response-col_status, .response-col_links, .responses-inner h4, .swagger-ui .responses-inner h5, .opblock-section-header .btn, .tab li, .info li, .info p, .info table, section.models h4, .info .title, table.model tr.description, .property-row': {
@@ -65,9 +65,11 @@ const useStyles = makeStyles(theme => ({
},
}));
export const OpenApiDefinitionWidget: FC<{
type Props = {
definition: any;
}> = ({ definition }) => {
};
export const OpenApiDefinitionWidget = ({ definition }: Props) => {
const classes = useStyles();
// Due to a bug in the swagger-ui-react component, the component needs
@@ -15,10 +15,13 @@
*/
import { CodeSnippet } from '@backstage/core';
import React, { FC } from 'react';
import React from 'react';
export const PlainApiDefinitionWidget: FC<{
type Props = {
definition: any;
}> = ({ definition }) => {
return <CodeSnippet text={definition} language="yaml" />;
language: string;
};
export const PlainApiDefinitionWidget = ({ definition, language }: Props) => {
return <CodeSnippet text={definition} language={language} />;
};
@@ -274,6 +274,13 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
await this.populateIdentity(response.backstageIdentity);
if (
response.providerInfo.refreshToken &&
response.providerInfo.refreshToken !== refreshToken
) {
this.setRefreshTokenCookie(res, response.providerInfo.refreshToken);
}
res.send(response);
} catch (error) {
res.status(401).send(`${error.message}`);
@@ -45,7 +45,6 @@ export const makeProfileInfo = (
if ((!email || !picture) && idToken) {
try {
const decoded: Record<string, string> = jwtDecoder(idToken);
if (!email && decoded.email) {
email = decoded.email;
}
@@ -133,7 +132,7 @@ export const executeRefreshTokenStrategy = async (
(
err: Error | null,
accessToken: string,
_refreshToken: string,
newRefreshToken: string,
params: any,
) => {
if (err) {
@@ -149,6 +148,7 @@ export const executeRefreshTokenStrategy = async (
resolve({
accessToken,
refreshToken: newRefreshToken,
params,
});
},
@@ -67,6 +67,7 @@ export class OAuth2AuthProvider implements OAuthProviderHandlers {
done: PassportDoneCallback<OAuthResponse, PrivateInfo>,
) => {
const profile = makeProfileInfo(rawProfile, params.id_token);
done(
undefined,
{
@@ -113,11 +114,16 @@ export class OAuth2AuthProvider implements OAuthProviderHandlers {
}
async refresh(refreshToken: string, scope: string): Promise<OAuthResponse> {
const { accessToken, params } = await executeRefreshTokenStrategy(
const refreshTokenResponse = await executeRefreshTokenStrategy(
this._strategy,
refreshToken,
scope,
);
const {
accessToken,
params,
refreshToken: updatedRefreshToken,
} = refreshTokenResponse;
const profile = await executeFetchUserProfileStrategy(
this._strategy,
@@ -128,6 +134,7 @@ export class OAuth2AuthProvider implements OAuthProviderHandlers {
return this.populateIdentity({
providerInfo: {
accessToken,
refreshToken: updatedRefreshToken,
idToken: params.id_token,
expiresInSeconds: params.expires_in,
scope: params.scope,
@@ -146,7 +153,6 @@ export class OAuth2AuthProvider implements OAuthProviderHandlers {
if (!profile.email) {
throw new Error('Profile does not contain a profile');
}
const id = profile.email.split('@')[0];
return { ...response, backstageIdentity: { id } };
@@ -213,6 +213,10 @@ export type OAuthProviderInfo = {
* Scopes granted for the access token.
*/
scope: string;
/**
* A refresh token issued for the signed in user
*/
refreshToken?: string;
};
export type OAuthPrivateInfo = {
@@ -280,6 +284,10 @@ export type RefreshTokenResponse = {
* An access token issued for the signed in user.
*/
accessToken: string;
/**
* Optionally, the server can issue a new Refresh Token for the user
*/
refreshToken?: string;
params: any;
};
@@ -31,6 +31,7 @@ import { GithubApiReaderProcessor } from './processors/GithubApiReaderProcessor'
import { GitlabApiReaderProcessor } from './processors/GitlabApiReaderProcessor';
import { GitlabReaderProcessor } from './processors/GitlabReaderProcessor';
import { BitbucketApiReaderProcessor } from './processors/BitbucketApiReaderProcessor';
import { AzureApiReaderProcessor } from './processors/AzureApiReaderProcessor';
import { UrlReaderProcessor } from './processors/UrlReaderProcessor';
import { LocationRefProcessor } from './processors/LocationEntityProcessor';
import { StaticLocationProcessor } from './processors/StaticLocationProcessor';
@@ -75,10 +76,11 @@ export class LocationReaders implements LocationReader {
StaticLocationProcessor.fromConfig(config),
new FileReaderProcessor(),
new GithubReaderProcessor(),
new GithubApiReaderProcessor(),
new GitlabApiReaderProcessor(),
new GithubApiReaderProcessor(config),
new GitlabApiReaderProcessor(config),
new GitlabReaderProcessor(),
new BitbucketApiReaderProcessor(),
new BitbucketApiReaderProcessor(config),
new AzureApiReaderProcessor(config),
new UrlReaderProcessor(),
new YamlProcessor(),
new EntityPolicyProcessor(entityPolicy),
@@ -0,0 +1,114 @@
/*
* 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 { AzureApiReaderProcessor } from './AzureApiReaderProcessor';
import { ConfigReader } from '@backstage/config';
describe('AzureApiReaderProcessor', () => {
const createConfig = (token: string | undefined) =>
ConfigReader.fromConfigs([
{
context: '',
data: {
catalog: {
processors: {
azureApi: {
privateToken: token,
},
},
},
},
},
]);
it('should build raw api', () => {
const processor = new AzureApiReaderProcessor(createConfig(undefined));
const tests = [
{
target:
'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml&version=GBmaster',
url: new URL(
'https://dev.azure.com/org-name/project-name/_apis/sourceProviders/TfsGit/filecontents?repository=repo-name&commitOrBranch=master&path=my-template.yaml&api-version=6.0-preview.1',
),
err: undefined,
},
{
target: 'https://api.com/a/b/blob/master/path/to/c.yaml',
url: null,
err:
'Incorrect url: https://api.com/a/b/blob/master/path/to/c.yaml, Error: Wrong Azure Devops URL or Invalid file path',
},
{
target: 'com/a/b/blob/master/path/to/c.yaml',
url: null,
err:
'Incorrect url: com/a/b/blob/master/path/to/c.yaml, TypeError: Invalid URL: com/a/b/blob/master/path/to/c.yaml',
},
];
for (const test of tests) {
if (test.err) {
expect(() => processor.buildRawUrl(test.target)).toThrowError(test.err);
} else if (test.url) {
expect(processor.buildRawUrl(test.target).toString()).toEqual(
test.url.toString(),
);
} else {
throw new Error(
'This should not have happened. Either err or url should have matched.',
);
}
}
});
it('should return request options', () => {
const tests = [
{
token: '0123456789',
expect: {
headers: {
Authorization: 'Basic OjAxMjM0NTY3ODk=',
},
},
},
{
token: '',
expect: {
headers: {},
},
err:
"Invalid type in config for key 'catalog.processors.azureApi.privateToken' in '', got empty-string, wanted string",
},
{
token: undefined,
expect: {
headers: {},
},
},
];
for (const test of tests) {
if (test.err) {
expect(
() => new AzureApiReaderProcessor(createConfig(test.token)),
).toThrowError(test.err);
} else {
const processor = new AzureApiReaderProcessor(createConfig(test.token));
expect(processor.getRequestOptions()).toEqual(test.expect);
}
}
});
});
@@ -0,0 +1,141 @@
/*
* 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 { LocationSpec } from '@backstage/catalog-model';
import fetch, { RequestInit, HeadersInit } from 'node-fetch';
import * as result from './results';
import { LocationProcessor, LocationProcessorEmit } from './types';
import { Config } from '@backstage/config';
export class AzureApiReaderProcessor implements LocationProcessor {
private privateToken: string;
constructor(config: Config) {
this.privateToken =
config.getOptionalString('catalog.processors.azureApi.privateToken') ??
'';
}
getRequestOptions(): RequestInit {
const headers: HeadersInit = {};
if (this.privateToken !== '') {
headers.Authorization = `Basic ${Buffer.from(
`:${this.privateToken}`,
'utf8',
).toString('base64')}`;
}
const requestOptions: RequestInit = {
headers,
};
return requestOptions;
}
async readLocation(
location: LocationSpec,
optional: boolean,
emit: LocationProcessorEmit,
): Promise<boolean> {
if (location.type !== 'azure/api') {
return false;
}
try {
const url = this.buildRawUrl(location.target);
const response = await fetch(url.toString(), this.getRequestOptions());
// for private repos when PAT is not valid, Azure API returns a http status code 203 with sign in page html
if (response.ok && response.status !== 203) {
const data = await response.buffer();
emit(result.data(location, data));
} else {
const message = `${location.target} could not be read as ${url}, ${response.status} ${response.statusText}`;
if (response.status === 404) {
if (!optional) {
emit(result.notFoundError(location, message));
}
} else {
emit(result.generalError(location, message));
}
}
} catch (e) {
const message = `Unable to read ${location.type} ${location.target}, ${e}`;
emit(result.generalError(location, message));
}
return true;
}
// Converts
// from: https://dev.azure.com/{organization}/{project}/_git/reponame?path={path}&version=GB{commitOrBranch}&_a=contents
// to: https://dev.azure.com/{organization}/{project}/_apis/sourceProviders/{providerName}/filecontents?repository={repository}&commitOrBranch={commitOrBranch}&path={path}&api-version=6.0-preview.1
buildRawUrl(target: string): URL {
try {
const url = new URL(target);
const [
empty,
userOrOrg,
project,
srcKeyword,
repoName,
] = url.pathname.split('/');
const path = url.searchParams.get('path') || '';
const ref = url.searchParams.get('version')?.substr(2);
if (
url.hostname !== 'dev.azure.com' ||
empty !== '' ||
userOrOrg === '' ||
project === '' ||
srcKeyword !== '_git' ||
repoName === '' ||
path === '' ||
ref === '' ||
!path.match(/\.yaml$/)
) {
throw new Error('Wrong Azure Devops URL or Invalid file path');
}
// transform to api
url.pathname = [
empty,
userOrOrg,
project,
'_apis',
'sourceProviders',
'TfsGit',
'filecontents',
].join('/');
url.search = [
`repository=${repoName}`,
`commitOrBranch=${ref}`,
`path=${path}`,
'api-version=6.0-preview.1',
].join('&');
url.protocol = 'https';
return url;
} catch (e) {
throw new Error(`Incorrect url: ${target}, ${e}`);
}
}
}
@@ -15,10 +15,33 @@
*/
import { BitbucketApiReaderProcessor } from './BitbucketApiReaderProcessor';
import { ConfigReader } from '@backstage/config';
describe('BitbucketApiReaderProcessor', () => {
const createConfig = (
username: string | undefined,
appPassword: string | undefined,
) =>
ConfigReader.fromConfigs([
{
context: '',
data: {
catalog: {
processors: {
bitbucketApi: {
username: username,
appPassword: appPassword,
},
},
},
},
},
]);
it('should build raw api', () => {
const processor = new BitbucketApiReaderProcessor();
const processor = new BitbucketApiReaderProcessor(
createConfig(undefined, undefined),
);
const tests = [
{
@@ -66,6 +89,8 @@ describe('BitbucketApiReaderProcessor', () => {
expect: {
headers: {},
},
err:
"Invalid type in config for key 'catalog.processors.bitbucketApi.username' in '', got empty-string, wanted string",
},
{
username: 'only-user-provided',
@@ -73,6 +98,8 @@ describe('BitbucketApiReaderProcessor', () => {
expect: {
headers: {},
},
err:
"Invalid type in config for key 'catalog.processors.bitbucketApi.appPassword' in '', got empty-string, wanted string",
},
{
username: '',
@@ -80,6 +107,8 @@ describe('BitbucketApiReaderProcessor', () => {
expect: {
headers: {},
},
err:
"Invalid type in config for key 'catalog.processors.bitbucketApi.username' in '', got empty-string, wanted string",
},
{
username: 'some-user',
@@ -90,13 +119,43 @@ describe('BitbucketApiReaderProcessor', () => {
},
},
},
{
username: undefined,
password: undefined,
expect: {
headers: {},
},
},
{
username: 'only-user-provided',
password: undefined,
expect: {
headers: {},
},
},
{
username: undefined,
password: 'only-password-provided',
expect: {
headers: {},
},
},
];
for (const test of tests) {
process.env.BITBUCKET_USERNAME = test.username;
process.env.BITBUCKET_APP_PASSWORD = test.password;
const processor = new BitbucketApiReaderProcessor();
expect(processor.getRequestOptions()).toEqual(test.expect);
if (test.err) {
expect(
() =>
new BitbucketApiReaderProcessor(
createConfig(test.username, test.password),
),
).toThrowError(test.err);
} else {
const processor = new BitbucketApiReaderProcessor(
createConfig(test.username, test.password),
);
expect(processor.getRequestOptions()).toEqual(test.expect);
}
}
});
});
@@ -18,10 +18,20 @@ import { LocationSpec } from '@backstage/catalog-model';
import fetch, { RequestInit, HeadersInit } from 'node-fetch';
import * as result from './results';
import { LocationProcessor, LocationProcessorEmit } from './types';
import { Config } from '@backstage/config';
export class BitbucketApiReaderProcessor implements LocationProcessor {
private username: string = process.env.BITBUCKET_USERNAME || '';
private password: string = process.env.BITBUCKET_APP_PASSWORD || '';
private username: string;
private password: string;
constructor(config: Config) {
this.username =
config.getOptionalString('catalog.processors.bitbucketApi.username') ??
'';
this.password =
config.getOptionalString('catalog.processors.bitbucketApi.appPassword') ??
'';
}
getRequestOptions(): RequestInit {
const headers: HeadersInit = {};
@@ -15,10 +15,27 @@
*/
import { GithubApiReaderProcessor } from './GithubApiReaderProcessor';
import { ConfigReader } from '@backstage/config';
describe('GithubApiReaderProcessor', () => {
const createConfig = (token: string | undefined) =>
ConfigReader.fromConfigs([
{
context: '',
data: {
catalog: {
processors: {
githubApi: {
privateToken: token,
},
},
},
},
},
]);
it('should build raw api', () => {
const processor = new GithubApiReaderProcessor();
const processor = new GithubApiReaderProcessor(createConfig(undefined));
const tests = [
{
@@ -78,6 +95,16 @@ describe('GithubApiReaderProcessor', () => {
},
{
token: '',
err:
"Invalid type in config for key 'catalog.processors.githubApi.privateToken' in '', got empty-string, wanted string",
expect: {
headers: {
Accept: 'application/vnd.github.v3.raw',
},
},
},
{
token: undefined,
expect: {
headers: {
Accept: 'application/vnd.github.v3.raw',
@@ -87,9 +114,16 @@ describe('GithubApiReaderProcessor', () => {
];
for (const test of tests) {
process.env.GITHUB_PRIVATE_TOKEN = test.token;
const processor = new GithubApiReaderProcessor();
expect(processor.getRequestOptions()).toEqual(test.expect);
if (test.err) {
expect(
() => new GithubApiReaderProcessor(createConfig(test.token)),
).toThrowError(test.err);
} else {
const processor = new GithubApiReaderProcessor(
createConfig(test.token),
);
expect(processor.getRequestOptions()).toEqual(test.expect);
}
}
});
});
@@ -18,9 +18,16 @@ import { LocationSpec } from '@backstage/catalog-model';
import fetch, { RequestInit, HeadersInit } from 'node-fetch';
import * as result from './results';
import { LocationProcessor, LocationProcessorEmit } from './types';
import { Config } from '@backstage/config';
export class GithubApiReaderProcessor implements LocationProcessor {
private privateToken: string = process.env.GITHUB_PRIVATE_TOKEN || '';
private privateToken: string;
constructor(config: Config) {
this.privateToken =
config.getOptionalString('catalog.processors.githubApi.privateToken') ??
'';
}
getRequestOptions(): RequestInit {
const headers: HeadersInit = {
@@ -15,10 +15,27 @@
*/
import { GitlabApiReaderProcessor } from './GitlabApiReaderProcessor';
import { ConfigReader } from '@backstage/config';
describe('GitlabApiReaderProcessor', () => {
const createConfig = (token: string | undefined) =>
ConfigReader.fromConfigs([
{
context: '',
data: {
catalog: {
processors: {
gitlabApi: {
privateToken: token,
},
},
},
},
},
]);
it('should build raw api', () => {
const processor = new GitlabApiReaderProcessor();
const processor = new GitlabApiReaderProcessor(createConfig(undefined));
const tests = [
{
@@ -83,6 +100,16 @@ describe('GitlabApiReaderProcessor', () => {
},
{
token: '',
err:
"Invalid type in config for key 'catalog.processors.gitlabApi.privateToken' in '', got empty-string, wanted string",
expect: {
headers: {
'PRIVATE-TOKEN': '',
},
},
},
{
token: undefined,
expect: {
headers: {
'PRIVATE-TOKEN': '',
@@ -92,9 +119,16 @@ describe('GitlabApiReaderProcessor', () => {
];
for (const test of tests) {
process.env.GITLAB_PRIVATE_TOKEN = test.token;
const processor = new GitlabApiReaderProcessor();
expect(processor.getRequestOptions()).toEqual(test.expect);
if (test.err) {
expect(
() => new GitlabApiReaderProcessor(createConfig(test.token)),
).toThrowError(test.err);
} else {
const processor = new GitlabApiReaderProcessor(
createConfig(test.token),
);
expect(processor.getRequestOptions()).toEqual(test.expect);
}
}
});
});
@@ -18,9 +18,16 @@ import { LocationSpec } from '@backstage/catalog-model';
import fetch, { RequestInit, HeadersInit } from 'node-fetch';
import * as result from './results';
import { LocationProcessor, LocationProcessorEmit } from './types';
import { Config } from '@backstage/config';
export class GitlabApiReaderProcessor implements LocationProcessor {
private privateToken: string = process.env.GITLAB_PRIVATE_TOKEN || '';
private privateToken: string;
constructor(config: Config) {
this.privateToken =
config.getOptionalString('catalog.processors.gitlabApi.privateToken') ??
'';
}
getRequestOptions(): RequestInit {
const headers: HeadersInit = { 'PRIVATE-TOKEN': '' };
@@ -51,6 +51,22 @@ describe('YamlProcessor', () => {
expect(never).not.toBeCalled();
});
it('should process url that contains yaml', async () => {
const containsYamlLocationSpec = {
type: 'url',
target: 'http://example.com/component?path=test.yaml&c=1&d=2',
};
const buffer = Buffer.from([]);
const emit = jest.fn();
expect(
await processor.parseData(buffer, containsYamlLocationSpec, emit),
).toBe(true);
expect(emit).toBeCalled();
});
it('should process entity with yaml', async () => {
const entity = {
apiVersion: 'backstage.io/v1alpha1',
@@ -26,7 +26,7 @@ export class YamlProcessor implements LocationProcessor {
location: LocationSpec,
emit: LocationProcessorEmit,
): Promise<boolean> {
if (!location.target.match(/\.ya?ml$/)) {
if (!location.target.match(/\.ya?ml/)) {
return false;
}
+1 -1
View File
@@ -54,7 +54,7 @@
"jest-fetch-mock": "^3.0.3",
"msw": "^0.20.5",
"react-test-renderer": "^16.13.1",
"whatwg-fetch": "^2.0.0"
"whatwg-fetch": "^3.4.0"
},
"files": [
"dist"
+13 -20
View File
@@ -18,25 +18,21 @@ import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { CatalogClient } from './CatalogClient';
import { Entity } from '@backstage/catalog-model';
import { UrlPatternDiscovery } from '@backstage/core';
const server = setupServer();
const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base';
const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl);
describe('CatalogClient', () => {
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
const mockApiOrigin = 'http://backstage:9191';
const mockBasePath = '/i-am-a-mock-base';
let client = new CatalogClient({
apiOrigin: mockApiOrigin,
basePath: mockBasePath,
});
let client = new CatalogClient({ discoveryApi });
beforeEach(() => {
client = new CatalogClient({
apiOrigin: mockApiOrigin,
basePath: mockBasePath,
});
client = new CatalogClient({ discoveryApi });
});
describe('getEntiies', () => {
@@ -61,7 +57,7 @@ describe('CatalogClient', () => {
beforeEach(() => {
server.use(
rest.get(`${mockApiOrigin}${mockBasePath}/entities`, (_, res, ctx) => {
rest.get(`${mockBaseUrl}/entities`, (_, res, ctx) => {
return res(ctx.json(defaultResponse));
}),
);
@@ -75,15 +71,12 @@ describe('CatalogClient', () => {
it('builds entity search filters properly', async () => {
expect.assertions(2);
server.use(
rest.get(
`${mockApiOrigin}${mockBasePath}/entities`,
(req, res, ctx) => {
expect(req.url.searchParams.toString()).toBe(
'a=1&b=2&b=3&%C3%B6=%3D',
);
return res(ctx.json([]));
},
),
rest.get(`${mockBaseUrl}/entities`, (req, res, ctx) => {
expect(req.url.searchParams.toString()).toBe(
'a=1&b=2&b=3&%C3%B6=%3D',
);
return res(ctx.json([]));
}),
);
const entities = await client.getEntities({
+8 -15
View File
@@ -20,24 +20,17 @@ import {
LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
import { CatalogApi, EntityCompoundName } from './types';
import { DiscoveryApi } from '@backstage/core';
export class CatalogClient implements CatalogApi {
private apiOrigin: string;
private basePath: string;
private readonly discoveryApi: DiscoveryApi;
constructor({
apiOrigin,
basePath,
}: {
apiOrigin: string;
basePath: string;
}) {
this.apiOrigin = apiOrigin;
this.basePath = basePath;
constructor(options: { discoveryApi: DiscoveryApi }) {
this.discoveryApi = options.discoveryApi;
}
private async getRequired(path: string): Promise<any> {
const url = `${this.apiOrigin}${this.basePath}${path}`;
const url = `${await this.discoveryApi.getBaseUrl('catalog')}${path}`;
const response = await fetch(url);
if (!response.ok) {
@@ -50,7 +43,7 @@ export class CatalogClient implements CatalogApi {
}
private async getOptional(path: string): Promise<any | undefined> {
const url = `${this.apiOrigin}${this.basePath}${path}`;
const url = `${await this.discoveryApi.getBaseUrl('catalog')}${path}`;
const response = await fetch(url);
if (!response.ok) {
@@ -100,7 +93,7 @@ export class CatalogClient implements CatalogApi {
async addLocation(type: string, target: string) {
const response = await fetch(
`${this.apiOrigin}${this.basePath}/locations`,
`${await this.discoveryApi.getBaseUrl('catalog')}/locations`,
{
headers: {
'Content-Type': 'application/json',
@@ -135,7 +128,7 @@ export class CatalogClient implements CatalogApi {
async removeEntityByUid(uid: string): Promise<void> {
const response = await fetch(
`${this.apiOrigin}${this.basePath}/entities/by-uid/${uid}`,
`${await this.discoveryApi.getBaseUrl('catalog')}/entities/by-uid/${uid}`,
{
method: 'DELETE',
},
@@ -36,6 +36,7 @@ import { catalogApiRef } from '../..';
import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu';
import { EntityPageDocs } from '../EntityPageDocs/EntityDocsPage';
import { EntityPageApi } from '../EntityPageApi/EntityPageApi';
import { EntityPageCi } from '../EntityPageCi/EntityPageCi';
import { EntityPageOverview } from '../EntityPageOverview/EntityPageOverview';
import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity';
import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog';
@@ -129,6 +130,7 @@ export const EntityPage: FC<{}> = () => {
{
id: 'ci',
label: 'CI/CD',
content: (e: Entity) => <EntityPageCi entity={e} />,
},
{
id: 'tests',
@@ -0,0 +1,35 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { Content } from '@backstage/core';
import { RecentWorkflowRunsCard as GithubActionsListWidget } from '@backstage/plugin-github-actions';
import { Grid } from '@material-ui/core';
import React, { FC } from 'react';
export const EntityPageCi: FC<{ entity: Entity }> = ({ entity }) => {
return (
<Content>
<Grid container spacing={3}>
{entity.metadata?.annotations?.['backstage.io/github-actions-id'] && (
<Grid item sm={12}>
<GithubActionsListWidget entity={entity} branch="master" />
</Grid>
)}
</Grid>
</Content>
);
};
@@ -15,25 +15,11 @@
*/
import React from 'react';
import { Route, MemoryRouter, Routes } from 'react-router';
import { BuildsPage, Builds } from '../pages/BuildsPage';
import { DetailedViewPage, BuildWithSteps } from '../pages/BuildWithStepsPage';
import { Builds } from '../pages/BuildsPage';
import { BuildWithSteps } from '../pages/BuildWithStepsPage';
import { AppStateProvider } from '../state';
import { Settings } from './Settings';
export const App = () => {
return (
<AppStateProvider>
<>
<Routes>
<Route path="*" element={<BuildsPage />} />
<Route path="/build/:buildId" element={<DetailedViewPage />} />
</Routes>
<Settings />
</>
</AppStateProvider>
);
};
// TODO: allow pass in settings as props
// When some shared settings workflow
// will be established
+1 -1
View File
@@ -17,4 +17,4 @@
export { plugin } from './plugin';
export * from './api';
export * from './route-refs';
export { CircleCIWidget } from './components/App';
export { CircleCIWidget } from './components/CircleCIWidget';
@@ -25,6 +25,8 @@ import { Layout } from '../../components/Layout';
import LaunchIcon from '@material-ui/icons/Launch';
import { useSettings } from '../../state/useSettings';
import { useBuildWithSteps } from '../../state/useBuildWithSteps';
import { AppStateProvider } from '../../state';
import { Settings } from '../../components/Settings';
const IconLink = IconButton as typeof Link;
const BuildName: FC<{ build?: BuildWithSteps }> = ({ build }) => (
@@ -93,11 +95,14 @@ const pickClassName = (
};
const Page = () => (
<Layout>
<Content>
<BuildWithStepsView />
</Content>
</Layout>
<AppStateProvider>
<Layout>
<Content>
<BuildWithStepsView />
<Settings />
</Content>
</Layout>
</AppStateProvider>
);
const BuildWithStepsView: FC<{}> = () => {
@@ -19,13 +19,18 @@ import { Grid } from '@material-ui/core';
import { Builds as BuildsComp } from './lib/Builds';
import { Layout } from '../../components/Layout';
import { PluginHeader } from '../../components/PluginHeader';
import { AppStateProvider } from '../../state/AppState';
import { Settings } from '../../components/Settings';
const BuildsPage: FC<{}> = () => (
<Layout>
<Content>
<Builds />
</Content>
</Layout>
<AppStateProvider>
<Layout>
<Content>
<Builds />
<Settings />
</Content>
</Layout>
</AppStateProvider>
);
const Builds = () => (
+5 -3
View File
@@ -14,12 +14,14 @@
* limitations under the License.
*/
import { createPlugin } from '@backstage/core';
import { App } from './components/App';
import { circleCIRouteRef } from './route-refs';
import { circleCIRouteRef, circleCIBuildRouteRef } from './route-refs';
import BuildsPage from './pages/BuildsPage/BuildsPage';
import BuildWithStepsPage from './pages/BuildWithStepsPage/BuildWithStepsPage';
export const plugin = createPlugin({
id: 'circleci',
register({ router }) {
router.addRoute(circleCIRouteRef, App, { exact: false });
router.addRoute(circleCIRouteRef, BuildsPage);
router.addRoute(circleCIBuildRouteRef, BuildWithStepsPage);
},
});
+6 -1
View File
@@ -34,5 +34,10 @@ const CircleCIIcon: FC<SvgIconProps> = props => (
export const circleCIRouteRef = createRouteRef({
icon: CircleCIIcon,
path: '/circleci',
title: 'CircleCI',
title: 'CircleCI | All builds',
});
export const circleCIBuildRouteRef = createRouteRef({
path: '/circleci/build/:buildId',
title: 'CircleCI | Build info',
});
-1
View File
@@ -70,7 +70,6 @@ export const transform = (
export function useBuilds() {
const [{ repo, owner, token }] = useSettings();
const api = useApi(circleCIApiRef);
const errorApi = useApi(errorApiRef);
@@ -15,7 +15,7 @@
*/
import React, { useEffect } from 'react';
import { useWorkflowRuns } from '../useWorkflowRuns';
import { WorkflowRun } from '../WorkflowRunsTable';
import { WorkflowRun, WorkflowRunsTable } from '../WorkflowRunsTable';
import { Entity } from '@backstage/catalog-model';
import { WorkflowRunStatus } from '../WorkflowRunStatus';
import {
@@ -108,3 +108,51 @@ export const Widget = ({
</InfoCard>
);
};
const RecentWorkflowRunsCardContent = ({
error,
loading,
branch,
}: {
error?: Error;
loading?: boolean;
branch: string;
}) => {
if (error) return <Typography>Couldn't fetch {branch} runs</Typography>;
if (loading) return <LinearProgress />;
return <WorkflowRunsTable />;
};
export const RecentWorkflowRunsCard = ({
entity,
branch = 'master',
}: {
entity: Entity;
branch: string;
}) => {
const errorApi = useApi(errorApiRef);
const [owner, repo] = (
entity?.metadata.annotations?.['backstage.io/github-actions-id'] ?? '/'
).split('/');
const [{ loading, error }] = useWorkflowRuns({
owner,
repo,
branch,
});
useEffect(() => {
if (error) {
errorApi.post(error);
}
}, [error, errorApi]);
return (
<InfoCard title={`${branch} builds`}>
<RecentWorkflowRunsCardContent
error={error}
loading={loading}
branch={branch}
/>
</InfoCard>
);
};
@@ -13,4 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { Widget } from './Widget';
export { Widget, RecentWorkflowRunsCard } from './Widget';
+1 -1
View File
@@ -16,4 +16,4 @@
export { plugin } from './plugin';
export * from './api';
export { Widget } from './components/Widget';
export { Widget, RecentWorkflowRunsCard } from './components/Widget';
@@ -26,6 +26,7 @@ import {
githubAuthApiRef,
GithubAuth,
OAuthRequestManager,
UrlPatternDiscovery,
} from '@backstage/core';
import { gitOpsApiRef, GitOpsRestApi } from '../../api';
@@ -37,8 +38,9 @@ describe('ProfileCatalog', () => {
[
githubAuthApiRef,
GithubAuth.create({
backendUrl: 'http://localhost:7000',
basePath: '/auth/',
discoveryApi: UrlPatternDiscovery.compile(
'http://example.com/{{pluginId}}',
),
oauthRequestApi,
}),
],
+1 -1
View File
@@ -37,7 +37,7 @@
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"graphiql": "^1.0.0-alpha.10",
"graphql": "15.1.0",
"graphql": "15.3.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-use": "^15.3.3"
+1 -1
View File
@@ -27,7 +27,7 @@
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"graphql": "^15.3.0",
"whatwg-fetch": "^2.0.0",
"whatwg-fetch": "^3.4.0",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
+12 -16
View File
@@ -1,6 +1,7 @@
# Proxy backend plugin
This is the backend plugin that enables proxy definitions to be declared in and read from app-config.yaml.
This is the backend plugin that enables proxy definitions to be declared in,
and read from, `app-config.yaml`.
Relies on the `http-proxy-middleware` package.
@@ -10,27 +11,22 @@ This backend plugin can be started in a standalone mode from directly in this pa
with `yarn start`. However, it will have limited functionality and that process is
most convenient when developing the plugin itself.
To run it within the backend do:
1. Register the router in `packages/backend/src/index.ts`:
```ts
const proxyEnv = useHotMemoize(module, () => createEnv('proxy'));
const service = createServiceBuilder(module)
.loadConfig(configReader)
/** several different routers */
.addRouter('/', await proxy(proxyEnv));
```
2. Start the backend
The proxy is already installed in the Backstage backend per default, so you can also
start up the full example backend to experiment with the proxy.
```bash
yarn workspace example-backend start
```
This will launch the full example backend.
## Configuration
See [the proxy docs](https://backstage.io/docs/plugins/proxying).
## Links
- [Call Existing API](https://backstage.io/docs/plugins/call-existing-api) helps the
decision process of what method of communication to use from a frontend plugin to
your API
- [The proxy plugin documentation](https://backstage.io/docs/plugins/proxying) describes
configuration options and more
- [http-proxy-middleware](https://www.npmjs.com/package/http-proxy-middleware)
@@ -26,6 +26,7 @@ describe('createRouter', () => {
const router = await createRouter({
config,
logger,
pathPrefix: '/proxy',
});
expect(router).toBeDefined();
});
+39 -3
View File
@@ -17,21 +17,57 @@
import { Config } from '@backstage/config';
import express from 'express';
import Router from 'express-promise-router';
import createProxyMiddleware from 'http-proxy-middleware';
import createProxyMiddleware, {
Config as ProxyConfig,
Proxy,
} from 'http-proxy-middleware';
import { Logger } from 'winston';
export interface RouterOptions {
logger: Logger;
config: Config;
// The URL path prefix that the router itself is mounted as, commonly "/proxy"
pathPrefix: string;
}
// Creates a proxy middleware, possibly with defaults added on top of the
// given config.
function buildMiddleware(
pathPrefix: string,
route: string,
config: string | ProxyConfig,
): Proxy {
const fullConfig =
typeof config === 'string' ? { target: config } : { ...config };
// Default is to do a path rewrite that strips out the proxy's path prefix
// and the rest of the route.
if (fullConfig.pathRewrite === undefined) {
const routeWithSlash = route.endsWith('/') ? route : `${route}/`;
fullConfig.pathRewrite = {
[`^${pathPrefix}${routeWithSlash}`]: '/',
};
}
// Default is to update the Host header to the target
if (fullConfig.changeOrigin === undefined) {
fullConfig.changeOrigin = true;
}
return createProxyMiddleware(fullConfig);
}
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const router = Router();
const proxyConfig = options.config.get('proxy') ?? {};
const proxyConfig = options.config.getOptional('proxy') ?? {};
Object.entries(proxyConfig).forEach(([route, proxyRouteConfig]) => {
router.use(route, createProxyMiddleware(proxyRouteConfig));
router.use(
route,
buildMiddleware(options.pathPrefix, route, proxyRouteConfig),
);
});
return router;
@@ -40,6 +40,7 @@ export async function startStandaloneServer(
const router = await createRouter({
config,
logger,
pathPrefix: '/proxy',
});
const service = createServiceBuilder(module)
.enableCors({ origin: 'http://localhost:3000' })
@@ -14,10 +14,10 @@
* limitations under the License.
*/
import { cleanup, fireEvent, render } from '@testing-library/react';
import React from 'react';
import { render, fireEvent, cleanup } from '@testing-library/react';
import RegisterComponentForm, { Props } from './RegisterComponentForm';
import { act } from 'react-dom/test-utils';
import RegisterComponentForm, { Props } from './RegisterComponentForm';
const setup = (props?: Partial<Props>) => {
return {
@@ -37,7 +37,7 @@ describe('RegisterComponentForm', () => {
const { rendered } = setup();
expect(
await rendered.findByText(
'Enter the full path to the component.yaml file in GitHub to start tracking your component. It must be in a public repo.',
'Enter the full path to the component.yaml file in GitHub, GitLab, Bitbucket or Azure to start tracking your component.',
),
).toBeInTheDocument();
@@ -14,17 +14,17 @@
* limitations under the License.
*/
import React, { FC } from 'react';
import { BackstageTheme } from '@backstage/theme';
import {
Button,
FormControl,
FormHelperText,
TextField,
LinearProgress,
TextField,
} from '@material-ui/core';
import { useForm } from 'react-hook-form';
import { makeStyles } from '@material-ui/core/styles';
import { BackstageTheme } from '@backstage/theme';
import React, { FC } from 'react';
import { useForm } from 'react-hook-form';
import { ComponentIdValidators } from '../../util/validate';
const useStyles = makeStyles<BackstageTheme>(theme => ({
@@ -71,7 +71,7 @@ const RegisterComponentForm: FC<Props> = ({ onSubmit, submitting }) => {
name="componentLocation"
required
margin="normal"
helperText="Enter the full path to the component.yaml file in GitHub to start tracking your component. It must be in a public repo."
helperText="Enter the full path to the component.yaml file in GitHub, GitLab, Bitbucket or Azure to start tracking your component."
inputRef={register({
required: true,
validate: ComponentIdValidators,
@@ -79,7 +79,16 @@ const RegisterComponentPage: FC<{}> = () => {
setFormState(FormStates.Submitting);
const { componentLocation: target } = formData;
try {
const data = await catalogApi.addLocation('github', target);
const typeMapping = [
{ url: /https:\/\/gitlab\.com\/.*/, type: 'gitlab' },
{ url: /https:\/\/bitbucket\.org\/.*/, type: 'bitbucket/api' },
{ url: /https:\/\/dev\.azure\.com\/.*/, type: 'azure/api' },
{ url: /.*/, type: 'github' },
];
const type = typeMapping.filter(item => item.url.test(target))[0].type;
const data = await catalogApi.addLocation(type, target);
if (!isMounted()) return;
@@ -31,11 +31,13 @@ describe('ComponentIdValidators', () => {
});
});
describe('yamlValidator', () => {
const errorMessage = "Must end with '.yaml'.";
const errorMessage = "Must contain '.yaml'.";
test.each([
[true, '.yaml'],
[true, 'http://example.com/blob/master/service.yaml'],
[true, 'https://example.yaml'],
[true, 'https://example.com?path=abc.yaml&c=1'],
[errorMessage, 'https://example.com?path=abc_yaml&c=1'],
[errorMessage, '.yml'],
[errorMessage, 'http://example.com/blob/master/service'],
[errorMessage, undefined],
@@ -19,6 +19,6 @@ export const ComponentIdValidators = {
(typeof value === 'string' && value.match(/^https:\/\//) !== null) ||
'Must start with https://.',
yamlValidator: (value: any) =>
(typeof value === 'string' && value.match(/.yaml$/) !== null) ||
"Must end with '.yaml'.",
(typeof value === 'string' && value.match(/\.yaml/) !== null) ||
"Must contain '.yaml'.",
};
+5 -12
View File
@@ -20,20 +20,13 @@ import {
RollbarProject,
RollbarTopActiveItem,
} from './types';
import { DiscoveryApi } from '@backstage/core';
export class RollbarClient implements RollbarApi {
private apiOrigin: string;
private basePath: string;
private readonly discoveryApi: DiscoveryApi;
constructor({
apiOrigin,
basePath,
}: {
apiOrigin: string;
basePath: string;
}) {
this.apiOrigin = apiOrigin;
this.basePath = basePath;
constructor(options: { discoveryApi: DiscoveryApi }) {
this.discoveryApi = options.discoveryApi;
}
async getAllProjects(): Promise<RollbarProject[]> {
@@ -59,7 +52,7 @@ export class RollbarClient implements RollbarApi {
}
private async get(path: string): Promise<any> {
const url = `${this.apiOrigin}${this.basePath}${path}`;
const url = `${await this.discoveryApi.getBaseUrl('rollbar')}${path}`;
const response = await fetch(url);
if (!response.ok) {
+1
View File
@@ -27,6 +27,7 @@
"@octokit/rest": "^18.0.0",
"@types/dockerode": "^2.5.32",
"@types/express": "^4.17.6",
"command-exists-promise": "^2.0.2",
"compression": "^1.7.4",
"cors": "^2.8.5",
"dockerode": "^3.2.0",
@@ -15,11 +15,13 @@
*/
import fs from 'fs-extra';
import { JsonValue } from '@backstage/config';
import { runDockerContainer } from './helpers';
import { runDockerContainer, runCommand } from './helpers';
import { TemplaterBase, TemplaterRunOptions } from '.';
import path from 'path';
import { TemplaterRunResult } from './types';
const commandExists = require('command-exists-promise');
export class CookieCutter implements TemplaterBase {
private async fetchTemplateCookieCutter(
directory: string,
@@ -51,21 +53,30 @@ export class CookieCutter implements TemplaterBase {
const templateDir = options.directory;
const resultDir = await fs.promises.mkdtemp(`${options.directory}-result`);
await runDockerContainer({
imageName: 'spotify/backstage-cookiecutter',
args: [
'cookiecutter',
'--no-input',
'-o',
'/result',
'/template',
'--verbose',
],
templateDir,
resultDir,
logStream: options.logStream,
dockerClient: options.dockerClient,
});
const cookieCutterInstalled = await commandExists('cookiecutter');
if (cookieCutterInstalled) {
await runCommand({
command: 'cookiecutter',
args: ['--no-input', '-o', resultDir, templateDir, '--verbose'],
logStream: options.logStream,
});
} else {
await runDockerContainer({
imageName: 'spotify/backstage-cookiecutter',
args: [
'cookiecutter',
'--no-input',
'-o',
'/result',
'/template',
'--verbose',
],
templateDir,
resultDir,
logStream: options.logStream,
dockerClient: options.dockerClient,
});
}
return {
resultDir: path.resolve(resultDir, options.values.component_id as string),
@@ -18,6 +18,7 @@ import Docker from 'dockerode';
import fs from 'fs';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { InputError } from '@backstage/backend-common';
import { spawn } from 'child_process';
export type RunDockerContainerOptions = {
imageName: string;
@@ -29,6 +30,12 @@ export type RunDockerContainerOptions = {
createOptions?: Docker.ContainerCreateOptions;
};
export type RunCommandOptions = {
command: string;
args: string[];
logStream?: Writable;
};
/**
* Gets the templater key to use for templating from the entity
* @param entity Template entity
@@ -43,6 +50,42 @@ export const getTemplaterKey = (entity: TemplateEntityV1alpha1): string => {
return templater;
};
/**
*
* @param options the options object
* @param options.command the command to run
* @param options.args the arguments to pass the command
* @param options.logStream the log streamer to capture log messages
*/
export const runCommand = async ({
command,
args,
logStream = new PassThrough(),
}: RunCommandOptions) => {
await new Promise((resolve, reject) => {
const process = spawn(command, args);
process.stdout.on('data', stream => {
logStream.write(stream);
});
process.stderr.on('data', stream => {
logStream.write(stream);
});
process.on('error', error => {
return reject(error);
});
process.on('close', code => {
if (code !== 0) {
return reject(`Command ${command} failed, exit code: ${code}`);
}
return resolve();
});
});
};
/**
*
* @param options the options object
@@ -36,7 +36,7 @@ export type TemplaterRunResult = {
};
/**
* The values that the templater will recieve. The directory of the
* The values that the templater will receive. The directory of the
* skeleton, with the values from the frontend. A dedicated log stream and a docker
* client to run any templater on top of your directory.
*/
+8 -16
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { createApiRef } from '@backstage/core';
import { createApiRef, DiscoveryApi } from '@backstage/core';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
export const scaffolderApiRef = createApiRef<ScaffolderApi>({
@@ -23,18 +23,10 @@ export const scaffolderApiRef = createApiRef<ScaffolderApi>({
});
export class ScaffolderApi {
private apiOrigin: string;
private basePath: string;
private readonly discoveryApi: DiscoveryApi;
constructor({
apiOrigin,
basePath,
}: {
apiOrigin: string;
basePath: string;
}) {
this.apiOrigin = apiOrigin;
this.basePath = basePath;
constructor(options: { discoveryApi: DiscoveryApi }) {
this.discoveryApi = options.discoveryApi;
}
/**
@@ -46,7 +38,7 @@ export class ScaffolderApi {
template: TemplateEntityV1alpha1,
values: Record<string, any>,
) {
const url = `${this.apiOrigin}${this.basePath}/jobs`;
const url = `${await this.discoveryApi.getBaseUrl('scaffolder')}/v1/jobs`;
const response = await fetch(url, {
method: 'POST',
headers: {
@@ -65,9 +57,9 @@ export class ScaffolderApi {
}
async getJob(jobId: string) {
const url = `${this.apiOrigin}${this.basePath}/job/${encodeURIComponent(
jobId,
)}`;
const url = `${await this.discoveryApi.getBaseUrl(
'scaffolder',
)}/v1/job/${encodeURIComponent(jobId)}`;
return fetch(url).then(x => x.json());
}
}
@@ -26,7 +26,7 @@ export type GeneratorRunResult = {
};
/**
* The values that the generator will recieve. The directory of the
* The values that the generator will receive. The directory of the
* uncompiled documentation, with the values from the frontend. A dedicated log stream and a docker
* client to run any generator on top of your directory.
*/
+4 -34
View File
@@ -1,45 +1,15 @@
# TechDocs Plugin
Welcome to the TechDocs plugin - Spotify's docs-like-code approach built directly into [Backstage](https://backstage.io). Watch [a video of our approach on YouTube](https://www.youtube.com/watch?v=uFGCaZmA6d4) to learn more.
**WIP: This plugin is a work in progress. It is not ready for use yet. Follow our progress on [the Backstage Discord](https://discord.gg/MUpMjP2) under #docs-like-code or on [our GitHub Milestone](https://github.com/spotify/backstage/milestone/15).**
## Getting started
Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/docs](http://localhost:3000/docs).
You can also serve the plugin in isolation by running `yarn start` in the plugin directory.
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory.
Set up Backstage and TechDocs by follow our guide on [Getting Started](../../docs/features/techdocs/getting-started.md).
## Configuration
### Custom Storage URL
TechDocs currently reads a static HTML file, generated by Mkdocs (see our `packages/techdocs-container` folder for more documentation) and stored on an external server, and loads that into Backstage. By default, we have set up a mock server with some example documentation sites over in Google Cloud Storage:
TechDocs will try to read your documentation from the URL you have specified in the `techdocs storageUrl` in `app-config.yml`.
```md
# Base URL
### TechDocs Storage Api
https://techdocs-mock-sites.storage.googleapis.com
# Home Page for the "mkdocs" docs
https://techdocs-mock-sites.storage.googleapis.com/mkdocs/index.html
# Home Page for the "backstage-microsite" docs
https://techdocs-mock-sites.storage.googleapis.com/backstage-microsite/index.html
```
Using your own setup (or ours which is being worked on as of Q3 2020), you can point it to your own server with your own hosted documentation sites. The only requirement is that it the output is from [Mkdocs](https://mkdocs.org) with the Material theme. You can always use our documentation generation tool located at `packages/techdocs-container` for easy setup.
To point TechDocs to your own server, simply update the `techdocs.storageUrl` value in your `app-config.yaml` file or set the environment variable `APP_CONFIG_techdocs_storageUrl` in your application:
```bash
git clone git@github.com:spotify/backstage.git
cd backstage/
yarn install
export APP_CONFIG_techdocs_storageUrl='"http://example-docs-site-server.com"'
yarn start
```
The default setup of TechDocs assumes your documentation is accessed by reading a page with the format of `<storageUrl>/<entity kind>/<entity namespace>/<entity name>`. If for some reason you want to change this it can be configured by implementing a new techdocs storage API. Do this by implementing TechDocsStorage found in `plugins/techdocs/src/api`. Add your new API to the application in `app/src/apis.ts` (or replace if it's already registered as an API).
+52
View File
@@ -0,0 +1,52 @@
/*
* 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 { ParsedEntityId } from '../src/types';
import { TechDocsStorage } from '../src/api';
export class TechDocsDevStorageApi implements TechDocsStorage {
public apiOrigin: string;
constructor({ apiOrigin }: { apiOrigin: string }) {
this.apiOrigin = apiOrigin;
}
async getEntityDocs(entityId: ParsedEntityId, path: string) {
const { name } = entityId;
const url = `${this.apiOrigin}/${name}/${path}`;
const request = await fetch(
`${url.endsWith('/') ? url : `${url}/`}index.html`,
);
if (request.status === 404) {
throw new Error('Page not found');
}
return request.text();
}
getBaseUrl(
oldBaseUrl: string,
entityId: ParsedEntityId,
path: string,
): string {
const { name } = entityId;
return new URL(oldBaseUrl, `${this.apiOrigin}/${name}/${path}`).toString();
}
}
+13 -1
View File
@@ -16,5 +16,17 @@
import { createDevApp } from '@backstage/dev-utils';
import { plugin } from '../src/plugin';
import { TechDocsDevStorageApi } from './api';
import { techdocsStorageApiRef } from '../src';
createDevApp().registerPlugin(plugin).render();
createDevApp()
.registerApiFactory({
deps: {},
factory: () =>
new TechDocsDevStorageApi({
apiOrigin: 'http://localhost:3000/api',
}),
implements: techdocsStorageApiRef,
})
.registerPlugin(plugin)
.render();
+20 -1
View File
@@ -1 +1,20 @@
# TODO
# TechDocs Reader
The TechDocs reader is a component that fetches a remote page, runs transformers on it and renders it into a shadow dom.
Currently there's no easy way to customize which transformers to run or add new ones. If that is needed you would have to fork the techdocs plugin and make your changes in that fork.
Transformers are functions that optionally takes in parameters from the Reader.tsx component and returns a function which gets passed the DOM of the fetched page. A very simple transformer can look like this.
```typescript
export const updateH1Text = (): Transformer => {
return dom => {
// Change the first occurance of H1 to say "TechDocs!"
dom.querySelector('h1')?.innerHTML = 'TechDocs!';
return dom;
};
};
```
The transformers are then registered in the Reader.tsx file. They are registered in two places, one place that runs before it's attached to the actual browser DOM (preTransformers) and once after (postTransfomers). Doing modifications is faster before it's attached, but doesn't allow us to do some things, such as attaching event listeners.
@@ -1 +0,0 @@
# TODO