Merge branch 'master' of github.com:spotify/backstage into shmidt-i/backend-hmr-2

This commit is contained in:
Ivan Shmidt
2020-06-15 15:46:23 +02:00
211 changed files with 4781 additions and 1562 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -15,7 +15,7 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.7",
"@backstage/backend-common": "^0.1.1-alpha.8",
"@types/cookie-parser": "^1.4.2",
"@types/jwt-decode": "2.2.1",
"@types/passport": "^1.0.3",
@@ -39,7 +39,7 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@types/body-parser": "^1.19.0",
"@types/passport-saml": "^1.1.2",
"jest-fetch-mock": "^3.0.3",
@@ -0,0 +1,62 @@
/*
* 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 { AuthProviderRouteHandlers } from '../providers/types';
import { NotFoundError } from '@backstage/backend-common';
export type EnvironmentHandlers = {
[key: string]: AuthProviderRouteHandlers;
};
export class EnvironmentHandler implements AuthProviderRouteHandlers {
constructor(private readonly providers: EnvironmentHandlers) {}
private getProviderForEnv(req: express.Request): AuthProviderRouteHandlers {
const env = req.query.env?.toString();
if (!this.providers.hasOwnProperty(env)) {
throw new NotFoundError(
`No environment for ${env} found in this provider`,
);
}
return this.providers[env];
}
async start(req: express.Request, res: express.Response): Promise<void> {
const provider = this.getProviderForEnv(req);
provider.start(req, res);
}
async frameHandler(
req: express.Request,
res: express.Response,
): Promise<void> {
const provider = this.getProviderForEnv(req);
provider.frameHandler(req, res);
}
async refresh(req: express.Request, res: express.Response): Promise<void> {
const provider = this.getProviderForEnv(req);
if (provider.refresh) {
provider.refresh(req, res);
}
}
async logout(req: express.Request, res: express.Response): Promise<void> {
const provider = this.getProviderForEnv(req);
provider.logout(req, res);
}
}
@@ -18,15 +18,12 @@ import express from 'express';
import {
ensuresXRequestedWith,
postMessageResponse,
removeRefreshTokenCookie,
setRefreshTokenCookie,
THOUSAND_DAYS_MS,
setNonceCookie,
TEN_MINUTES_MS,
verifyNonce,
OAuthProvider,
} from './OAuthProvider';
import { AuthResponse, OAuthProviderHandlers } from './types';
import { AuthResponse, OAuthProviderHandlers } from '../providers/types';
describe('OAuthProvider Utils', () => {
describe('verifyNonce', () => {
@@ -80,52 +77,8 @@ describe('OAuthProvider Utils', () => {
});
});
describe('setNonceCookie', () => {
it('should set nonce cookie', () => {
const mockResponse = ({
cookie: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
setNonceCookie(mockResponse, 'providera');
expect(mockResponse.cookie).toBeCalledTimes(1);
expect(mockResponse.cookie).toBeCalledWith(
'providera-nonce',
expect.any(String),
expect.objectContaining({ maxAge: TEN_MINUTES_MS }),
);
});
});
describe('setRefreshTokenCookie', () => {
it('should set refresh token cookie', () => {
const mockResponse = ({
cookie: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
setRefreshTokenCookie(mockResponse, 'providera', 'REFRESH_TOKEN');
expect(mockResponse.cookie).toBeCalledTimes(1);
expect(mockResponse.cookie).toBeCalledWith(
'providera-refresh-token',
'REFRESH_TOKEN',
expect.objectContaining({ maxAge: THOUSAND_DAYS_MS }),
);
});
});
describe('removeRefreshTokenCookie', () => {
it('should remove refresh token cookie', () => {
const mockResponse = ({
cookie: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
removeRefreshTokenCookie(mockResponse, 'providera');
expect(mockResponse.cookie).toBeCalledTimes(1);
expect(mockResponse.cookie).toBeCalledWith(
'providera-refresh-token',
'',
expect.objectContaining({ maxAge: 0 }),
);
});
});
describe('postMessageResponse', () => {
const appOrigin = 'http://localhost:3000';
it('should post a message back with payload success', () => {
const mockResponse = ({
end: jest.fn().mockReturnThis(),
@@ -144,7 +97,7 @@ describe('OAuthProvider Utils', () => {
const jsonData = JSON.stringify(data);
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
postMessageResponse(mockResponse, data);
postMessageResponse(mockResponse, appOrigin, data);
expect(mockResponse.setHeader).toBeCalledTimes(2);
expect(mockResponse.end).toBeCalledTimes(1);
expect(mockResponse.end).toBeCalledWith(
@@ -165,7 +118,7 @@ describe('OAuthProvider Utils', () => {
const jsonData = JSON.stringify(data);
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
postMessageResponse(mockResponse, data);
postMessageResponse(mockResponse, appOrigin, data);
expect(mockResponse.setHeader).toBeCalledTimes(2);
expect(mockResponse.end).toBeCalledTimes(1);
expect(mockResponse.end).toBeCalledWith(
@@ -221,10 +174,19 @@ describe('OAuthProvider', () => {
}
}
const providerInstance = new MyAuthProvider();
const providerId = 'test-provider';
const oAuthProviderOptions = {
providerId: 'test-provider',
secure: false,
disableRefresh: true,
baseUrl: 'http://localhost:7000/auth',
appOrigin: 'http://localhost:3000',
};
it('sets the correct headers in start', async () => {
const oauthProvider = new OAuthProvider(providerInstance, providerId);
const oauthProvider = new OAuthProvider(
providerInstance,
oAuthProviderOptions,
);
const mockRequest = ({
query: {
scope: 'user',
@@ -239,6 +201,14 @@ describe('OAuthProvider', () => {
} as unknown) as express.Response;
await oauthProvider.start(mockRequest, mockResponse);
// nonce cookie checks
expect(mockResponse.cookie).toBeCalledTimes(1);
expect(mockResponse.cookie).toBeCalledWith(
`${oAuthProviderOptions.providerId}-nonce`,
expect.any(String),
expect.objectContaining({ maxAge: TEN_MINUTES_MS }),
);
// redirect checks
expect(mockResponse.setHeader).toHaveBeenCalledTimes(2);
expect(mockResponse.setHeader).toHaveBeenCalledWith('Location', '/url');
expect(mockResponse.setHeader).toHaveBeenCalledWith('Content-Length', '0');
@@ -247,7 +217,10 @@ describe('OAuthProvider', () => {
});
it('sets the refresh cookie if refresh is enabled', async () => {
const oauthProvider = new OAuthProvider(providerInstance, providerId);
const oauthProvider = new OAuthProvider(providerInstance, {
...oAuthProviderOptions,
disableRefresh: false,
});
const mockRequest = ({
cookies: {
@@ -269,12 +242,18 @@ describe('OAuthProvider', () => {
expect(mockResponse.cookie).toHaveBeenCalledWith(
expect.stringContaining('test-provider-refresh-token'),
expect.stringContaining('token'),
expect.objectContaining({ path: '/auth/test-provider' }),
expect.objectContaining({
path: '/auth/test-provider',
maxAge: THOUSAND_DAYS_MS,
}),
);
});
it('does no set the refresh cookie if refresh is disabled', async () => {
const oauthProvider = new OAuthProvider(providerInstance, providerId, true);
it('does not set the refresh cookie if refresh is disabled', async () => {
const oauthProvider = new OAuthProvider(providerInstance, {
...oAuthProviderOptions,
disableRefresh: true,
});
const mockRequest = ({
cookies: {
@@ -296,7 +275,10 @@ describe('OAuthProvider', () => {
});
it('removes refresh cookie when logging out', async () => {
const oauthProvider = new OAuthProvider(providerInstance, providerId);
const oauthProvider = new OAuthProvider(providerInstance, {
...oAuthProviderOptions,
disableRefresh: false,
});
const mockRequest = ({
header: () => 'XMLHttpRequest',
@@ -317,7 +299,11 @@ describe('OAuthProvider', () => {
});
it('gets new access-token when refreshing', async () => {
const oauthProvider = new OAuthProvider(providerInstance, providerId);
oAuthProviderOptions.disableRefresh = false;
const oauthProvider = new OAuthProvider(providerInstance, {
...oAuthProviderOptions,
disableRefresh: false,
});
const mockRequest = ({
header: () => 'XMLHttpRequest',
@@ -341,7 +327,10 @@ describe('OAuthProvider', () => {
});
it('handles refresh without capabilities', async () => {
const oauthProvider = new OAuthProvider(providerInstance, providerId, true);
const oauthProvider = new OAuthProvider(providerInstance, {
...oAuthProviderOptions,
disableRefresh: true,
});
const mockRequest = ({
header: () => 'XMLHttpRequest',
@@ -14,20 +14,29 @@
* limitations under the License.
*/
import express, { CookieOptions } from 'express';
import express from 'express';
import crypto from 'crypto';
import { URL } from 'url';
import {
AuthResponse,
AuthProviderRouteHandlers,
OAuthProviderHandlers,
} from './types';
} from '../providers/types';
import { InputError } from '@backstage/backend-common';
export const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000;
export const TEN_MINUTES_MS = 600 * 1000;
export const verifyNonce = (req: express.Request, provider: string) => {
const cookieNonce = req.cookies[`${provider}-nonce`];
export type Options = {
providerId: string;
secure: boolean;
disableRefresh?: boolean;
baseUrl: string;
appOrigin: string;
};
export const verifyNonce = (req: express.Request, providerId: string) => {
const cookieNonce = req.cookies[`${providerId}-nonce`];
const stateNonce = req.query.state;
if (!cookieNonce || !stateNonce) {
@@ -39,58 +48,9 @@ export const verifyNonce = (req: express.Request, provider: string) => {
}
};
export const setNonceCookie = (res: express.Response, provider: string) => {
const nonce = crypto.randomBytes(16).toString('base64');
const options: CookieOptions = {
maxAge: TEN_MINUTES_MS,
secure: false,
sameSite: 'none',
domain: 'localhost',
path: `/auth/${provider}/handler`,
httpOnly: true,
};
res.cookie(`${provider}-nonce`, nonce, options);
return nonce;
};
export const setRefreshTokenCookie = (
res: express.Response,
provider: string,
refreshToken: string,
) => {
const options: CookieOptions = {
maxAge: THOUSAND_DAYS_MS,
secure: false,
sameSite: 'none',
domain: 'localhost',
path: `/auth/${provider}`,
httpOnly: true,
};
res.cookie(`${provider}-refresh-token`, refreshToken, options);
};
export const removeRefreshTokenCookie = (
res: express.Response,
provider: string,
) => {
const options: CookieOptions = {
maxAge: 0,
secure: false,
sameSite: 'none',
domain: 'localhost',
path: `/auth/${provider}`,
httpOnly: true,
};
res.cookie(`${provider}-refresh-token`, '', options);
};
export const postMessageResponse = (
res: express.Response,
appOrigin: string,
data: AuthResponse,
) => {
const jsonData = JSON.stringify(data);
@@ -104,7 +64,7 @@ export const postMessageResponse = (
<html>
<body>
<script>
(window.opener || window.parent).postMessage(JSON.parse(atob('${base64Data}')), 'http://localhost:3000')
(window.opener || window.parent).postMessage(JSON.parse(atob('${base64Data}')), '${appOrigin}')
window.close()
</script>
</body>
@@ -122,17 +82,16 @@ export const ensuresXRequestedWith = (req: express.Request) => {
};
export class OAuthProvider implements AuthProviderRouteHandlers {
private readonly provider: string;
private readonly providerHandlers: OAuthProviderHandlers;
private readonly disableRefresh: boolean;
private readonly domain: string;
private readonly basePath: string;
constructor(
providerHandlers: OAuthProviderHandlers,
provider: string,
disableRefresh?: boolean,
private readonly providerHandlers: OAuthProviderHandlers,
private readonly options: Options,
) {
this.provider = provider;
this.providerHandlers = providerHandlers;
this.disableRefresh = disableRefresh ?? false;
const url = new URL(options.baseUrl);
this.domain = url.hostname;
this.basePath = url.pathname;
}
async start(req: express.Request, res: express.Response): Promise<any> {
@@ -143,8 +102,9 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
throw new InputError('missing scope parameter');
}
const nonce = crypto.randomBytes(16).toString('base64');
// set a nonce cookie before redirecting to oauth provider
const nonce = setNonceCookie(res, this.provider);
this.setNonceCookie(res, nonce);
const options = {
scope,
@@ -152,6 +112,7 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
prompt: 'consent',
state: nonce,
};
const { url, status } = await this.providerHandlers.start(req, options);
res.statusCode = status || 302;
@@ -166,11 +127,11 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
): Promise<any> {
try {
// verify nonce cookie and state cookie on callback
verifyNonce(req, this.provider);
verifyNonce(req, this.options.providerId);
const { user, info } = await this.providerHandlers.handler(req);
if (!this.disableRefresh) {
if (!this.options.disableRefresh) {
// throw error if missing refresh token
const { refreshToken } = info;
if (!refreshToken) {
@@ -178,17 +139,17 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
}
// set new refresh token
setRefreshTokenCookie(res, this.provider, refreshToken);
this.setRefreshTokenCookie(res, refreshToken);
}
// post message back to popup if successful
return postMessageResponse(res, {
return postMessageResponse(res, this.options.appOrigin, {
type: 'auth-result',
payload: user,
});
} catch (error) {
// post error message back to popup if failure
return postMessageResponse(res, {
return postMessageResponse(res, this.options.appOrigin, {
type: 'auth-result',
error: {
name: error.name,
@@ -203,9 +164,9 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
return res.status(401).send('Invalid X-Requested-With header');
}
if (!this.disableRefresh) {
if (!this.options.disableRefresh) {
// remove refresh token cookie before logout
removeRefreshTokenCookie(res, this.provider);
this.removeRefreshTokenCookie(res);
}
return res.send('logout!');
}
@@ -215,14 +176,15 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
return res.status(401).send('Invalid X-Requested-With header');
}
if (!this.providerHandlers.refresh || this.disableRefresh) {
if (!this.providerHandlers.refresh || this.options.disableRefresh) {
return res.send(
`Refresh token not supported for provider: ${this.provider}`,
`Refresh token not supported for provider: ${this.options.providerId}`,
);
}
try {
const refreshToken = req.cookies[`${this.provider}-refresh-token`];
const refreshToken =
req.cookies[`${this.options.providerId}-refresh-token`];
// throw error if refresh token is missing in the request
if (!refreshToken) {
@@ -241,4 +203,40 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
return res.status(401).send(`${error.message}`);
}
}
private setNonceCookie = (res: express.Response, nonce: string) => {
res.cookie(`${this.options.providerId}-nonce`, nonce, {
maxAge: TEN_MINUTES_MS,
secure: this.options.secure,
sameSite: 'none',
domain: this.domain,
path: `${this.basePath}/${this.options.providerId}/handler`,
httpOnly: true,
});
};
private setRefreshTokenCookie = (
res: express.Response,
refreshToken: string,
) => {
res.cookie(`${this.options.providerId}-refresh-token`, refreshToken, {
maxAge: THOUSAND_DAYS_MS,
secure: this.options.secure,
sameSite: 'none',
domain: this.domain,
path: `${this.basePath}/${this.options.providerId}`,
httpOnly: true,
});
};
private removeRefreshTokenCookie = (res: express.Response) => {
res.cookie(`${this.options.providerId}-refresh-token`, '', {
maxAge: 0,
secure: false,
sameSite: 'none',
domain: `${this.domain}`,
path: `${this.basePath}/${this.options.providerId}`,
httpOnly: true,
});
};
}
@@ -17,7 +17,11 @@
import express from 'express';
import passport from 'passport';
import jwtDecoder from 'jwt-decode';
import { RedirectInfo, RefreshTokenResponse, ProfileInfo } from './types';
import {
RedirectInfo,
RefreshTokenResponse,
ProfileInfo,
} from '../providers/types';
export const makeProfileInfo = (
profile: passport.Profile,
@@ -1,43 +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.
*/
export const providers = [
{
provider: 'google',
options: {
clientID: process.env.AUTH_GOOGLE_CLIENT_ID!,
clientSecret: process.env.AUTH_GOOGLE_CLIENT_SECRET!,
callbackURL: 'http://localhost:7000/auth/google/handler/frame',
},
},
{
provider: 'github',
options: {
clientID: process.env.AUTH_GITHUB_CLIENT_ID!,
clientSecret: process.env.AUTH_GITHUB_CLIENT_SECRET!,
callbackURL: 'http://localhost:7000/auth/github/handler/frame',
},
disableRefresh: true,
},
{
provider: 'saml',
options: {
path: '/auth/saml/handler/frame',
entryPoint: 'http://localhost:7001/',
issuer: 'passport-saml',
},
},
];
@@ -19,6 +19,7 @@ import { createGithubProvider } from './github';
import { createGoogleProvider } from './google';
import { createSamlProvider } from './saml';
import { AuthProviderFactory, AuthProviderConfig } from './types';
import { Logger } from 'winston';
const factories: { [providerId: string]: AuthProviderFactory } = {
google: createGoogleProvider,
@@ -26,17 +27,18 @@ const factories: { [providerId: string]: AuthProviderFactory } = {
saml: createSamlProvider,
};
export function createAuthProvider(providerId: string, config: any) {
export const createAuthProviderRouter = (
providerId: string,
globalConfig: AuthProviderConfig,
providerConfig: any, // TODO: make this a config reader object of sorts
logger: Logger,
) => {
const factory = factories[providerId];
if (!factory) {
throw Error(`No auth provider available for '${providerId}'`);
}
return factory(config);
}
export const createAuthProviderRouter = (config: AuthProviderConfig) => {
const providerId = config.provider;
const provider = createAuthProvider(providerId, config);
const provider = factory(globalConfig, providerConfig, logger);
const router = Router();
router.get('/start', provider.start.bind(provider));
@@ -46,5 +48,6 @@ export const createAuthProviderRouter = (config: AuthProviderConfig) => {
if (provider.refresh) {
router.get('/refresh', provider.refresh.bind(provider));
}
return router;
};
@@ -19,24 +19,30 @@ import { Strategy as GithubStrategy } from 'passport-github2';
import {
executeFrameHandlerStrategy,
executeRedirectStrategy,
} from '../PassportStrategyHelper';
} from '../../lib/PassportStrategyHelper';
import {
OAuthProviderHandlers,
AuthProviderConfig,
RedirectInfo,
AuthInfoBase,
AuthInfoPrivate,
EnvironmentProviderConfig,
OAuthProviderOptions,
OAuthProviderConfig,
} from '../types';
import { OAuthProvider } from '../OAuthProvider';
import { OAuthProvider } from '../../lib/OAuthProvider';
import {
EnvironmentHandlers,
EnvironmentHandler,
} from '../../lib/EnvironmentHandler';
import { Logger } from 'winston';
export class GithubAuthProvider implements OAuthProviderHandlers {
private readonly providerConfig: AuthProviderConfig;
private readonly _strategy: GithubStrategy;
constructor(providerConfig: AuthProviderConfig) {
this.providerConfig = providerConfig;
constructor(options: OAuthProviderOptions) {
this._strategy = new GithubStrategy(
{ ...this.providerConfig.options },
{ ...options },
(accessToken: any, _: any, params: any, profile: any, done: any) => {
done(undefined, {
profile,
@@ -59,8 +65,42 @@ export class GithubAuthProvider implements OAuthProviderHandlers {
}
}
export function createGithubProvider(config: AuthProviderConfig) {
const provider = new GithubAuthProvider(config);
const oauthProvider = new OAuthProvider(provider, config.provider, true);
return oauthProvider;
export function createGithubProvider(
{ baseUrl }: AuthProviderConfig,
providerConfig: EnvironmentProviderConfig,
logger: Logger,
) {
const envProviders: EnvironmentHandlers = {};
for (const [env, envConfig] of Object.entries(providerConfig)) {
const config = (envConfig as unknown) as OAuthProviderConfig;
const { secure, appOrigin } = config;
const callbackURLParam = `?env=${env}`;
const opts = {
clientID: config.clientId,
clientSecret: config.clientSecret,
callbackURL: `${baseUrl}/github/handler/frame${callbackURLParam}`,
};
if (!opts.clientID || !opts.clientSecret) {
if (process.env.NODE_ENV !== 'development') {
throw new Error(
'Failed to initialize Github auth provider, set AUTH_GITHUB_CLIENT_ID and AUTH_GITHUB_CLIENT_SECRET env vars',
);
}
logger.warn(
'Github auth provider disabled, set AUTH_GITHUB_CLIENT_ID and AUTH_GITHUB_CLIENT_SECRET env vars to enable',
);
continue;
}
envProviders[env] = new OAuthProvider(new GithubAuthProvider(opts), {
providerId: 'github',
secure,
baseUrl,
appOrigin,
});
}
return new EnvironmentHandler(envProviders);
}
@@ -22,7 +22,7 @@ import {
executeRefreshTokenStrategy,
makeProfileInfo,
executeFetchUserProfileStrategy,
} from '../PassportStrategyHelper';
} from '../../lib/PassportStrategyHelper';
import {
OAuthProviderHandlers,
AuthInfoBase,
@@ -30,19 +30,27 @@ import {
RedirectInfo,
AuthProviderConfig,
AuthInfoWithProfile,
EnvironmentProviderConfig,
OAuthProviderOptions,
OAuthProviderConfig,
} from '../types';
import { OAuthProvider } from '../OAuthProvider';
import { OAuthProvider } from '../../lib/OAuthProvider';
import passport from 'passport';
import {
EnvironmentHandler,
EnvironmentHandlers,
} from '../../lib/EnvironmentHandler';
import { Logger } from 'winston';
export class GoogleAuthProvider implements OAuthProviderHandlers {
private readonly providerConfig: AuthProviderConfig;
private readonly _strategy: GoogleStrategy;
constructor(providerConfig: AuthProviderConfig) {
this.providerConfig = providerConfig;
constructor(options: OAuthProviderOptions) {
// TODO: throw error if env variables not set?
this._strategy = new GoogleStrategy(
{ ...this.providerConfig.options },
// We need passReqToCallback set to false to get params, but there's
// no matching type signature for that, so instead behold this beauty
{ ...options, passReqToCallback: false as true },
(
accessToken: any,
refreshToken: any,
@@ -104,8 +112,42 @@ export class GoogleAuthProvider implements OAuthProviderHandlers {
}
}
export function createGoogleProvider(config: AuthProviderConfig) {
const provider = new GoogleAuthProvider(config);
const oauthProvider = new OAuthProvider(provider, config.provider);
return oauthProvider;
export function createGoogleProvider(
{ baseUrl }: AuthProviderConfig,
providerConfig: EnvironmentProviderConfig,
logger: Logger,
) {
const envProviders: EnvironmentHandlers = {};
for (const [env, envConfig] of Object.entries(providerConfig)) {
const config = (envConfig as unknown) as OAuthProviderConfig;
const { secure, appOrigin } = config;
const callbackURLParam = `?env=${env}`;
const opts = {
clientID: config.clientId,
clientSecret: config.clientSecret,
callbackURL: `${baseUrl}/google/handler/frame${callbackURLParam}`,
};
if (!opts.clientID || !opts.clientSecret) {
if (process.env.NODE_ENV !== 'development') {
throw new Error(
'Failed to initialize Google auth provider, set AUTH_GOOGLE_CLIENT_ID and AUTH_GOOGLE_CLIENT_SECRET env vars',
);
}
logger.warn(
'Google auth provider disabled, set AUTH_GOOGLE_CLIENT_ID and AUTH_GOOGLE_CLIENT_SECRET env vars to enable',
);
continue;
}
envProviders[env] = new OAuthProvider(new GoogleAuthProvider(opts), {
providerId: 'google',
secure,
baseUrl,
appOrigin,
});
}
return new EnvironmentHandler(envProviders);
}
@@ -19,16 +19,26 @@ import { Strategy as SamlStrategy } from 'passport-saml';
import {
executeFrameHandlerStrategy,
executeRedirectStrategy,
} from '../PassportStrategyHelper';
import { AuthProviderConfig, AuthProviderRouteHandlers } from '../types';
import { postMessageResponse } from '../OAuthProvider';
} from '../../lib/PassportStrategyHelper';
import {
AuthProviderConfig,
AuthProviderRouteHandlers,
EnvironmentProviderConfig,
SAMLProviderConfig,
} from '../types';
import { postMessageResponse } from '../../lib/OAuthProvider';
import {
EnvironmentHandlers,
EnvironmentHandler,
} from '../../lib/EnvironmentHandler';
import { Logger } from 'winston';
export class SamlAuthProvider implements AuthProviderRouteHandlers {
private readonly strategy: SamlStrategy;
constructor(providerConfig: AuthProviderConfig) {
constructor(options: SAMLProviderOptions) {
this.strategy = new SamlStrategy(
{ ...providerConfig.options },
{ ...options },
(profile: any, done: any) => {
// TODO: There's plenty more validation and profile handling to do here,
// this provider is currently only intended to validate the provider pattern
@@ -57,12 +67,12 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers {
try {
const { user } = await executeFrameHandlerStrategy(req, this.strategy);
return postMessageResponse(res, {
return postMessageResponse(res, 'http://localhost:3000', {
type: 'auth-result',
payload: user,
});
} catch (error) {
return postMessageResponse(res, {
return postMessageResponse(res, 'http://localhost:3000', {
type: 'auth-result',
error: {
name: error.name,
@@ -77,6 +87,36 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers {
}
}
export function createSamlProvider(config: AuthProviderConfig) {
return new SamlAuthProvider(config);
type SAMLProviderOptions = {
entryPoint: string;
issuer: string;
path: string;
};
export function createSamlProvider(
_authProviderConfig: AuthProviderConfig,
providerConfig: EnvironmentProviderConfig,
logger: Logger,
) {
const envProviders: EnvironmentHandlers = {};
for (const [env, envConfig] of Object.entries(providerConfig)) {
const config = (envConfig as unknown) as SAMLProviderConfig;
const opts = {
entryPoint: config.entryPoint,
issuer: config.issuer,
path: '/auth/saml/handler/frame',
};
if (!opts.entryPoint || !opts.issuer) {
logger.warn(
'SAML auth provider disabled, set entryPoint and entryPoint in saml auth config to enable',
);
continue;
}
envProviders[env] = new SamlAuthProvider(opts);
}
return new EnvironmentHandler(envProviders);
}
+31 -4
View File
@@ -15,11 +15,32 @@
*/
import express from 'express';
import { Logger } from 'winston';
export type OAuthProviderOptions = {
clientID: string;
clientSecret: string;
callbackURL: string;
};
export type SAMLProviderConfig = {
entryPoint: string;
issuer: string;
};
export type EnvironmentProviderConfig = {
[key: string]: OAuthProviderConfig | SAMLProviderConfig;
};
export type AuthProviderConfig = {
provider: string;
options: any;
disableRefresh?: boolean;
baseUrl: string;
};
export type OAuthProviderConfig = {
secure: boolean;
appOrigin: string; // http://localhost:3000
clientId: string;
clientSecret: string;
};
export interface OAuthProviderHandlers {
@@ -36,8 +57,14 @@ export interface AuthProviderRouteHandlers {
logout(req: express.Request, res: express.Response): Promise<any>;
}
export type SAMLEnvironmentProviderConfig = {
[key: string]: SAMLProviderConfig;
};
export type AuthProviderFactory = (
config: AuthProviderConfig,
globalConfig: AuthProviderConfig,
providerConfig: EnvironmentProviderConfig,
logger: Logger,
) => AuthProviderRouteHandlers;
export type AuthInfoBase = {
+55 -8
View File
@@ -19,7 +19,6 @@ import Router from 'express-promise-router';
import cookieParser from 'cookie-parser';
import bodyParser from 'body-parser';
import { Logger } from 'winston';
import { providers } from './../providers/config';
import { createAuthProviderRouter } from '../providers';
export interface RouterOptions {
@@ -36,13 +35,61 @@ export async function createRouter(
router.use(bodyParser.urlencoded({ extended: false }));
router.use(bodyParser.json());
// configure all the providers
for (const providerConfig of providers) {
const { provider } = providerConfig;
const providerRouter = createAuthProviderRouter(providerConfig);
logger.info(`Configuring provider, ${provider}`);
router.use(`/${provider}`, providerRouter);
}
// TODO: read from app config
const config = {
backend: {
baseUrl: 'http://localhost:7000',
},
auth: {
providers: {
google: {
development: {
appOrigin: 'http://localhost:3000',
secure: false,
clientId: process.env.AUTH_GOOGLE_CLIENT_ID!,
clientSecret: process.env.AUTH_GOOGLE_CLIENT_SECRET!,
},
production: {
appOrigin: 'http://localhost:3000',
secure: false,
clientId: '',
clientSecret: '',
},
},
github: {
development: {
appOrigin: 'http://localhost:3000',
secure: false,
clientId: process.env.AUTH_GITHUB_CLIENT_ID!,
clientSecret: process.env.AUTH_GITHUB_CLIENT_SECRET!,
},
},
saml: {
development: {
entryPoint: 'http://localhost:7001/',
issuer: 'passport-saml',
},
},
},
},
};
const providerConfigs = config.auth.providers;
for (const [providerId, providerConfig] of Object.entries(providerConfigs)) {
const baseUrl = `${config.backend.baseUrl}/auth`;
logger.info(`Configuring provider, ${providerId}`);
try {
const providerRouter = createAuthProviderRouter(
providerId,
{ baseUrl },
providerConfig,
logger,
);
router.use(`/${providerId}`, providerRouter);
} catch (e) {
logger.error(e.message);
}
}
return router;
}
+25 -4
View File
@@ -1,11 +1,32 @@
# Catalog Backend
WORK IN PROGRESS
This is the backend part of the default catalog plugin.
It responds to requests from the frontend part, and fulfills them by delegating
to your existing catalog related services.
It comes with a builtin database backed implementation of the catalog, that can store
and serve your catalog for you.
It can also act as a bridge to your existing catalog solutions, either ingesting their
data to store in the database, or by effectively proxying calls to an external catalog
service.
## Getting Started
This backend plugin can be started in a standalone mode from directly in this package
with `yarn start`. However, it will have limited functionality and that process is
most convenient when developing the catalog backend plugin itself.
To evaluate the catalog and have a greater amount of functionality available, instead do
```bash
# in one terminal window, run this from from the very root of the Backstage project
cd packages/backend
yarn start
# open another terminal window, and run the following from the very root of the Backstage project
yarn lerna run mock-catalog-data
```
This will launch the full example backend and populate its catalog with some mock entities.
## Links
@@ -0,0 +1,60 @@
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: podcast-api
description: Podcast API
spec:
type: service
lifecycle: experimental
owner: tools@example.com
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: artist-lookup
description: Artist Lookup
spec:
type: service
lifecycle: experimental
owner: tools@example.com
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: searcher
description: Searcher
spec:
type: service
lifecycle: production
owner: tools@example.com
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: playback-order
description: Playback Order
spec:
type: service
lifecycle: production
owner: tools@example.com
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: shuffle-api
description: Shuffle API
spec:
type: service
lifecycle: production
owner: tools@example.com
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: queue-proxy
description: Queue Proxy
spec:
type: website
lifecycle: production
owner: tools@example.com
@@ -0,0 +1,8 @@
---
apiVersion: backstage.io/v1beta1
kind: Location
metadata:
name: location-1
spec:
type: github
target: https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/examples/example-components.yaml
@@ -1,6 +0,0 @@
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: component3
spec:
type: service
@@ -1,14 +0,0 @@
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: playlist-proxy
spec:
type: service
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: artist-web
spec:
type: website
@@ -14,9 +14,12 @@
* limitations under the License.
*/
import * as Knex from 'knex';
// @ts-check
export async function up(knex: Knex): Promise<any> {
/**
* @param {import('knex')} knex
*/
exports.up = async function up(knex) {
return (
knex.schema
//
@@ -114,9 +117,12 @@ export async function up(knex: Knex): Promise<any> {
.comment('The corresponding value to match on');
})
);
}
};
export async function down(knex: Knex): Promise<any> {
/**
* @param {import('knex')} knex
*/
exports.down = async function down(knex) {
return knex.schema
.dropTable('entities_search')
.alterTable('entities', table => {
@@ -124,4 +130,4 @@ export async function down(knex: Knex): Promise<any> {
})
.dropTable('entities')
.dropTable('locations');
}
};
@@ -13,16 +13,17 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as Knex from 'knex';
export async function up(knex: Knex): Promise<any> {
// @ts-check
/**
* @param {import('knex')} knex
*/
exports.up = async function up(knex) {
return knex.schema.createTable('location_update_log', table => {
table.uuid('id').primary();
table.enum('status', ['success', 'fail']).notNullable();
table
.dateTime('created_at')
.defaultTo(knex.fn.now())
.notNullable();
table.dateTime('created_at').defaultTo(knex.fn.now()).notNullable();
table.string('message');
table
.uuid('location_id')
@@ -32,8 +33,11 @@ export async function up(knex: Knex): Promise<any> {
.onDelete('CASCADE');
table.string('entity_name').nullable();
});
}
};
export async function down(knex: Knex): Promise<any> {
/**
* @param {import('knex')} knex
*/
exports.down = async function down(knex) {
return knex.schema.dropTableIfExists('location_update_log');
}
};
@@ -13,9 +13,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as Knex from 'knex';
export async function up(knex: Knex): Promise<any> {
// @ts-check
/**
* @param {import('knex')} knex
*/
exports.up = async function up(knex) {
// Need to first order by date of creation
const query = knex
.select()
@@ -28,8 +32,11 @@ export async function up(knex: Knex): Promise<any> {
await knex.schema.raw(
`CREATE VIEW location_update_log_latest AS ${groupedQuery.toString()};`,
);
}
};
export async function down(knex: Knex): Promise<any> {
/**
* @param {import('knex')} knex
*/
exports.down = async function down(knex) {
return knex.schema.raw(`DROP VIEW location_update_log_latest;`);
}
};
+7 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-backend",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -13,11 +13,11 @@
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean",
"mock-data": "./scripts/mock-data"
"mock-catalog-data": "./scripts/mock-data"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.7",
"@backstage/catalog-model": "^0.1.1-alpha.7",
"@backstage/backend-common": "^0.1.1-alpha.8",
"@backstage/catalog-model": "^0.1.1-alpha.8",
"esm": "^3.2.25",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
@@ -34,7 +34,7 @@
"yup": "^0.28.5"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@types/lodash": "^4.14.151",
"@types/node-fetch": "^2.5.7",
"@types/supertest": "^2.0.8",
@@ -45,7 +45,8 @@
"tsc-watch": "^4.2.3"
},
"files": [
"dist"
"dist",
"migrations"
],
"nodemonConfig": {
"watch": "./dist"
+1 -1
View File
@@ -5,5 +5,5 @@ curl \
--header 'Content-Type: application/json' \
--data-raw '{
"type": "github",
"target": "https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/fixtures/two_components.yaml"
"target": "https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/examples/example-components.yaml"
}'
@@ -28,6 +28,7 @@ describe('DatabaseEntitiesCatalog', () => {
updateEntity: jest.fn(),
entities: jest.fn(),
entity: jest.fn(),
entityByUid: jest.fn(),
removeEntity: jest.fn(),
addLocation: jest.fn(),
removeLocation: jest.fn(),
@@ -15,6 +15,9 @@
*/
import type { Entity } from '@backstage/catalog-model';
import { LOCATION_ANNOTATION } from '@backstage/catalog-model';
import { NotFoundError } from '@backstage/backend-common';
import type { Database, DbEntityResponse, EntityFilters } from '../database';
import type { EntitiesCatalog } from './types';
@@ -78,7 +81,28 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
async removeEntityByUid(uid: string): Promise<void> {
return await this.database.transaction(async tx => {
await this.database.removeEntity(tx, uid);
const entityResponse = await this.database.entityByUid(tx, uid);
if (!entityResponse) {
throw new NotFoundError(`Entity with ID ${uid} was not found`);
}
const location =
entityResponse.entity.metadata.annotations?.[LOCATION_ANNOTATION];
const colocatedEntities = location
? await this.database.entities(tx, [
{
key: LOCATION_ANNOTATION,
values: [location],
},
])
: [entityResponse];
for (const dbResponse of colocatedEntities) {
await this.database.removeEntity(tx, dbResponse?.entity.metadata.uid!);
}
if (entityResponse.locationId) {
await this.database.removeLocation(tx, entityResponse?.locationId!);
}
return undefined;
});
}
@@ -14,29 +14,14 @@
* limitations under the License.
*/
import { getVoidLogger } from '@backstage/backend-common';
import Knex from 'knex';
import path from 'path';
import { CommonDatabase } from '../database';
import { DatabaseManager } from '../database';
import { DatabaseLocationsCatalog } from './DatabaseLocationsCatalog';
describe('DatabaseLocationsCatalog', () => {
let catalog: DatabaseLocationsCatalog;
beforeEach(async () => {
const knex = Knex({
client: 'sqlite3',
connection: ':memory:',
useNullAsDefault: true,
});
knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
resource.run('PRAGMA foreign_keys = ON', () => {});
});
await knex.migrate.latest({
directory: path.resolve(__dirname, '../database/migrations'),
loadExtensions: ['.ts'],
});
const db = new CommonDatabase(knex, getVoidLogger());
const db = await DatabaseManager.createTestDatabase();
catalog = new DatabaseLocationsCatalog(db);
});
@@ -31,7 +31,7 @@ export class DatabaseLocationsCatalog implements LocationsCatalog {
}
async removeLocation(id: string): Promise<void> {
await this.database.removeLocation(id);
await this.database.transaction(tx => this.database.removeLocation(tx, id));
}
async locations(): Promise<LocationResponse[]> {
@@ -14,16 +14,10 @@
* limitations under the License.
*/
import {
ConflictError,
getVoidLogger,
NotFoundError,
} from '@backstage/backend-common';
import { ConflictError, NotFoundError } from '@backstage/backend-common';
import type { Entity, Location } from '@backstage/catalog-model';
import Knex from 'knex';
import path from 'path';
import { CommonDatabase } from './CommonDatabase';
import { DatabaseLocationUpdateLogStatus } from './types';
import { DatabaseManager } from './DatabaseManager';
import { Database, DatabaseLocationUpdateLogStatus } from './types';
import type {
DbEntityRequest,
DbEntityResponse,
@@ -31,22 +25,12 @@ import type {
} from './types';
describe('CommonDatabase', () => {
let knex: Knex;
let db: Database;
let entityRequest: DbEntityRequest;
let entityResponse: DbEntityResponse;
beforeEach(async () => {
knex = Knex({
client: 'sqlite3',
connection: ':memory:',
useNullAsDefault: true,
});
await knex.raw('PRAGMA foreign_keys = ON');
await knex.migrate.latest({
directory: path.resolve(__dirname, 'migrations'),
loadExtensions: ['.ts'],
});
db = await DatabaseManager.createTestDatabase();
entityRequest = {
entity: {
@@ -84,7 +68,6 @@ describe('CommonDatabase', () => {
});
it('manages locations', async () => {
const db = new CommonDatabase(knex, getVoidLogger());
const input: Location = {
id: 'dd12620d-0436-422f-93bd-929aa0788123',
type: 'a',
@@ -105,8 +88,7 @@ describe('CommonDatabase', () => {
expect(locations).toEqual([output]);
const location = await db.location(locations[0].id);
expect(location).toEqual(output);
await db.removeLocation(locations[0].id);
await db.transaction(tx => db.removeLocation(tx, locations[0].id));
await expect(db.locations()).resolves.toEqual([]);
await expect(db.location(locations[0].id)).rejects.toThrow(
@@ -116,55 +98,76 @@ describe('CommonDatabase', () => {
describe('addEntity', () => {
it('happy path: adds entity to empty database', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const added = await catalog.transaction(tx =>
catalog.addEntity(tx, entityRequest),
);
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
expect(added).toStrictEqual(entityResponse);
expect(added.entity.metadata.generation).toBe(1);
});
it('rejects adding the same-named entity twice', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
await catalog.transaction(tx => catalog.addEntity(tx, entityRequest));
await db.transaction(tx => db.addEntity(tx, entityRequest));
await expect(
catalog.transaction(tx => catalog.addEntity(tx, entityRequest)),
db.transaction(tx => db.addEntity(tx, entityRequest)),
).rejects.toThrow(ConflictError);
});
it('rejects adding the almost-same-kind entity twice', async () => {
entityRequest.entity.kind = 'some-kind';
await db.transaction(tx => db.addEntity(tx, entityRequest));
entityRequest.entity.kind = 'SomeKind';
await expect(
db.transaction(tx => db.addEntity(tx, entityRequest)),
).rejects.toThrow(ConflictError);
});
it('rejects adding the almost-same-named entity twice', async () => {
entityRequest.entity.metadata.name = 'some-name';
await db.transaction(tx => db.addEntity(tx, entityRequest));
entityRequest.entity.metadata.name = 'SomeName';
await expect(
db.transaction(tx => db.addEntity(tx, entityRequest)),
).rejects.toThrow(ConflictError);
});
it('rejects adding the almost-same-namespace entity twice', async () => {
entityRequest.entity.metadata.namespace = undefined;
await db.transaction(tx => db.addEntity(tx, entityRequest));
entityRequest.entity.metadata.namespace = '';
await expect(
db.transaction(tx => db.addEntity(tx, entityRequest)),
).rejects.toThrow(ConflictError);
});
it('accepts adding the same-named entity twice if on different namespaces', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
entityRequest.entity.metadata.namespace = 'namespace1';
await catalog.transaction(tx => catalog.addEntity(tx, entityRequest));
await db.transaction(tx => db.addEntity(tx, entityRequest));
entityRequest.entity.metadata.namespace = 'namespace2';
await expect(
catalog.transaction(tx => catalog.addEntity(tx, entityRequest)),
db.transaction(tx => db.addEntity(tx, entityRequest)),
).resolves.toBeDefined();
});
});
describe('locationHistory', () => {
it('outputs the history correctly', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const location: Location = {
id: 'dd12620d-0436-422f-93bd-929aa0788123',
type: 'a',
target: 'b',
};
await catalog.addLocation(location);
await db.addLocation(location);
await catalog.addLocationUpdateLogEvent(
await db.addLocationUpdateLogEvent(
'dd12620d-0436-422f-93bd-929aa0788123',
DatabaseLocationUpdateLogStatus.SUCCESS,
);
await catalog.addLocationUpdateLogEvent(
await db.addLocationUpdateLogEvent(
'dd12620d-0436-422f-93bd-929aa0788123',
DatabaseLocationUpdateLogStatus.FAIL,
undefined,
'Something went wrong',
);
const result = await catalog.locationHistory(
const result = await db.locationHistory(
'dd12620d-0436-422f-93bd-929aa0788123',
);
expect(result).toEqual([
@@ -190,12 +193,9 @@ describe('CommonDatabase', () => {
describe('updateEntity', () => {
it('can read and no-op-update an entity', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const added = await catalog.transaction(tx =>
catalog.addEntity(tx, entityRequest),
);
const updated = await catalog.transaction(tx =>
catalog.updateEntity(tx, { entity: added.entity }),
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
const updated = await db.transaction(tx =>
db.updateEntity(tx, { entity: added.entity }),
);
expect(updated.entity.apiVersion).toEqual(added.entity.apiVersion);
expect(updated.entity.kind).toEqual(added.entity.kind);
@@ -212,77 +212,55 @@ describe('CommonDatabase', () => {
});
it('can update name if uid matches', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const added = await catalog.transaction(tx =>
catalog.addEntity(tx, entityRequest),
);
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
added.entity.metadata.name! = 'new!';
const updated = await catalog.transaction(tx =>
catalog.updateEntity(tx, { entity: added.entity }),
const updated = await db.transaction(tx =>
db.updateEntity(tx, { entity: added.entity }),
);
expect(updated.entity.metadata.name).toEqual('new!');
});
it('can update fields if kind, name, and namespace match', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const added = await catalog.transaction(tx =>
catalog.addEntity(tx, entityRequest),
);
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
added.entity.apiVersion = 'something.new';
delete added.entity.metadata.uid;
delete added.entity.metadata.generation;
const updated = await catalog.transaction(tx =>
catalog.updateEntity(tx, { entity: added.entity }),
const updated = await db.transaction(tx =>
db.updateEntity(tx, { entity: added.entity }),
);
expect(updated.entity.apiVersion).toEqual('something.new');
});
it('rejects if kind, name, but not namespace match', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const added = await catalog.transaction(tx =>
catalog.addEntity(tx, entityRequest),
);
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
added.entity.apiVersion = 'something.new';
delete added.entity.metadata.uid;
delete added.entity.metadata.generation;
added.entity.metadata.namespace = 'something.wrong';
await expect(
catalog.transaction(tx =>
catalog.updateEntity(tx, { entity: added.entity }),
),
db.transaction(tx => db.updateEntity(tx, { entity: added.entity })),
).rejects.toThrow(NotFoundError);
});
it('fails to update an entity if etag does not match', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const added = await catalog.transaction(tx =>
catalog.addEntity(tx, entityRequest),
);
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
added.entity.metadata.etag = 'garbage';
await expect(
catalog.transaction(tx =>
catalog.updateEntity(tx, { entity: added.entity }),
),
db.transaction(tx => db.updateEntity(tx, { entity: added.entity })),
).rejects.toThrow(ConflictError);
});
it('fails to update an entity if generation does not match', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const added = await catalog.transaction(tx =>
catalog.addEntity(tx, entityRequest),
);
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
added.entity.metadata.generation! += 100;
await expect(
catalog.transaction(tx =>
catalog.updateEntity(tx, { entity: added.entity }),
),
db.transaction(tx => db.updateEntity(tx, { entity: added.entity })),
).rejects.toThrow(ConflictError);
});
});
describe('entities', () => {
it('can get all entities with empty filters list', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const e1: Entity = {
apiVersion: 'a',
kind: 'k1',
@@ -294,13 +272,11 @@ describe('CommonDatabase', () => {
metadata: { name: 'n' },
spec: { c: null },
};
await catalog.transaction(async tx => {
await catalog.addEntity(tx, { entity: e1 });
await catalog.addEntity(tx, { entity: e2 });
await db.transaction(async tx => {
await db.addEntity(tx, { entity: e1 });
await db.addEntity(tx, { entity: e2 });
});
const result = await catalog.transaction(async tx =>
catalog.entities(tx, []),
);
const result = await db.transaction(async tx => db.entities(tx, []));
expect(result.length).toEqual(2);
expect(result).toEqual(
expect.arrayContaining([
@@ -317,7 +293,6 @@ describe('CommonDatabase', () => {
});
it('can get all specific entities for matching filters (naive case)', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const entities: Entity[] = [
{ apiVersion: 'a', kind: 'k1', metadata: { name: 'n' } },
{
@@ -334,15 +309,15 @@ describe('CommonDatabase', () => {
},
];
await catalog.transaction(async tx => {
await db.transaction(async tx => {
for (const entity of entities) {
await catalog.addEntity(tx, { entity });
await db.addEntity(tx, { entity });
}
});
await expect(
catalog.transaction(async tx =>
catalog.entities(tx, [
db.transaction(async tx =>
db.entities(tx, [
{ key: 'kind', values: ['k2'] },
{ key: 'spec.c', values: ['some'] },
]),
@@ -356,7 +331,6 @@ describe('CommonDatabase', () => {
});
it('can get all specific entities for matching filters with nulls (both missing and literal null value)', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const entities: Entity[] = [
{ apiVersion: 'a', kind: 'k1', metadata: { name: 'n' } },
{
@@ -373,14 +347,14 @@ describe('CommonDatabase', () => {
},
];
await catalog.transaction(async tx => {
await db.transaction(async tx => {
for (const entity of entities) {
await catalog.addEntity(tx, { entity });
await db.addEntity(tx, { entity });
}
});
const rows = await catalog.transaction(async tx =>
catalog.entities(tx, [
const rows = await db.transaction(async tx =>
db.entities(tx, [
{ key: 'apiVersion', values: ['a'] },
{ key: 'spec.c', values: [null, 'some'] },
]),
@@ -43,7 +43,6 @@ function getStrippedMetadata(metadata: EntityMeta): EntityMeta {
delete output.uid;
delete output.etag;
delete output.generation;
return output;
}
@@ -70,7 +69,7 @@ function toEntityRow(
generation: entity.metadata.generation!,
api_version: entity.apiVersion,
kind: entity.kind,
name: entity.metadata.name || null,
name: entity.metadata.name,
namespace: entity.metadata.namespace || null,
metadata: serializeMetadata(entity.metadata),
spec: serializeSpec(entity.spec),
@@ -124,6 +123,7 @@ function generateEtag(): string {
export class CommonDatabase implements Database {
constructor(
private readonly database: Knex,
private readonly normalize: (value: string) => string,
private readonly logger: Logger,
) {}
@@ -158,6 +158,8 @@ export class CommonDatabase implements Database {
throw new InputError('May not specify generation for new entities');
}
await this.ensureNoSimilarNames(tx, request.entity);
const newEntity = lodash.cloneDeep(request.entity);
newEntity.metadata = {
...newEntity.metadata,
@@ -255,6 +257,8 @@ export class CommonDatabase implements Database {
}
}
await this.ensureNoSimilarNames(tx, newEntity);
// Store the updated entity; select on the old etag to ensure that we do
// not lose to another writer
const newRow = toEntityRow(request.locationId, newEntity);
@@ -278,23 +282,50 @@ export class CommonDatabase implements Database {
const tx = txOpaque as Knex.Transaction<any, any>;
let builder = tx<DbEntitiesRow>('entities');
for (const [index, filter] of (filters ?? []).entries()) {
for (const [indexU, filter] of (filters ?? []).entries()) {
const index = Number(indexU);
const key = filter.key.replace('*', '%');
const keyOp = filter.key.includes('*') ? 'like' : '=';
let matchNulls = false;
const matchIn: string[] = [];
const matchLike: string[] = [];
for (const value of filter.values) {
if (!value) {
matchNulls = true;
} else if (value.includes('*')) {
matchLike.push(value.replace('*', '%'));
} else {
matchIn.push(value);
}
}
builder = builder
.leftOuterJoin(`entities_search as t${index}`, function join() {
this.on('entities.id', '=', `t${index}.entity_id`).onIn(
`t${index}.value`,
filter.values.filter(x => x),
);
if (filter.values.some(x => !x)) {
this.orOnNull(`t${index}.value`);
}
.leftOuterJoin(`entities_search as t${index}`, function joins() {
this.on('entities.id', '=', `t${index}.entity_id`);
this.andOn(`t${index}.key`, keyOp, tx.raw('?', [key]));
})
.where(`t${index}.key`, '=', filter.key);
.where(function rules() {
if (matchIn.length) {
this.orWhereIn(`t${index}.value`, matchIn);
}
if (matchLike.length) {
for (const x of matchLike) {
this.orWhere(`t${index}.value`, 'like', tx.raw('?', [x]));
}
}
if (matchNulls) {
this.orWhereNull(`t${index}.value`);
}
});
}
const rows = await builder
.orderBy('namespace', 'name')
.select('entities.*')
.orderBy('kind', 'asc')
.orderBy('namespace', 'asc')
.orderBy('name', 'asc')
.groupBy('id');
return rows.map(row => toEntityResponse(row));
@@ -319,6 +350,21 @@ export class CommonDatabase implements Database {
return toEntityResponse(rows[0]);
}
async entityByUid(
txOpaque: unknown,
id: string,
): Promise<DbEntityResponse | undefined> {
const tx = txOpaque as Knex.Transaction<any, any>;
const rows = await tx<DbEntitiesRow>('entities').where({ id }).select();
if (rows.length !== 1) {
return undefined;
}
return toEntityResponse(rows[0]);
}
async removeEntity(txOpaque: unknown, uid: string): Promise<void> {
const tx = txOpaque as Knex.Transaction<any, any>;
@@ -341,10 +387,14 @@ export class CommonDatabase implements Database {
});
}
async removeLocation(id: string): Promise<void> {
const result = await this.database<DbLocationsRow>('locations')
.where({ id })
.del();
async removeLocation(txOpaque: unknown, id: string): Promise<void> {
const tx = txOpaque as Knex.Transaction<any, any>;
await tx<DbEntitiesRow>('entities')
.where({ location_id: id })
.update({ location_id: null });
const result = await tx<DbLocationsRow>('locations').where({ id }).del();
if (!result) {
throw new NotFoundError(`Found no location with ID ${id}`);
@@ -432,4 +482,46 @@ export class CommonDatabase implements Database {
// we got around to writing the entries
}
}
private async ensureNoSimilarNames(
tx: Knex.Transaction<any, any>,
data: Entity,
): Promise<void> {
const newKind = data.kind;
const newName = data.metadata.name;
const newNamespace = data.metadata.namespace;
const newKindNorm = this.normalize(newKind);
const newNameNorm = this.normalize(newName);
const newNamespaceNorm = this.normalize(newNamespace || '');
for (const item of await this.entities(tx)) {
if (data.metadata.uid === item.entity.metadata.uid) {
continue;
}
const oldKind = item.entity.kind;
const oldName = item.entity.metadata.name;
const oldNamespace = item.entity.metadata.namespace;
const oldKindNorm = this.normalize(oldKind);
const oldNameNorm = this.normalize(oldName);
const oldNamespaceNorm = this.normalize(oldNamespace || '');
if (
oldKindNorm === newKindNorm &&
oldNameNorm === newNameNorm &&
oldNamespaceNorm === newNamespaceNorm
) {
// Only throw if things were actually different - for completely equal
// things, we let the database handle the conflict
if (
oldKind !== newKind ||
oldName !== newName ||
oldNamespace !== newNamespace
) {
const message = `Kind, namespace, name are too similar to an existing entity`;
throw new ConflictError(message);
}
}
}
}
}
@@ -14,26 +14,43 @@
* limitations under the License.
*/
import { getVoidLogger } from '@backstage/backend-common';
import { makeValidator } from '@backstage/catalog-model';
import Knex from 'knex';
import path from 'path';
import { Logger } from 'winston';
import { CommonDatabase } from './CommonDatabase';
import { Database } from './types';
const migrationsDir = path.resolve(
require.resolve('@backstage/plugin-catalog-backend/package.json'),
'../migrations',
);
export type CreateDatabaseOptions = {
logger: Logger;
fieldNormalizer: (value: string) => string;
};
const defaultOptions: CreateDatabaseOptions = {
logger: getVoidLogger(),
fieldNormalizer: makeValidator().normalizeEntityName,
};
export class DatabaseManager {
public static async createDatabase(
knex: Knex,
logger: Logger,
options: Partial<CreateDatabaseOptions> = {},
): Promise<Database> {
await knex.migrate.latest({
directory: path.resolve(__dirname, 'migrations'),
loadExtensions: ['.js'],
directory: migrationsDir,
});
return new CommonDatabase(knex, logger);
const { logger, fieldNormalizer } = { ...defaultOptions, ...options };
return new CommonDatabase(knex, fieldNormalizer, logger);
}
public static async createInMemoryDatabase(
logger: Logger,
options: Partial<CreateDatabaseOptions> = {},
): Promise<Database> {
const knex = Knex({
client: 'sqlite3',
@@ -43,6 +60,22 @@ export class DatabaseManager {
knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
resource.run('PRAGMA foreign_keys = ON', () => {});
});
return DatabaseManager.createDatabase(knex, logger);
return DatabaseManager.createDatabase(knex, options);
}
public static async createTestDatabase(): Promise<Database> {
const knex = Knex({
client: 'sqlite3',
connection: ':memory:',
useNullAsDefault: true,
});
knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
resource.run('PRAGMA foreign_keys = ON', () => {});
});
await knex.migrate.latest({
directory: migrationsDir,
});
const { logger, fieldNormalizer } = defaultOptions;
return new CommonDatabase(knex, fieldNormalizer, logger);
}
}
@@ -130,11 +130,13 @@ export type Database = {
namespace?: string,
): Promise<DbEntityResponse | undefined>;
entityByUid(tx: unknown, uid: string): Promise<DbEntityResponse | undefined>;
removeEntity(tx: unknown, uid: string): Promise<void>;
addLocation(location: Location): Promise<DbLocationsRow>;
removeLocation(id: string): Promise<void>;
removeLocation(tx: unknown, id: string): Promise<void>;
location(id: string): Promise<DbLocationsRowWithStatus>;
@@ -26,6 +26,7 @@ import { AnnotateLocationEntityProcessor } from './processors/AnnotateLocationEn
import { EntityPolicyProcessor } from './processors/EntityPolicyProcessor';
import { FileReaderProcessor } from './processors/FileReaderProcessor';
import { GithubReaderProcessor } from './processors/GithubReaderProcessor';
import { LocationRefProcessor } from './processors/LocationEntityProcessor';
import * as result from './processors/results';
import {
LocationProcessor,
@@ -57,6 +58,7 @@ export class LocationReaders implements LocationReader {
new GithubReaderProcessor(),
new YamlProcessor(),
new EntityPolicyProcessor(entityPolicy),
new LocationRefProcessor(),
new AnnotateLocationEntityProcessor(),
];
}
@@ -0,0 +1,46 @@
/*
* 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, LocationEntity, LocationSpec } from '@backstage/catalog-model';
import * as result from './results';
import { LocationProcessor, LocationProcessorEmit } from './types';
export class LocationRefProcessor implements LocationProcessor {
async processEntity(
entity: Entity,
_location: LocationSpec,
emit: LocationProcessorEmit,
): Promise<Entity> {
if (entity.kind === 'Location') {
const location = entity as LocationEntity;
if (location.spec.target) {
emit(
result.location(
{ type: location.spec.type, target: location.spec.target },
false,
),
);
}
if (location.spec.targets) {
for (const target of location.spec.targets) {
emit(result.location({ type: location.spec.type, target }, false));
}
}
}
return entity;
}
}
@@ -110,7 +110,7 @@ export async function createRouter(
.delete('/locations/:id', async (req, res) => {
const { id } = req.params;
await locationsCatalog.removeLocation(id);
res.status(200).send();
res.status(204).send();
});
}
@@ -36,7 +36,7 @@ export async function startStandaloneServer(
const logger = options.logger.child({ service: 'catalog-backend' });
logger.debug('Creating application...');
const db = await DatabaseManager.createInMemoryDatabase(logger);
const db = await DatabaseManager.createInMemoryDatabase({ logger });
const entitiesCatalog = new DatabaseEntitiesCatalog(db);
const locationsCatalog = new DatabaseLocationsCatalog(db);
const locationReader = new LocationReaders();
+1
View File
@@ -9,6 +9,7 @@
"target": "es2019",
"module": "commonjs",
"esModuleInterop": true,
"allowJs": true,
"lib": ["es2019"],
"types": ["node", "jest", "webpack-env"]
}
+16 -14
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
@@ -22,25 +22,25 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.1.1-alpha.7",
"@backstage/core": "^0.1.1-alpha.7",
"@backstage/plugin-scaffolder": "^0.1.1-alpha.7",
"@backstage/plugin-sentry": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.7",
"@backstage/catalog-model": "^0.1.1-alpha.8",
"@backstage/core": "^0.1.1-alpha.8",
"@backstage/plugin-scaffolder": "^0.1.1-alpha.8",
"@backstage/plugin-sentry": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"node-cache": "^5.1.1",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"react-use": "^14.2.0"
"react-router": "^6.0.0-alpha.5",
"react-router-dom": "^6.0.0-alpha.5",
"react-use": "^14.2.0",
"swr": "^0.2.2"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/dev-utils": "^0.1.1-alpha.7",
"@backstage/test-utils": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/dev-utils": "^0.1.1-alpha.8",
"@backstage/test-utils": "^0.1.1-alpha.8",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/react-hooks": "^3.3.0",
@@ -49,7 +49,9 @@
"@types/node": "^12.0.0",
"@types/testing-library__jest-dom": "^5.0.4",
"jest-fetch-mock": "^3.0.3",
"react-test-renderer": "^16.13.1"
"msw": "^0.19.0",
"react-test-renderer": "^16.13.1",
"whatwg-fetch": "^3.0.0"
},
"files": [
"dist/**/*.{js,d.ts}"
+76 -7
View File
@@ -14,15 +14,84 @@
* limitations under the License.
*/
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { CatalogClient } from './CatalogClient';
import mockFetch from 'jest-fetch-mock';
import { Entity } from '@backstage/catalog-model';
const server = setupServer();
describe('CatalogClient', () => {
it('builds entity search filters properly', async () => {
mockFetch.mockResponse('[]');
const client = new CatalogClient({ apiOrigin: '', basePath: '' });
const entities = await client.getEntities({ a: '1', ö: '=' });
expect(entities).toEqual([]);
expect(mockFetch).toBeCalledWith('/entities?a=1&%C3%B6=%3D');
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
const mockApiOrigin = 'http://backstage:9191';
const mockBasePath = '/i-am-a-mock-base';
let client = new CatalogClient({
apiOrigin: mockApiOrigin,
basePath: mockBasePath,
});
beforeEach(() => {
client = new CatalogClient({
apiOrigin: mockApiOrigin,
basePath: mockBasePath,
});
});
describe('getEntiies', () => {
const defaultResponse: Entity[] = [
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'Test1',
namespace: 'test1',
},
},
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'Test2',
namespace: 'test1',
},
},
];
beforeEach(() => {
server.use(
rest.get(`${mockApiOrigin}${mockBasePath}/entities`, (_, res, ctx) => {
return res(ctx.json(defaultResponse));
}),
);
});
it('should entities from correct endpoint', async () => {
const entities = await client.getEntities();
expect(entities).toEqual(defaultResponse);
});
it('builds entity search filters properly', async () => {
expect.assertions(2);
server.use(
rest.get(
`${mockApiOrigin}${mockBasePath}/entities`,
(req, res, ctx) => {
expect(req.url.searchParams.toString()).toBe(
'a=1&b=2&b=3&%C3%B6=%3D',
);
return res(ctx.json([]));
},
),
);
const entities = await client.getEntities({
a: '1',
b: ['2', '3'],
ö: '=',
});
expect(entities).toEqual([]);
});
});
});
+29 -15
View File
@@ -19,15 +19,9 @@ import {
Location,
LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
import Cache from 'node-cache';
import { DescriptorEnvelope } from '../types';
import { CatalogApi, EntityCompoundName } from './types';
export class CatalogClient implements CatalogApi {
// TODO(blam): This cache is just temporary until we have GraphQL.
// And client side caching using things like React Apollo or Relay.
// There's a lot of loading states that cause flickering around the app which aren't needed.
private cache: Cache;
private apiOrigin: string;
private basePath: string;
@@ -40,7 +34,6 @@ export class CatalogClient implements CatalogApi {
}) {
this.apiOrigin = apiOrigin;
this.basePath = basePath;
this.cache = new Cache({ stdTTL: 10 });
}
private async getRequired(path: string): Promise<any> {
@@ -78,16 +71,21 @@ export class CatalogClient implements CatalogApi {
}
async getEntities(
filter?: Record<string, string>,
): Promise<DescriptorEnvelope[]> {
const cachedValue = this.cache.get<DescriptorEnvelope[]>(
`get:${JSON.stringify(filter)}`,
);
if (cachedValue) return cachedValue;
filter?: Record<string, string | string[]>,
): Promise<Entity[]> {
let path = `/entities`;
if (filter) {
path += `?${new URLSearchParams(filter).toString()}`;
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filter)) {
if (Array.isArray(value)) {
for (const v of value) {
params.append(key, v);
}
} else {
params.append(key, value);
}
}
path += `?${params.toString()}`;
}
return await this.getRequired(path);
@@ -134,4 +132,20 @@ export class CatalogClient implements CatalogApi {
.map(r => r.data)
.find(l => locationCompound === `${l.type}:${l.target}`);
}
async removeEntityByUid(uid: string): Promise<void> {
const response = await fetch(
`${this.apiOrigin}${this.basePath}/entities/by-uid/${uid}`,
{
method: 'DELETE',
},
);
if (!response.ok) {
const payload = await response.text();
throw new Error(
`Request failed with ${response.status} ${response.statusText}, ${payload}`,
);
}
return undefined;
}
}
+3 -1
View File
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createApiRef } from '@backstage/core';
import { Entity, Location } from '@backstage/catalog-model';
@@ -33,9 +34,10 @@ export interface CatalogApi {
getEntityByName(
compoundName: EntityCompoundName,
): Promise<Entity | undefined>;
getEntities(filter?: Record<string, string>): Promise<Entity[]>;
getEntities(filter?: Record<string, string | string[]>): Promise<Entity[]>;
addLocation(type: string, target: string): Promise<AddLocationResponse>;
getLocationByEntity(entity: Entity): Promise<Location | undefined>;
removeEntityByUid(uid: string): Promise<void>;
}
export type AddLocationResponse = { location: Location; entities: Entity[] };
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { useApi } from '@backstage/core';
import { catalogApiRef } from '../../api/types';
@@ -18,7 +18,7 @@ import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { CatalogFilter, CatalogFilterGroup } from './CatalogFilter';
import { FilterGroupItem } from '../../types';
import { EntityFilterType } from '../../data/filters';
describe('Catalog Filter', () => {
it('should render the different groups', async () => {
@@ -41,11 +41,11 @@ describe('Catalog Filter', () => {
name: 'Test Group 1',
items: [
{
id: FilterGroupItem.ALL,
id: EntityFilterType.ALL,
label: 'First Label',
},
{
id: FilterGroupItem.STARRED,
id: EntityFilterType.STARRED,
label: 'Second Label',
},
],
@@ -68,12 +68,12 @@ describe('Catalog Filter', () => {
name: 'Test Group 1',
items: [
{
id: FilterGroupItem.ALL,
id: EntityFilterType.ALL,
label: 'First Label',
count: 100,
},
{
id: FilterGroupItem.STARRED,
id: EntityFilterType.STARRED,
label: 'Second Label',
count: 400,
},
@@ -97,12 +97,12 @@ describe('Catalog Filter', () => {
name: 'Test Group 1',
items: [
{
id: FilterGroupItem.ALL,
id: EntityFilterType.ALL,
label: 'First Label',
count: 100,
},
{
id: FilterGroupItem.STARRED,
id: EntityFilterType.STARRED,
label: 'Second Label',
count: 400,
},
@@ -136,12 +136,12 @@ describe('Catalog Filter', () => {
name: 'Test Group 1',
items: [
{
id: FilterGroupItem.ALL,
id: EntityFilterType.ALL,
label: 'First Label',
count: () => <b>BACKSTAGE!</b>,
},
{
id: FilterGroupItem.STARRED,
id: EntityFilterType.STARRED,
label: 'Second Label',
count: 400,
},
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import {
Card,
@@ -25,9 +26,9 @@ import {
makeStyles,
} from '@material-ui/core';
import type { IconComponent } from '@backstage/core';
import { FilterGroupItem } from '../../types';
import { EntityFilterType } from '../../data/filters';
export type CatalogFilterItem = {
id: FilterGroupItem;
id: EntityFilterType;
label: string;
icon?: IconComponent;
count?: number | React.FC;
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { useStarredEntities } from '../../hooks/useStarredEntites';
@@ -14,7 +14,6 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import {
ApiProvider,
ApiRegistry,
@@ -28,6 +27,7 @@ import React from 'react';
import { catalogApiRef } from '../..';
import { CatalogApi } from '../../api/types';
import { CatalogPage } from './CatalogPage';
import { Entity } from '@backstage/catalog-model';
describe('CatalogPage', () => {
const mockErrorApi = new MockErrorApi();
@@ -35,9 +35,8 @@ import Star from '@material-ui/icons/Star';
import StarOutline from '@material-ui/icons/StarBorder';
import React, { FC, useCallback, useState } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { useAsync } from 'react-use';
import { catalogApiRef } from '../..';
import { dataResolvers, defaultFilter, filterGroups } from '../../data/filters';
import { defaultFilter, entityFilters, filterGroups } from '../../data/filters';
import { findLocationForEntityMeta } from '../../data/utils';
import { useStarredEntities } from '../../hooks/useStarredEntites';
import {
@@ -45,6 +44,7 @@ import {
CatalogFilterItem,
} from '../CatalogFilter/CatalogFilter';
import { CatalogTable } from '../CatalogTable/CatalogTable';
import useStaleWhileRevalidate from 'swr';
const useStyles = makeStyles(theme => ({
contentWrapper: {
@@ -61,20 +61,22 @@ const useStyles = makeStyles(theme => ({
export const CatalogPage: FC<{}> = () => {
const catalogApi = useApi(catalogApiRef);
const {
starredEntities,
toggleStarredEntity,
isStarredEntity,
} = useStarredEntities();
const { toggleStarredEntity, isStarredEntity } = useStarredEntities();
const [selectedFilter, setSelectedFilter] = useState<CatalogFilterItem>(
defaultFilter,
);
const { value, error, loading } = useAsync(
() => dataResolvers[selectedFilter.id]({ catalogApi, isStarredEntity }),
[selectedFilter.id, starredEntities.size],
const { data: entities, error } = useStaleWhileRevalidate(
['catalog/all', entityFilters[selectedFilter.id]],
async () => catalogApi.getEntities(),
);
const data =
entities?.filter(e =>
entityFilters[selectedFilter.id](e, { isStarred: isStarredEntity(e) }),
) ?? [];
const onFilterSelected = useCallback(
selected => setSelectedFilter(selected),
[],
@@ -146,6 +148,10 @@ export const CatalogPage: FC<{}> = () => {
id: 'documentation',
label: 'Documentation',
},
{
id: 'other',
label: 'Other',
},
];
return (
@@ -182,7 +188,7 @@ export const CatalogPage: FC<{}> = () => {
>
Create Service
</Button>
<SupportButton>All your components</SupportButton>
<SupportButton>All your software catalog entities</SupportButton>
</ContentHeader>
<div className={styles.contentWrapper}>
<div>
@@ -194,8 +200,8 @@ export const CatalogPage: FC<{}> = () => {
</div>
<CatalogTable
titlePreamble={selectedFilter.label}
entities={value || []}
loading={loading}
entities={data || []}
loading={!data && !error}
error={error}
actions={actions}
/>
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { wrapInTestApp } from '@backstage/test-utils';
import { render } from '@testing-library/react';
@@ -50,12 +51,12 @@ describe('CatalogTable component', () => {
),
);
const errorMessage = await rendered.findByText(
/Error encountered while fetching components./,
/Error encountered while fetching catalog entities./,
);
expect(errorMessage).toBeInTheDocument();
});
it('should display component names when loading has finished and no error occurred', async () => {
it('should display entity names when loading has finished and no error occurred', async () => {
const rendered = render(
wrapInTestApp(
<CatalogTable
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { Table, TableColumn } from '@backstage/core';
import { Link } from '@material-ui/core';
@@ -44,8 +45,12 @@ const columns: TableColumn[] = [
),
},
{
title: 'Kind',
field: 'kind',
title: 'Owner',
field: 'spec.owner',
},
{
title: 'Lifecycle',
field: 'spec.lifecycle',
},
{
title: 'Description',
@@ -72,7 +77,7 @@ export const CatalogTable: FC<CatalogTableProps> = ({
return (
<div>
<Alert severity="error">
Error encountered while fetching components. {error.toString()}
Error encountered while fetching catalog entities. {error.toString()}
</Alert>
</div>
);
@@ -13,21 +13,22 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentContextMenu } from './ComponentContextMenu';
import { render } from '@testing-library/react';
import { render, fireEvent } from '@testing-library/react';
import * as React from 'react';
import { act } from 'react-dom/test-utils';
import { EntityContextMenu } from './EntityContextMenu';
describe('ComponentContextMenu', () => {
it('should call onUnregisterComponent on button click', async () => {
it('should call onUnregisterEntity on button click', async () => {
await act(async () => {
const mockCallback = jest.fn();
const menu = render(
<ComponentContextMenu onUnregisterComponent={mockCallback} />,
<EntityContextMenu onUnregisterEntity={mockCallback} />,
);
const button = await menu.findByTestId('menu-button');
button.click();
const unregister = await menu.findByText('Unregister component');
fireEvent.click(button);
const unregister = await menu.findByText('Unregister entity');
expect(unregister).toBeInTheDocument();
});
});
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
IconButton,
ListItemIcon,
@@ -34,13 +35,11 @@ const useStyles = makeStyles({
},
});
type ComponentContextMenuProps = {
onUnregisterComponent: () => void;
type Props = {
onUnregisterEntity: () => void;
};
export const ComponentContextMenu: FC<ComponentContextMenuProps> = ({
onUnregisterComponent,
}) => {
export const EntityContextMenu: FC<Props> = ({ onUnregisterEntity }) => {
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement>();
const classes = useStyles();
@@ -53,7 +52,7 @@ export const ComponentContextMenu: FC<ComponentContextMenuProps> = ({
};
return (
<div>
<>
<IconButton
aria-label="more"
aria-controls="long-menu"
@@ -75,13 +74,13 @@ export const ComponentContextMenu: FC<ComponentContextMenuProps> = ({
<MenuItem
onClick={() => {
onClose();
onUnregisterComponent();
onUnregisterEntity();
}}
>
<ListItemIcon>
<Cancel fontSize="small" />
</ListItemIcon>
<Typography variant="inherit">Unregister component</Typography>
<Typography variant="inherit">Unregister entity</Typography>
</MenuItem>
<MenuItem>
<ListItemIcon>
@@ -91,6 +90,6 @@ export const ComponentContextMenu: FC<ComponentContextMenuProps> = ({
</MenuItem>
</MenuList>
</Popover>
</div>
</>
);
};
@@ -13,21 +13,20 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { render } from '@testing-library/react';
import React from 'react';
import { ComponentMetadataCard } from './ComponentMetadataCard';
import { EntityMetadataCard } from './EntityMetadataCard';
describe('ComponentMetadataCard component', () => {
it('should display component name if provided', async () => {
describe('EntityMetadataCard component', () => {
it('should display entity name if provided', async () => {
const testEntity: Entity = {
apiVersion: 'backstage.io/v1beta1',
kind: 'Component',
metadata: { name: 'test' },
};
const rendered = await render(
<ComponentMetadataCard entity={testEntity} />,
);
const rendered = await render(<EntityMetadataCard entity={testEntity} />);
expect(await rendered.findByText('test')).toBeInTheDocument();
});
});
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { InfoCard, StructuredMetadataTable } from '@backstage/core';
import React, { FC } from 'react';
@@ -21,7 +22,7 @@ type Props = {
entity: Entity;
};
export const ComponentMetadataCard: FC<Props> = ({ entity }) => (
export const EntityMetadataCard: FC<Props> = ({ entity }) => (
<InfoCard title="Metadata">
<StructuredMetadataTable metadata={entity.metadata} />
</InfoCard>
@@ -13,32 +13,39 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentPage } from './ComponentPage';
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(),
};
});
import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
import { wrapInTestApp } from '@backstage/test-utils';
import { render, wait } from '@testing-library/react';
import * as React from 'react';
import { wrapInTestApp } from '@backstage/test-utils';
import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
import { catalogApiRef, CatalogApi } from '../../api/types';
const getTestProps = (name: string) => {
return {
match: {
params: {
optionalNamespaceAndName: name,
kind: 'Component',
},
},
history: {
push: jest.fn(),
},
};
};
import { CatalogApi, catalogApiRef } from '../../api/types';
import { EntityPage } from './EntityPage';
const {
useParams,
useNavigate,
}: { useParams: jest.Mock; useNavigate: () => jest.Mock } = jest.requireMock(
'react-router-dom',
);
const errorApi = { post: () => {} };
describe('ComponentPage', () => {
it('should redirect to component table page when name is not provided', async () => {
const props = getTestProps('');
describe('EntityPage', () => {
it('should redirect to catalog page when name is not provided', async () => {
useParams.mockReturnValue({
kind: 'Component',
optionalNamespaceAndName: '',
});
render(
wrapInTestApp(
<ApiProvider
@@ -52,13 +59,11 @@ describe('ComponentPage', () => {
],
])}
>
<ComponentPage {...props} />
<EntityPage />
</ApiProvider>,
),
);
await wait(() =>
expect(props.history.push).toHaveBeenCalledWith('/catalog'),
);
await wait(() => expect(useNavigate()).toHaveBeenCalledWith('/catalog'));
});
});
@@ -31,24 +31,12 @@ import { Alert } from '@material-ui/lab';
import React, { FC, useEffect, useState } from 'react';
import { useAsync } from 'react-use';
import { catalogApiRef } from '../..';
import { ComponentContextMenu } from '../ComponentContextMenu/ComponentContextMenu';
import { ComponentMetadataCard } from '../ComponentMetadataCard/ComponentMetadataCard';
import { ComponentRemovalDialog } from '../ComponentRemovalDialog/ComponentRemovalDialog';
import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu';
import { EntityMetadataCard } from '../EntityMetadataCard/EntityMetadataCard';
import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog';
import { useParams, useNavigate } from 'react-router-dom';
const REDIRECT_DELAY = 1000;
type ComponentPageProps = {
match: {
params: {
optionalNamespaceAndName: string;
kind: string;
};
};
history: {
push: (url: string) => void;
};
};
function headerProps(
kind: string,
namespace: string | undefined,
@@ -68,8 +56,12 @@ function headerProps(
};
}
export const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
const { optionalNamespaceAndName, kind } = match.params;
export const EntityPage: FC<{}> = () => {
const { optionalNamespaceAndName, kind } = useParams() as {
optionalNamespaceAndName: string;
kind: string;
};
const navigate = useNavigate();
const [name, namespace] = optionalNamespaceAndName.split(':').reverse();
const errorApi = useApi(errorApiRef);
@@ -83,26 +75,24 @@ export const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
useEffect(() => {
if (!error && !loading && !entity) {
errorApi.post(new Error('Component not found!'));
errorApi.post(new Error('Entity not found!'));
setTimeout(() => {
history.push('/');
navigate('/');
}, REDIRECT_DELAY);
}
}, [errorApi, history, error, loading, entity]);
}, [errorApi, navigate, error, loading, entity]);
if (!name) {
history.push('/catalog');
navigate('/catalog');
return null;
}
const removeComponent = async () => {
const cleanUpAfterRemoval = async () => {
setConfirmationDialogOpen(false);
// await componentFactory.removeComponentByName(componentName);
history.push('/');
navigate('/');
};
const showRemovalDialog = () => setConfirmationDialogOpen(true);
const hideRemovalDialog = () => setConfirmationDialogOpen(false);
// TODO - Replace with proper tabs implementation
const tabs = [
@@ -143,9 +133,7 @@ export const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
// TODO: Switch theme and type props based on component type (website, library, ...)
<Page theme={pageTheme.service}>
<Header title={headerTitle} type={headerType}>
{entity && (
<ComponentContextMenu onUnregisterComponent={showRemovalDialog} />
)}
{entity && <EntityContextMenu onUnregisterEntity={showRemovalDialog} />}
</Header>
{loading && <Progress />}
@@ -163,7 +151,7 @@ export const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
<Content>
<Grid container spacing={3} direction="column">
<Grid item>
<ComponentMetadataCard entity={entity} />
<EntityMetadataCard entity={entity} />
</Grid>
<Grid item>
<SentryIssuesWidget
@@ -174,11 +162,11 @@ export const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
</Grid>
</Content>
<ComponentRemovalDialog
<UnregisterEntityDialog
open={confirmationDialogOpen}
entity={entity}
onClose={hideRemovalDialog}
onConfirm={removeComponent}
onConfirm={cleanUpAfterRemoval}
onClose={() => setConfirmationDialogOpen(false)}
/>
</>
)}
@@ -15,7 +15,7 @@
*/
import { Entity, LOCATION_ANNOTATION } from '@backstage/catalog-model';
import { Progress, useApi } from '@backstage/core';
import { Progress, useApi, alertApiRef } from '@backstage/core';
import {
Button,
Dialog,
@@ -33,7 +33,7 @@ import { useAsync } from 'react-use';
import { AsyncState } from 'react-use/lib/useAsync';
import { catalogApiRef } from '../../api/types';
type ComponentRemovalDialogProps = {
type Props = {
open: boolean;
onConfirm: () => any;
onClose: () => any;
@@ -50,7 +50,7 @@ function useColocatedEntities(entity: Entity): AsyncState<Entity[]> {
}, [catalogApi, entity]);
}
export const ComponentRemovalDialog: FC<ComponentRemovalDialogProps> = ({
export const UnregisterEntityDialog: FC<Props> = ({
open,
onConfirm,
onClose,
@@ -59,11 +59,24 @@ export const ComponentRemovalDialog: FC<ComponentRemovalDialogProps> = ({
const { value: entities, loading, error } = useColocatedEntities(entity);
const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const catalogApi = useApi(catalogApiRef);
const alertApi = useApi(alertApiRef);
const removeEntity = async () => {
const uid = entity.metadata.uid;
try {
await catalogApi.removeEntityByUid(uid!);
} catch (err) {
alertApi.post({ message: err.message });
}
onConfirm();
};
return (
<Dialog fullScreen={fullScreen} open={open} onClose={onClose}>
<DialogTitle id="responsive-dialog-title">
Are you sure you want to unregister this component?
Are you sure you want to unregister this entity?
</DialogTitle>
<DialogContent>
{loading ? <Progress /> : null}
@@ -90,21 +103,23 @@ export const ComponentRemovalDialog: FC<ComponentRemovalDialogProps> = ({
<Typography component="div">
<ul>
<li>
{entities[0]?.metadata?.annotations?.[LOCATION_ANNOTATION]}
{entities[0]?.metadata.annotations?.[LOCATION_ANNOTATION]}
</li>
</ul>
</Typography>
<DialogContentText>
To undo, just re-register the component in Backstage.
To undo, just re-register the entity in Backstage.
</DialogContentText>
</>
) : null}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button onClick={onClose} color="primary">
Cancel
</Button>
<Button
disabled={!!(loading || error)}
onClick={onConfirm}
onClick={removeEntity}
color="secondary"
>
Unregister
+22 -25
View File
@@ -13,30 +13,35 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import SettingsIcon from '@material-ui/icons/Settings';
import StarIcon from '@material-ui/icons/Star';
import { AllServicesCount } from '../components/CatalogFilter/AllServicesCount';
import {
CatalogFilterGroup,
CatalogFilterItem,
} from '../components/CatalogFilter/CatalogFilter';
import SettingsIcon from '@material-ui/icons/Settings';
import StarIcon from '@material-ui/icons/Star';
import { StarredCount } from '../components/CatalogFilter/StarredCount';
import { AllServicesCount } from '../components/CatalogFilter/AllServicesCount';
import { FilterGroupItem } from '../types';
import { CatalogApi } from '../..';
import { Entity } from '@backstage/catalog-model';
export enum EntityFilterType {
ALL = 'ALL',
STARRED = 'STARRED',
OWNED = 'OWNED',
}
export const filterGroups: CatalogFilterGroup[] = [
{
name: 'Personal',
items: [
{
id: FilterGroupItem.OWNED,
id: EntityFilterType.OWNED,
label: 'Owned',
count: 0,
icon: SettingsIcon,
},
{
id: FilterGroupItem.STARRED,
id: EntityFilterType.STARRED,
label: 'Starred',
count: StarredCount,
icon: StarIcon,
@@ -48,7 +53,7 @@ export const filterGroups: CatalogFilterGroup[] = [
name: 'Company',
items: [
{
id: FilterGroupItem.ALL,
id: EntityFilterType.ALL,
label: 'All Services',
count: AllServicesCount,
},
@@ -56,24 +61,16 @@ export const filterGroups: CatalogFilterGroup[] = [
},
];
type ResolverFunction = ({
catalogApi,
isStarredEntity,
}: {
catalogApi: CatalogApi;
isStarredEntity: (entity: Entity) => boolean;
}) => Promise<Entity[]>;
type EntityFilter = (entity: Entity, options: EntityFilterOptions) => boolean;
export const dataResolvers: Record<FilterGroupItem, ResolverFunction> = {
[FilterGroupItem.OWNED]: async () => [],
[FilterGroupItem.ALL]: async ({ catalogApi }) => {
return catalogApi.getEntities();
},
[FilterGroupItem.STARRED]: async ({ catalogApi, isStarredEntity }) => {
const allEntities = await catalogApi.getEntities();
type EntityFilterOptions = {
isStarred: boolean;
};
return allEntities.filter(entity => isStarredEntity(entity));
},
export const entityFilters: Record<string, EntityFilter> = {
[EntityFilterType.OWNED]: () => false,
[EntityFilterType.ALL]: () => true,
[EntityFilterType.STARRED]: (_, { isStarred }) => isStarred,
};
export const defaultFilter: CatalogFilterItem = filterGroups[0].items[0];
+2 -13
View File
@@ -13,23 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Entity,
EntityMeta,
LocationSpec,
LOCATION_ANNOTATION,
EntityMeta,
} from '@backstage/catalog-model';
import { Component } from './component';
export function entityToComponent(envelope: Entity): Component {
return {
name: envelope.metadata.name,
namespace: envelope.metadata.namespace,
kind: envelope.kind,
metadata: envelope.metadata,
description: envelope.metadata.annotations?.description ?? 'placeholder',
};
}
export function findLocationForEntityMeta(
meta: EntityMeta,
@@ -13,10 +13,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState, useEffect, useCallback } from 'react';
import { useApi, storageApiRef } from '@backstage/core';
import { useObservable } from 'react-use';
import { Entity } from '@backstage/catalog-model';
import { storageApiRef, useApi } from '@backstage/core';
import { useCallback, useEffect, useState } from 'react';
import { useObservable } from 'react-use';
const buildEntityKey = (component: Entity) =>
`entity:${component.kind}:${component.metadata.namespace ?? 'default'}:${
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { renderHook, act } from '@testing-library/react-hooks';
import { useStarredEntities } from './useStarredEntites';
-1
View File
@@ -17,5 +17,4 @@
export { plugin } from './plugin';
export * from './api/CatalogClient';
export * from './api/types';
export * from './types';
export * from './routes';
+2 -2
View File
@@ -16,13 +16,13 @@
import { createPlugin } from '@backstage/core';
import { CatalogPage } from './components/CatalogPage/CatalogPage';
import { ComponentPage } from './components/ComponentPage/ComponentPage';
import { EntityPage } from './components/EntityPage/EntityPage';
import { entityRoute, rootRoute } from './routes';
export const plugin = createPlugin({
id: 'catalog',
register({ router }) {
router.addRoute(rootRoute, CatalogPage);
router.addRoute(entityRoute, ComponentPage);
router.addRoute(entityRoute, EntityPage);
},
});
+1 -1
View File
@@ -15,4 +15,4 @@
*/
import '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();
import 'whatwg-fetch';
-165
View File
@@ -1,165 +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.
*/
export interface ComponentDescriptorV1beta1 extends DescriptorEnvelope {
spec: {
type: string;
};
}
export type ComponentDescriptor = ComponentDescriptorV1beta1;
/**
* Metadata fields common to all versions/kinds of entity.
*
* @see https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta
*/
export type EntityMeta = {
/**
* A globally unique ID for the entity.
*
* This field can not be set by the user at creation time, and the server
* will reject an attempt to do so. The field will be populated in read
* operations. The field can (optionally) be specified when performing
* update or delete operations, but the server is free to reject requests
* that do so in such a way that it breaks semantics.
*/
uid?: string;
/**
* An opaque string that changes for each update operation to any part of
* the entity, including metadata.
*
* This field can not be set by the user at creation time, and the server
* will reject an attempt to do so. The field will be populated in read
* operations. The field can (optionally) be specified when performing
* update or delete operations, and the server will then reject the
* operation if it does not match the current stored value.
*/
etag?: string;
/**
* A positive nonzero number that indicates the current generation of data
* for this entity; the value is incremented each time the spec changes.
*
* This field can not be set by the user at creation time, and the server
* will reject an attempt to do so. The field will be populated in read
* operations.
*/
generation?: number;
/**
* The name of the entity.
*
* Must be uniqe within the catalog at any given point in time, for any
* given namespace, for any given kind.
*/
name: string;
/**
* The short description of the entity.
*
* A a human readable string.
*/
description: string;
/**
* The namespace that the entity belongs to.
*/
namespace?: string;
/**
* Key/value pairs of identifying information attached to the entity.
*/
labels?: Record<string, string>;
/**
* Key/value pairs of non-identifying auxiliary information attached to the
* entity.
*/
annotations?: Record<string, string>;
};
/**
* The format envelope that's common to all versions/kinds.
*
* @see https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/
*/
export type DescriptorEnvelope = {
/**
* The version of specification format for this particular entity that
* this is written against.
*/
apiVersion: string;
/**
* The high level entity type being described.
*/
kind: string;
/**
* Optional metadata related to the entity.
*/
metadata: EntityMeta;
/**
* The specification data describing the entity itself.
*/
spec?: object;
};
/**
* Parses and validates descriptors.
*
* The output must be validated and well formed.
*/
export type DescriptorParser = {
/**
* Parses and validates a single raw descriptor.
*
* @param descriptor A raw descriptor object
* @returns A structure describing the parsed and validated descriptor
* @throws An Error if the descriptor was malformed
*/
parse(descriptor: object): Promise<DescriptorEnvelope>;
};
/**
* Parses and validates a single envelope into its materialized kind.
*
* These parsers may assume that the envelope is already validated and well
* formed.
*/
export type KindParser = {
/**
* Try to parse an envelope into a materialized kind.
*
* @param envelope A valid descriptor envelope
* @returns A materialized type, or undefined if the given version/kind is
* not meant to be handled by this parser
* @throws An Error if the type was handled and found to not be properly
* formatted
*/
tryParse(
envelope: DescriptorEnvelope,
): Promise<DescriptorEnvelope | undefined>;
};
export enum FilterGroupItem {
ALL = 'ALL',
STARRED = 'STARRED',
OWNED = 'OWNED',
}
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-circleci",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
@@ -31,8 +31,8 @@
"postpack": "backstage-cli postpack"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.7",
"@backstage/core": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -42,13 +42,13 @@
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-lazylog": "^4.5.2",
"react-router": "^5.1.2",
"react-router-dom": "^5.1.2",
"react-router": "^6.0.0-alpha.5",
"react-router-dom": "^6.0.0-alpha.5",
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/dev-utils": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/dev-utils": "^0.1.1-alpha.8",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
+9 -14
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React from 'react';
import { Switch, Route, MemoryRouter } from 'react-router';
import { Route, MemoryRouter, Routes } from 'react-router';
import { BuildsPage, Builds } from '../pages/BuildsPage';
import { DetailedViewPage, BuildWithSteps } from '../pages/BuildWithStepsPage';
import { AppStateProvider } from '../state';
@@ -24,14 +24,13 @@ export const App = () => {
return (
<AppStateProvider>
<>
<Switch>
<Route path="/circleci" exact component={BuildsPage} />
<Routes>
<Route path="/circleci" element={<BuildsPage />} />
<Route
path="/circleci/build/:buildId"
exact
component={DetailedViewPage}
element={<DetailedViewPage />}
/>
</Switch>
</Routes>
<Settings />
</>
</AppStateProvider>
@@ -45,14 +44,10 @@ export const CircleCIWidget = () => (
<MemoryRouter initialEntries={['/circleci']}>
<AppStateProvider>
<>
<Switch>
<Route path="/circleci" exact component={Builds} />
<Route
path="/circleci/build/:buildId"
exact
component={BuildWithSteps}
/>
</Switch>
<Routes>
<Route path="/circleci" element={<Builds />} />
<Route path="/circleci/build/:buildId" element={<BuildWithSteps />} />
</Routes>
<Settings />
</>
</AppStateProvider>
@@ -15,20 +15,12 @@
*/
import React from 'react';
import { Header, Page, pageTheme, HeaderLabel } from '@backstage/core';
import { Box } from '@material-ui/core';
export const Layout: React.FC = ({ children }) => {
return (
<Page theme={pageTheme.tool}>
<Header
pageTitleOverride="Circle CI"
title={
<Box display="flex" alignItems="center">
<Box mr={1} /> Circle CI
</Box>
}
>
<HeaderLabel label="Owner" value="Team X" />
<Header title="CircleCI" subtitle="See recent builds and their status">
<HeaderLabel label="Owner" value="Spotify" />
<HeaderLabel label="Lifecycle" value="Alpha" />
</Header>
{children}
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-explore",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
@@ -22,8 +22,8 @@
"start": "backstage-cli plugin:serve"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.7",
"@backstage/core": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -33,9 +33,9 @@
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/dev-utils": "^0.1.1-alpha.7",
"@backstage/test-utils": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/dev-utils": "^0.1.1-alpha.8",
"@backstage/test-utils": "^0.1.1-alpha.8",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
@@ -80,6 +80,14 @@ const toolsCards = [
'https://camo.githubusercontent.com/517398c3fbe0687d3d4dcbe05da82970b882e75a/68747470733a2f2f64337676366c703535716a6171632e636c6f756466726f6e742e6e65742f6974656d732f33413061324e314c3346324f304c3377326e316a2f477261706869514c382e706e673f582d436c6f75644170702d56697369746f722d49643d3433363432',
tags: ['graphql', 'dev'],
},
{
title: 'GitOps Clusters',
description:
'Create GitOps-managed clusters with Backstage. Currently supports EKS flavors and profiles like Machine Learning Ops (MLOps)',
url: '/gitops-clusters',
image: 'https://miro.medium.com/max/801/1*R28u8gj-hVdDFISoYqPhrQ.png',
tags: ['gitops', 'dev'],
},
];
const ExplorePluginPage: FC<{}> = () => {
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+26
View File
@@ -0,0 +1,26 @@
# gitops-profiles
Welcome to the gitops-profiles plugin!
This plugin is for creating GitOps-managed Kubernetes clusters. Currently, it supports provisioning EKS clusters on GitHub via GitHub Actions.
_This plugin was created through the Backstage CLI_
## Plugin Development
Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/gitops-clusters](http://localhost:3000/gitops-profiles).
You can also serve the plugin in isolation by running `yarn start` in the plugin directory.
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
It is only meant for local development, and the setup for it can be found inside the [/dev](/dev) directory.
## Use GitOps-API backend with Backstage
The backend of this plugin is written in Golang and its source code is available [here](https://github.com/chanwit/gitops-api) as a separate GitHub repository.
The binary of this plugin is available as a ready-to-use Docker image, [https://hub.docker.com/chanwit/gitops-api](https://hub.docker.com/chanwit/gitops-api).
To start using GitOps with Backstage, you have to start the backend using the following command:
```bash
$ docker run -d --init -p 3008:8080 chanwit/gitops-api
```
Please note that this plugin requires the backend to run on port 3008.
@@ -13,13 +13,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EntityMeta } from '@backstage/catalog-model';
import { ReactNode } from 'react';
export type Component = {
name: string;
namespace?: string;
kind: string;
metadata: EntityMeta;
description: ReactNode;
};
import { createDevApp } from '@backstage/dev-utils';
import { plugin } from '../src/plugin';
createDevApp().registerPlugin(plugin).render();
+49
View File
@@ -0,0 +1,49 @@
{
"name": "@backstage/plugin-gitops-profiles",
"version": "0.1.1-alpha.8",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"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/core": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "^5.2.0",
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/dev-utils": "^0.1.1-alpha.8",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"@types/testing-library__jest-dom": "^5.0.4",
"jest-fetch-mock": "^3.0.3"
},
"files": [
"dist/**/*.{js,d.ts}"
]
}
+170
View File
@@ -0,0 +1,170 @@
/*
* 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-api';
export interface CloneFromTemplateRequest {
templateRepository: string;
secrets: {
awsAccessKeyId: string;
awsSecretAccessKey: string;
};
targetOrg: string;
targetRepo: string;
gitHubUser: string;
gitHubToken: string;
}
export interface ApplyProfileRequest {
targetOrg: string;
targetRepo: string;
gitHubUser: string;
gitHubToken: string;
profiles: string[];
}
export interface ChangeClusterStateRequest {
targetOrg: string;
targetRepo: string;
gitHubUser: string;
gitHubToken: string;
clusterState: 'present' | 'absent'; // /api/cluster/state
}
export interface PollLogRequest {
targetOrg: string;
targetRepo: string;
gitHubUser: string;
gitHubToken: string;
}
export interface Status {
status: string; // queued, in_progress, or completed
message: string;
conclusion: string; // success, failure, neutral, cancelled, skipped, timed_out, or action_required
}
export interface StatusResponse {
result: Status[];
link: string;
status: string;
}
export interface ClusterStatus {
name: string;
link: string;
status: string;
conclusion: string;
runStatus: Status[];
}
export interface ListClusterStatusesResponse {
result: ClusterStatus[];
}
export interface ListClusterRequest {
gitHubUser: string;
gitHubToken: string;
}
export class FetchError extends Error {
get name(): string {
return this.constructor.name;
}
static async forResponse(resp: Response): Promise<FetchError> {
return new FetchError(
`Request failed with status code ${
resp.status
}.\nReason: ${await resp.text()}`,
);
}
}
export type GitOpsApi = {
url: string;
fetchLog(req: PollLogRequest): Promise<StatusResponse>;
changeClusterState(req: ChangeClusterStateRequest): Promise<any>;
cloneClusterFromTemplate(req: CloneFromTemplateRequest): Promise<any>;
applyProfiles(req: ApplyProfileRequest): Promise<any>;
listClusters(req: ListClusterRequest): Promise<ListClusterStatusesResponse>;
};
export const gitOpsApiRef = createApiRef<GitOpsApi>({
id: 'plugin.gitops.service',
description: 'Used by the GitOps profiles plugin to make requests',
});
export class GitOpsRestApi implements GitOpsApi {
constructor(public url: string = '') {}
private async fetch<T = any>(path: string, init?: RequestInit): Promise<T> {
const resp = await fetch(`${this.url}${path}`, init);
if (!resp.ok) throw await FetchError.forResponse(resp);
return await resp.json();
}
async fetchLog(req: PollLogRequest): Promise<StatusResponse> {
return await this.fetch<StatusResponse>(`/api/cluster/run-status`, {
method: 'post',
headers: new Headers({
'Content-Type': 'application/json',
}),
body: JSON.stringify(req),
});
}
async changeClusterState(req: ChangeClusterStateRequest): Promise<any> {
return await this.fetch<any>('/api/cluster/state', {
method: 'post',
headers: new Headers({
'Content-Type': 'application/json',
}),
body: JSON.stringify(req),
});
}
async cloneClusterFromTemplate(req: CloneFromTemplateRequest): Promise<any> {
return await this.fetch<any>('/api/cluster/clone-from-template', {
method: 'post',
headers: new Headers({
'Content-Type': 'application/json',
}),
body: JSON.stringify(req),
});
}
async applyProfiles(req: ApplyProfileRequest): Promise<any> {
return await this.fetch<any>('/api/cluster/profiles', {
method: 'post',
headers: new Headers({
'Content-Type': 'application/json',
}),
body: JSON.stringify(req),
});
}
async listClusters(
req: ListClusterRequest,
): Promise<ListClusterStatusesResponse> {
return await this.fetch<ListClusterStatusesResponse>('/api/clusters', {
method: 'post',
headers: new Headers({
'Content-Type': 'application/json',
}),
body: JSON.stringify(req),
});
}
}
@@ -0,0 +1,95 @@
/*
* 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, { FC } from 'react';
import {
Content,
ContentHeader,
Header,
SupportButton,
Page,
pageTheme,
Progress,
HeaderLabel,
useApi,
} from '@backstage/core';
import ClusterTable from '../ClusterTable/ClusterTable';
import { Button, Typography } from '@material-ui/core';
import { useAsync, useLocalStorage } from 'react-use';
import { gitOpsApiRef, ListClusterStatusesResponse } from '../../api';
const ClusterList: FC<{}> = () => {
const [loginInfo] = useLocalStorage<{
token: string;
username: string;
name: string;
}>('githubLoginDetails');
const api = useApi(gitOpsApiRef);
const { loading, error, value } = useAsync<ListClusterStatusesResponse>(
() => {
return api.listClusters({
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
});
},
);
let content: JSX.Element;
if (loading) {
content = (
<Content>
<Progress />
</Content>
);
} else if (error) {
content = (
<Content>
<Typography variant="h4" color="error">
Failed to load cluster, {String(error)}
</Typography>
</Content>
);
} else {
content = (
<Content>
<ContentHeader title="Clusters">
<Button
variant="contained"
color="primary"
href="/gitops-cluster-create"
>
Create GitOps-managed Cluster
</Button>
<SupportButton>All clusters</SupportButton>
</ContentHeader>
<ClusterTable components={value!.result} />
</Content>
);
}
return (
<Page theme={pageTheme.home}>
<Header title="GitOps-managed Clusters">
<HeaderLabel label="Welcome" value={loginInfo.name} />
</Header>
{content}
</Page>
);
};
export default ClusterList;
@@ -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 { default } from './ClusterList';
@@ -0,0 +1,102 @@
/*
* 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, { FC, useEffect, useState } from 'react';
import {
Content,
Header,
Page,
pageTheme,
Table,
Progress,
HeaderLabel,
useApi,
} from '@backstage/core';
import { Link } from '@material-ui/core';
import { useParams } from 'react-router-dom';
import { useLocalStorage } from 'react-use';
import { gitOpsApiRef, Status } from '../../api';
import { transformRunStatus } from '../ProfileCatalog';
const ClusterPage: FC<{}> = () => {
const params = useParams() as { owner: string; repo: string };
const [loginInfo] = useLocalStorage<{
token: string;
username: string;
name: string;
}>('githubLoginDetails');
const [pollingLog, setPollingLog] = useState(true);
const [runStatus, setRunStatus] = useState<Status[]>([]);
const [runLink, setRunLink] = useState<string>('');
const [showProgress, setShowProgress] = useState(true);
const api = useApi(gitOpsApiRef);
const columns = [
{ field: 'status', title: 'Status' },
{ field: 'message', title: 'Message' },
];
useEffect(() => {
if (pollingLog) {
const interval = setInterval(async () => {
const resp = await api.fetchLog({
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
targetOrg: params.owner,
targetRepo: params.repo,
});
setRunStatus(resp.result);
setRunLink(resp.link);
if (resp.status === 'completed') {
setPollingLog(false);
setShowProgress(false);
}
}, 10000);
return () => clearInterval(interval);
}
return () => {};
}, [pollingLog, api, loginInfo, params]);
return (
<Page theme={pageTheme.home}>
<Header title={`Cluster ${params.owner}/${params.repo}`}>
<HeaderLabel label="Welcome" value={loginInfo.name} />
</Header>
<Content>
<Progress hidden={!showProgress} />
<Table
options={{ search: false, paging: false, toolbar: false }}
data={transformRunStatus(runStatus)}
columns={columns}
/>
<Link
hidden={runLink === ''}
rel="noopener noreferrer"
href={`${runLink}?check_suite_focus=true`}
target="_blank"
>
Details
</Link>
</Content>
</Page>
);
};
export default ClusterPage;
@@ -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 { default } from './ClusterPage';
@@ -0,0 +1,69 @@
/*
* 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, { FC } from 'react';
import { Table, TableColumn } from '@backstage/core';
import { Link } from '@material-ui/core';
import { ClusterStatus } from '../../api';
import { transformStatus } from '../ProfileCatalog/ProfileCatalog';
const columns: TableColumn[] = [
{
title: 'Cluster Name',
field: 'name',
highlight: true,
render: (componentData: any) => (
<Link href={`/gitops-cluster/${componentData.name}`}>
{componentData.name}
</Link>
),
},
{
title: 'Status',
field: 'status',
render: (componentData: any) => (
<>
{transformStatus({
status: componentData.status,
conclusion: componentData.conclusion,
message: componentData.status,
})}
</>
),
},
{
title: 'Conclusion',
field: 'Conclusion',
render: (componentData: any) => (
<>
{transformStatus({
status: componentData.status,
conclusion: componentData.conclusion,
message: componentData.conclusion,
})}
</>
),
},
];
type ClusterTableProps = {
components: ClusterStatus[];
};
const ClusterTable: FC<ClusterTableProps> = ({ components }) => {
return (
<Table columns={columns} options={{ paging: false }} data={components} />
);
};
export default ClusterTable;
@@ -0,0 +1,102 @@
/*
* 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, { FC } from 'react';
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles';
import Card from '@material-ui/core/Card';
import CardHeader from '@material-ui/core/CardHeader';
import CardContent from '@material-ui/core/CardContent';
import CardActions from '@material-ui/core/CardActions';
import Avatar from '@material-ui/core/Avatar';
import IconButton from '@material-ui/core/IconButton';
import Typography from '@material-ui/core/Typography';
import { red } from '@material-ui/core/colors';
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank';
import CheckBoxIcon from '@material-ui/icons/CheckBox';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
maxWidth: 345,
},
media: {
height: 0,
paddingTop: '56.25%', // 16:9
},
expand: {
transform: 'rotate(0deg)',
marginLeft: 'auto',
transition: theme.transitions.create('transform', {
duration: theme.transitions.duration.shortest,
}),
},
expandOpen: {
transform: 'rotate(180deg)',
},
avatar: {
fontSize: '1.0rem',
backgroundColor: red[500],
},
}),
);
interface Props {
platformName: string;
title: string;
repository: string;
description: string;
index: number;
onClick: (i: number, repo: string) => void;
activeIndex: number;
}
const ClusterTemplateCard: FC<Props> = props => {
const classes = useStyles();
const handleSelect = () => {
props.onClick(props.index, props.repository);
};
return (
<Card className={classes.root}>
<CardHeader
avatar={
<Avatar aria-label="recipe" className={classes.avatar}>
{props.platformName}
</Avatar>
}
action={<IconButton aria-label="settings" />}
title={props.title}
subheader={props.repository}
/>
<CardContent>
<Typography variant="body2" color="textSecondary" component="p">
{props.description}
</Typography>
</CardContent>
<CardActions disableSpacing>
<IconButton aria-label="select" onClick={handleSelect}>
{props.activeIndex === props.index ? (
<CheckBoxIcon color="primary" />
) : (
<CheckBoxOutlineBlankIcon />
)}
</IconButton>
</CardActions>
</Card>
);
};
export default ClusterTemplateCard;
@@ -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 { default } from './ClusterTemplateCard';
@@ -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, { FC } from 'react';
import { Grid } from '@material-ui/core';
import ClusterTemplateCard from '../ClusterTemplateCard';
interface Props {
template: {
platformName: string;
title: string;
repository: string;
description: string;
}[];
}
const ClusterTemplateCardList: FC<Props> = props => {
const [activeIndex, setActiveIndex] = React.useState(-1);
const handleClicked = (index: number, repository: string) => {
setActiveIndex(index);
window.localStorage.setItem('gitops-template-repo', repository);
};
return (
<Grid container xl={12} spacing={4}>
{props.template.map((value, index) => (
<Grid item xl={2} key={index}>
<ClusterTemplateCard
activeIndex={activeIndex}
onClick={handleClicked}
index={index}
key={index}
platformName={value.platformName}
title={value.title}
repository={value.repository}
description={value.description}
/>
</Grid>
))}
</Grid>
);
};
export default ClusterTemplateCardList;
@@ -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 { default } from './ClusterTemplateCardList';
@@ -0,0 +1,109 @@
/*
* 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, { FC, useState } from 'react';
import {
Avatar,
Card,
CardActions,
CardContent,
CardHeader,
createStyles,
IconButton,
Theme,
Typography,
} from '@material-ui/core';
import { green } from '@material-ui/core/colors';
import { makeStyles } from '@material-ui/core/styles';
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank';
import CheckBoxIcon from '@material-ui/icons/CheckBox';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
maxWidth: 345,
},
media: {
height: 0,
paddingTop: '56.25%', // 16:9
},
expand: {
transform: 'rotate(0deg)',
marginLeft: 'auto',
transition: theme.transitions.create('transform', {
duration: theme.transitions.duration.shortest,
}),
},
expandOpen: {
transform: 'rotate(180deg)',
},
avatar: {
backgroundColor: green[500],
},
}),
);
interface Props {
shortName: string;
title: string;
repository: string;
description: string;
index: number;
onClick: (i: number, repository: string) => void;
selections: Set<number>;
}
const ProfileCard: FC<Props> = props => {
const [selection, setSelection] = useState(false);
const handleSelect = () => {
props.onClick(props.index, props.repository);
setSelection(props.selections.has(props.index));
};
const classes = useStyles();
return (
<Card className={classes.root}>
<CardHeader
avatar={
<Avatar aria-label="recipe" className={classes.avatar}>
{props.shortName}
</Avatar>
}
action={<IconButton aria-label="settings" />}
title={props.title}
subheader={props.repository.replace('https://github.com/', '')}
/>
<CardContent>
<Typography variant="body2" color="textSecondary" component="p">
{props.description}
</Typography>
</CardContent>
<CardActions disableSpacing>
<IconButton aria-label="select" onClick={handleSelect}>
{selection ? (
<CheckBoxIcon color="primary" />
) : (
<CheckBoxOutlineBlankIcon />
)}
</IconButton>
</CardActions>
</Card>
);
};
export default ProfileCard;
@@ -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 { default } from './ProfileCard';
@@ -0,0 +1,71 @@
/*
* 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, { FC, useState } from 'react';
import { Grid } from '@material-ui/core';
import ProfileCard from '../ProfileCard';
interface Props {
profileTemplates: {
shortName: string;
title: string;
repository: string;
description: string;
}[];
}
const ProfileCardList: FC<Props> = props => {
const [selections, setSelections] = useState<Set<number>>(new Set<number>());
const [profiles, setProfiles] = useState<Set<string>>(new Set<string>());
const handleClicked = (index: number, repository: string) => {
if (selections.has(index)) {
selections.delete(index);
profiles.delete(repository);
} else {
selections.add(index);
profiles.add(repository);
}
setSelections(selections);
setProfiles(profiles);
window.localStorage.setItem(
'gitops-profiles',
JSON.stringify(Array.from(profiles)),
);
};
return (
<Grid container xl={12} spacing={4}>
{props.profileTemplates.map((value, index) => (
<Grid item xl={2} key={index}>
<ProfileCard
shortName={value.shortName}
selections={selections}
onClick={handleClicked}
key={index}
index={index}
title={value.title}
repository={value.repository}
description={value.description}
/>
</Grid>
))}
</Grid>
);
};
export default ProfileCardList;
@@ -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 { default } from './ProfileCardList';
@@ -0,0 +1,43 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { render } from '@testing-library/react';
import mockFetch from 'jest-fetch-mock';
import ProfileCatalog from './ProfileCatalog';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import { ApiProvider, ApiRegistry } from '@backstage/core-api';
import { gitOpsApiRef, GitOpsRestApi } from '../../api';
describe('ProfileCatalog', () => {
it('should render', () => {
const apis = ApiRegistry.from([
[gitOpsApiRef, new GitOpsRestApi('http://localhost:3008')],
]);
mockFetch.mockResponse(() => new Promise(() => {}));
const rendered = render(
<ThemeProvider theme={lightTheme}>
<ApiProvider apis={apis}>
<ProfileCatalog />
</ApiProvider>
</ThemeProvider>,
);
expect(
rendered.getByText('Create GitOps-managed Cluster'),
).toBeInTheDocument();
});
});
@@ -0,0 +1,335 @@
/*
* 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, { FC, useEffect, useState } from 'react';
import {
Header,
Page,
pageTheme,
Content,
ContentHeader,
HeaderLabel,
SupportButton,
SimpleStepper,
SimpleStepperStep,
InfoCard,
Progress,
Table,
StatusWarning,
StatusOK,
StatusRunning,
StatusError,
StatusPending,
StatusAborted,
useApi,
} from '@backstage/core';
import { TextField, List, ListItem, Link } from '@material-ui/core';
import ClusterTemplateCardList from '../ClusterTemplateCardList';
import ProfileCardList from '../ProfileCardList';
import { useLocalStorage } from 'react-use';
import { gitOpsApiRef, Status } from '../../api';
// OK = (completed, success)
// Error = (?,failure)
// Aborted = (?,cancelled)
// Error = (?,timed_out)
// Warning = (?, skipped)
// Running = (queued, ?)
// Running = (in_progress,?)
export const transformStatus = (value: Status): JSX.Element => {
let status: JSX.Element = <StatusRunning>Unknown</StatusRunning>;
if (value.status === 'completed' && value.conclusion === 'success') {
status = <StatusOK>Success</StatusOK>;
} else if (value.conclusion === 'failure') {
status = <StatusError>Failure</StatusError>;
} else if (value.conclusion === 'cancelled') {
status = <StatusAborted>Cancelled</StatusAborted>;
} else if (value.conclusion === 'timed_out') {
status = <StatusError>Timed out</StatusError>;
} else if (value.conclusion === 'skipped') {
status = <StatusWarning>Skipped</StatusWarning>;
} else if (value.status === 'queued') {
status = <StatusPending>Queued</StatusPending>;
} else if (value.status === 'in_progress') {
status = <StatusRunning>In Progress</StatusRunning>;
}
return status;
};
export const transformRunStatus = (x: Status[]) => {
return x.map(value => {
return {
status: transformStatus(value),
message: value.message,
};
});
};
const ProfileCatalog: FC<{}> = () => {
// TODO: get data from REST API
const [clusterTemplates] = React.useState([
{
platformName: '15m',
title: 'EKS 2 workers',
repository: 'chanwit/eks-cluster-template',
description: 'EKS with Kubernetes 1.16 / 2 nodes of m5.xlarge (15 mins)',
},
{
platformName: '15m',
title: 'EKS 1 worker',
repository: 'chanwit/template-2',
description: 'EKS with Kubernetes 1.16 / 1 node of m5.xlarge (15 mins)',
},
]);
const [profileTemplates] = React.useState([
{
shortName: 'ml',
title: 'MLOps',
repository: 'https://github.com/weaveworks/mlops-profile',
description: 'Kubeflow-based Machine Learning pipeline',
},
{
shortName: 'ai',
title: 'COVID ML',
repository: 'https://github.com/weaveworks/covid-ml-profile',
description: 'Fk-covid Application profile',
},
]);
const [loginInfo] = useLocalStorage('githubLoginDetails', {
name: 'Guest',
username: '',
token: '',
});
const [templateRepo] = useLocalStorage<string>('gitops-template-repo');
const [gitopsProfiles] = useLocalStorage<string[]>('gitops-profiles');
const [showProgress, setShowProgress] = useState(false);
const [pollingLog, setPollingLog] = useState(false);
const [gitHubOrg, setGitHubOrg] = useState(loginInfo.username);
const [gitHubRepo, setGitHubRepo] = useState('new-cluster');
const [awsAccessKeyId, setAwsAccessKeyId] = useState(String);
const [awsSecretAccessKey, setAwsSecretAccessKey] = useState(String);
const [runStatus, setRunStatus] = useState<Status[]>([]);
const [runLink, setRunLink] = useState<string>('');
const api = useApi(gitOpsApiRef);
useEffect(() => {
if (pollingLog) {
const interval = setInterval(async () => {
const resp = await api.fetchLog({
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
targetOrg: gitHubOrg,
targetRepo: gitHubRepo,
});
setRunStatus(resp.result);
setRunLink(resp.link);
if (resp.status === 'completed') {
setPollingLog(false);
setShowProgress(false);
}
}, 10000);
return () => clearInterval(interval);
}
return () => {};
}, [pollingLog, api, gitHubOrg, gitHubRepo, loginInfo]);
const showFailureMessage = (msg: string) => {
setRunStatus(
runStatus.concat([
{
status: 'completed',
message: msg,
conclusion: 'failure',
},
]),
);
};
const showSuccessMessage = (msg: string) => {
setRunStatus(
runStatus.concat([
{
status: 'completed',
message: msg,
conclusion: 'success',
},
]),
);
};
const doCreateCluster = async () => {
setShowProgress(true);
setRunStatus([]);
const cloneResponse = await api.cloneClusterFromTemplate({
templateRepository: templateRepo,
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
targetOrg: gitHubOrg,
targetRepo: gitHubRepo,
secrets: {
awsAccessKeyId: awsAccessKeyId,
awsSecretAccessKey: awsSecretAccessKey,
},
});
if (cloneResponse.error === undefined) {
showSuccessMessage('Forked new cluster repo');
} else {
setShowProgress(false);
showFailureMessage(cloneResponse.error);
}
const applyProfileResp = await api.applyProfiles({
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
targetOrg: gitHubOrg,
targetRepo: gitHubRepo,
profiles: gitopsProfiles,
});
if (applyProfileResp.error === undefined) {
showSuccessMessage('Applied profiles to the repo');
} else {
setShowProgress(false);
showFailureMessage(applyProfileResp.error);
}
const clusterStateResp = await api.changeClusterState({
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
targetOrg: gitHubOrg,
targetRepo: gitHubRepo,
clusterState: 'present',
});
if (clusterStateResp.error === undefined) {
// cluster creation start, so start pulling log
setPollingLog(true);
showSuccessMessage('Changed desired cluster state to present');
} else {
setPollingLog(false);
setShowProgress(false);
showFailureMessage(clusterStateResp.error);
}
};
const columns = [
{ field: 'status', title: 'Status' },
{ field: 'message', title: 'Message' },
];
return (
<Page theme={pageTheme.tool}>
<Header
title="Create GitOps-managed Cluster"
subtitle="Kubernetes cluster with ready-to-use profiles"
>
<HeaderLabel label="Welcome" value={loginInfo.name} />
</Header>
<Content>
<ContentHeader title="Create Cluster">
<SupportButton>A description of your plugin goes here.</SupportButton>
</ContentHeader>
<SimpleStepper>
<SimpleStepperStep title="Choose Cluster Template">
<ClusterTemplateCardList template={clusterTemplates} />
</SimpleStepperStep>
<SimpleStepperStep title="Select GitOps Profile">
<ProfileCardList profileTemplates={profileTemplates} />
</SimpleStepperStep>
<SimpleStepperStep
title="Create Cluster"
actions={{ nextText: 'Create', onNext: () => doCreateCluster() }}
>
<InfoCard>
<List>
<ListItem>
<TextField
name="github-org-tf"
label="GitHub Organization"
defaultValue={gitHubOrg}
required
onChange={e => {
setGitHubOrg(e.target.value);
}}
/>
</ListItem>
<ListItem>
<TextField
name="github-repo-tf"
label="New Repository"
defaultValue={gitHubRepo}
required
onChange={e => {
setGitHubRepo(e.target.value);
}}
/>
</ListItem>
<ListItem>
<TextField
name="aws-access-key-id-tf"
label="Access Key ID"
required
type="password"
onChange={e => {
setAwsAccessKeyId(e.target.value);
}}
/>
</ListItem>
<ListItem>
<TextField
name="aws-secret-access-key-tf"
label="Secret Access Key"
required
type="password"
onChange={e => {
setAwsSecretAccessKey(e.target.value);
}}
/>
</ListItem>
</List>
</InfoCard>
</SimpleStepperStep>
</SimpleStepper>
<div>
<Progress hidden={!showProgress} />
<Table
options={{ search: false, paging: false, toolbar: false }}
data={transformRunStatus(runStatus)}
columns={columns}
/>
<Link
hidden={runLink === ''}
rel="noopener noreferrer"
href={`${runLink}?check_suite_focus=true`}
target="_blank"
>
Details
</Link>
</div>
</Content>
</Page>
);
};
export default ProfileCatalog;
@@ -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 { default, transformRunStatus } from './ProfileCatalog';
+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 * from './api';
@@ -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('gitops-profiles', () => {
it('should export plugin', () => {
expect(plugin).toBeDefined();
});
});
+29
View File
@@ -0,0 +1,29 @@
/*
* 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 { createPlugin } from '@backstage/core';
import ProfileCatalog from './components/ProfileCatalog';
import ClusterPage from './components/ClusterPage';
import ClusterList from './components/ClusterList';
export const plugin = createPlugin({
id: 'gitops-profiles',
register({ router }) {
router.registerRoute('/gitops-clusters', ClusterList);
router.registerRoute('/gitops-cluster/:owner/:repo', ClusterPage);
router.registerRoute('/gitops-cluster-create', ProfileCatalog);
},
});
+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.
*/
import '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();
+8 -8
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/plugin-graphiql",
"description": "Backstage plugin for browsing GraphQL APIs",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"private": false,
"publishConfig": {
"access": "public",
@@ -32,8 +32,8 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.7",
"@backstage/core": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -44,18 +44,18 @@
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/dev-utils": "^0.1.1-alpha.7",
"@backstage/test-utils": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/dev-utils": "^0.1.1-alpha.8",
"@backstage/test-utils": "^0.1.1-alpha.8",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
"@types/codemirror": "^0.0.95",
"@types/codemirror": "^0.0.96",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"@types/testing-library__jest-dom": "^5.0.4",
"jest-fetch-mock": "^3.0.3",
"react-router-dom": "^5.2.0"
"react-router-dom": "6.0.0-alpha.5"
},
"files": [
"dist/**/*.{js,d.ts}"
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-home-page",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
@@ -22,8 +22,8 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.7",
"@backstage/core": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -32,8 +32,8 @@
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/dev-utils": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/dev-utils": "^0.1.1-alpha.8",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
@@ -41,7 +41,7 @@
"@types/node": "^12.0.0",
"@types/testing-library__jest-dom": "^5.0.4",
"jest-fetch-mock": "^3.0.3",
"react-router-dom": "^5.2.0"
"react-router-dom": "6.0.0-alpha.5"
},
"files": [
"dist/**/*.{js,d.ts}"
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-identity-backend",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -15,7 +15,7 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.7",
"@backstage/backend-common": "^0.1.1-alpha.8",
"compression": "^1.7.4",
"cors": "^2.8.5",
"express": "^4.17.1",
@@ -27,7 +27,7 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"jest-fetch-mock": "^3.0.3",
"tsc-watch": "^4.2.3"
},
+8 -8
View File
@@ -1,11 +1,11 @@
{
"name": "@backstage/plugin-lighthouse",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
@@ -22,21 +22,21 @@
"start": "backstage-cli plugin:serve"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.7",
"@backstage/core": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-markdown": "^4.3.1",
"react-router-dom": "^5.2.0",
"react-router-dom": "6.0.0-alpha.5",
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/dev-utils": "^0.1.1-alpha.7",
"@backstage/test-utils": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/dev-utils": "^0.1.1-alpha.8",
"@backstage/test-utils": "^0.1.1-alpha.8",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
@@ -16,13 +16,10 @@
jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');
const mocks = {
replace: jest.fn(),
push: jest.fn(),
};
const mockNavigation = jest.fn();
return {
...actual,
useHistory: jest.fn(() => mocks),
useNavigate: jest.fn(() => mockNavigation),
};
});
@@ -41,7 +38,7 @@ import AuditList from '.';
import * as data from '../../__fixtures__/website-list-response.json';
const { useHistory } = jest.requireMock('react-router-dom');
const { useNavigate } = jest.requireMock('react-router-dom');
const websiteListResponse = data as WebsiteListResponse;
describe('AuditList', () => {
@@ -145,7 +142,8 @@ describe('AuditList', () => {
);
const element = await rendered.findByLabelText(/Go to page 1/);
fireEvent.click(element);
expect(useHistory().replace).toHaveBeenCalledWith(`/lighthouse?page=1`);
expect(useNavigate()).toHaveBeenCalledWith(`/lighthouse?page=1`);
});
});
});
@@ -16,7 +16,7 @@
import React, { useState, useMemo, FC, ReactNode } from 'react';
import { useLocalStorage, useAsync } from 'react-use';
import { useHistory } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import { Grid, Button } from '@material-ui/core';
import Alert from '@material-ui/lab/Alert';
import Pagination from '@material-ui/lab/Pagination';
@@ -65,7 +65,7 @@ const AuditList: FC<{}> = () => {
return 0;
}, [value?.total, value?.limit]);
const history = useHistory();
const navigate = useNavigate();
let content: ReactNode = null;
if (value) {
@@ -77,7 +77,7 @@ const AuditList: FC<{}> = () => {
page={page}
count={pageCount}
onChange={(_event: Event, newPage: number) => {
history.replace(`/lighthouse?page=${newPage}`);
navigate(`/lighthouse?page=${newPage}`);
}}
/>
)}
@@ -18,10 +18,10 @@ import { Link, useParams } from 'react-router-dom';
import { useAsync } from 'react-use';
import {
makeStyles,
Button,
Grid,
List,
ListItem,
Button,
ListItemIcon,
ListItemText,
} from '@material-ui/core';
@@ -68,7 +68,7 @@ const AuditLinkList: FC<AuditLinkListProps> = ({
component="nav"
aria-label="lighthouse audit history"
>
{audits.map((audit) => (
{audits.map(audit => (
<ListItem
key={audit.id}
selected={audit.id === selectedId}
@@ -88,7 +88,7 @@ const AuditLinkList: FC<AuditLinkListProps> = ({
const AuditView: FC<{ audit?: Audit }> = ({ audit }: { audit?: Audit }) => {
const classes = useStyles();
const params = useParams<{ id: string }>();
const params = useParams() as { id: string };
const { url: lighthouseUrl } = useApi(lighthouseApiRef);
if (audit?.status === 'RUNNING') return <Progress />;
@@ -114,7 +114,7 @@ const AuditView: FC<{ audit?: Audit }> = ({ audit }: { audit?: Audit }) => {
const ConnectedAuditView: FC<{}> = () => {
const lighthouseApi = useApi(lighthouseApiRef);
const params = useParams<{ id: string }>();
const params = useParams() as { id: string };
const classes = useStyles();
const { loading, error, value: nextValue } = useAsync<Website>(
@@ -136,7 +136,7 @@ const ConnectedAuditView: FC<{}> = () => {
<AuditLinkList audits={value?.audits} selectedId={params.id} />
</Grid>
<Grid item xs={9}>
<AuditView audit={value?.audits.find((a) => a.id === params.id)} />
<AuditView audit={value?.audits.find(a => a.id === params.id)} />
</Grid>
</Grid>
);

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