Merge branch 'master' of github.com:spotify/backstage into shmidt-i/backend-hmr-2
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
+49
-60
@@ -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',
|
||||
+73
-75
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
+5
-1
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
+11
-5
@@ -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
-9
@@ -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');
|
||||
}
|
||||
};
|
||||
+12
-5
@@ -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;`);
|
||||
}
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"target": "es2019",
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"allowJs": true,
|
||||
"lib": ["es2019"],
|
||||
"types": ["node", "jest", "webpack-env"]
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
+7
-6
@@ -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();
|
||||
});
|
||||
});
|
||||
+8
-9
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+5
-6
@@ -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();
|
||||
});
|
||||
});
|
||||
+2
-1
@@ -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>
|
||||
+30
-25
@@ -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'));
|
||||
});
|
||||
});
|
||||
+21
-33
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
+23
-8
@@ -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
|
||||
@@ -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];
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -17,5 +17,4 @@
|
||||
export { plugin } from './plugin';
|
||||
export * from './api/CatalogClient';
|
||||
export * from './api/types';
|
||||
export * from './types';
|
||||
export * from './routes';
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -15,4 +15,4 @@
|
||||
*/
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
require('jest-fetch-mock').enableMocks();
|
||||
import 'whatwg-fetch';
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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<{}> = () => {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint')],
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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}"
|
||||
]
|
||||
}
|
||||
@@ -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';
|
||||
+58
@@ -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';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user