Merge branch 'master' of github.com:spotify/backstage into mob/register-unregister-components

This commit is contained in:
Ivan Shmidt
2020-06-04 17:09:15 +02:00
88 changed files with 2288 additions and 425 deletions
+8
View File
@@ -19,6 +19,14 @@ read -r AUTH_GOOGLE_CLIENT_SECRET
export AUTH_GOOGLE_CLIENT_SECRET
run `yarn start` in packages/backend folder
### SAML
To try out SAML, you can use the mock identity provider:
```bash
./scripts/start-saml-idp.sh
```
## Links
- (The Backstage homepage)[https://backstage.io]
+4
View File
@@ -20,6 +20,7 @@
"@types/passport": "^1.0.3",
"@types/passport-github2": "^1.2.4",
"@types/passport-google-oauth20": "^2.0.3",
"body-parser": "^1.19.0",
"compression": "^1.7.4",
"cookie-parser": "^1.4.5",
"cors": "^2.8.5",
@@ -31,11 +32,14 @@
"passport": "^0.4.1",
"passport-github2": "^0.1.12",
"passport-google-oauth20": "^2.0.0",
"passport-saml": "^1.3.3",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.6",
"@types/body-parser": "^1.19.0",
"@types/passport-saml": "^1.1.2",
"jest-fetch-mock": "^3.0.3",
"tsc-watch": "^4.2.3"
},
+1
View File
@@ -0,0 +1 @@
*.pem
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
cd "$DIR"
if [[ ! -f idp-public-cert.pem ]]; then
echo "Generating new SAML Certificates"
openssl req \
-x509 \
-newkey rsa:1024 \
-days 3650 \
-nodes \
-subj '/CN=localhost' \
-keyout "idp-private-key.pem" \
-out "idp-public-cert.pem"
fi
echo "Downloading and starting SAML-IdP"
export NPM_CONFIG_REGISTRY=https://registry.npmjs.org
exec npx saml-idp --acsUrl "http://localhost:7000/auth/saml/handler/frame" --audience "http://localhost:7000" --port 7001
@@ -0,0 +1,364 @@
/*
* 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 {
ensuresXRequestedWith,
postMessageResponse,
removeRefreshTokenCookie,
setRefreshTokenCookie,
THOUSAND_DAYS_MS,
setNonceCookie,
TEN_MINUTES_MS,
verifyNonce,
OAuthProvider,
} from './OAuthProvider';
import { AuthResponse, OAuthProviderHandlers } from './types';
describe('OAuthProvider Utils', () => {
describe('verifyNonce', () => {
it('should throw error if cookie nonce missing', () => {
const mockRequest = ({
cookies: {},
query: {
state: 'NONCE',
},
} as unknown) as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).toThrowError('Missing nonce');
});
it('should throw error if state nonce missing', () => {
const mockRequest = ({
cookies: {
'providera-nonce': 'NONCE',
},
query: {},
} as unknown) as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).toThrowError('Missing nonce');
});
it('should throw error if nonce mismatch', () => {
const mockRequest = ({
cookies: {
'providera-nonce': 'NONCEA',
},
query: {
state: 'NONCEB',
},
} as unknown) as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).toThrowError('Invalid nonce');
});
it('should not throw any error if nonce matches', () => {
const mockRequest = ({
cookies: {
'providera-nonce': 'NONCE',
},
query: {
state: 'NONCE',
},
} as unknown) as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).not.toThrow();
});
});
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', () => {
it('should post a message back with payload success', () => {
const mockResponse = ({
end: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
const data: AuthResponse = {
type: 'auth-result',
payload: {
accessToken: 'ACCESS_TOKEN',
idToken: 'ID_TOKEN',
expiresInSeconds: 10,
scope: 'email',
},
};
const jsonData = JSON.stringify(data);
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
postMessageResponse(mockResponse, data);
expect(mockResponse.setHeader).toBeCalledTimes(2);
expect(mockResponse.end).toBeCalledTimes(1);
expect(mockResponse.end).toBeCalledWith(
expect.stringContaining(base64Data),
);
});
it('should post a message back with payload error', () => {
const mockResponse = ({
end: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
const data: AuthResponse = {
type: 'auth-result',
error: new Error('Unknown error occured'),
};
const jsonData = JSON.stringify(data);
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
postMessageResponse(mockResponse, data);
expect(mockResponse.setHeader).toBeCalledTimes(2);
expect(mockResponse.end).toBeCalledTimes(1);
expect(mockResponse.end).toBeCalledWith(
expect.stringContaining(base64Data),
);
});
});
describe('ensuresXRequestedWith', () => {
it('should return false if no header present', () => {
const mockRequest = ({
header: () => jest.fn(),
} as unknown) as express.Request;
expect(ensuresXRequestedWith(mockRequest)).toBe(false);
});
it('should return false if header present with incorrect value', () => {
const mockRequest = ({
header: () => 'INVALID',
} as unknown) as express.Request;
expect(ensuresXRequestedWith(mockRequest)).toBe(false);
});
it('should return true if header present with correct value', () => {
const mockRequest = ({
header: () => 'XMLHttpRequest',
} as unknown) as express.Request;
expect(ensuresXRequestedWith(mockRequest)).toBe(true);
});
});
});
describe('OAuthProvider', () => {
class MyAuthProvider implements OAuthProviderHandlers {
async start() {
return {
url: '/url',
status: 301,
};
}
async handler() {
return {
user: {},
info: {
refreshToken: 'token',
},
};
}
async refresh() {
return {
accessToken: 'token',
};
}
}
const providerInstance = new MyAuthProvider();
const providerId = 'test-provider';
it('sets the correct headers in start', async () => {
const oauthProvider = new OAuthProvider(providerInstance, providerId);
const mockRequest = ({
query: {
scope: 'user',
},
} as unknown) as express.Request;
const mockResponse = ({
cookie: jest.fn().mockReturnThis(),
end: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
statusCode: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
await oauthProvider.start(mockRequest, mockResponse);
expect(mockResponse.setHeader).toHaveBeenCalledTimes(2);
expect(mockResponse.setHeader).toHaveBeenCalledWith('Location', '/url');
expect(mockResponse.setHeader).toHaveBeenCalledWith('Content-Length', '0');
expect(mockResponse.statusCode).toEqual(301);
expect(mockResponse.end).toHaveBeenCalledTimes(1);
});
it('sets the refresh cookie if refresh is enabled', async () => {
const oauthProvider = new OAuthProvider(providerInstance, providerId);
const mockRequest = ({
cookies: {
'test-provider-nonce': 'nonce',
},
query: {
state: 'nonce',
},
} as unknown) as express.Request;
const mockResponse = ({
cookie: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
end: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
await oauthProvider.frameHandler(mockRequest, mockResponse);
expect(mockResponse.cookie).toHaveBeenCalledTimes(1);
expect(mockResponse.cookie).toHaveBeenCalledWith(
expect.stringContaining('test-provider-refresh-token'),
expect.stringContaining('token'),
expect.objectContaining({ path: '/auth/test-provider' }),
);
});
it('does no set the refresh cookie if refresh is disabled', async () => {
const oauthProvider = new OAuthProvider(providerInstance, providerId, true);
const mockRequest = ({
cookies: {
'test-provider-nonce': 'nonce',
},
query: {
state: 'nonce',
},
} as unknown) as express.Request;
const mockResponse = ({
cookie: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
end: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
await oauthProvider.frameHandler(mockRequest, mockResponse);
expect(mockResponse.cookie).toHaveBeenCalledTimes(0);
});
it('removes refresh cookie when logging out', async () => {
const oauthProvider = new OAuthProvider(providerInstance, providerId);
const mockRequest = ({
header: () => 'XMLHttpRequest',
} as unknown) as express.Request;
const mockResponse = ({
cookie: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
await oauthProvider.logout(mockRequest, mockResponse);
expect(mockResponse.cookie).toHaveBeenCalledTimes(1);
expect(mockResponse.cookie).toHaveBeenCalledWith(
expect.stringContaining('test-provider-refresh-token'),
'',
expect.objectContaining({ path: '/auth/test-provider' }),
);
});
it('gets new access-token when refreshing', async () => {
const oauthProvider = new OAuthProvider(providerInstance, providerId);
const mockRequest = ({
header: () => 'XMLHttpRequest',
cookies: {
'test-provider-refresh-token': 'token',
},
query: {},
} as unknown) as express.Request;
const mockResponse = ({
send: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
await oauthProvider.refresh(mockRequest, mockResponse);
expect(mockResponse.send).toHaveBeenCalledTimes(1);
expect(mockResponse.send).toHaveBeenCalledWith(
expect.objectContaining({
accessToken: 'token',
}),
);
});
it('handles refresh without capabilities', async () => {
const oauthProvider = new OAuthProvider(providerInstance, providerId, true);
const mockRequest = ({
header: () => 'XMLHttpRequest',
cookies: {
'test-provider-refresh-token': 'token',
},
query: {},
} as unknown) as express.Request;
const mockResponse = ({
send: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
await oauthProvider.refresh(mockRequest, mockResponse);
expect(mockResponse.send).toHaveBeenCalledTimes(1);
expect(mockResponse.send).toHaveBeenCalledWith(
'Refresh token not supported for provider: test-provider',
);
});
});
@@ -55,7 +55,7 @@ export const executeFrameHandlerStrategy = async (
reject(new Error('Unexpected redirect'));
};
strategy.authenticate(req);
strategy.authenticate(req, {});
});
};
@@ -32,4 +32,12 @@ export const providers = [
},
disableRefresh: true,
},
{
provider: 'saml',
options: {
path: '/auth/saml/handler/frame',
entryPoint: 'http://localhost:7001/',
issuer: 'passport-saml',
},
},
];
+30 -30
View File
@@ -14,37 +14,37 @@
* limitations under the License.
*/
import {
AuthProviderFactories,
AuthProviderRouteHandlers,
AuthProviderConfig,
} from './types';
import { GoogleAuthProvider } from './google';
import { GithubAuthProvider } from './github';
import { OAuthProvider } from './OAuthProvider';
import Router from 'express-promise-router';
import { createGithubProvider } from './github';
import { createGoogleProvider } from './google';
import { createSamlProvider } from './saml';
import { AuthProviderFactory, AuthProviderConfig } from './types';
export class ProviderFactories {
private static readonly providerFactories: AuthProviderFactories = {
google: GoogleAuthProvider,
github: GithubAuthProvider,
};
const factories: { [providerId: string]: AuthProviderFactory } = {
google: createGoogleProvider,
github: createGithubProvider,
saml: createSamlProvider,
};
public static getProviderFactory(
config: AuthProviderConfig,
): AuthProviderRouteHandlers {
const providerId = config.provider;
const ProviderImpl = ProviderFactories.providerFactories[providerId];
if (!ProviderImpl) {
throw Error(
`Provider Implementation missing for : ${providerId} auth provider`,
);
}
const providerInstance = new ProviderImpl(config);
const oauthProvider = new OAuthProvider(
providerInstance,
providerId,
config.disableRefresh,
);
return oauthProvider;
export function createAuthProvider(providerId: string, config: any) {
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 router = Router();
router.get('/start', provider.start.bind(provider));
router.get('/handler/frame', provider.frameHandler.bind(provider));
router.post('/handler/frame', provider.frameHandler.bind(provider));
router.post('/logout', provider.logout.bind(provider));
if (provider.refresh) {
router.get('/refresh', provider.refresh.bind(provider));
}
return router;
};
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { GithubAuthProvider } from './provider';
export { createGithubProvider } from './provider';
@@ -27,6 +27,7 @@ import {
AuthInfoBase,
AuthInfoPrivate,
} from '../types';
import { OAuthProvider } from '../OAuthProvider';
export class GithubAuthProvider implements OAuthProviderHandlers {
private readonly providerConfig: AuthProviderConfig;
@@ -57,3 +58,9 @@ export class GithubAuthProvider implements OAuthProviderHandlers {
return await executeFrameHandlerStrategy(req, this._strategy);
}
}
export function createGithubProvider(config: AuthProviderConfig) {
const provider = new GithubAuthProvider(config);
const oauthProvider = new OAuthProvider(provider, config.provider, true);
return oauthProvider;
}
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { GoogleAuthProvider } from './provider';
export { createGoogleProvider } from './provider';
@@ -28,6 +28,7 @@ import {
RedirectInfo,
AuthProviderConfig,
} from '../types';
import { OAuthProvider } from '../OAuthProvider';
export class GoogleAuthProvider implements OAuthProviderHandlers {
private readonly providerConfig: AuthProviderConfig;
@@ -87,3 +88,9 @@ export class GoogleAuthProvider implements OAuthProviderHandlers {
};
}
}
export function createGoogleProvider(config: AuthProviderConfig) {
const provider = new GoogleAuthProvider(config);
const oauthProvider = new OAuthProvider(provider, config.provider);
return oauthProvider;
}
+1 -21
View File
@@ -14,24 +14,4 @@
* limitations under the License.
*/
import Router from 'express-promise-router';
import { AuthProviderRouteHandlers, AuthProviderConfig } from './types';
import { ProviderFactories } from './factories';
export const defaultRouter = (provider: AuthProviderRouteHandlers) => {
const router = Router();
router.get('/start', provider.start.bind(provider));
router.get('/handler/frame', provider.frameHandler.bind(provider));
router.get('/logout', provider.logout.bind(provider));
if (provider.refresh) {
router.get('/refresh', provider.refresh.bind(provider));
}
return router;
};
export const makeProvider = (config: AuthProviderConfig) => {
const providerId = config.provider;
const oauthProvider = ProviderFactories.getProviderFactory(config);
const providerRouter = defaultRouter(oauthProvider);
return { providerId, providerRouter };
};
export { createAuthProviderRouter } from './factories';
@@ -14,10 +14,4 @@
* limitations under the License.
*/
import { defaultRouter } from '.';
describe('test', () => {
it('unbreaks the test runner', () => {
expect(defaultRouter).toBeDefined();
});
});
export { createSamlProvider } from './provider';
@@ -0,0 +1,82 @@
/*
* 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 { Strategy as SamlStrategy } from 'passport-saml';
import {
executeFrameHandlerStrategy,
executeRedirectStrategy,
} from '../PassportStrategyHelper';
import { AuthProviderConfig, AuthProviderRouteHandlers } from '../types';
import { postMessageResponse } from '../OAuthProvider';
export class SamlAuthProvider implements AuthProviderRouteHandlers {
private readonly strategy: SamlStrategy;
constructor(providerConfig: AuthProviderConfig) {
this.strategy = new SamlStrategy(
{ ...providerConfig.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
// for non-oauth auth flows.
// TODO: This flow doesn't issue an identity token that can be used to validate
// the identity of the user in other backends, which we need in some form.
done(undefined, {
email: profile.email,
firstName: profile.firstName,
lastName: profile.lastName,
displayName: profile.displayName,
});
},
);
}
async start(req: express.Request, res: express.Response): Promise<any> {
const { url } = await executeRedirectStrategy(req, this.strategy, {});
res.redirect(url);
}
async frameHandler(
req: express.Request,
res: express.Response,
): Promise<any> {
try {
const { user } = await executeFrameHandlerStrategy(req, this.strategy);
return postMessageResponse(res, {
type: 'auth-result',
payload: user,
});
} catch (error) {
return postMessageResponse(res, {
type: 'auth-result',
error: {
name: error.name,
message: error.message,
},
});
}
}
async logout(_req: express.Request, res: express.Response): Promise<any> {
res.send('noop');
}
}
export function createSamlProvider(config: AuthProviderConfig) {
return new SamlAuthProvider(config);
}
+3 -7
View File
@@ -37,13 +37,9 @@ export interface AuthProviderRouteHandlers {
logout(req: express.Request, res: express.Response): Promise<any>;
}
export type AuthProviderFactories = {
[key: string]: AuthProviderFactory;
};
export type AuthProviderFactory = {
new (providerConfig: any): OAuthProviderHandlers;
};
export type AuthProviderFactory = (
config: AuthProviderConfig,
) => AuthProviderRouteHandlers;
export type AuthInfoBase = {
accessToken: string;
+8 -4
View File
@@ -17,9 +17,10 @@
import express from 'express';
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 { makeProvider } from '../providers';
import { createAuthProviderRouter } from '../providers';
export interface RouterOptions {
logger: Logger;
@@ -32,12 +33,15 @@ export async function createRouter(
const logger = options.logger.child({ plugin: 'auth' });
router.use(cookieParser());
router.use(bodyParser.urlencoded({ extended: false }));
router.use(bodyParser.json());
// configure all the providers
for (const providerConfig of providers) {
const { providerId, providerRouter } = makeProvider(providerConfig);
logger.info(`Configuring provider, ${providerId}`);
router.use(`/${providerId}`, providerRouter);
const { provider } = providerConfig;
const providerRouter = createAuthProviderRouter(providerConfig);
logger.info(`Configuring provider, ${provider}`);
router.use(`/${provider}`, providerRouter);
}
return router;
+4 -2
View File
@@ -6,19 +6,21 @@
"license": "Apache-2.0",
"private": true,
"scripts": {
"start": "tsc-watch --onFirstSuccess \"cross-env NODE_ENV=development nodemon dist/run.js\"",
"start": "backstage-cli watch-deps --build -- tsc-watch --onFirstSuccess \\\"cross-env NODE_ENV=development nodemon -r esm dist/run.js\\\"",
"build": "tsc",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
"clean": "backstage-cli clean",
"mock-data": "./scripts/mock-data"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.6",
"@backstage/catalog-model": "^0.1.1-alpha.6",
"compression": "^1.7.4",
"cors": "^2.8.5",
"esm": "^3.2.25",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"fs-extra": "^9.0.0",
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env sh
curl \
--location \
--request POST 'localhost:3003/locations' \
--header 'Content-Type: application/json' \
--data-raw '{
"type": "github",
"target": "https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/fixtures/two_components.yaml"
}'
@@ -18,7 +18,7 @@ import Knex from 'knex';
import path from 'path';
import { Logger } from 'winston';
import { CommonDatabase } from './CommonDatabase';
import type { Database } from './types';
import { Database } from './types';
export class DatabaseManager {
public static async createDatabase(
@@ -31,4 +31,18 @@ export class DatabaseManager {
});
return new CommonDatabase(knex, logger);
}
public static async createInMemoryDatabase(
logger: Logger,
): 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', () => {});
});
return DatabaseManager.createDatabase(knex, logger);
}
}
@@ -18,4 +18,4 @@ export * from './descriptor';
export { HigherOrderOperations } from './HigherOrderOperations';
export { IngestionModels } from './IngestionModels';
export * from './source';
export type { IngestionModel } from './types';
export type { HigherOrderOperation, IngestionModel } from './types';
@@ -28,4 +28,5 @@ export type IngestionModel = {
export type HigherOrderOperation = {
addLocation(spec: LocationSpec): Promise<AddLocationResult>;
refreshAllLocations(): Promise<void>;
};
@@ -48,6 +48,7 @@ describe('createRouter', () => {
};
higherOrderOperation = {
addLocation: jest.fn(),
refreshAllLocations: jest.fn(),
};
const router = await createRouter({
entitiesCatalog,
@@ -80,6 +81,7 @@ describe('createRouter', () => {
const response = await request(app).get('/entities?a=1&a=&a=3&b=4&c=');
expect(response.status).toEqual(200);
expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1);
expect(entitiesCatalog.entities).toHaveBeenCalledWith([
{ key: 'a', values: ['1', null, '3'] },
{ key: 'b', values: ['4'] },
@@ -101,14 +103,19 @@ describe('createRouter', () => {
const response = await request(app).get('/entities/by-uid/zzz');
expect(entitiesCatalog.entityByUid).toHaveBeenCalledTimes(1);
expect(entitiesCatalog.entityByUid).toHaveBeenCalledWith('zzz');
expect(response.status).toEqual(200);
expect(response.body).toEqual(expect.objectContaining(entity));
});
it('responds with a 404 for missing entities', async () => {
entitiesCatalog.entityByUid.mockResolvedValue(undefined);
const response = await request(app).get('/entities/by-uid/zzz');
expect(entitiesCatalog.entityByUid).toHaveBeenCalledTimes(1);
expect(entitiesCatalog.entityByUid).toHaveBeenCalledWith('zzz');
expect(response.status).toEqual(404);
expect(response.text).toMatch(/uid/);
});
@@ -118,16 +125,18 @@ describe('createRouter', () => {
it('can fetch entity by name', async () => {
const entity: Entity = {
apiVersion: 'a',
kind: 'b',
kind: 'k',
metadata: {
name: 'c',
namespace: 'd',
name: 'n',
namespace: 'ns',
},
};
entitiesCatalog.entityByName.mockResolvedValue(entity);
const response = await request(app).get('/entities/by-name/b/d/c');
const response = await request(app).get('/entities/by-name/k/ns/n');
expect(entitiesCatalog.entityByName).toHaveBeenCalledTimes(1);
expect(entitiesCatalog.entityByName).toHaveBeenCalledWith('k', 'ns', 'n');
expect(response.status).toEqual(200);
expect(response.body).toEqual(expect.objectContaining(entity));
});
@@ -135,8 +144,10 @@ describe('createRouter', () => {
it('responds with a 404 for missing entities', async () => {
entitiesCatalog.entityByName.mockResolvedValue(undefined);
const response = await request(app).get('/entities/by-name//b/d/c');
const response = await request(app).get('/entities/by-name/b/d/c');
expect(entitiesCatalog.entityByName).toHaveBeenCalledTimes(1);
expect(entitiesCatalog.entityByName).toHaveBeenCalledWith('b', 'd', 'c');
expect(response.status).toEqual(404);
expect(response.text).toMatch(/name/);
});
@@ -149,9 +160,9 @@ describe('createRouter', () => {
.set('Content-Type', 'application/json')
.send();
expect(entitiesCatalog.addOrUpdateEntity).not.toHaveBeenCalled();
expect(response.status).toEqual(400);
expect(response.text).toMatch(/body/);
expect(entitiesCatalog.addOrUpdateEntity).not.toHaveBeenCalled();
});
it('passes the body down', async () => {
@@ -171,13 +182,13 @@ describe('createRouter', () => {
.send(entity)
.set('Content-Type', 'application/json');
expect(response.status).toEqual(200);
expect(response.body).toEqual(entity);
expect(entitiesCatalog.addOrUpdateEntity).toHaveBeenCalledTimes(1);
expect(entitiesCatalog.addOrUpdateEntity).toHaveBeenNthCalledWith(
1,
entity,
);
expect(response.status).toEqual(200);
expect(response.body).toEqual(entity);
});
});
@@ -187,8 +198,9 @@ describe('createRouter', () => {
const response = await request(app).delete('/entities/by-uid/apa');
expect(response.status).toEqual(204);
expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledTimes(1);
expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledWith('apa');
expect(response.status).toEqual(204);
});
it('responds with a 404 for missing entities', async () => {
@@ -198,8 +210,9 @@ describe('createRouter', () => {
const response = await request(app).delete('/entities/by-uid/apa');
expect(response.status).toEqual(404);
expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledTimes(1);
expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledWith('apa');
expect(response.status).toEqual(404);
});
});
@@ -229,8 +242,8 @@ describe('createRouter', () => {
const response = await request(app).post('/locations').send(spec);
expect(response.status).toEqual(400);
expect(higherOrderOperation.addLocation).not.toHaveBeenCalled();
expect(response.status).toEqual(400);
});
it('passes the body down', async () => {
@@ -246,9 +259,14 @@ describe('createRouter', () => {
const response = await request(app).post('/locations').send(spec);
expect(response.status).toEqual(201);
expect(higherOrderOperation.addLocation).toHaveBeenCalledTimes(1);
expect(higherOrderOperation.addLocation).toHaveBeenCalledWith(spec);
expect(response.status).toEqual(201);
expect(response.body).toEqual(
expect.objectContaining({
location: { id: 'a', ...spec },
}),
);
});
});
});
@@ -25,19 +25,27 @@ import express from 'express';
import helmet from 'helmet';
import { Logger } from 'winston';
import { EntitiesCatalog, LocationsCatalog } from '../catalog';
import { HigherOrderOperation } from '../ingestion';
import { createRouter } from './router';
export interface ApplicationOptions {
enableCors: boolean;
entitiesCatalog: EntitiesCatalog;
locationsCatalog?: LocationsCatalog;
higherOrderOperation?: HigherOrderOperation;
logger: Logger;
}
export async function createStandaloneApplication(
options: ApplicationOptions,
): Promise<express.Application> {
const { enableCors, entitiesCatalog, locationsCatalog, logger } = options;
const {
enableCors,
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
logger,
} = options;
const app = express();
app.use(helmet());
@@ -49,7 +57,12 @@ export async function createStandaloneApplication(
app.use(requestLoggingHandler());
app.use(
'/',
await createRouter({ entitiesCatalog, locationsCatalog, logger }),
await createRouter({
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
logger,
}),
);
app.use(notFoundHandler());
app.use(errorHandler());
@@ -16,8 +16,13 @@
import { Server } from 'http';
import { Logger } from 'winston';
import { StaticEntitiesCatalog } from '../catalog';
import { createStandaloneApplication } from './standaloneApplication';
import { DatabaseEntitiesCatalog } from '../catalog/DatabaseEntitiesCatalog';
import { DatabaseManager } from '../database/DatabaseManager';
import { DatabaseLocationsCatalog } from '../catalog/DatabaseLocationsCatalog';
import { LocationReaders } from '../ingestion/source/LocationReaders';
import { IngestionModels, DescriptorParsers, HigherOrderOperations } from '..';
import { EntityPolicies } from '@backstage/catalog-model';
export interface ServerOptions {
port: number;
@@ -30,25 +35,28 @@ export async function startStandaloneServer(
): Promise<Server> {
const logger = options.logger.child({ service: 'catalog-backend' });
const entitiesCatalog = new StaticEntitiesCatalog([
{
apiVersion: 'backstage.io/v1beta1',
kind: 'Component',
metadata: { name: 'c1' },
spec: { type: 'service' },
},
{
apiVersion: 'backstage.io/v1beta1',
kind: 'Component',
metadata: { name: 'c2' },
spec: { type: 'service' },
},
]);
const db = await DatabaseManager.createInMemoryDatabase(logger);
const entitiesCatalog = new DatabaseEntitiesCatalog(db);
const locationsCatalog = new DatabaseLocationsCatalog(db);
const ingestionModel = new IngestionModels(
new LocationReaders(),
new DescriptorParsers(),
new EntityPolicies(),
);
const higherOrderOperation = new HigherOrderOperations(
entitiesCatalog,
locationsCatalog,
ingestionModel,
logger,
);
logger.debug('Creating application...');
const app = await createStandaloneApplication({
enableCors: options.enableCors,
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
logger,
});
+19
View File
@@ -16,6 +16,7 @@
import { CatalogApi } from './types';
import { DescriptorEnvelope } from '../types';
import { Entity, Location } from '@backstage/catalog-model';
export class CatalogClient implements CatalogApi {
private apiOrigin: string;
@@ -69,4 +70,22 @@ export class CatalogClient implements CatalogApi {
entities,
};
}
async getLocationByEntity(entity: Entity): Promise<Location | undefined> {
const findLocationIdInEntity = (e: Entity): string | undefined =>
e.metadata.annotations?.['backstage.io/managed-by-location'];
const locationId = findLocationIdInEntity(entity);
if (!locationId) return undefined;
const response = await fetch(
`${this.apiOrigin}${this.basePath}/locations/${locationId}`,
);
if (response.ok) {
const location = await response.json();
if (location) return location.data;
}
return undefined;
}
}
+1
View File
@@ -26,6 +26,7 @@ export interface CatalogApi {
getEntities(): Promise<Entity[]>;
getEntityByName(name: string): Promise<Entity>;
addLocation(type: string, target: string): Promise<AddLocationResponse>;
getLocationByEntity(entity: Entity): Promise<Location | undefined>;
}
export type AddLocationResponse = { location: Location; entities: Entity[] };
@@ -35,6 +35,8 @@ const catalogApi: Partial<CatalogApi> = {
kind: 'Component',
},
] as Entity[]),
getLocationByEntity: () =>
Promise.resolve({ id: 'id', type: 'github', target: 'url' }),
};
describe('CatalogPage', () => {
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React, { FC } from 'react';
import React, { FC, useCallback, useState, useEffect } from 'react';
import {
Content,
ContentHeader,
@@ -26,7 +26,7 @@ import {
pageTheme,
useApi,
} from '@backstage/core';
import { useAsync } from 'react-use';
import { useAsync, useMountedState } from 'react-use';
import CatalogTable from '../CatalogTable/CatalogTable';
import {
CatalogFilter,
@@ -36,6 +36,8 @@ import { Button, makeStyles, Typography, Link } from '@material-ui/core';
import { filterGroups, defaultFilter } from '../../data/filters';
import { Link as RouterLink } from 'react-router-dom';
import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder';
import GitHub from '@material-ui/icons/GitHub';
import { Entity, Location } from '@backstage/catalog-model';
const useStyles = makeStyles(theme => ({
contentWrapper: {
@@ -48,20 +50,64 @@ const useStyles = makeStyles(theme => ({
import { catalogApiRef } from '../..';
import { envelopeToComponent } from '../../data/utils';
import { Component } from '../../data/component';
const CatalogPage: FC<{}> = () => {
const catalogApi = useApi(catalogApiRef);
const { value, error, loading } = useAsync(() => catalogApi.getEntities());
const [selectedFilter, setSelectedFilter] = React.useState<CatalogFilterItem>(
const [locations, setLocations] = useState<Location[]>([]);
const [selectedFilter, setSelectedFilter] = useState<CatalogFilterItem>(
defaultFilter,
);
const isMounted = useMountedState();
const onFilterSelected = React.useCallback(
const onFilterSelected = useCallback(
selected => setSelectedFilter(selected),
[],
);
const styles = useStyles();
useEffect(() => {
const getLocationDataForEntities = async (entities: Entity[]) => {
return Promise.all(
entities.map(entity => catalogApi.getLocationByEntity(entity)),
);
};
if (value) {
getLocationDataForEntities(value)
.then(
(location): Location[] =>
location.filter(l => !!l) as Array<Location>,
)
.then(location => {
if (isMounted()) setLocations(location);
});
}
}, [value, catalogApi, isMounted]);
const actions = [
(rowData: Component) => ({
icon: GitHub,
tooltip: 'View on GitHub',
onClick: () => {
if (!rowData || !rowData.location) return;
window.open(rowData.location.target, '_blank');
},
hidden:
rowData && rowData.location ? rowData.location.type !== 'github' : true,
}),
];
const findLocationForEntity = (
entity: Entity,
l: Location[],
): Location | undefined => {
const entityLocationId =
entity.metadata.annotations?.['backstage.io/managed-by-location'];
return l.find(location => location.id === entityLocationId);
};
return (
<Page theme={pageTheme.home}>
<Header title="Service Catalog" subtitle="Keep track of your software">
@@ -105,9 +151,19 @@ const CatalogPage: FC<{}> = () => {
</div>
<CatalogTable
titlePreamble={selectedFilter.label}
components={(value && value.map(envelopeToComponent)) || []}
components={
(value &&
value.map(val =>
envelopeToComponent(
val,
findLocationForEntity(val, locations),
),
)) ||
[]
}
loading={loading}
error={error}
actions={actions}
/>
</div>
</Content>
@@ -49,12 +49,14 @@ type CatalogTableProps = {
titlePreamble: string;
loading: boolean;
error?: any;
actions?: any;
};
const CatalogTable: FC<CatalogTableProps> = ({
components,
loading,
error,
titlePreamble,
actions,
}) => {
if (loading) {
return <Progress />;
@@ -71,9 +73,10 @@ const CatalogTable: FC<CatalogTableProps> = ({
return (
<Table
columns={columns}
options={{ paging: false }}
options={{ paging: false, actionsColumnIndex: -1 }}
title={`${titlePreamble} (${(components && components.length) || 0})`}
data={components}
actions={actions}
/>
);
};
+3
View File
@@ -13,8 +13,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Location } from '@backstage/catalog-model';
export type Component = {
name: string;
kind: string;
description: string;
location?: Location;
};
+6 -2
View File
@@ -14,12 +14,16 @@
* limitations under the License.
*/
import { Component } from './component';
import { Entity } from '@backstage/catalog-model';
import { Entity, Location } from '@backstage/catalog-model';
export function envelopeToComponent(envelope: Entity): Component {
export function envelopeToComponent(
envelope: Entity,
location?: Location,
): Component {
return {
name: envelope.metadata?.name ?? '',
kind: envelope.kind ?? 'unknown',
description: envelope.metadata?.annotations?.description ?? 'placeholder',
location,
};
}
+1 -1
View File
@@ -48,7 +48,7 @@
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
"@types/codemirror": "^0.0.93",
"@types/codemirror": "^0.0.95",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"@types/testing-library__jest-dom": "^5.0.4",
@@ -19,7 +19,7 @@ jobs:
- name: get yarn cache
id: yarn-cache
run: echo "::set-output name=dir::$(yarn cache dir)"
- uses: actions/cache@v1
- uses: actions/cache@v2
with:
path: ${{ steps.yarn-cache.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}