This commit is contained in:
Ryan Vazquez
2020-11-30 16:22:19 -05:00
347 changed files with 9012 additions and 1739 deletions
+29
View File
@@ -1,5 +1,34 @@
# @backstage/plugin-api-docs
## 0.3.0
### Minor Changes
- f3bb55ee3: APIs now have real entity pages that are customizable in the app.
Therefore the old entity page from this plugin is removed.
See the `packages/app` on how to create and customize the API entity page.
### Patch Changes
- 6f70ed7a9: Replace usage of implementsApis with relations
- Updated dependencies [6f70ed7a9]
- Updated dependencies [ab94c9542]
- Updated dependencies [2daf18e80]
- Updated dependencies [069cda35f]
- @backstage/plugin-catalog@0.2.4
- @backstage/catalog-model@0.3.1
## 0.2.2
### Patch Changes
- Updated dependencies [475fc0aaa]
- Updated dependencies [1166fcc36]
- Updated dependencies [1185919f3]
- @backstage/core@0.3.2
- @backstage/catalog-model@0.3.0
- @backstage/plugin-catalog@0.2.3
## 0.2.1
### Patch Changes
+1 -1
View File
@@ -21,7 +21,7 @@ Right now, the following API formats are supported:
Other formats are displayed as plain text, but this can easily be extended.
To fill the catalog with APIs, [provide entities of kind API](https://backstage.io/docs/features/software-catalog/descriptor-format#kind-api).
To link that an component implements an API, see [`implementsApis` property on components](https://backstage.io/docs/features/software-catalog/descriptor-format#specimplementsapis-optional).
To link that a component provides or consumes an API, see the [`providesApis`](https://backstage.io/docs/features/software-catalog/descriptor-format#specprovidesapis-optional) and [`consumesApis`](https://backstage.io/docs/features/software-catalog/descriptor-format#specconsumesapis-optional) properties on the Component kind.
## Links
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-api-docs",
"version": "0.2.1",
"version": "0.3.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -20,15 +20,16 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.2.0",
"@backstage/core": "^0.3.1",
"@backstage/plugin-catalog": "^0.2.2",
"@backstage/catalog-model": "^0.3.1",
"@backstage/core": "^0.3.2",
"@backstage/plugin-catalog": "^0.2.4",
"@backstage/theme": "^0.2.1",
"@kyma-project/asyncapi-react": "^0.14.2",
"@material-icons/font": "^1.0.2",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@types/react": "^16.9",
"graphiql": "^1.0.0-alpha.10",
"graphql": "^15.3.0",
"react": "^16.13.1",
@@ -39,7 +40,7 @@
"swagger-ui-react": "^3.31.1"
},
"devDependencies": {
"@backstage/cli": "^0.3.0",
"@backstage/cli": "^0.3.2",
"@backstage/dev-utils": "^0.1.4",
"@backstage/test-utils": "^0.1.3",
"@testing-library/jest-dom": "^5.10.1",
@@ -47,7 +48,6 @@
"@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",
"cross-fetch": "^3.0.6",
"msw": "^0.21.2"
@@ -28,7 +28,7 @@ spec:
type: service
lifecycle: production
owner: guest
implementsApis:
providesApis:
- example-api
`;
@@ -47,11 +47,10 @@ export const MissingImplementsApisEmptyState = () => {
missing="field"
title="No APIs implemented by this entity"
description={
<Typography>
<>
Components can implement APIs that are displayed on this page. You
need to fill the <code>implementsApis</code> field to enable this
tool.
</Typography>
need to fill the <code>providesApis</code> field to enable this tool.
</>
}
action={
<>
@@ -71,7 +70,7 @@ export const MissingImplementsApisEmptyState = () => {
<Button
variant="contained"
color="primary"
href="https://backstage.io/docs/features/software-catalog/descriptor-format#specimplementsapis-optional"
href="https://backstage.io/docs/features/software-catalog/descriptor-format#specprovidesapis-optional"
>
Read more
</Button>
+3 -2
View File
@@ -15,14 +15,15 @@
*/
import React from 'react';
import { Entity } from '@backstage/catalog-model';
import { Entity, RELATION_PROVIDES_API } from '@backstage/catalog-model';
import { Route, Routes } from 'react-router';
import { catalogRoute } from '../routes';
import { EntityPageApi } from './EntityPageApi';
import { MissingImplementsApisEmptyState } from './MissingImplementsApisEmptyState';
const isPluginApplicableToEntity = (entity: Entity) => {
return ((entity.spec?.implementsApis as string[]) || []).length > 0;
// TODO: Also support RELATION_CONSUMES_API
return entity.relations?.some(r => r.type === RELATION_PROVIDES_API);
};
export const Router = ({ entity }: { entity: Entity }) =>
@@ -1,71 +0,0 @@
/*
* 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 { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog';
import { wrapInTestApp } from '@backstage/test-utils';
import { render, waitFor } from '@testing-library/react';
import * as React from 'react';
import { ApiEntityPage } from './ApiEntityPage';
jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');
const mockNavigate = jest.fn();
return {
...actual,
useNavigate: jest.fn(() => mockNavigate),
useParams: jest.fn(),
};
});
const {
useParams,
useNavigate,
}: { useParams: jest.Mock; useNavigate: () => jest.Mock } = jest.requireMock(
'react-router-dom',
);
const errorApi = { post: () => {} };
describe('ApiEntityPage', () => {
it('should redirect to catalog page when name is not provided', async () => {
useParams.mockReturnValue({
kind: 'Component',
optionalNamespaceAndName: '',
});
render(
wrapInTestApp(
<ApiProvider
apis={ApiRegistry.from([
[errorApiRef, errorApi],
[
catalogApiRef,
({
async getEntityByName() {},
} as Partial<CatalogApi>) as CatalogApi,
],
])}
>
<ApiEntityPage />
</ApiProvider>,
),
);
await waitFor(() =>
expect(useNavigate()).toHaveBeenCalledWith('/api-docs'),
);
});
});
@@ -1,127 +0,0 @@
/*
* 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 { ApiEntity, Entity } from '@backstage/catalog-model';
import {
Content,
errorApiRef,
Header,
Page,
Progress,
useApi,
} from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog';
import { Box } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React, { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useAsync } from 'react-use';
import { ApiDefinitionCard } from '../ApiDefinitionCard';
const REDIRECT_DELAY = 1000;
function headerProps(
kind: string,
namespace: string | undefined,
name: string,
entity: Entity | undefined,
): { headerTitle: string; headerType: string } {
return {
headerTitle: `${name}${namespace ? ` in ${namespace}` : ''}`,
headerType: (() => {
let t = kind.toLowerCase();
if (entity && entity.spec && 'type' in entity.spec) {
t += ' — ';
t += (entity.spec as { type: string }).type.toLowerCase();
}
return t;
})(),
};
}
type EntityPageTitleProps = {
title: string;
entity: Entity | undefined;
};
const EntityPageTitle = ({ title }: EntityPageTitleProps) => (
<Box display="inline-flex" alignItems="center" height="1em">
{title}
</Box>
);
export const ApiEntityPage = () => {
const { optionalNamespaceAndName } = useParams() as {
optionalNamespaceAndName: string;
};
const navigate = useNavigate();
const [name, namespace] = optionalNamespaceAndName.split(':').reverse();
const errorApi = useApi(errorApiRef);
const catalogApi = useApi(catalogApiRef);
const { value: entity, error, loading } = useAsync(
() => catalogApi.getEntityByName({ kind: 'API', namespace, name }),
[catalogApi, namespace, name],
);
useEffect(() => {
if (!error && !loading && !entity) {
errorApi.post(new Error('Entity not found!'));
setTimeout(() => {
navigate('/');
}, REDIRECT_DELAY);
}
}, [errorApi, navigate, error, loading, entity]);
if (!name) {
navigate('/api-docs');
return null;
}
const { headerTitle, headerType } = headerProps(
'API',
namespace,
name,
entity,
);
return (
<Page themeId={entity?.spec?.type?.toString() ?? 'home'}>
<Header
title={<EntityPageTitle title={headerTitle} entity={entity} />}
pageTitleOverride={headerTitle}
type={headerType}
/>
{loading && <Progress />}
{error && (
<Content>
<Alert severity="error">{error.toString()}</Alert>
</Content>
)}
{entity && (
<>
<Content>
<ApiDefinitionCard apiEntity={entity as ApiEntity} />
</Content>
</>
)}
</Page>
);
};
@@ -22,7 +22,7 @@ import * as React from 'react';
import { apiDocsConfigRef } from '../../config';
import { ApiExplorerTable } from './ApiExplorerTable';
const entites: Entity[] = [
const entities: Entity[] = [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
@@ -70,7 +70,7 @@ describe('ApiCatalogTable component', () => {
const rendered = render(
wrapInTestApp(
<ApiProvider apis={apiRegistry}>
<ApiExplorerTable entities={entites} loading={false} />
<ApiExplorerTable entities={entities} loading={false} />
</ApiProvider>,
),
);
@@ -23,12 +23,12 @@ import {
useApi,
useQueryParamState,
} from '@backstage/core';
import { entityRoute, entityRouteParams } from '@backstage/plugin-catalog';
import { Chip, Link } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React from 'react';
import { generatePath, Link as RouterLink } from 'react-router-dom';
import { apiDocsConfigRef } from '../../config';
import { entityRoute } from '../../routes';
const ApiTypeTitle = ({ apiEntity }: { apiEntity: ApiEntityV1alpha1 }) => {
const config = useApi(apiDocsConfigRef);
@@ -46,16 +46,10 @@ const columns: TableColumn<Entity>[] = [
render: (entity: any) => (
<Link
component={RouterLink}
to={generatePath(entityRoute.path, {
optionalNamespaceAndName: [
entity.metadata.namespace,
entity.metadata.name,
]
.filter(Boolean)
.join(':'),
kind: entity.kind,
selectedTabId: 'overview',
})}
to={generatePath(
`/catalog/${entityRoute.path}`,
entityRouteParams(entity),
)}
>
{entity.metadata.name}
</Link>
@@ -14,8 +14,16 @@
* limitations under the License.
*/
import { ComponentEntity } from '@backstage/catalog-model';
import {
ComponentEntity,
RELATION_PROVIDES_API,
} from '@backstage/catalog-model';
export const useComponentApiNames = (entity: ComponentEntity) => {
return (entity.spec?.implementsApis as string[]) || [];
// TODO: This code doesn't handle namespaces and kinds correctly, but will be removed soon
return (
entity.relations
?.filter(r => r.type === RELATION_PROVIDES_API)
?.map(r => r.target.name) || []
);
};
+2 -1
View File
@@ -14,5 +14,6 @@
* limitations under the License.
*/
export { Router } from './catalog';
export * from './catalog';
export * from './components';
export { plugin } from './plugin';
+1 -3
View File
@@ -18,8 +18,7 @@ import { ApiEntity } from '@backstage/catalog-model';
import { createApiFactory, createPlugin } from '@backstage/core';
import { ApiExplorerPage } from './components/ApiExplorerPage/ApiExplorerPage';
import { defaultDefinitionWidgets } from './components/ApiDefinitionCard';
import { ApiEntityPage } from './components/ApiEntityPage/ApiEntityPage';
import { entityRoute, rootRoute } from './routes';
import { rootRoute } from './routes';
import { apiDocsConfigRef } from './config';
export const plugin = createPlugin({
@@ -40,6 +39,5 @@ export const plugin = createPlugin({
],
register({ router }) {
router.addRoute(rootRoute, ApiExplorerPage);
router.addRoute(entityRoute, ApiEntityPage);
},
});
-6
View File
@@ -24,12 +24,6 @@ export const rootRoute = createRouteRef({
title: 'APIs',
});
export const entityRoute = createRouteRef({
icon: NoIcon,
path: '/api-docs/:optionalNamespaceAndName/',
title: 'API',
});
export const catalogRoute = createRouteRef({
icon: NoIcon,
path: '',
+9
View File
@@ -1,5 +1,14 @@
# @backstage/plugin-app-backend
## 0.3.1
### Patch Changes
- ff1301d28: Warn if the app-backend can't start-up because the static directory that should be served is unavailable.
- Updated dependencies [3aa7efb3f]
- Updated dependencies [b3d4e4e57]
- @backstage/backend-common@0.3.2
## 0.3.0
### Minor Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-app-backend",
"version": "0.3.0",
"version": "0.3.1",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -20,7 +20,7 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.3.0",
"@backstage/backend-common": "^0.3.2",
"@backstage/config-loader": "^0.3.0",
"@backstage/config": "^0.1.1",
"@types/express": "^4.17.6",
@@ -31,7 +31,7 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.3.0",
"@backstage/cli": "^0.3.2",
"@types/supertest": "^2.0.8",
"msw": "^0.20.5",
"supertest": "^4.0.2"
+1 -1
View File
@@ -88,7 +88,7 @@ export async function readConfigs(options: ReadOptions): Promise<AppConfig[]> {
const frontendConfigs = await schema.process(
[{ data: config.get() as JsonObject, context: 'app' }],
{ visiblity: ['frontend'] },
{ visibility: ['frontend'] },
);
appConfigs.push(...frontendConfigs);
}
+11 -1
View File
@@ -21,6 +21,7 @@ import { Logger } from 'winston';
import { notFoundHandler, resolvePackagePath } from '@backstage/backend-common';
import { Config } from '@backstage/config';
import { injectConfig, readConfigs } from '../lib/config';
import fs from 'fs-extra';
export interface RouterOptions {
config: Config;
@@ -35,9 +36,18 @@ export async function createRouter(
const { config, logger, appPackageName, staticFallbackHandler } = options;
const appDistDir = resolvePackagePath(appPackageName, 'dist');
logger.info(`Serving static app content from ${appDistDir}`);
const staticDir = resolvePath(appDistDir, 'static');
if (!(await fs.pathExists(staticDir))) {
logger.warn(
`Can't serve static app content from ${staticDir}, directory doesn't exist`,
);
return Router();
}
logger.info(`Serving static app content from ${appDistDir}`);
const appConfigs = await readConfigs({
config,
appDistDir,
+26
View File
@@ -1,5 +1,31 @@
# @backstage/plugin-auth-backend
## 0.2.4
### Patch Changes
- 50eff1d00: Allow the backend to register custom AuthProviderFactories
- 700a212b4: bug fix: issue 3223 - detect mismatching origin and indicate it in the message at auth failure
- Updated dependencies [3aa7efb3f]
- Updated dependencies [ab94c9542]
- Updated dependencies [2daf18e80]
- Updated dependencies [069cda35f]
- Updated dependencies [b3d4e4e57]
- @backstage/backend-common@0.3.2
- @backstage/catalog-model@0.3.1
## 0.2.3
### Patch Changes
- Updated dependencies [1166fcc36]
- Updated dependencies [bff3305aa]
- Updated dependencies [1185919f3]
- Updated dependencies [b47dce06f]
- @backstage/catalog-model@0.3.0
- @backstage/backend-common@0.3.1
- @backstage/catalog-client@0.3.1
## 0.2.2
### Patch Changes
+34 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend",
"version": "0.2.2",
"version": "0.2.4",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -20,9 +20,9 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.3.0",
"@backstage/catalog-client": "^0.3.0",
"@backstage/catalog-model": "^0.2.0",
"@backstage/backend-common": "^0.3.2",
"@backstage/catalog-client": "^0.3.1",
"@backstage/catalog-model": "^0.3.1",
"@backstage/config": "^0.1.1",
"@types/express": "^4.17.6",
"compression": "^1.7.4",
@@ -31,6 +31,7 @@
"cross-fetch": "^3.0.6",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"express-session": "^1.17.1",
"fs-extra": "^9.0.0",
"got": "^11.5.2",
"helmet": "^4.0.0",
@@ -39,6 +40,7 @@
"knex": "^0.21.6",
"moment": "^2.26.0",
"morgan": "^1.10.0",
"openid-client": "^4.2.1",
"passport": "^0.4.1",
"passport-github2": "^0.1.12",
"passport-gitlab2": "^5.0.0",
@@ -53,19 +55,44 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.3.0",
"@backstage/cli": "^0.3.2",
"@types/body-parser": "^1.19.0",
"@types/cookie-parser": "^1.4.2",
"@types/express-session": "^1.17.2",
"@types/jwt-decode": "2.2.1",
"@types/nock": "^11.1.0",
"@types/openid-client": "^3.7.0",
"@types/passport": "^1.0.3",
"@types/passport-github2": "^1.2.4",
"@types/passport-google-oauth20": "^2.0.3",
"@types/passport-microsoft": "^0.0.0",
"@types/passport-saml": "^1.1.2",
"msw": "^0.21.2"
"msw": "^0.21.2",
"nock": "^13.0.5"
},
"files": [
"dist",
"migrations"
]
],
"configSchema": {
"$schema": "https://backstage.io/schema/config-v1",
"title": "@backstage/auth-backend",
"type": "object",
"properties": {
"auth": {
"type": "object",
"properties": {
"session": {
"type": "object",
"properties": {
"secret": {
"type": "string",
"visibility": "secret"
}
}
}
}
}
}
}
}
+8
View File
@@ -15,3 +15,11 @@
*/
export * from './service/router';
export * from './providers';
// flow package provides 2 functions
// ensuresXRequestedWith and postMessageResponse to safely handle CORS requests for login. The WebMessageResponse type in flow is used to type the response from the login-popup
export * from './lib/flow';
// OAuth wrapper over a passport or a custom `startegy`.
export * from './lib/oauth';
@@ -81,6 +81,52 @@ describe('oauth helpers', () => {
expect(mockResponse.end).toBeCalledWith(expect.stringContaining(encoded));
});
it('should call postMessage twice but only one of them with target *', () => {
let responseBody = '';
const mockResponse = ({
end: jest.fn(body => {
responseBody = body;
return this;
}),
setHeader: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
const data: WebMessageResponse = {
type: 'authorization_response',
response: {
providerInfo: {
accessToken: 'ACCESS_TOKEN',
idToken: 'ID_TOKEN',
expiresInSeconds: 10,
scope: 'email',
},
profile: {
email: 'foo@bar.com',
},
backstageIdentity: {
id: 'a',
idToken: 'a.b.c',
},
},
};
postMessageResponse(mockResponse, appOrigin, data);
expect(responseBody.match(/.postMessage\(/g)).toHaveLength(2);
expect(
responseBody.match(/.postMessage\([a-zA-z.()]*, \'\*\'\)/g),
).toHaveLength(1);
const errData: WebMessageResponse = {
type: 'authorization_response',
error: new Error('Unknown error occurred'),
};
postMessageResponse(mockResponse, appOrigin, errData);
expect(responseBody.match(/.postMessage\(/g)).toHaveLength(2);
expect(
responseBody.match(/.postMessage\([a-zA-z.()]*, \'\*\'\)/g),
).toHaveLength(1);
});
it('handles single quotes and unicode chars safely', () => {
const mockResponse = ({
end: jest.fn().mockReturnThis(),
@@ -38,10 +38,24 @@ export const postMessageResponse = (
// data.
// TODO: Make target app origin configurable globally
//
// postMessage fails silently if the targetOrigin is disallowed.
// So 2 postMessages are sent from the popup to the parent window.
// First, the origin being used to post the actual authorization response is
// shared with the parent window with a postMessage with targetOrigin '*'.
// Second, the actual authorization response is sent with the app origin
// as the targetOrigin.
// If the first message was received but the actual auth response was
// never received, the event listener can conclude that targetOrigin
// was disallowed, indicating potential misconfiguration.
//
const script = `
var json = decodeURIComponent('${base64Data}');
var authResponse = decodeURIComponent('${base64Data}');
var origin = decodeURIComponent('${base64Origin}');
(window.opener || window.parent).postMessage(JSON.parse(json), origin);
var originInfo = {'type': 'config_info', 'targetOrigin': origin};
(window.opener || window.parent).postMessage(originInfo, '*');
(window.opener || window.parent).postMessage(JSON.parse(authResponse), origin);
window.close();
`;
const hash = crypto.createHash('sha256').update(script).digest('base64');
@@ -15,3 +15,5 @@
*/
export { ensuresXRequestedWith, postMessageResponse } from './authFlowHelpers';
export type { WebMessageResponse } from './types';
@@ -149,12 +149,12 @@ export class Auth0AuthProvider implements OAuthHandlers {
}
export const createAuth0Provider: AuthProviderFactory = ({
providerId,
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'auth0';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const domain = envConfig.getString('domain');
@@ -18,14 +18,15 @@ import { createGithubProvider } from './github';
import { createGitlabProvider } from './gitlab';
import { createGoogleProvider } from './google';
import { createOAuth2Provider } from './oauth2';
import { createOidcProvider } from './oidc';
import { createOktaProvider } from './okta';
import { createSamlProvider } from './saml';
import { createAuth0Provider } from './auth0';
import { createMicrosoftProvider } from './microsoft';
import { createOneLoginProvider } from './onelogin';
import { AuthProviderFactory, AuthProviderFactoryOptions } from './types';
import { AuthProviderFactory } from './types';
const factories: { [providerId: string]: AuthProviderFactory } = {
export const factories: { [providerId: string]: AuthProviderFactory } = {
google: createGoogleProvider,
github: createGithubProvider,
gitlab: createGitlabProvider,
@@ -34,17 +35,6 @@ const factories: { [providerId: string]: AuthProviderFactory } = {
auth0: createAuth0Provider,
microsoft: createMicrosoftProvider,
oauth2: createOAuth2Provider,
oidc: createOidcProvider,
onelogin: createOneLoginProvider,
};
export function createAuthProvider(
providerId: string,
options: AuthProviderFactoryOptions,
) {
const factory = factories[providerId];
if (!factory) {
throw Error(`No auth provider available for '${providerId}'`);
}
return factory(options);
}
@@ -137,12 +137,12 @@ export class GithubAuthProvider implements OAuthHandlers {
}
export const createGithubProvider: AuthProviderFactory = ({
providerId,
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'github';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const enterpriseInstanceUrl = envConfig.getOptionalString(
@@ -140,12 +140,12 @@ export class GitlabAuthProvider implements OAuthHandlers {
}
export const createGitlabProvider: AuthProviderFactory = ({
providerId,
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'gitlab';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const audience = envConfig.getString('audience');
@@ -175,6 +175,7 @@ export class GoogleAuthProvider implements OAuthHandlers {
}
export const createGoogleProvider: AuthProviderFactory = ({
providerId,
globalConfig,
config,
logger,
@@ -182,7 +183,6 @@ export const createGoogleProvider: AuthProviderFactory = ({
catalogApi,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'google';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
+13 -1
View File
@@ -14,4 +14,16 @@
* limitations under the License.
*/
export { createAuthProvider } from './factories';
export { factories as defaultAuthProviderFactories } from './factories';
// Export the minimal interface required for implementing a
// custom Authorization Handler
export type {
AuthProviderRouteHandlers,
AuthProviderFactoryOptions,
AuthProviderFactory,
} from './types';
// These types are needed for a postMessage from the login pop-up
// to the frontend
export type { AuthResponse, BackstageIdentity, ProfileInfo } from './types';
@@ -163,7 +163,7 @@ export class MicrosoftAuthProvider implements OAuthHandlers {
});
}
private getUserPhoto(accessToken: string): Promise<string> {
private getUserPhoto(accessToken: string): Promise<string | undefined> {
return new Promise(resolve => {
got
.get('https://graph.microsoft.com/v1.0/me/photos/48x48/$value', {
@@ -184,7 +184,7 @@ export class MicrosoftAuthProvider implements OAuthHandlers {
`Could not retrieve user profile photo from Microsoft Graph API: ${error}`,
);
// User profile photo is optional, ignore errors and resolve undefined
resolve();
resolve(undefined);
});
});
}
@@ -206,13 +206,12 @@ export class MicrosoftAuthProvider implements OAuthHandlers {
}
export const createMicrosoftProvider: AuthProviderFactory = ({
providerId,
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'microsoft';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const tenantID = envConfig.getString('tenantId');
@@ -157,12 +157,12 @@ export class OAuth2AuthProvider implements OAuthHandlers {
}
export const createOAuth2Provider: AuthProviderFactory = ({
providerId,
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'oauth2';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { ApiEntityPage } from './ApiEntityPage';
export { createOidcProvider } from './provider';
@@ -0,0 +1,128 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import express from 'express';
import { Session } from 'express-session';
import nock from 'nock';
import { ClientMetadata, IssuerMetadata } from 'openid-client';
import { createOidcProvider, OidcAuthProvider } from './provider';
import { JWT, JWK } from 'jose';
import { AuthProviderFactoryOptions } from '../types';
import { Config } from '@backstage/config';
import { OAuthAdapter } from '../../lib/oauth';
const issuerMetadata = {
issuer: 'https://oidc.test',
authorization_endpoint: 'https://oidc.test/as/authorization.oauth2',
token_endpoint: 'https://oidc.test/as/token.oauth2',
revocation_endpoint: 'https://oidc.test/as/revoke_token.oauth2',
userinfo_endpoint: 'https://oidc.test/idp/userinfo.openid',
introspection_endpoint: 'https://oidc.test/as/introspect.oauth2',
jwks_uri: 'https://oidc.test/pf/JWKS',
scopes_supported: ['openid'],
claims_supported: ['email'],
response_types_supported: ['code'],
id_token_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'],
token_endpoint_auth_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'],
request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'],
};
const clientMetadata = {
callbackUrl: 'https://oidc.test/callback',
clientId: 'testclientid',
clientSecret: 'testclientsecret',
metadataUrl: 'https://oidc.test/.well-known/openid-configuration',
tokenSignedResponseAlg: 'none',
};
describe('OidcAuthProvider', () => {
it('hit the metadata url', async () => {
const scope = nock('https://oidc.test')
.get('/.well-known/openid-configuration')
.reply(200, issuerMetadata);
const provider = new OidcAuthProvider(clientMetadata);
const { strategy } = ((await (provider as any).implementation) as any) as {
strategy: {
_client: ClientMetadata;
_issuer: IssuerMetadata;
};
};
// Assert that the expected request to the metadaurl was made.
expect(scope.isDone()).toBeTruthy();
const { _client, _issuer } = strategy;
expect(_client.client_id).toBe(clientMetadata.clientId);
expect(_issuer.token_endpoint).toBe(issuerMetadata.token_endpoint);
});
it('OidcAuthProvider#handler successfully invokes the oidc endpoints', async () => {
const jwt = {
sub: 'alice',
iss: 'https://oidc.test',
aud: clientMetadata.clientId,
exp: Date.now() + 10000,
};
const scope = nock('https://oidc.test')
.get('/.well-known/openid-configuration')
.reply(200, issuerMetadata)
.post('/as/token.oauth2')
.reply(200, {
id_token: JWT.sign(jwt, JWK.None),
access_token: 'test',
authorization_signed_response_alg: 'HS256',
})
.get('/idp/userinfo.openid')
.reply(200, {
sub: 'alice',
email: 'alice@oidc.test',
});
const provider = new OidcAuthProvider(clientMetadata);
const req = {
method: 'GET',
url: 'https://oidc.test/?code=test2',
session: ({ 'oidc:oidc.test': 'test' } as any) as Session,
} as express.Request;
await provider.handler(req);
expect(scope.isDone()).toBeTruthy();
});
it('createOidcProvider', async () => {
const scope = nock('https://oidc.test')
.get('/.well-known/openid-configuration')
.reply(200, issuerMetadata);
const options = {
globalConfig: {
appUrl: 'https://oidc.test',
baseUrl: 'https://oidc.test',
},
config: ({
keys: jest.fn(() => ['test']),
getConfig: jest.fn(() => ({
getString: (key: string) => {
const conf = {
...clientMetadata,
metadataUrl: 'https://oidc.test/.well-known/openid-configuration',
} as any;
return conf[key] as string;
},
})),
} as any) as Config,
} as AuthProviderFactoryOptions;
const provider = createOidcProvider(options) as OAuthAdapter;
expect(provider.start).toBeDefined();
await new Promise(resolve => process.nextTick(resolve)); // advance a tick to give nock a chance to intercept the request
expect(scope.isDone()).toBeTruthy();
});
});
@@ -0,0 +1,201 @@
/*
* 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 express from 'express';
import {
Issuer,
Client,
Strategy as OidcStrategy,
TokenSet,
UserinfoResponse,
} from 'openid-client';
import {
OAuthAdapter,
OAuthProviderOptions,
OAuthHandlers,
OAuthResponse,
OAuthEnvironmentHandler,
OAuthStartRequest,
encodeState,
OAuthRefreshRequest,
} from '../../lib/oauth';
import {
executeFrameHandlerStrategy,
executeRedirectStrategy,
PassportDoneCallback,
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory, ProfileInfo } from '../types';
type PrivateInfo = {
refreshToken: string;
};
type OidcImpl = {
strategy: OidcStrategy<UserinfoResponse, Client>;
client: Client;
};
export type OidcAuthProviderOptions = OAuthProviderOptions & {
metadataUrl: string;
tokenSignedResponseAlg?: string;
};
export class OidcAuthProvider implements OAuthHandlers {
private readonly implementation: Promise<OidcImpl>;
constructor(options: OidcAuthProviderOptions) {
this.implementation = this.setupStrategy(options);
}
async start(req: OAuthStartRequest): Promise<RedirectInfo> {
const { strategy } = await this.implementation;
return await executeRedirectStrategy(req, strategy, {
accessType: 'offline',
prompt: 'none',
scope: `${req.scope} default`,
state: encodeState(req.state),
});
}
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken: string }> {
const { strategy } = await this.implementation;
const { response, privateInfo } = await executeFrameHandlerStrategy<
OAuthResponse,
PrivateInfo
>(req, strategy);
return {
response: await this.populateIdentity(response),
refreshToken: privateInfo.refreshToken,
};
}
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
const { client } = await this.implementation;
const tokenset = await client.refresh(req.refreshToken);
if (!tokenset.access_token) {
throw new Error('Refresh failed');
}
const profile = await client.userinfo(tokenset.access_token);
return this.populateIdentity({
providerInfo: {
accessToken: tokenset.access_token,
refreshToken: tokenset.refresh_token,
expiresInSeconds: tokenset.expires_in,
idToken: tokenset.id_token,
scope: tokenset.scope || '',
},
profile,
});
}
private async setupStrategy(
options: OidcAuthProviderOptions,
): Promise<OidcImpl> {
const issuer = await Issuer.discover(options.metadataUrl);
const client = new issuer.Client({
client_id: options.clientId,
client_secret: options.clientSecret,
redirect_uris: [options.callbackUrl],
response_types: ['code'],
id_token_signed_response_alg: options.tokenSignedResponseAlg || 'RS256',
});
const strategy = new OidcStrategy(
{
client,
passReqToCallback: false as true,
},
(
tokenset: TokenSet,
userinfo: UserinfoResponse,
done: PassportDoneCallback<OAuthResponse, PrivateInfo>,
) => {
const profile: ProfileInfo = {
displayName: userinfo.name,
email: userinfo.email,
picture: userinfo.picture,
};
done(
undefined,
{
providerInfo: {
idToken: tokenset.id_token || '',
accessToken: tokenset.access_token || '',
scope: tokenset.scope || '',
expiresInSeconds: tokenset.expires_in,
},
profile,
},
{
refreshToken: tokenset.refresh_token || '',
},
);
},
);
strategy.error = console.error;
return { strategy, client };
}
// Use this function to grab the user profile info from the token
// Then populate the profile with it
private async populateIdentity(
response: OAuthResponse,
): Promise<OAuthResponse> {
const { profile } = response;
if (!profile.email) {
throw new Error('Profile does not contain an email');
}
const id = profile.email.split('@')[0];
return { ...response, backstageIdentity: { id } };
}
}
export const createOidcProvider: AuthProviderFactory = ({
providerId,
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const metadataUrl = envConfig.getString('metadataUrl');
const tokenSignedResponseAlg = envConfig.getString(
'tokenSignedResponseAlg',
);
const provider = new OidcAuthProvider({
clientId,
clientSecret,
callbackUrl,
tokenSignedResponseAlg,
metadataUrl,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: false,
providerId,
tokenIssuer,
});
});
@@ -168,12 +168,12 @@ export class OktaAuthProvider implements OAuthHandlers {
}
export const createOktaProvider: AuthProviderFactory = ({
providerId,
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'okta';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const audience = envConfig.getString('audience');
@@ -147,12 +147,12 @@ export class OneLoginProvider implements OAuthHandlers {
}
export const createOneLoginProvider: AuthProviderFactory = ({
providerId,
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'onelogin';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const issuer = envConfig.getString('issuer');
@@ -121,12 +121,12 @@ type SAMLProviderOptions = {
};
export const createSamlProvider: AuthProviderFactory = ({
providerId,
globalConfig,
config,
tokenIssuer,
}) => {
const url = new URL(globalConfig.baseUrl);
const providerId = 'saml';
const entryPoint = config.getString('entryPoint');
const issuer = config.getString('issuer');
const opts = {
@@ -113,6 +113,7 @@ export interface AuthProviderRouteHandlers {
}
export type AuthProviderFactoryOptions = {
providerId: string;
globalConfig: AuthProviderConfig;
config: Config;
logger: Logger;
+35 -7
View File
@@ -14,6 +14,14 @@
* limitations under the License.
*/
import express from 'express';
import Router from 'express-promise-router';
import cookieParser from 'cookie-parser';
import { Logger } from 'winston';
import {
defaultAuthProviderFactories,
AuthProviderFactory,
} from '../providers';
import {
NotFoundError,
PluginDatabaseManager,
@@ -21,18 +29,18 @@ import {
} from '@backstage/backend-common';
import { CatalogClient } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import cookieParser from 'cookie-parser';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import { createOidcRouter, DatabaseKeyStore, TokenFactory } from '../identity';
import { createAuthProvider } from '../providers';
import session from 'express-session';
import passport from 'passport';
type ProviderFactories = { [s: string]: AuthProviderFactory };
export interface RouterOptions {
logger: Logger;
database: PluginDatabaseManager;
config: Config;
discovery: PluginEndpointDiscovery;
providerFactories?: ProviderFactories;
}
export async function createRouter({
@@ -40,6 +48,7 @@ export async function createRouter({
config,
discovery,
database,
providerFactories,
}: RouterOptions): Promise<express.Router> {
const router = Router();
@@ -59,17 +68,36 @@ export async function createRouter({
});
const catalogApi = new CatalogClient({ discoveryApi: discovery });
router.use(cookieParser());
const secret = config.getOptionalString('auth.session.secret');
if (secret) {
router.use(cookieParser(secret));
// TODO: Configure the server-side session storage. The default MemoryStore is not designed for production
router.use(session({ secret, saveUninitialized: false, resave: false }));
router.use(passport.initialize());
router.use(passport.session());
} else {
router.use(cookieParser());
}
router.use(express.urlencoded({ extended: false }));
router.use(express.json());
const allProviderFactories = {
...defaultAuthProviderFactories,
...providerFactories,
};
const providersConfig = config.getConfig('auth.providers');
const providers = providersConfig.keys();
for (const providerId of providers) {
logger.info(`Configuring provider, ${providerId}`);
try {
const provider = createAuthProvider(providerId, {
const providerFactory = allProviderFactories[providerId];
if (!providerFactory) {
throw Error(`No auth provider available for '${providerId}'`);
}
const provider = providerFactory({
providerId,
globalConfig: { baseUrl: authUrl, appUrl },
config: providersConfig.getConfig(providerId),
logger,
+51
View File
@@ -1,5 +1,56 @@
# @backstage/plugin-catalog-backend
## 0.2.3
### Patch Changes
- 1ec19a3f4: Ignore empty YAML documents. Having a YAML file like this is now ingested without an error:
```yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: web
spec:
type: website
---
```
This behaves now the same way as Kubernetes handles multiple documents in a single YAML file.
- ab94c9542: Add `providesApis` and `consumesApis` to the component entity spec.
- 2daf18e80: Start emitting all known relation types from the core entity kinds, based on their spec data.
- Updated dependencies [3aa7efb3f]
- Updated dependencies [ab94c9542]
- Updated dependencies [2daf18e80]
- Updated dependencies [069cda35f]
- Updated dependencies [b3d4e4e57]
- @backstage/backend-common@0.3.2
- @backstage/catalog-model@0.3.1
## 0.2.2
### Patch Changes
- 0c2121240: Add support for reading groups and users from the Microsoft Graph API.
- 1185919f3: Marked the `Group` entity fields `ancestors` and `descendants` for deprecation on Dec 6th, 2020. See https://github.com/backstage/backstage/issues/3049 for details.
Code that consumes these fields should remove those usages as soon as possible. There is no current or planned replacement for these fields.
The BuiltinKindsEntityProcessor has been updated to inject these fields as empty arrays if they are missing. Therefore, if you are on a catalog instance that uses the updated version of this code, you can start removing the fields from your source catalog-info.yaml data as well, without breaking validation.
After Dec 6th, the fields will be removed from types and classes of the Backstage repository. At the first release after that, they will not be present in released packages either.
If your catalog-info.yaml files still contain these fields after the deletion, they will still be valid and your ingestion will not break, but they won't be visible in the types for consuming code.
- Updated dependencies [1166fcc36]
- Updated dependencies [bff3305aa]
- Updated dependencies [1185919f3]
- Updated dependencies [b47dce06f]
- @backstage/catalog-model@0.3.0
- @backstage/backend-common@0.3.1
## 0.2.1
### Patch Changes
@@ -0,0 +1,93 @@
/*
* 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.
*/
// @ts-check
/**
* @param {import('knex')} knex
*/
exports.up = async function up(knex) {
if (knex.client.config.client === 'sqlite3') {
// sqlite doesn't support dropPrimary so we recreate it properly instead
await knex.schema.dropTable('entities_relations');
await knex.schema.createTable('entities_relations', table => {
table.comment('All relations between entities in the catalog');
table
.uuid('originating_entity_id')
.references('id')
.inTable('entities')
.onDelete('CASCADE')
.notNullable()
.comment('The entity that provided the relation');
table
.string('source_full_name')
.notNullable()
.comment('The full name of the source entity of the relation');
table
.string('type')
.notNullable()
.comment('The type of the relation between the entities');
table
.string('target_full_name')
.notNullable()
.comment('The full name of the target entity of the relation');
table.index('source_full_name', 'source_full_name_idx');
});
} else {
await knex.schema.alterTable('entities_relations', table => {
table.dropPrimary();
table.index('source_full_name', 'source_full_name_idx');
});
}
};
/**
* @param {import('knex')} knex
*/
exports.down = async function down(knex) {
if (knex.client.config.client === 'sqlite3') {
await knex.schema.dropTable('entities_relations');
await knex.schema.createTable('entities_relations', table => {
table.comment('All relations between entities in the catalog');
table
.uuid('originating_entity_id')
.references('id')
.inTable('entities')
.onDelete('CASCADE')
.notNullable()
.comment('The entity that provided the relation');
table
.string('source_full_name')
.notNullable()
.comment('The full name of the source entity of the relation');
table
.string('type')
.notNullable()
.comment('The type of the relation between the entities');
table
.string('target_full_name')
.notNullable()
.comment('The full name of the target entity of the relation');
table.primary(['source_full_name', 'type', 'target_full_name']);
});
} else {
await knex.schema.alterTable('entities_relations', table => {
table.dropIndex([], 'source_full_name_idx');
table.primary(['source_full_name', 'type', 'target_full_name']);
});
}
};
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-backend",
"version": "0.2.1",
"version": "0.2.3",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -21,11 +21,12 @@
},
"dependencies": {
"@azure/msal-node": "^1.0.0-alpha.8",
"@backstage/backend-common": "^0.3.0",
"@backstage/catalog-model": "^0.2.0",
"@backstage/backend-common": "^0.3.2",
"@backstage/catalog-model": "^0.3.1",
"@backstage/config": "^0.1.1",
"@octokit/graphql": "^4.5.6",
"@types/express": "^4.17.6",
"@types/ldapjs": "^1.0.9",
"codeowners-utils": "^1.0.2",
"core-js": "^3.6.5",
"cross-fetch": "^3.0.6",
@@ -47,11 +48,10 @@
"yup": "^0.29.3"
},
"devDependencies": {
"@backstage/cli": "^0.3.0",
"@backstage/cli": "^0.3.2",
"@backstage/test-utils": "^0.1.3",
"@types/core-js": "^2.5.4",
"@types/git-url-parse": "^9.0.0",
"@types/ldapjs": "^1.0.9",
"@types/lodash": "^4.14.151",
"@types/supertest": "^2.0.8",
"@types/uuid": "^8.0.0",
@@ -345,7 +345,11 @@ export class CommonDatabase implements Database {
);
// TODO(blam): translate constraint failures to sane NotFoundError instead
await tx.batchInsert('entities_relations', relationsRows, BATCH_SIZE);
await tx.batchInsert(
'entities_relations',
deduplicateRelations(relationsRows),
BATCH_SIZE,
);
}
async addLocation(
@@ -506,7 +510,7 @@ export class CommonDatabase implements Database {
.orderBy(['type', 'target_full_name'])
.select();
entity.relations = relations.map(r => ({
entity.relations = deduplicateRelations(relations).map(r => ({
target: parseEntityName(r.target_full_name),
type: r.type,
}));
@@ -517,3 +521,12 @@ export class CommonDatabase implements Database {
};
}
}
function deduplicateRelations(
rows: DbEntitiesRelationsRow[],
): DbEntitiesRelationsRow[] {
return lodash.uniqBy(
rows,
r => `${r.source_full_name}:${r.target_full_name}:${r.type}`,
);
}
@@ -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 { Logger } from 'winston';
import parseGitUri from 'git-url-parse';
import {
AnalyzeLocationRequest,
AnalyzeLocationResponse,
LocationAnalyzer,
} from './types';
export class RepoLocationAnalyzer implements LocationAnalyzer {
private readonly logger: Logger;
constructor(logger: Logger) {
this.logger = logger;
}
async analyzeLocation(
request: AnalyzeLocationRequest,
): Promise<AnalyzeLocationResponse> {
const { owner, name, source } = parseGitUri(request.location.target);
const entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: name,
// Probably won't handle properly self-hosted git providers with custom url
annotations: { [`${source}/project-slug`]: `${owner}/${name}` },
},
spec: { type: 'other', lifecycle: 'unknown' },
};
this.logger.debug(`entity created for ${request.location.target}`);
return {
existingEntityFiles: [],
generateEntities: [{ entity, fields: [] }],
};
}
}
@@ -16,12 +16,5 @@
export { HigherOrderOperations } from './HigherOrderOperations';
export { LocationReaders } from './LocationReaders';
export type {
AddLocationResult,
HigherOrderOperation,
LocationReader,
ReadLocationEntity,
ReadLocationError,
ReadLocationResult,
} from './types';
export * from './types';
export * from './processors';
@@ -0,0 +1,261 @@
/*
* 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 {
ApiEntity,
ComponentEntity,
GroupEntity,
UserEntity,
} from '@backstage/catalog-model';
import { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor';
describe('BuiltinKindsEntityProcessor', () => {
it('fills in fields for #3049', async () => {
const p = new BuiltinKindsEntityProcessor();
const result = await p.preProcessEntity({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: {
name: 'n',
},
spec: {
type: 't',
children: [],
} as any,
});
expect(result).toEqual({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: {
name: 'n',
},
spec: {
type: 't',
children: [],
ancestors: [],
descendants: [],
},
});
});
describe('postProcessEntity', () => {
const processor = new BuiltinKindsEntityProcessor();
const location = { type: 'a', target: 'b' };
const emit = jest.fn();
afterEach(() => jest.resetAllMocks());
it('generates relations for component entities', async () => {
const entity: ComponentEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'n' },
spec: {
type: 'service',
owner: 'o',
lifecycle: 'l',
implementsApis: ['a'],
providesApis: ['b'],
consumesApis: ['c'],
},
};
await processor.postProcessEntity(entity, location, emit);
expect(emit).toBeCalledTimes(8);
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'Group', namespace: 'default', name: 'o' },
type: 'ownerOf',
target: { kind: 'Component', namespace: 'default', name: 'n' },
},
});
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'Component', namespace: 'default', name: 'n' },
type: 'ownedBy',
target: { kind: 'Group', namespace: 'default', name: 'o' },
},
});
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'API', namespace: 'default', name: 'a' },
type: 'apiProvidedBy',
target: { kind: 'Component', namespace: 'default', name: 'n' },
},
});
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'Component', namespace: 'default', name: 'n' },
type: 'providesApi',
target: { kind: 'API', namespace: 'default', name: 'a' },
},
});
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'API', namespace: 'default', name: 'b' },
type: 'apiProvidedBy',
target: { kind: 'Component', namespace: 'default', name: 'n' },
},
});
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'Component', namespace: 'default', name: 'n' },
type: 'providesApi',
target: { kind: 'API', namespace: 'default', name: 'b' },
},
});
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'API', namespace: 'default', name: 'c' },
type: 'apiConsumedBy',
target: { kind: 'Component', namespace: 'default', name: 'n' },
},
});
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'Component', namespace: 'default', name: 'n' },
type: 'consumesApi',
target: { kind: 'API', namespace: 'default', name: 'c' },
},
});
});
it('generates relations for api entities', async () => {
const entity: ApiEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: { name: 'n' },
spec: {
type: 'service',
owner: 'o',
lifecycle: 'l',
definition: 'd',
},
};
await processor.postProcessEntity(entity, location, emit);
expect(emit).toBeCalledTimes(2);
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'Group', namespace: 'default', name: 'o' },
type: 'ownerOf',
target: { kind: 'API', namespace: 'default', name: 'n' },
},
});
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'API', namespace: 'default', name: 'n' },
type: 'ownedBy',
target: { kind: 'Group', namespace: 'default', name: 'o' },
},
});
});
it('generates relations for user entities', async () => {
const entity: UserEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: { name: 'n' },
spec: {
memberOf: ['g'],
},
};
await processor.postProcessEntity(entity, location, emit);
expect(emit).toBeCalledTimes(2);
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'User', namespace: 'default', name: 'n' },
type: 'memberOf',
target: { kind: 'Group', namespace: 'default', name: 'g' },
},
});
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'Group', namespace: 'default', name: 'g' },
type: 'hasMember',
target: { kind: 'User', namespace: 'default', name: 'n' },
},
});
});
it('generates relations for group entities', async () => {
const entity: GroupEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: { name: 'n' },
spec: {
type: 't',
parent: 'p',
ancestors: [],
children: ['c'],
descendants: [],
},
};
await processor.postProcessEntity(entity, location, emit);
expect(emit).toBeCalledTimes(4);
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'Group', namespace: 'default', name: 'n' },
type: 'childOf',
target: { kind: 'Group', namespace: 'default', name: 'p' },
},
});
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'Group', namespace: 'default', name: 'p' },
type: 'parentOf',
target: { kind: 'Group', namespace: 'default', name: 'n' },
},
});
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'Group', namespace: 'default', name: 'c' },
type: 'childOf',
target: { kind: 'Group', namespace: 'default', name: 'n' },
},
});
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'Group', namespace: 'default', name: 'n' },
type: 'parentOf',
target: { kind: 'Group', namespace: 'default', name: 'c' },
},
});
});
});
});
@@ -15,15 +15,33 @@
*/
import {
ApiEntity,
apiEntityV1alpha1Validator,
ComponentEntity,
componentEntityV1alpha1Validator,
Entity,
getEntityName,
GroupEntity,
groupEntityV1alpha1Validator,
locationEntityV1alpha1Validator,
LocationSpec,
parseEntityRef,
RELATION_API_CONSUMED_BY,
RELATION_API_PROVIDED_BY,
RELATION_CHILD_OF,
RELATION_CONSUMES_API,
RELATION_HAS_MEMBER,
RELATION_MEMBER_OF,
RELATION_OWNED_BY,
RELATION_OWNER_OF,
RELATION_PARENT_OF,
RELATION_PROVIDES_API,
templateEntityV1alpha1Validator,
UserEntity,
userEntityV1alpha1Validator,
} from '@backstage/catalog-model';
import { CatalogProcessor } from './types';
import * as result from './results';
import { CatalogProcessor, CatalogProcessorEmit } from './types';
export class BuiltinKindsEntityProcessor implements CatalogProcessor {
private readonly validators = [
@@ -35,6 +53,25 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor {
userEntityV1alpha1Validator,
];
async preProcessEntity(entity: Entity): Promise<Entity> {
// NOTE(freben): Part of Group field deprecation on Nov 22nd, 2020. Fields
// scheduled for removal Dec 6th, 2020. This code can be deleted after that
// point. See https://github.com/backstage/backstage/issues/3049
if (
entity.apiVersion === 'backstage.io/v1alpha1' &&
entity.kind === 'Group' &&
entity.spec
) {
if (!entity.spec.ancestors) {
entity.spec.ancestors = [];
}
if (!entity.spec.descendants) {
entity.spec.descendants = [];
}
}
return entity;
}
async validateEntityKind(entity: Entity): Promise<boolean> {
for (const validator of this.validators) {
const result = await validator.check(entity);
@@ -45,4 +82,126 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor {
return false;
}
async postProcessEntity(
entity: Entity,
_location: LocationSpec,
emit: CatalogProcessorEmit,
): Promise<Entity> {
const selfRef = getEntityName(entity);
/*
* Utilities
*/
function doEmit(
targets: string | string[] | undefined,
context: { defaultKind: string; defaultNamespace: string },
outgoingRelation: string,
incomingRelation: string,
): void {
if (!targets) {
return;
}
for (const target of [targets].flat()) {
const targetRef = parseEntityRef(target, context);
emit(
result.relation({
source: selfRef,
type: outgoingRelation,
target: targetRef,
}),
);
emit(
result.relation({
source: targetRef,
type: incomingRelation,
target: selfRef,
}),
);
}
}
/*
* Emit relations for the Component kind
*/
if (entity.kind === 'Component') {
const component = entity as ComponentEntity;
doEmit(
component.spec.owner,
{ defaultKind: 'Group', defaultNamespace: selfRef.namespace },
RELATION_OWNED_BY,
RELATION_OWNER_OF,
);
doEmit(
component.spec.implementsApis,
{ defaultKind: 'API', defaultNamespace: selfRef.namespace },
RELATION_PROVIDES_API,
RELATION_API_PROVIDED_BY,
);
doEmit(
component.spec.providesApis,
{ defaultKind: 'API', defaultNamespace: selfRef.namespace },
RELATION_PROVIDES_API,
RELATION_API_PROVIDED_BY,
);
doEmit(
component.spec.consumesApis,
{ defaultKind: 'API', defaultNamespace: selfRef.namespace },
RELATION_CONSUMES_API,
RELATION_API_CONSUMED_BY,
);
}
/*
* Emit relations for the API kind
*/
if (entity.kind === 'API') {
const api = entity as ApiEntity;
doEmit(
api.spec.owner,
{ defaultKind: 'Group', defaultNamespace: selfRef.namespace },
RELATION_OWNED_BY,
RELATION_OWNER_OF,
);
}
/*
* Emit relations for the User kind
*/
if (entity.kind === 'User') {
const user = entity as UserEntity;
doEmit(
user.spec.memberOf,
{ defaultKind: 'Group', defaultNamespace: selfRef.namespace },
RELATION_MEMBER_OF,
RELATION_HAS_MEMBER,
);
}
/*
* Emit relations for the Group kind
*/
if (entity.kind === 'Group') {
const group = entity as GroupEntity;
doEmit(
group.spec.parent,
{ defaultKind: 'Group', defaultNamespace: selfRef.namespace },
RELATION_CHILD_OF,
RELATION_PARENT_OF,
);
doEmit(
group.spec.children,
{ defaultKind: 'Group', defaultNamespace: selfRef.namespace },
RELATION_PARENT_OF,
RELATION_CHILD_OF,
);
}
return entity;
}
}
@@ -1,74 +0,0 @@
/*
* 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,
ENTITY_DEFAULT_NAMESPACE,
LocationSpec,
parseEntityRef,
ApiEntityV1alpha1,
ComponentEntityV1alpha1,
RELATION_OWNED_BY,
RELATION_OWNER_OF,
getEntityName,
} from '@backstage/catalog-model';
import { CatalogProcessor, CatalogProcessorEmit } from './types';
import * as result from './results';
const includedKinds = new Set(['api', 'component']);
export class OwnerRelationProcessor implements CatalogProcessor {
async postProcessEntity(
entity: Entity,
_location: LocationSpec,
emit: CatalogProcessorEmit,
): Promise<Entity> {
if (!includedKinds.has(entity.kind.toLowerCase())) {
return entity;
}
const apiOrComponentEntity = entity as
| ApiEntityV1alpha1
| ComponentEntityV1alpha1;
const owner = apiOrComponentEntity.spec?.owner;
if (owner) {
const namespace = entity.metadata.namespace ?? ENTITY_DEFAULT_NAMESPACE;
const selfRef = getEntityName(entity);
const ownerRef = parseEntityRef(owner, {
defaultKind: 'group',
defaultNamespace: namespace,
});
emit(
result.relation({
source: selfRef,
type: RELATION_OWNED_BY,
target: ownerRef,
}),
);
emit(
result.relation({
source: ownerRef,
type: RELATION_OWNER_OF,
target: selfRef,
}),
);
}
return entity;
}
}
@@ -22,10 +22,11 @@ export * from './types';
export { parseEntityYaml } from './util/parse';
export { AnnotateLocationEntityProcessor } from './AnnotateLocationEntityProcessor';
export { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor';
export { CodeOwnersProcessor } from './CodeOwnersProcessor';
export { FileReaderProcessor } from './FileReaderProcessor';
export { GithubOrgReaderProcessor } from './GithubOrgReaderProcessor';
export { OwnerRelationProcessor } from './OwnerRelationProcessor';
export { LdapOrgReaderProcessor } from './LdapOrgReaderProcessor';
export { LocationRefProcessor } from './LocationEntityProcessor';
export { MicrosoftGraphOrgReaderProcessor } from './MicrosoftGraphOrgReaderProcessor';
export { PlaceholderProcessor } from './PlaceholderProcessor';
@@ -115,6 +115,41 @@ describe('parseEntityYaml', () => {
]);
});
it('should handle empty yaml documents', () => {
// This happens if the user accidentially adds a "---"
// at the end of a file
const results = Array.from(
parseEntityYaml(
Buffer.from(
`
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: web
spec:
type: website
---
`,
'utf8',
),
testLoc,
),
);
expect(results).toEqual([
result.entity(testLoc, {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'web',
},
spec: {
type: 'website',
},
}),
]);
});
it('should emit parsing errors', () => {
const results = Array.from(
parseEntityYaml(Buffer.from('`', 'utf8'), testLoc),
@@ -40,6 +40,9 @@ export function* parseEntityYaml(
const json = document.toJSON();
if (lodash.isPlainObject(json)) {
yield result.entity(location, json as Entity);
} else if (json === null) {
// Ignore null values, these happen if there is an empty document in the
// YAML file, for example if --- is added to the end of the file.
} else {
const message = `Expected object at root, got ${typeof json}`;
yield result.generalError(location, message);
+74 -1
View File
@@ -14,12 +14,13 @@
* limitations under the License.
*/
import type {
import {
Entity,
EntityRelationSpec,
Location,
LocationSpec,
} from '@backstage/catalog-model';
import { RecursivePartial } from '../util/RecursivePartial';
//
// HigherOrderOperation
@@ -68,3 +69,75 @@ export type ReadLocationError = {
location: LocationSpec;
error: Error;
};
//
// LocationAnalyzer
//
export type LocationAnalyzer = {
/**
* Generates an entity configuration for given git repository. It's used for
* importing new component to the backstage app.
*
* @param location Git repository to analyze and generate config for.
*/
analyzeLocation(
location: AnalyzeLocationRequest,
): Promise<AnalyzeLocationResponse>;
};
export type AnalyzeLocationRequest = {
location: LocationSpec;
};
export type AnalyzeLocationResponse = {
existingEntityFiles: AnalyzeLocationExistingEntity[];
generateEntities: AnalyzeLocationGenerateEntity[];
};
// If the folder pointed to already contained catalog info yaml files, they are
// read and emitted like this so that the frontend can inform the user that it
// located them and can make sure to register them as well if they weren't
// already
type AnalyzeLocationExistingEntity = {
location: LocationSpec;
isRegistered: boolean;
entity: Entity;
};
// This is some form of representation of what the analyzer could deduce.
// We should probably have a chat about how this can best be conveyed to
// the frontend. It'll probably contain a (possibly incomplete) entity, plus
// enough info for the frontend to know what form data to show to the user
// for overriding/completing the info.
type AnalyzeLocationGenerateEntity = {
// Some form of partial representation of the entity
entity: RecursivePartial<Entity>;
// Lists the suggestions that the user may want to override
fields: AnalyzeLocationEntityField[];
};
// This is where I get really vague. Something like this perhaps? Or it could be
// something like a json-schema that contains enough info for the frontend to
// be able to present a form and explanations
type AnalyzeLocationEntityField = {
// e.g. "spec.owner"? The frontend needs to know how to "inject" the field into the
// entity again if the user wants to change it
field: string;
// The outcome of the analysis for this particular field
state:
| 'analysisSuggestedValue'
| 'analysisSuggestedNoValue'
| 'needsUserInput';
// If the analysis did suggest a value, this is where it would be. Not sure if we want
// to limit this to strings or if we want it to be any JsonValue
value: string | null;
// A text to show to the user to inform about the choices made. Like, it could say
// "Found a CODEOWNERS file that covers this target, so we suggest leaving this
// field empty; which would currently make it owned by X" where X is taken from the
// codeowners file.
description: string;
};
@@ -144,7 +144,7 @@ describe('CatalogBuilder', () => {
owner: 'o',
lifecycle: 'l',
},
relations: [],
relations: expect.anything(),
},
]);
});
@@ -37,15 +37,16 @@ import {
import { DatabaseManager } from '../database';
import {
AnnotateLocationEntityProcessor,
BuiltinKindsEntityProcessor,
CatalogProcessor,
CodeOwnersProcessor,
FileReaderProcessor,
GithubOrgReaderProcessor,
HigherOrderOperation,
HigherOrderOperations,
LdapOrgReaderProcessor,
LocationReaders,
LocationRefProcessor,
OwnerRelationProcessor,
MicrosoftGraphOrgReaderProcessor,
PlaceholderProcessor,
PlaceholderResolver,
@@ -53,13 +54,13 @@ import {
UrlReaderProcessor,
} from '../ingestion';
import { CatalogRulesEnforcer } from '../ingestion/CatalogRules';
import { BuiltinKindsEntityProcessor } from '../ingestion/processors/BuiltinKindsEntityProcessor';
import { LdapOrgReaderProcessor } from '../ingestion/processors/LdapOrgReaderProcessor';
import { RepoLocationAnalyzer } from '../ingestion/LocationAnalyzer';
import {
jsonPlaceholderResolver,
textPlaceholderResolver,
yamlPlaceholderResolver,
} from '../ingestion/processors/PlaceholderProcessor';
import { LocationAnalyzer } from '../ingestion/types';
export type CatalogEnvironment = {
logger: Logger;
@@ -203,6 +204,7 @@ export class CatalogBuilder {
entitiesCatalog: EntitiesCatalog;
locationsCatalog: LocationsCatalog;
higherOrderOperation: HigherOrderOperation;
locationAnalyzer: LocationAnalyzer;
}> {
const { config, database, logger } = this.env;
@@ -230,11 +232,13 @@ export class CatalogBuilder {
locationReader,
logger,
);
const locationAnalyzer = new RepoLocationAnalyzer(logger);
return {
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
locationAnalyzer,
};
}
@@ -283,7 +287,6 @@ export class CatalogBuilder {
new UrlReaderProcessor({ reader, logger }),
new CodeOwnersProcessor({ reader }),
new LocationRefProcessor(),
new OwnerRelationProcessor(),
new AnnotateLocationEntityProcessor(),
);
}
+21 -4
View File
@@ -15,29 +15,38 @@
*/
import { errorHandler } from '@backstage/backend-common';
import {
locationSpecSchema,
analyzeLocationSchema,
} from '@backstage/catalog-model';
import type { Entity } from '@backstage/catalog-model';
import { locationSpecSchema } from '@backstage/catalog-model';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import yn from 'yn';
import { EntitiesCatalog, LocationsCatalog } from '../catalog';
import { HigherOrderOperation } from '../ingestion/types';
import { EntityFilters } from './EntityFilters';
import { LocationAnalyzer, HigherOrderOperation } from '../ingestion/types';
import { translateQueryToFieldMapper } from './filterQuery';
import { EntityFilters } from './EntityFilters';
import { requireRequestBody, validateRequestBody } from './util';
export interface RouterOptions {
entitiesCatalog?: EntitiesCatalog;
locationsCatalog?: LocationsCatalog;
higherOrderOperation?: HigherOrderOperation;
locationAnalyzer?: LocationAnalyzer;
logger: Logger;
}
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { entitiesCatalog, locationsCatalog, higherOrderOperation } = options;
const {
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
locationAnalyzer,
} = options;
const router = Router();
router.use(express.json());
@@ -127,6 +136,14 @@ export async function createRouter(
});
}
if (locationAnalyzer) {
router.post('/analyze-location', async (req, res) => {
const input = await validateRequestBody(req, analyzeLocationSchema);
const output = await locationAnalyzer.analyzeLocation(input);
res.status(200).send(output);
});
}
router.use(errorHandler());
return router;
}
@@ -27,7 +27,7 @@
export function runPeriodically(fn: () => any, delayMs: number): () => void {
let cancel: () => void;
let cancelled = false;
const cancellationPromise = new Promise(resolve => {
const cancellationPromise = new Promise<void>(resolve => {
cancel = () => {
resolve();
cancelled = true;
+11
View File
@@ -1,5 +1,16 @@
# @backstage/plugin-catalog-graphql
## 0.2.2
### Patch Changes
- Updated dependencies [1166fcc36]
- Updated dependencies [bff3305aa]
- Updated dependencies [1185919f3]
- Updated dependencies [b47dce06f]
- @backstage/catalog-model@0.3.0
- @backstage/backend-common@0.3.1
## 0.2.1
### Patch Changes
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-graphql",
"version": "0.2.1",
"version": "0.2.2",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -20,8 +20,8 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.3.0",
"@backstage/catalog-model": "^0.2.0",
"@backstage/backend-common": "^0.3.1",
"@backstage/catalog-model": "^0.3.0",
"@backstage/config": "^0.1.1",
"@graphql-modules/core": "^0.7.17",
"apollo-server": "^2.16.1",
@@ -32,7 +32,7 @@
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/cli": "^0.3.0",
"@backstage/cli": "^0.3.1",
"@backstage/test-utils": "^0.1.3",
"@graphql-codegen/cli": "^1.17.7",
"@graphql-codegen/typescript": "^1.17.7",
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+21
View File
@@ -0,0 +1,21 @@
# Catalog import plugin
Welcome to the catalog-import plugin!
This plugin allows you to bootstrap a component-config YAML file for your repository and open a pull request to add it.
When installed it is accessible on [localhost:3000/catalog-import](localhost:3000/catalog-import).
<img src="./src/assets/catalog-import-screenshot.png" />
## Running
Just run the backstage.
```
yarn start && yarn --cwd packages/backend start
```
## Usage
Pretty straightforward, navigate to [localhost:3000/catalog-import](localhost:3000/catalog-import) and enter your repo's URL.
+20
View File
@@ -0,0 +1,20 @@
/*
* 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 { createDevApp } from '@backstage/dev-utils';
import { plugin } from '../src/plugin';
createDevApp().registerPlugin(plugin).render();
+57
View File
@@ -0,0 +1,57 @@
{
"name": "@backstage/plugin-catalog-import",
"version": "0.2.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.3.0",
"@backstage/core": "^0.3.2",
"@backstage/plugin-catalog": "^0.2.0",
"@backstage/plugin-catalog-backend": "^0.2.2",
"@backstage/theme": "^0.2.1",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@octokit/rest": "^18.0.6",
"git-url-parse": "^11.4.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-hook-form": "^6.6.0",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^15.3.3",
"yaml": "^1.10.0"
},
"devDependencies": {
"@backstage/cli": "^0.3.2",
"@backstage/dev-utils": "^0.1.4",
"@backstage/test-utils": "^0.1.3",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"cross-fetch": "^3.0.6",
"msw": "^0.21.2"
},
"files": [
"dist"
]
}
@@ -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 { createApiRef } from '@backstage/core';
import { PartialEntity } from '../util/types';
export const catalogImportApiRef = createApiRef<CatalogImportApi>({
id: 'plugin.catalogimport.service',
description: 'Used by the catalog import plugin to make requests',
});
export interface CatalogImportApi {
submitPrToRepo(options: {
owner: string;
repo: string;
fileContent: string;
}): Promise<{ link: string; location: string }>;
createRepositoryLocation(options: { location: string }): Promise<void>;
generateEntityDefinitions(options: {
repo: string;
}): Promise<PartialEntity[]>;
}
@@ -0,0 +1,192 @@
/*
* 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 { Octokit } from '@octokit/rest';
import { DiscoveryApi, OAuthApi } from '@backstage/core';
import { CatalogImportApi } from './CatalogImportApi';
import { AnalyzeLocationResponse } from '@backstage/plugin-catalog-backend';
import { PartialEntity } from '../util/types';
export class CatalogImportClient implements CatalogImportApi {
private readonly discoveryApi: DiscoveryApi;
private readonly githubAuthApi: OAuthApi;
constructor(options: {
discoveryApi: DiscoveryApi;
githubAuthApi: OAuthApi;
}) {
this.discoveryApi = options.discoveryApi;
this.githubAuthApi = options.githubAuthApi;
}
async generateEntityDefinitions({
repo,
}: {
repo: string;
}): Promise<PartialEntity[]> {
const response = await fetch(
`${await this.discoveryApi.getBaseUrl('catalog')}/analyze-location`,
{
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify({
location: { type: 'github', target: repo },
}),
},
).catch(e => {
throw new Error(`Failed to generate entity definitions, ${e.message}`);
});
if (!response.ok) {
throw new Error(
`Failed to generate entity definitions. Received http response ${response.status}: ${response.statusText}`,
);
}
const payload = (await response.json()) as AnalyzeLocationResponse;
return payload.generateEntities.map(x => x.entity);
}
async createRepositoryLocation({
location,
}: {
location: string;
}): Promise<void> {
const response = await fetch(
`${await this.discoveryApi.getBaseUrl('catalog')}/locations`,
{
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify({
type: 'github',
target: location,
presence: 'optional',
}),
},
);
if (!response.ok) {
throw new Error(
`Received http response ${response.status}: ${response.statusText}`,
);
}
}
async submitPrToRepo({
owner,
repo,
fileContent,
}: {
owner: string;
repo: string;
fileContent: string;
}): Promise<{ link: string; location: string }> {
const token = await this.githubAuthApi.getAccessToken(['repo']);
const octo = new Octokit({
auth: token,
});
const branchName = 'backstage-integration';
const fileName = 'catalog-info.yaml';
const repoData = await octo.repos
.get({
owner,
repo,
})
.catch(e => {
throw new Error(formatHttpErrorMessage("Couldn't fetch repo data", e));
});
const parentRef = await octo.git
.getRef({
owner,
repo,
ref: `heads/${repoData.data.default_branch}`,
})
.catch(e => {
throw new Error(
formatHttpErrorMessage("Couldn't fetch default branch data", e),
);
});
await octo.git
.createRef({
owner,
repo,
ref: `refs/heads/${branchName}`,
sha: parentRef.data.object.sha,
})
.catch(e => {
throw new Error(
formatHttpErrorMessage(
`Couldn't create a new branch with name '${branchName}'`,
e,
),
);
});
await octo.repos
.createOrUpdateFileContents({
owner,
repo,
path: fileName,
message: `Add ${fileName} config file`,
content: btoa(fileContent),
branch: branchName,
})
.catch(e => {
throw new Error(
formatHttpErrorMessage(
`Couldn't create a commit with ${fileName} file added`,
e,
),
);
});
const pullRequestRespone = await octo.pulls
.create({
owner,
repo,
title: `Add ${fileName} config file`,
head: branchName,
base: repoData.data.default_branch,
})
.catch(e => {
throw new Error(
formatHttpErrorMessage(
`Couldn't create a pull request for ${branchName} branch`,
e,
),
);
});
return {
link: pullRequestRespone.data.html_url,
location: `https://github.com/${owner}/${repo}/blob/${repoData.data.default_branch}/${fileName}`,
};
}
}
function formatHttpErrorMessage(
message: string,
error: { status: number; message: string },
) {
return `${message}, received http response status code ${error.status}: ${error.message}`;
}
@@ -14,8 +14,5 @@
* limitations under the License.
*/
export type ParsedEntityId = {
kind: string;
namespace?: string;
name: string;
};
export * from './CatalogImportApi';
export * from './CatalogImportClient';
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1,197 @@
/*
* 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, { useCallback, useState } from 'react';
import {
Button,
CircularProgress,
Grid,
Link,
List,
ListItem,
Typography,
Divider,
} from '@material-ui/core';
import { useGithubRepos } from '../util/useGithubRepos';
import { ConfigSpec } from './ImportComponentPage';
import {
errorApiRef,
RouteRef,
StructuredMetadataTable,
useApi,
} from '@backstage/core';
import parseGitUri from 'git-url-parse';
import { PartialEntity } from '../util/types';
import { generatePath, resolvePath } from 'react-router';
import { entityRoute, entityRouteParams } from '@backstage/plugin-catalog';
import { Entity } from '@backstage/catalog-model';
import { Link as RouterLink } from 'react-router-dom';
import * as YAML from 'yaml';
const getEntityCatalogPath = ({
entity,
catalogRouteRef,
}: {
entity: PartialEntity;
catalogRouteRef: RouteRef;
}) => {
const relativeEntityPathInsideCatalog = generatePath(
entityRoute.path,
entityRouteParams(entity as Entity),
);
const resolvedAbsolutePath = resolvePath(
relativeEntityPathInsideCatalog,
catalogRouteRef.path,
)?.pathname;
return resolvedAbsolutePath;
};
type Props = {
nextStep: (options?: { reset: boolean }) => void;
configFile: ConfigSpec;
savePRLink: (PRLink: string) => void;
catalogRouteRef: RouteRef;
};
const ComponentConfigDisplay = ({
nextStep,
configFile,
savePRLink,
catalogRouteRef,
}: Props) => {
const [errorOccured, setErrorOccured] = useState(false);
const [submitting, setSubmitting] = useState(false);
const errorApi = useApi(errorApiRef);
const { submitPrToRepo, addLocation } = useGithubRepos();
const onNext = useCallback(async () => {
try {
setSubmitting(true);
if (!parseGitUri(configFile.location).filepathtype) {
const result = await submitPrToRepo(configFile);
savePRLink(result.link);
setSubmitting(false);
nextStep();
} else {
addLocation(configFile.location);
setSubmitting(false);
nextStep();
}
} catch (e) {
setErrorOccured(true);
setSubmitting(false);
errorApi.post(e);
}
}, [submitPrToRepo, configFile, nextStep, savePRLink, errorApi, addLocation]);
return (
<Grid container direction="column" spacing={1}>
{!parseGitUri(configFile.location).filepathtype ? (
<Typography>
Following config object will be submitted in a pull request to the
repository{' '}
<Link
href={configFile.location}
target="_blank"
rel="noopener noreferrer"
>
{configFile.location}
</Link>{' '}
and added as a new location to the backend
</Typography>
) : (
<Typography>
Following config object will be added as a new location to the backend{' '}
<Link
href={configFile.location}
target="_blank"
rel="noopener noreferrer"
>
{configFile.location}
</Link>
</Typography>
)}
<Grid item>
{!parseGitUri(configFile.location).filepathtype ? (
<pre>{YAML.stringify(configFile.config)}</pre>
) : (
<List>
{configFile.config.map((entity: any, index: number) => {
const entityPath = getEntityCatalogPath({
entity,
catalogRouteRef,
});
return (
<React.Fragment
key={`${entity.metadata.namespace}-${entity.metadata.name}`}
>
<ListItem>
<StructuredMetadataTable
dense
metadata={{
name: entity.metadata.name,
type: entity.spec.type,
link: (
<Link component={RouterLink} to={entityPath}>
{entityPath}
</Link>
),
}}
/>
</ListItem>
{index < configFile.config.length - 1 && (
<Divider component="li" />
)}
</React.Fragment>
);
})}
</List>
)}
</Grid>
<Grid item container spacing={1}>
{submitting ? (
<Grid item>
<CircularProgress size="2rem" />
</Grid>
) : null}
<Grid item>
<Button
disabled={submitting}
variant="contained"
color="primary"
onClick={onNext}
>
Next
</Button>
{errorOccured ? (
<Button
style={{ marginLeft: '8px' }}
variant="outlined"
color="primary"
onClick={() => nextStep({ reset: true })}
>
Start again
</Button>
) : null}
</Grid>
</Grid>
</Grid>
);
};
export default ComponentConfigDisplay;
@@ -0,0 +1,131 @@
/*
* 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 { errorApiRef, useApi } from '@backstage/core';
import { BackstageTheme } from '@backstage/theme';
import {
Button,
FormControl,
FormHelperText,
TextField,
} from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
import React from 'react';
import { useForm } from 'react-hook-form';
import { useMountedState } from 'react-use';
import parseGitUri from 'git-url-parse';
import { ComponentIdValidators } from '../util/validate';
import { useGithubRepos } from '../util/useGithubRepos';
import { ConfigSpec } from './ImportComponentPage';
import { catalogApiRef } from '@backstage/plugin-catalog';
const useStyles = makeStyles<BackstageTheme>(theme => ({
form: {
alignItems: 'flex-start',
display: 'flex',
flexFlow: 'column nowrap',
},
submit: {
marginTop: theme.spacing(1),
},
}));
type Props = {
nextStep: () => void;
saveConfig: (configFile: ConfigSpec) => void;
};
export const RegisterComponentForm = ({ nextStep, saveConfig }: Props) => {
const { register, handleSubmit, errors, formState } = useForm({
mode: 'onChange',
});
const classes = useStyles();
const hasErrors = !!errors.componentLocation;
const dirty = formState?.isDirty;
const catalogApi = useApi(catalogApiRef);
const isMounted = useMountedState();
const errorApi = useApi(errorApiRef);
const { generateEntityDefinitions } = useGithubRepos();
const onSubmit = async (formData: Record<string, string>) => {
const { componentLocation: target } = formData;
try {
if (!isMounted()) return;
const type = !parseGitUri(target).filepathtype ? 'repo' : 'file';
if (type === 'repo') {
saveConfig({
type,
location: target,
config: await generateEntityDefinitions(target),
});
} else {
const data = await catalogApi.addLocation({ target });
saveConfig({
type,
location: data.location.target,
config: data.entities,
});
}
nextStep();
} catch (e) {
errorApi.post(e);
}
};
return (
<form
autoComplete="off"
onSubmit={handleSubmit(onSubmit)}
className={classes.form}
>
<FormControl>
<TextField
id="registerComponentInput"
variant="outlined"
label="Repository URL"
error={hasErrors}
placeholder="https://github.com/spotify/backstage"
name="componentLocation"
required
margin="normal"
helperText="Enter the full path to the repository in GitHub to start tracking your component."
inputRef={register({
required: true,
validate: ComponentIdValidators,
})}
/>
{errors.componentLocation && (
<FormHelperText error={hasErrors} id="register-component-helper-text">
{errors.componentLocation.message}
</FormHelperText>
)}
</FormControl>
<Button
variant="contained"
color="primary"
type="submit"
disabled={!dirty || hasErrors}
className={classes.submit}
>
Next
</Button>
</form>
);
};
@@ -0,0 +1,111 @@
/*
* 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, { useState } from 'react';
import { Grid } from '@material-ui/core';
import {
InfoCard,
Page,
Content,
Header,
SupportButton,
ContentHeader,
RouteRef,
} from '@backstage/core';
import { RegisterComponentForm } from './ImportComponentForm';
import ImportStepper from './ImportStepper';
import ComponentConfigDisplay from './ComponentConfigDisplay';
import { ImportFinished } from './ImportFinished';
import { PartialEntity } from '../util/types';
export type ConfigSpec = {
type: 'repo' | 'file';
location: string;
config: PartialEntity[];
};
export const ImportComponentPage = ({
catalogRouteRef,
}: {
catalogRouteRef: RouteRef;
}) => {
const [activeStep, setActiveStep] = useState(0);
const [configFile, setConfigFile] = useState<ConfigSpec>({
type: 'repo',
location: '',
config: [],
});
const [endLink, setEndLink] = useState<string>('');
const nextStep = (options?: { reset: boolean }) => {
setActiveStep(step => (options?.reset ? 0 : step + 1));
};
return (
<Page themeId="home">
<Header title="Register an existing component" />
<Content>
<ContentHeader title="Start tracking your component on backstage">
<SupportButton>
Start tracking your component in Backstage. TODO: Add more
information about what this is.
</SupportButton>
</ContentHeader>
<Grid container spacing={3} direction="column">
<Grid item>
<InfoCard>
<ImportStepper
steps={[
{
step: 'Insert GitHub repo URL or Entity File URL',
content: (
<RegisterComponentForm
nextStep={nextStep}
saveConfig={setConfigFile}
/>
),
},
{
step: 'Review',
content: (
<ComponentConfigDisplay
nextStep={nextStep}
configFile={configFile}
savePRLink={setEndLink}
catalogRouteRef={catalogRouteRef}
/>
),
},
{
step: 'Finish',
content: (
<ImportFinished
nextStep={nextStep}
PRLink={endLink}
type={configFile.type}
/>
),
},
]}
activeStep={activeStep}
nextStep={nextStep}
/>
</InfoCard>
</Grid>
</Grid>
</Content>
</Page>
);
};
@@ -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 { Alert } from '@material-ui/lab';
import { Button, Grid, Link } from '@material-ui/core';
type Props = {
type: 'repo' | 'file';
nextStep: (options?: { reset: boolean }) => void;
PRLink: string;
};
export const ImportFinished = ({ nextStep, PRLink, type }: Props) => {
return (
<Grid container direction="column" spacing={1}>
<Grid item>
<Alert severity="success">
{type === 'repo'
? 'Pull requests have been successfully opened. You can start again to import more repositories'
: 'Entity added to catalog successfully'}
</Alert>
</Grid>
<Grid item>
{type === 'repo' ? (
<Link
href={PRLink}
style={{ marginRight: '8px' }}
target="_blank"
rel="noopener noreferrer"
>
View pull request on GitHub
</Link>
) : null}
<Button
variant="contained"
color="primary"
onClick={() => nextStep({ reset: true })}
>
Register another
</Button>
</Grid>
</Grid>
);
};
@@ -0,0 +1,42 @@
/*
* 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 Stepper from '@material-ui/core/Stepper';
import Step from '@material-ui/core/Step';
import StepLabel from '@material-ui/core/StepLabel';
import { StepContent } from '@material-ui/core';
type Props = {
steps: { step: string; content: React.ReactNode }[];
activeStep: number;
nextStep: (options?: { reset: boolean }) => void;
};
export default function ImportStepper({ steps, activeStep }: Props) {
return (
<Stepper activeStep={activeStep} orientation="vertical">
{steps.map(({ step }) => {
const stepProps: { completed?: boolean } = {};
return (
<Step key={step} {...stepProps}>
<StepLabel>{step}</StepLabel>
<StepContent>{steps[activeStep].content}</StepContent>
</Step>
);
})}
</Stepper>
);
}
@@ -0,0 +1,28 @@
/*
* 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 { RouteRef } from '@backstage/core';
import React from 'react';
import { Route, Routes } from 'react-router-dom';
import { ImportComponentPage } from './ImportComponentPage';
export const Router = ({ catalogRouteRef }: { catalogRouteRef: RouteRef }) => (
<Routes>
<Route
element={<ImportComponentPage catalogRouteRef={catalogRouteRef} />}
/>
</Routes>
);
+18
View File
@@ -0,0 +1,18 @@
/*
* 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 { plugin } from './plugin';
export { Router } from './components/Router';
+23
View File
@@ -0,0 +1,23 @@
/*
* 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 { plugin } from './plugin';
describe('catalog-import', () => {
it('should export plugin', () => {
expect(plugin).toBeDefined();
});
});
+42
View File
@@ -0,0 +1,42 @@
/*
* 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 {
createApiFactory,
createPlugin,
createRouteRef,
discoveryApiRef,
githubAuthApiRef,
} from '@backstage/core';
import { catalogImportApiRef } from './api/CatalogImportApi';
import { CatalogImportClient } from './api/CatalogImportClient';
export const rootRouteRef = createRouteRef({
path: '',
title: 'catalog-import',
});
export const plugin = createPlugin({
id: 'catalog-import',
apis: [
createApiFactory({
api: catalogImportApiRef,
deps: { discoveryApi: discoveryApiRef, githubAuthApi: githubAuthApiRef },
factory: ({ discoveryApi, githubAuthApi }) =>
new CatalogImportClient({ discoveryApi, githubAuthApi }),
}),
],
});
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '@testing-library/jest-dom';
@@ -14,28 +14,14 @@
* limitations under the License.
*/
import fetch from 'cross-fetch';
import { Entity } from '@backstage/catalog-model';
export class TechDocsMetadata {
private async getMetadataFile(docsUrl: String) {
const metadataURL = `${docsUrl}/techdocs_metadata.json`;
export type RecursivePartial<T> = {
[P in keyof T]?: T[P] extends (infer U)[]
? RecursivePartial<U>[]
: T[P] extends object
? RecursivePartial<T[P]>
: T[P];
};
try {
const req = await fetch(metadataURL);
return await req.json();
} catch (error) {
throw new Error(error);
}
}
public async getMkDocsMetaData(docsUrl: any) {
const mkDocsMetadata = await this.getMetadataFile(docsUrl);
if (!mkDocsMetadata) return null;
return {
...mkDocsMetadata,
};
}
}
export type PartialEntity = RecursivePartial<Entity>;
@@ -0,0 +1,57 @@
/*
* 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 * as YAML from 'yaml';
import { useApi } from '@backstage/core';
import { catalogImportApiRef } from '../api/CatalogImportApi';
import { ConfigSpec } from '../components/ImportComponentPage';
export function useGithubRepos() {
const api = useApi(catalogImportApiRef);
const submitPrToRepo = async (selectedRepo: ConfigSpec) => {
const [ownerName, repoName] = selectedRepo.location.split('/').slice(-2);
const submitPRResponse = await api
.submitPrToRepo({
owner: ownerName,
repo: repoName,
fileContent: selectedRepo.config
.map(entity => `---\n${YAML.stringify(entity)}`)
.join('\n'),
})
.catch(e => {
throw new Error(`Failed to submit PR to repo:\n${e.message}`);
});
await api
.createRepositoryLocation({
location: submitPRResponse.location,
})
.catch(e => {
throw new Error(`Failed to create repository location:\n${e.message}`);
});
return submitPRResponse;
};
return {
submitPrToRepo,
generateEntityDefinitions: (repo: string) =>
api.generateEntityDefinitions({ repo }),
addLocation: (location: string) =>
api.createRepositoryLocation({ location }),
};
}
@@ -0,0 +1,33 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentIdValidators } from './validate';
describe('ComponentIdValidators', () => {
describe('httpsValidator validator', () => {
const errorMessage = 'Must start with https://.';
test.each([
[true, 'https://example.com'],
[errorMessage, 'http://example.com'],
[errorMessage, 'example.com'],
[errorMessage, 'www.example.com'],
[errorMessage, ''],
[errorMessage, undefined],
])('should return %p for %s', (expected: string | boolean, arg: any) => {
expect(ComponentIdValidators.httpsValidator(arg)).toBe(expected);
});
});
});
@@ -0,0 +1,21 @@
/*
* 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 const ComponentIdValidators = {
httpsValidator: (value: any) =>
(typeof value === 'string' && value.match(/^https:\/\//) !== null) ||
'Must start with https://.',
};
+26
View File
@@ -1,5 +1,31 @@
# @backstage/plugin-catalog
## 0.2.4
### Patch Changes
- 6f70ed7a9: Replace usage of implementsApis with relations
- Updated dependencies [4b53294a6]
- Updated dependencies [ab94c9542]
- Updated dependencies [2daf18e80]
- Updated dependencies [069cda35f]
- @backstage/plugin-techdocs@0.3.0
- @backstage/catalog-model@0.3.1
## 0.2.3
### Patch Changes
- Updated dependencies [475fc0aaa]
- Updated dependencies [1166fcc36]
- Updated dependencies [ef2831dde]
- Updated dependencies [1185919f3]
- @backstage/core@0.3.2
- @backstage/catalog-model@0.3.0
- @backstage/plugin-scaffolder@0.3.1
- @backstage/catalog-client@0.3.1
- @backstage/plugin-techdocs@0.2.3
## 0.2.2
### Patch Changes
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog",
"version": "0.2.2",
"version": "0.2.4",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -21,11 +21,11 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-client": "^0.3.0",
"@backstage/catalog-model": "^0.2.0",
"@backstage/core": "^0.3.1",
"@backstage/plugin-scaffolder": "^0.3.0",
"@backstage/plugin-techdocs": "^0.2.2",
"@backstage/catalog-client": "^0.3.1",
"@backstage/catalog-model": "^0.3.1",
"@backstage/core": "^0.3.2",
"@backstage/plugin-scaffolder": "^0.3.1",
"@backstage/plugin-techdocs": "^0.3.0",
"@backstage/theme": "^0.2.1",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
@@ -43,7 +43,7 @@
"swr": "^0.3.0"
},
"devDependencies": {
"@backstage/cli": "^0.3.0",
"@backstage/cli": "^0.3.2",
"@backstage/dev-utils": "^0.1.4",
"@backstage/test-utils": "^0.1.3",
"@microsoft/microsoft-graph-types": "^1.25.0",
@@ -18,6 +18,7 @@ import {
Entity,
ENTITY_DEFAULT_NAMESPACE,
RELATION_OWNED_BY,
RELATION_PROVIDES_API,
serializeEntityRef,
} from '@backstage/catalog-model';
import {
@@ -111,6 +112,8 @@ type AboutCardProps = {
export function AboutCard({ entity, variant }: AboutCardProps) {
const classes = useStyles();
const codeLink = getCodeLinkInfo(entity);
// TODO: Also support RELATION_CONSUMES_API here
const hasApis = entity.relations?.some(r => r.type === RELATION_PROVIDES_API);
return (
<Card className={variant === 'gridItem' ? classes.gridItemCard : ''}>
@@ -146,9 +149,9 @@ export function AboutCard({ entity, variant }: AboutCardProps) {
}/${entity.kind}/${entity.metadata.name}`}
/>
<IconLinkVertical
disabled={!entity.spec?.implementsApis}
disabled={!hasApis}
label="View API"
title={!entity.spec?.implementsApis ? 'No APIs available' : ''}
title={hasApis ? '' : 'No APIs available'}
icon={<ExtensionIcon />}
href="api"
/>
+12
View File
@@ -1,5 +1,17 @@
# @backstage/plugin-circleci
## 0.2.2
### Patch Changes
- a8de7f554: Improved CircleCI builds table to show more information and relevant links
- Updated dependencies [475fc0aaa]
- Updated dependencies [1166fcc36]
- Updated dependencies [1185919f3]
- @backstage/core@0.3.2
- @backstage/catalog-model@0.3.0
- @backstage/plugin-catalog@0.2.3
## 0.2.1
### Patch Changes
+3 -16
View File
@@ -7,25 +7,12 @@ Website: [https://circleci.com/](https://circleci.com/)
## Setup
0. If you have standalone app (you didn't clone this repo), then do
1. If you have standalone app (you didn't clone this repo), then do
```bash
yarn add @backstage/plugin-circleci
```
1. Add plugin API to your Backstage instance:
```js
// packages/app/src/api.ts
import { ApiHolder } from '@backstage/core';
import { CircleCIApi, circleCIApiRef } from '@backstage/plugin-circleci';
const builder = ApiRegistry.builder();
builder.add(circleCIApiRef, new CircleCIApi(/* optional custom url for your own CircleCI instance */));
export default builder.build() as ApiHolder;
```
2. Add plugin itself:
```js
@@ -61,7 +48,7 @@ proxy:
```
5. Get and provide `CIRCLECI_AUTH_TOKEN` as env variable (https://circleci.com/docs/api/#add-an-api-token)
6. Add `circleci.com/project-slug` annotation to your component-info.yaml file in format <git-provider>/<owner>/<project> (https://backstage.io/docs/architecture-decisions/adrs-adr002#format)
6. Add `circleci.com/project-slug` annotation to your catalog-info.yaml file in format <git-provider>/<owner>/<project> (https://backstage.io/docs/architecture-decisions/adrs-adr002#format)
```yaml
apiVersion: backstage.io/v1alpha1
@@ -88,4 +75,4 @@ spec:
## Limitations
- CircleCI has pretty strict rate limits per token, be careful with opened tabs
- CircelCI doesn't provide a way to auth by 3rd party (e.g. GitHub) token, nor by calling their OAuth endpoints, which currently stands in the way of better auth integration with Backstage (https://discuss.circleci.com/t/circleci-api-authorization-with-github-token/5356)
- CircleCI doesn't provide a way to auth by 3rd party (e.g. GitHub) token, nor by calling their OAuth endpoints, which currently stands in the way of better auth integration with Backstage (https://discuss.circleci.com/t/circleci-api-authorization-with-github-token/5356)
+7 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-circleci",
"version": "0.2.1",
"version": "0.2.2",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -21,14 +21,16 @@
"postpack": "backstage-cli postpack"
},
"dependencies": {
"@backstage/catalog-model": "^0.2.0",
"@backstage/core": "^0.3.1",
"@backstage/plugin-catalog": "^0.2.2",
"@backstage/catalog-model": "^0.3.0",
"@backstage/core": "^0.3.2",
"@backstage/plugin-catalog": "^0.2.3",
"@backstage/theme": "^0.2.1",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"circleci-api": "^4.0.0",
"dayjs": "^1.9.4",
"lodash": "^4.17.15",
"moment": "^2.25.3",
"react": "^16.13.1",
"react-dom": "^16.13.1",
@@ -38,7 +40,7 @@
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.3.0",
"@backstage/cli": "^0.3.2",
"@backstage/dev-utils": "^0.1.4",
"@backstage/test-utils": "^0.1.3",
"@testing-library/jest-dom": "^5.10.1",
@@ -13,7 +13,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC, useEffect } from 'react';
import React, { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { InfoCard, Progress, Link } from '@backstage/core';
import { BuildWithSteps, BuildStepAction } from '../../api';
@@ -31,7 +32,8 @@ import LaunchIcon from '@material-ui/icons/Launch';
import { useBuildWithSteps } from '../../state/useBuildWithSteps';
const IconLink = (IconButton as any) as typeof MaterialLink;
const BuildName: FC<{ build?: BuildWithSteps }> = ({ build }) => (
const BuildName = ({ build }: { build?: BuildWithSteps }) => (
<Box display="flex" alignItems="center">
#{build?.build_num} - {build?.subject}
<IconLink href={build?.build_url} target="_blank">
@@ -39,6 +41,7 @@ const BuildName: FC<{ build?: BuildWithSteps }> = ({ build }) => (
</IconLink>
</Box>
);
const useStyles = makeStyles(theme => ({
neutral: {},
failed: {
@@ -96,7 +99,7 @@ const pickClassName = (
return classes.neutral;
};
const BuildsList: FC<{ build?: BuildWithSteps }> = ({ build }) => (
const BuildsList = ({ build }: { build?: BuildWithSteps }) => (
<Box>
{build &&
build.steps &&
@@ -108,8 +111,11 @@ const BuildsList: FC<{ build?: BuildWithSteps }> = ({ build }) => (
</Box>
);
const ActionsList: FC<{ actions: BuildStepAction[]; name: string }> = ({
const ActionsList = ({
actions,
}: {
actions: BuildStepAction[];
name: string;
}) => {
const classes = useStyles();
return (
@@ -13,11 +13,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC } from 'react';
import React from 'react';
import { CITable } from '../CITable';
import { useBuilds } from '../../../../state';
export const Builds: FC<{}> = () => {
export const Builds = () => {
const [
{ total, loading, value, projectName, page, pageSize },
{ setPage, retry, setPageSize },
@@ -13,10 +13,19 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC } from 'react';
import { Link, Typography, Box, IconButton } from '@material-ui/core';
import React from 'react';
import {
Avatar,
Link,
Typography,
Box,
IconButton,
makeStyles,
} from '@material-ui/core';
import RetryIcon from '@material-ui/icons/Replay';
import GitHubIcon from '@material-ui/icons/GitHub';
import LaunchIcon from '@material-ui/icons/Launch';
import { Link as RouterLink, generatePath } from 'react-router-dom';
import {
StatusError,
@@ -27,17 +36,22 @@ import {
Table,
TableColumn,
} from '@backstage/core';
import { durationHumanized, relativeTimeTo } from '../../../../util';
import { circleCIBuildRouteRef } from '../../../../route-refs';
export type CITableBuildInfo = {
id: string;
buildName: string;
buildUrl?: string;
startTime?: string;
stopTime?: string;
source: {
branchName: string;
commit: {
hash: string;
shortHash: string;
url: string;
committerName?: string;
};
};
status: string;
@@ -48,6 +62,18 @@ export type CITableBuildInfo = {
failed: number;
testUrl: string; // fixme better name
};
workflow: {
id: string;
url: string;
name?: string;
jobName?: string;
};
user: {
isUser: boolean;
login: string;
name?: string;
avatarUrl?: string;
};
onRestartClick: () => void;
};
@@ -69,6 +95,43 @@ const getStatusComponent = (status: string | undefined = '') => {
}
};
const useStyles = makeStyles(theme => ({
root: {
display: 'flex',
'& > *': {
margin: theme.spacing(1),
verticalAlign: 'center',
},
},
small: {
width: theme.spacing(3),
height: theme.spacing(3),
},
}));
const SourceInfo = ({ build }: { build: CITableBuildInfo }) => {
const classes = useStyles();
const { user, source } = build;
return (
<Box display="flex" alignItems="center" className={classes.root}>
<Avatar alt={user.name} src={user.avatarUrl} className={classes.small} />
<Box>
<Typography variant="button">{source?.branchName}</Typography>
<Typography variant="body1">
{source?.commit?.url !== undefined ? (
<Link href={source?.commit?.url} target="_blank">
{source?.commit.shortHash}
</Link>
) : (
source?.commit.shortHash
)}
</Typography>
</Box>
</Box>
);
};
const generatedColumns: TableColumn[] = [
{
title: 'ID',
@@ -80,26 +143,43 @@ const generatedColumns: TableColumn[] = [
title: 'Build',
field: 'buildName',
highlight: true,
width: '20%',
render: (row: Partial<CITableBuildInfo>) => (
<Link
component={RouterLink}
to={`${generatePath(circleCIBuildRouteRef.path, { buildId: row.id! })}`}
to={`${generatePath(circleCIBuildRouteRef.path, {
buildId: row.id!,
})}`}
>
{row.buildName}
{row.buildName ? row.buildName : row?.workflow?.name}
</Link>
),
},
{
title: 'Job',
field: 'buildName',
highlight: true,
render: (row: Partial<CITableBuildInfo>) => (
<Link href={row?.buildUrl} target="_blank">
<Box display="flex" alignItems="center">
<LaunchIcon fontSize="small" color="disabled" />
<Box mr={1} />
{row?.workflow?.jobName}
</Box>
</Link>
),
},
{
title: 'Source',
field: 'source.commit.hash',
highlight: true,
render: (row: Partial<CITableBuildInfo>) => (
<>
<p>{row.source?.branchName}</p>
<p>{row.source?.commit.hash}</p>
</>
<SourceInfo build={row as any} />
),
},
{
title: 'Status',
field: 'status',
render: (row: Partial<CITableBuildInfo>) => (
<Box display="flex" alignItems="center">
{getStatusComponent(row.status)}
@@ -108,14 +188,32 @@ const generatedColumns: TableColumn[] = [
</Box>
),
},
{
title: 'Time',
field: 'startTime',
render: (row: Partial<CITableBuildInfo>) => (
<>
<Typography variant="body2">
run {relativeTimeTo(row?.startTime)}
</Typography>
<Typography variant="body2">
took {durationHumanized(row?.startTime, row?.stopTime)}
</Typography>
</>
),
},
{
title: 'Workflow',
field: 'workflow.name',
},
{
title: 'Actions',
width: '10%',
render: (row: Partial<CITableBuildInfo>) => (
<IconButton onClick={row.onRestartClick}>
<RetryIcon />
</IconButton>
),
width: '10%',
},
];
@@ -130,7 +228,8 @@ type Props = {
pageSize: number;
onChangePageSize: (pageSize: number) => void;
};
export const CITable: FC<Props> = ({
export const CITable = ({
projectName,
loading,
pageSize,
@@ -140,11 +239,16 @@ export const CITable: FC<Props> = ({
onChangePage,
onChangePageSize,
total,
}) => {
}: Props) => {
return (
<Table
isLoading={loading}
options={{ paging: true, pageSize }}
options={{
paging: true,
pageSize,
padding: 'dense',
pageSizeOptions: [10, 20, 50],
}}
totalCount={total}
page={page}
actions={[
+42 -8
View File
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { errorApiRef, useApi } from '@backstage/core';
import { BuildSummary, GitType } from 'circleci-api';
import { useCallback, useEffect, useState } from 'react';
@@ -21,6 +22,7 @@ import { circleCIApiRef } from '../api/index';
import type { CITableBuildInfo } from '../components/BuildsPage/lib/CITable';
import { useEntity } from '@backstage/plugin-catalog';
import { CIRCLECI_ANNOTATION } from '../constants';
import { getOr } from 'lodash/fp';
const makeReadableStatus = (status: string | undefined) => {
if (!status) return '';
@@ -41,6 +43,39 @@ const makeReadableStatus = (status: string | undefined) => {
} as Record<string, string>)[status];
};
const mapWorkflowDetails = (buildData: BuildSummary) => {
// Workflows should be an object: fixed in https://github.com/worldturtlemedia/circleci-api/pull/787
const { workflows } = (buildData as any) ?? {};
return {
id: workflows?.workflow_id,
url: `${buildData.build_url}/workflows/${workflows?.workflow_id}`,
jobName: workflows?.job_name,
name: workflows?.workflow_name,
};
};
const mapSourceDetails = (buildData: BuildSummary) => {
const commitDetails = getOr({}, 'all_commit_details[0]', buildData);
return {
branchName: String(buildData.branch),
commit: {
hash: String(buildData.vcs_revision),
shortHash: String(buildData.vcs_revision).substr(0, 7),
committerName: buildData.committer_name,
url: commitDetails.commit_url,
},
};
};
const mapUser = (buildData: BuildSummary) => ({
isUser: buildData?.user?.is_user || false,
login: buildData?.user?.login || 'none',
name: (buildData?.user as any)?.name,
avatarUrl: (buildData?.user as any)?.avatar_url,
});
export const transform = (
buildsData: BuildSummary[],
restartBuild: { (buildId: number): Promise<void> },
@@ -52,16 +87,14 @@ export const transform = (
? buildData.subject +
(buildData.retry_of ? ` (retry of #${buildData.retry_of})` : '')
: '',
startTime: buildData.start_time,
stopTime: buildData.stop_time,
onRestartClick: () =>
typeof buildData.build_num !== 'undefined' &&
restartBuild(buildData.build_num),
source: {
branchName: String(buildData.branch),
commit: {
hash: String(buildData.vcs_revision),
url: 'todo',
},
},
source: mapSourceDetails(buildData),
workflow: mapWorkflowDetails(buildData),
user: mapUser(buildData),
status: makeReadableStatus(buildData.status),
buildUrl: buildData.build_url,
};
@@ -79,6 +112,7 @@ export const useProjectSlugFromEntity = () => {
export function mapVcsType(vcs: string): GitType {
switch (vcs) {
case 'gh':
case 'github':
return GitType.GITHUB;
default:
@@ -93,7 +127,7 @@ export function useBuilds() {
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(5);
const [pageSize, setPageSize] = useState(10);
const getBuilds = useCallback(
async ({ limit, offset }: { limit: number; offset: number }) => {
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './time';
+31
View File
@@ -0,0 +1,31 @@
/*
* 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 { durationHumanized, relativeTimeTo } from './time';
describe('times utils', () => {
describe('toRelativeTime', () => {
it('should give a relative time of x from today', () => {
expect(relativeTimeTo('2020-01-01')).toEqual(expect.any(String));
});
});
describe('durationHumanized', () => {
it('should give a humanized duration', () => {
expect(durationHumanized('2020-11-01', '2020-11-03')).toBe('2 days');
});
});
});
+35
View File
@@ -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 dayjs from 'dayjs';
import durationPlugin from 'dayjs/plugin/duration';
import relativeTimePlugin from 'dayjs/plugin/relativeTime';
dayjs.extend(durationPlugin);
dayjs.extend(relativeTimePlugin);
type DateTimeObject = Date | string | number | undefined;
export function relativeTimeTo(time: DateTimeObject, withoutSuffix = false) {
return dayjs().to(dayjs(time), withoutSuffix);
}
export function durationHumanized(
startTime: DateTimeObject,
endTime: DateTimeObject,
) {
return dayjs.duration(dayjs(startTime).diff(dayjs(endTime))).humanize();
}
+11
View File
@@ -1,5 +1,16 @@
# @backstage/plugin-cloudbuild
## 0.2.2
### Patch Changes
- Updated dependencies [475fc0aaa]
- Updated dependencies [1166fcc36]
- Updated dependencies [1185919f3]
- @backstage/core@0.3.2
- @backstage/catalog-model@0.3.0
- @backstage/plugin-catalog@0.2.3
## 0.2.1
### Patch Changes
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-cloudbuild",
"version": "0.2.1",
"version": "0.2.2",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -20,9 +20,9 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.2.0",
"@backstage/core": "^0.3.1",
"@backstage/plugin-catalog": "^0.2.2",
"@backstage/catalog-model": "^0.3.0",
"@backstage/core": "^0.3.2",
"@backstage/plugin-catalog": "^0.2.3",
"@backstage/theme": "^0.2.1",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
@@ -39,7 +39,7 @@
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.3.0",
"@backstage/cli": "^0.3.2",
"@backstage/dev-utils": "^0.1.4",
"@backstage/test-utils": "^0.1.3",
"@testing-library/jest-dom": "^5.10.1",
+9
View File
@@ -1,5 +1,14 @@
# @backstage/plugin-cost-insights
## 0.4.1
### Patch Changes
- 8e6728e25: fix product icon configuration
- c93a14b49: truncate large percentages > 1000%
- Updated dependencies [475fc0aaa]
- @backstage/core@0.3.2
## 0.4.0
### Minor Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-cost-insights",
"version": "0.4.0",
"version": "0.4.1",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -22,7 +22,7 @@
},
"dependencies": {
"@backstage/config": "^0.1.1",
"@backstage/core": "^0.3.1",
"@backstage/core": "^0.3.2",
"@backstage/test-utils": "^0.1.3",
"@backstage/theme": "^0.2.1",
"@material-ui/core": "^4.11.0",
@@ -46,7 +46,7 @@
"yup": "^0.29.3"
},
"devDependencies": {
"@backstage/cli": "^0.3.0",
"@backstage/cli": "^0.3.2",
"@backstage/dev-utils": "^0.1.4",
"@backstage/test-utils": "^0.1.3",
"@testing-library/jest-dom": "^5.10.1",
+13 -48
View File
@@ -20,9 +20,7 @@ import { CostInsightsApi, ProductInsightsOptions } from '../src/api';
import {
Alert,
Cost,
DateAggregation,
DEFAULT_DATE_FORMAT,
Duration,
Entity,
Group,
MetricData,
@@ -34,52 +32,13 @@ import {
ProjectGrowthAlert,
UnlabeledDataflowAlert,
} from '../src/utils/alerts';
import { inclusiveStartDateOf } from '../src/utils/duration';
import { trendlineOf, changeOf, entityOf } from './utils/mockData';
type IntervalFields = {
duration: Duration;
endDate: string;
};
function parseIntervals(intervals: string): IntervalFields {
const match = intervals.match(
/\/(?<duration>P\d+[DM])\/(?<date>\d{4}-\d{2}-\d{2})/,
);
if (Object.keys(match?.groups || {}).length !== 2) {
throw new Error(`Invalid intervals: ${intervals}`);
}
const { duration, date } = match!.groups!;
return {
duration: duration as Duration,
endDate: date,
};
}
function aggregationFor(
intervals: string,
baseline: number,
): DateAggregation[] {
const { duration, endDate } = parseIntervals(intervals);
const days = dayjs(endDate).diff(
inclusiveStartDateOf(duration, endDate),
'day',
);
return [...Array(days).keys()].reduce(
(values: DateAggregation[], i: number): DateAggregation[] => {
const last = values.length ? values[values.length - 1].amount : baseline;
values.push({
date: dayjs(inclusiveStartDateOf(duration, endDate))
.add(i, 'day')
.format(DEFAULT_DATE_FORMAT),
amount: Math.max(0, last + (baseline / 20) * (Math.random() * 2 - 1)),
});
return values;
},
[],
);
}
import {
trendlineOf,
changeOf,
entityOf,
getGroupedProducts,
aggregationFor,
} from './utils/mockData';
export class ExampleCostInsightsClient implements CostInsightsApi {
private request(_: any, res: any): Promise<any> {
@@ -140,6 +99,9 @@ export class ExampleCostInsightsClient implements CostInsightsApi {
aggregation: aggregation,
change: changeOf(aggregation),
trendline: trendlineOf(aggregation),
// Optional field on Cost which needs to be supplied in order to see
// the product breakdown view in the top panel.
groupedCosts: getGroupedProducts(intervals),
},
);
@@ -155,6 +117,9 @@ export class ExampleCostInsightsClient implements CostInsightsApi {
aggregation: aggregation,
change: changeOf(aggregation),
trendline: trendlineOf(aggregation),
// Optional field on Cost which needs to be supplied in order to see
// the product breakdown view in the top panel.
groupedCosts: getGroupedProducts(intervals),
},
);
@@ -21,11 +21,12 @@ import { currencyFormatter } from '../../utils/formatters';
import { CostInsightsTheme } from '../../types';
import { useBarChartLayoutStyles as useStyles } from '../../utils/styles';
type BarChartLegendOptions = {
export type BarChartLegendOptions = {
previousName: string;
previousFill: string;
currentName: string;
currentFill: string;
hideMarker?: boolean;
};
export type BarChartLegendProps = {
@@ -56,12 +57,18 @@ export const BarChartLegend = ({
return (
<Box className={classes.legend} display="flex" flexDirection="row">
<Box marginRight={2}>
<LegendItem title={data.previousName} markerColor={data.previousFill}>
<LegendItem
title={data.previousName}
markerColor={options.hideMarker ? undefined : data.previousFill}
>
{currencyFormatter.format(costStart)}
</LegendItem>
</Box>
<Box marginRight={2}>
<LegendItem title={data.currentName} markerColor={data.currentFill}>
<LegendItem
title={data.currentName}
markerColor={options.hideMarker ? undefined : data.currentFill}
>
{currencyFormatter.format(costEnd)}
</LegendItem>
</Box>
@@ -17,7 +17,10 @@
export { BarChart } from './BarChart';
export type { BarChartProps } from './BarChart';
export { BarChartLegend } from './BarChartLegend';
export type { BarChartLegendProps } from './BarChartLegend';
export type {
BarChartLegendProps,
BarChartLegendOptions,
} from './BarChartLegend';
export { BarChartTooltip } from './BarChartTooltip';
export type { BarChartTooltipProps } from './BarChartTooltip';
export { BarChartTooltipItem } from './BarChartTooltipItem';

Some files were not shown because too many files have changed in this diff Show More