Merge branch 'master' into feature/AwsIamAuthForKubernetes

This commit is contained in:
Jonah Back
2021-01-23 16:01:49 -08:00
346 changed files with 8073 additions and 2363 deletions
+16
View File
@@ -1,5 +1,21 @@
# @backstage/plugin-api-docs
## 0.4.3
### Patch Changes
- 8855f61f6: Update `@asyncapi/react-component` to 0.18.2
- Updated dependencies [def2307f3]
- Updated dependencies [efd6ef753]
- Updated dependencies [593632f07]
- Updated dependencies [33846acfc]
- Updated dependencies [a187b8ad0]
- Updated dependencies [f04db53d7]
- Updated dependencies [a93f42213]
- @backstage/catalog-model@0.7.0
- @backstage/core@0.5.0
- @backstage/plugin-catalog@0.2.12
## 0.4.2
### Patch Changes
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-api-docs",
"version": "0.4.2",
"version": "0.4.3",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -29,11 +29,11 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.6.0",
"@backstage/core": "^0.4.4",
"@backstage/plugin-catalog": "^0.2.9",
"@asyncapi/react-component": "^0.18.2",
"@backstage/catalog-model": "^0.7.0",
"@backstage/core": "^0.5.0",
"@backstage/plugin-catalog": "^0.2.12",
"@backstage/theme": "^0.2.2",
"@kyma-project/asyncapi-react": "^0.14.2",
"@material-icons/font": "^1.0.2",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
@@ -49,8 +49,8 @@
"swagger-ui-react": "^3.37.2"
},
"devDependencies": {
"@backstage/cli": "^0.4.6",
"@backstage/dev-utils": "^0.1.7",
"@backstage/cli": "^0.4.7",
"@backstage/dev-utils": "^0.1.8",
"@backstage/test-utils": "^0.1.6",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
@@ -14,10 +14,10 @@
* limitations under the License.
*/
import AsyncApi from '@kyma-project/asyncapi-react';
import AsyncApi from '@asyncapi/react-component';
import '@asyncapi/react-component/lib/styles/fiori.css';
import { fade, makeStyles } from '@material-ui/core/styles';
import React from 'react';
import { makeStyles, fade } from '@material-ui/core/styles';
import '@kyma-project/asyncapi-react/lib/styles/fiori.css';
const useStyles = makeStyles(theme => ({
root: {
+11
View File
@@ -1,5 +1,16 @@
# @backstage/plugin-app-backend
## 0.3.4
### Patch Changes
- Updated dependencies [0b135e7e0]
- Updated dependencies [294a70cab]
- Updated dependencies [0ea032763]
- Updated dependencies [5345a1f98]
- Updated dependencies [09a370426]
- @backstage/backend-common@0.5.0
## 0.3.3
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-app-backend",
"version": "0.3.3",
"version": "0.3.4",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -29,7 +29,7 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.4.0",
"@backstage/backend-common": "^0.5.0",
"@backstage/config-loader": "^0.4.0",
"@backstage/config": "^0.1.2",
"@types/express": "^4.17.6",
@@ -40,7 +40,7 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.4.1",
"@backstage/cli": "^0.4.7",
"@types/supertest": "^2.0.8",
"msw": "^0.20.5",
"supertest": "^4.0.2"
+17
View File
@@ -1,5 +1,22 @@
# @backstage/plugin-auth-backend
## 0.2.11
### Patch Changes
- 0643a3336: Add AWS ALB OIDC reverse proxy authentication provider
- a2291d7cc: Optional identity token authorization of api requests
- Updated dependencies [def2307f3]
- Updated dependencies [0b135e7e0]
- Updated dependencies [294a70cab]
- Updated dependencies [0ea032763]
- Updated dependencies [5345a1f98]
- Updated dependencies [09a370426]
- Updated dependencies [a93f42213]
- @backstage/catalog-model@0.7.0
- @backstage/backend-common@0.5.0
- @backstage/catalog-client@0.3.5
## 0.2.10
### Patch Changes
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend",
"version": "0.2.10",
"version": "0.2.11",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -29,9 +29,9 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.4.3",
"@backstage/catalog-client": "^0.3.4",
"@backstage/catalog-model": "^0.6.1",
"@backstage/backend-common": "^0.5.0",
"@backstage/catalog-client": "^0.3.5",
"@backstage/catalog-model": "^0.7.0",
"@backstage/config": "^0.1.2",
"@types/express": "^4.17.6",
"compression": "^1.7.4",
@@ -49,6 +49,7 @@
"knex": "^0.21.6",
"moment": "^2.26.0",
"morgan": "^1.10.0",
"node-cache": "^5.1.2",
"openid-client": "^4.2.1",
"passport": "^0.4.1",
"passport-github2": "^0.1.12",
@@ -64,7 +65,7 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.4.6",
"@backstage/cli": "^0.4.7",
"@types/body-parser": "^1.19.0",
"@types/cookie-parser": "^1.4.2",
"@types/express-session": "^1.17.2",
@@ -0,0 +1,248 @@
/*
* 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 { JWT, JSONWebKey } from 'jose';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import {
getVoidLogger,
PluginEndpointDiscovery,
} from '@backstage/backend-common';
import { IdentityClient } from './IdentityClient';
import { MemoryKeyStore } from './MemoryKeyStore';
import { TokenFactory } from './TokenFactory';
import { KeyStore } from './types';
const logger = getVoidLogger();
function jwtKid(jwt: string): string {
const { header } = JWT.decode(jwt, { complete: true }) as {
header: { kid: string };
};
return header.kid;
}
const server = setupServer();
const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base';
const discovery: PluginEndpointDiscovery = {
async getBaseUrl() {
return mockBaseUrl;
},
async getExternalBaseUrl() {
return mockBaseUrl;
},
};
describe('IdentityClient', () => {
let client: IdentityClient;
let factory: TokenFactory;
let keyStore: KeyStore;
const keyDurationSeconds = 5;
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterAll(() => server.close());
afterEach(() => server.resetHandlers());
beforeEach(() => {
client = new IdentityClient({ discovery, issuer: mockBaseUrl });
keyStore = new MemoryKeyStore();
factory = new TokenFactory({
issuer: mockBaseUrl,
keyStore: keyStore,
keyDurationSeconds,
logger,
});
});
describe('authenticate', () => {
beforeEach(() => {
server.use(
rest.get(
`${mockBaseUrl}/.well-known/jwks.json`,
async (_, res, ctx) => {
const keys = await factory.listPublicKeys();
return res(ctx.json(keys));
},
),
);
});
it('should use the correct endpoint', async () => {
await factory.issueToken({ claims: { sub: 'foo' } });
const keys = await factory.listPublicKeys();
const response = await client.listPublicKeys();
expect(response).toEqual(keys);
});
it('should throw on undefined header', async () => {
return expect(async () => {
await client.authenticate(undefined);
}).rejects.toThrow();
});
it('should accept fresh token', async () => {
const token = await factory.issueToken({ claims: { sub: 'foo' } });
const response = await client.authenticate(token);
expect(response).toEqual({ id: 'foo', idToken: token });
});
it('should throw on incorrect issuer', async () => {
const hackerFactory = new TokenFactory({
issuer: 'hacker',
keyStore,
keyDurationSeconds,
logger,
});
return expect(async () => {
const token = await hackerFactory.issueToken({
claims: { sub: 'foo' },
});
await client.authenticate(token);
}).rejects.toThrow();
});
it('should throw on expired token', async () => {
return expect(async () => {
const fixedTime = Date.now();
jest
.spyOn(Date, 'now')
.mockImplementation(() => fixedTime - keyDurationSeconds * 1000 * 2);
const token = await factory.issueToken({
claims: { sub: 'foo' },
});
jest.spyOn(Date, 'now').mockImplementation(() => fixedTime);
await client.authenticate(token);
}).rejects.toThrow();
});
it('should throw on incorrect signing key', async () => {
const hackerFactory = new TokenFactory({
issuer: mockBaseUrl,
keyStore: new MemoryKeyStore(),
keyDurationSeconds,
logger,
});
return expect(async () => {
const token = await hackerFactory.issueToken({
claims: { sub: 'foo' },
});
await client.authenticate(token);
}).rejects.toThrow();
});
it('should accept token from new key', async () => {
const fixedTime = Date.now();
jest
.spyOn(Date, 'now')
.mockImplementation(() => fixedTime - keyDurationSeconds * 1000 * 2);
const token1 = await factory.issueToken({ claims: { sub: 'foo1' } });
try {
// This throws as token has already expired
await client.authenticate(token1);
} catch (_err) {
// Ignore thrown error
}
// Move forward in time where the signing key has been rotated
jest.spyOn(Date, 'now').mockImplementation(() => fixedTime);
const token = await factory.issueToken({ claims: { sub: 'foo' } });
const response = await client.authenticate(token);
expect(response).toEqual({ id: 'foo', idToken: token });
});
it('should not be fooled by the none algorithm', async () => {
return expect(async () => {
const token = await factory.issueToken({ claims: { sub: 'foo' } });
const header = btoa(
JSON.stringify({ alg: 'none', kid: jwtKid(token) }),
);
const payload = btoa(
JSON.stringify({
iss: mockBaseUrl,
sub: 'foo',
aud: 'backstage',
iat: Date.now() / 1000,
exp: Date.now() / 1000 + 60000,
}),
);
const fakeToken = `${header}.${payload}.`;
return await client.authenticate(fakeToken);
}).rejects.toThrow();
});
});
describe('getBearerToken', () => {
it('should return undefined on undefined input', async () => {
const token = IdentityClient.getBearerToken(undefined);
expect(token).toBeUndefined();
});
it('should return undefined on malformed input', async () => {
const token = IdentityClient.getBearerToken('malformed');
expect(token).toBeUndefined();
});
it('should return undefined on unexpected scheme', async () => {
const token = IdentityClient.getBearerToken('Basic token');
expect(token).toBeUndefined();
});
it('should return Bearer token', async () => {
const token = IdentityClient.getBearerToken('Bearer token');
expect(token).toEqual('token');
});
it('should return Bearer token despite extra space', async () => {
const token = IdentityClient.getBearerToken('Bearer \n token ');
expect(token).toEqual('token');
});
it('should return Bearer token despite unconventionial case', async () => {
const token = IdentityClient.getBearerToken('bEARER token');
expect(token).toEqual('token');
});
});
describe('listPublicKeys', () => {
const defaultServiceResponse: {
keys: JSONWebKey[];
} = {
keys: [
{
crv: 'P-256',
x: 'JWy80Goa-8C3oaeDLnk0ANVPPMfI9T3u_T5T7W2b_ls',
y: 'Ge6jAhCDW1PFBfme2RA5ZsXN0cESiCwW29LMRPX5wkw',
kty: 'EC',
kid: 'kid-a',
alg: 'ES256',
use: 'sig',
},
],
};
beforeEach(() => {
server.use(
rest.get(`${mockBaseUrl}/.well-known/jwks.json`, (_, res, ctx) => {
return res(ctx.json(defaultServiceResponse));
}),
);
});
it('should use the correct endpoint', async () => {
const response = await client.listPublicKeys();
expect(response).toEqual(defaultServiceResponse);
});
});
});
@@ -0,0 +1,146 @@
/*
* 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 fetch from 'cross-fetch';
import { JWK, JWT, JWKS, JSONWebKey } from 'jose';
import { BackstageIdentity } from '../providers';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
const CLOCK_MARGIN_S = 10;
/**
* A identity client to interact with auth-backend
* and authenticate backstage identity tokens
*
* @experimental This is not a stable API yet
*/
export class IdentityClient {
private readonly discovery: PluginEndpointDiscovery;
private readonly issuer: string;
private keyStore: JWKS.KeyStore;
private keyStoreUpdated: number;
constructor(options: { discovery: PluginEndpointDiscovery; issuer: string }) {
this.discovery = options.discovery;
this.issuer = options.issuer;
this.keyStore = new JWKS.KeyStore();
this.keyStoreUpdated = 0;
}
/**
* Verifies the given backstage identity token
* Returns a BackstageIdentity (user) matching the token.
* The method throws an error if verification fails.
*/
async authenticate(token: string | undefined): Promise<BackstageIdentity> {
// Extract token from header
if (!token) {
throw new Error('No token specified');
}
// Get signing key matching token
const key = await this.getKey(token);
if (!key) {
throw new Error('No signing key matching token found');
}
// Verify token claims and signature
// Note: Claims must match those set by TokenFactory when issuing tokens
// Note: verify throws if verification fails
const decoded = JWT.IdToken.verify(token, key, {
algorithms: ['ES256'],
audience: 'backstage',
issuer: this.issuer,
}) as { sub: string };
// Verified, return the matching user as BackstageIdentity
// TODO: Settle internal user format/properties
const user: BackstageIdentity = {
id: decoded.sub,
idToken: token,
};
return user;
}
/**
* Parses the given authorization header and returns
* the bearer token, or null if no bearer token is given
*/
static getBearerToken(
authorizationHeader: string | undefined,
): string | undefined {
if (typeof authorizationHeader !== 'string') {
return undefined;
}
const matches = authorizationHeader.match(/Bearer\s+(\S+)/i);
return matches?.[1];
}
/**
* Returns the public signing key matching the given jwt token,
* or null if no matching key was found
*/
private async getKey(rawJwtToken: string): Promise<JWK.Key | null> {
const { header, payload } = JWT.decode(rawJwtToken, {
complete: true,
}) as {
header: { kid: string };
payload: { iat: number };
};
// Refresh public keys if needed
// Add a small margin in case clocks are out of sync
const keyStoreHasKey = !!this.keyStore.get({ kid: header.kid });
const issuedAfterLastRefresh =
payload?.iat && payload.iat > this.keyStoreUpdated - CLOCK_MARGIN_S;
if (!keyStoreHasKey && issuedAfterLastRefresh) {
await this.refreshKeyStore();
}
return this.keyStore.get({ kid: header.kid });
}
/**
* Lists public part of keys used to sign Backstage Identity tokens
*/
async listPublicKeys(): Promise<{
keys: JSONWebKey[];
}> {
const url = `${await this.discovery.getBaseUrl(
'auth',
)}/.well-known/jwks.json`;
const response = await fetch(url);
if (!response.ok) {
const payload = await response.text();
const message = `Request failed with ${response.status} ${response.statusText}, ${payload}`;
throw new Error(message);
}
const publicKeys: { keys: JSONWebKey[] } = await response.json();
return publicKeys;
}
/**
* Fetches public keys and caches them locally
*/
private async refreshKeyStore(): Promise<void> {
const now = Date.now() / 1000;
const publicKeys = await this.listPublicKeys();
this.keyStore = JWKS.asKeyStore({
keys: publicKeys.keys.map(key => key as JSONWebKey),
});
this.keyStoreUpdated = now;
}
}
@@ -0,0 +1,47 @@
/*
* 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 { utc } from 'moment';
import { KeyStore, AnyJWK, StoredKey } from './types';
export class MemoryKeyStore implements KeyStore {
private readonly keys = new Map<
string,
{ createdAt: moment.Moment; key: string }
>();
async addKey(key: AnyJWK): Promise<void> {
this.keys.set(key.kid, {
createdAt: utc(),
key: JSON.stringify(key),
});
}
async removeKeys(kids: string[]): Promise<void> {
for (const kid of kids) {
this.keys.delete(kid);
}
}
async listKeys(): Promise<{ items: StoredKey[] }> {
return {
items: Array.from(this.keys).map(([, { createdAt, key: keyStr }]) => ({
createdAt,
key: JSON.parse(keyStr),
})),
};
}
}
@@ -14,43 +14,13 @@
* limitations under the License.
*/
import { utc } from 'moment';
import { MemoryKeyStore } from './MemoryKeyStore';
import { TokenFactory } from './TokenFactory';
import { getVoidLogger } from '@backstage/backend-common';
import { KeyStore, AnyJWK, StoredKey } from './types';
import { JWKS, JSONWebKey, JWT } from 'jose';
const logger = getVoidLogger();
class MemoryKeyStore implements KeyStore {
private readonly keys = new Map<
string,
{ createdAt: moment.Moment; key: string }
>();
async addKey(key: AnyJWK): Promise<void> {
this.keys.set(key.kid, {
createdAt: utc(),
key: JSON.stringify(key),
});
}
async removeKeys(kids: string[]): Promise<void> {
for (const kid of kids) {
this.keys.delete(kid);
}
}
async listKeys(): Promise<{ items: StoredKey[] }> {
return {
items: Array.from(this.keys).map(([, { createdAt, key: keyStr }]) => ({
createdAt,
key: JSON.parse(keyStr),
})),
};
}
}
function jwtKid(jwt: string): string {
const { header } = JWT.decode(jwt, { complete: true }) as {
header: { kid: string };
@@ -15,6 +15,7 @@
*/
export { createOidcRouter } from './router';
export { IdentityClient } from './IdentityClient';
export { TokenFactory } from './TokenFactory';
export { DatabaseKeyStore } from './DatabaseKeyStore';
export type { KeyStore, TokenIssuer, TokenParams } from './types';
+1
View File
@@ -15,6 +15,7 @@
*/
export * from './service/router';
export { IdentityClient } from './identity';
export * from './providers';
// flow package provides 2 functions
@@ -0,0 +1,16 @@
/*
* Copyright 2021 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 { createAwsAlbProvider } from './provider';
@@ -0,0 +1,179 @@
/*
* 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 { getVoidLogger } from '@backstage/backend-common';
import express from 'express';
import { JWT } from 'jose';
import { AwsAlbAuthProvider } from './provider';
import { AuthResponse } from '../types';
const jwtMock = JWT as jest.Mocked<any>;
const mockKey = async () => {
return `-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEnuN4LlaJhaUpx+qZFTzYCrSBLk0I
yOlxJ2VW88mLAQGJ7HPAvOdylxZsItMnzCuqNzZvie8m/NJsOjhDncVkrw==
-----END PUBLIC KEY-----
`;
};
jest.mock('jose');
jest.mock('cross-fetch', () => ({
__esModule: true,
default: async () => {
return {
text: async () => {
return mockKey();
},
};
},
}));
const identityResolutionCallbackMock = async (): Promise<AuthResponse<any>> => {
return {
backstageIdentity: {
id: 'foo',
idToken: '',
},
profile: {
displayName: 'Foo Bar',
},
providerInfo: {},
};
};
const identityResolutionCallbackRejectedMock = async (): Promise<
AuthResponse<any>
> => {
throw new Error('failed');
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('AwsALBAuthProvider', () => {
const catalogApi = {
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
addLocation: jest.fn(),
getEntities: jest.fn(),
getLocationByEntity: jest.fn(),
getLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
getEntityByName: jest.fn(),
};
const mockResponseSend = jest.fn();
const mockRequest = ({
header: jest.fn(() => {
return 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZvbyIsImlzc3VlciI6ImZvbyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.zUkMYAuMwC1T0tyHMpxXrkbFDa4aGhB8d9um_tI2hsI';
}),
} as unknown) as express.Request;
const mockRequestWithoutJwt = ({
header: jest.fn(() => {
return undefined;
}),
} as unknown) as express.Request;
const mockResponse = ({
header: () => jest.fn(),
send: mockResponseSend,
} as unknown) as express.Response;
describe('should transform to type OAuthResponse', () => {
it('when JWT is valid and identity is resolved successfully', async () => {
const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, {
region: 'us-west-2',
identityResolutionCallback: identityResolutionCallbackMock,
issuer: 'foo',
});
jwtMock.verify.mockImplementationOnce(() => ({
sub: 'foo',
}));
await provider.refresh(mockRequest, mockResponse);
expect(mockResponseSend.mock.calls[0][0]).toEqual({
backstageIdentity: {
id: 'foo',
idToken: '',
},
profile: {
displayName: 'Foo Bar',
},
providerInfo: {},
});
});
});
describe('should fail when', () => {
it('JWT is missing', async () => {
const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, {
region: 'us-west-2',
identityResolutionCallback: identityResolutionCallbackMock,
issuer: 'foo',
});
await provider.refresh(mockRequestWithoutJwt, mockResponse);
expect(mockResponseSend.mock.calls[0][0]).toEqual(401);
});
it('JWT is invalid', async () => {
const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, {
region: 'us-west-2',
identityResolutionCallback: identityResolutionCallbackMock,
issuer: 'foo',
});
jwtMock.verify.mockImplementationOnce(() => {
throw new Error('bad JWT');
});
await provider.refresh(mockRequest, mockResponse);
expect(mockResponseSend.mock.calls[0][0]).toEqual(401);
});
it('issuer is invalid', async () => {
const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, {
region: 'us-west-2',
identityResolutionCallback: identityResolutionCallbackMock,
issuer: 'foobar',
});
jwtMock.verify.mockReturnValueOnce({});
await provider.refresh(mockRequest, mockResponse);
expect(mockResponseSend.mock.calls[0][0]).toEqual(401);
});
it('identity resolution callback rejects', async () => {
const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, {
region: 'us-west-2',
identityResolutionCallback: identityResolutionCallbackRejectedMock,
issuer: 'foo',
});
jwtMock.verify.mockReturnValueOnce({});
await provider.refresh(mockRequest, mockResponse);
expect(mockResponseSend.mock.calls[0][0]).toEqual(401);
});
});
});
@@ -0,0 +1,130 @@
/*
* Copyright 2021 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 {
AuthProviderFactoryOptions,
AuthProviderRouteHandlers,
ExperimentalIdentityResolver,
} from '../types';
import express from 'express';
import fetch from 'cross-fetch';
import * as crypto from 'crypto';
import { KeyObject } from 'crypto';
import { Logger } from 'winston';
import NodeCache from 'node-cache';
import { JWT } from 'jose';
import { CatalogApi } from '@backstage/catalog-client';
const ALB_JWT_HEADER = 'x-amzn-oidc-data';
/**
* A callback function that receives a verified JWT and returns a UserEntity
* @param {payload} The verified JWT payload
*/
type AwsAlbAuthProviderOptions = {
region: string;
issuer: string;
identityResolutionCallback: ExperimentalIdentityResolver;
};
export const getJWTHeaders = (input: string) => {
const encoded = input.split('.')[0];
return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8'));
};
export class AwsAlbAuthProvider implements AuthProviderRouteHandlers {
private logger: Logger;
private readonly catalogClient: CatalogApi;
private options: AwsAlbAuthProviderOptions;
private readonly keyCache: NodeCache;
constructor(
logger: Logger,
catalogClient: CatalogApi,
options: AwsAlbAuthProviderOptions,
) {
this.logger = logger;
this.catalogClient = catalogClient;
this.options = options;
this.keyCache = new NodeCache({ stdTTL: 3600 });
}
frameHandler(): Promise<void> {
return Promise.resolve(undefined);
}
async refresh(req: express.Request, res: express.Response): Promise<void> {
const jwt = req.header(ALB_JWT_HEADER);
if (jwt !== undefined) {
try {
const headers = getJWTHeaders(jwt);
const key = await this.getKey(headers.kid);
const payload = JWT.verify(jwt, key);
if (
this.options.issuer !== '' &&
headers.issuer !== this.options.issuer
) {
throw new Error('issuer mismatch on JWT');
}
const resolvedEntity = await this.options.identityResolutionCallback(
payload,
this.catalogClient,
);
res.send(resolvedEntity);
} catch (e) {
this.logger.error('exception occurred during JWT processing', e);
res.send(401);
}
} else {
res.send(401);
}
}
start(): Promise<void> {
return Promise.resolve(undefined);
}
async getKey(keyId: string): Promise<KeyObject> {
const optionalCacheKey = this.keyCache.get<KeyObject>(keyId);
if (optionalCacheKey) {
return optionalCacheKey;
}
const keyText: string = await fetch(
`https://public-keys.auth.elb.${this.options.region}.amazonaws.com/${keyId}`,
).then(response => response.text());
const keyValue = crypto.createPublicKey(keyText);
this.keyCache.set(keyId, keyValue);
return keyValue;
}
}
export const createAwsAlbProvider = ({
logger,
catalogApi,
config,
identityResolver,
}: AuthProviderFactoryOptions) => {
const region = config.getString('region');
const issuer = config.getString('iss');
if (identityResolver !== undefined) {
return new AwsAlbAuthProvider(logger, catalogApi, {
region,
issuer,
identityResolutionCallback: identityResolver,
});
}
throw new Error(
'Identity resolver is required to use this authentication provider',
);
};
@@ -25,6 +25,7 @@ import { createAuth0Provider } from './auth0';
import { createMicrosoftProvider } from './microsoft';
import { createOneLoginProvider } from './onelogin';
import { AuthProviderFactory } from './types';
import { createAwsAlbProvider } from './aws-alb';
export const factories: { [providerId: string]: AuthProviderFactory } = {
google: createGoogleProvider,
@@ -37,4 +38,5 @@ export const factories: { [providerId: string]: AuthProviderFactory } = {
oauth2: createOAuth2Provider,
oidc: createOidcProvider,
onelogin: createOneLoginProvider,
awsalb: createAwsAlbProvider,
};
@@ -71,6 +71,7 @@ describe('OidcAuthProvider', () => {
const jwt = {
sub: 'alice',
iss: 'https://oidc.test',
iat: Date.now(),
aud: clientMetadata.clientId,
exp: Date.now() + 10000,
};
@@ -112,6 +112,19 @@ export interface AuthProviderRouteHandlers {
logout?(req: express.Request, res: express.Response): Promise<void>;
}
/**
* EXPERIMENTAL - this will almost certainly break in a future release.
*
* Used to resolve an identity from auth information in some auth providers.
*/
export type ExperimentalIdentityResolver = (
/**
* An object containing information specific to the auth provider.
*/
payload: object,
catalogApi: CatalogApi,
) => Promise<AuthResponse<any>>;
export type AuthProviderFactoryOptions = {
providerId: string;
globalConfig: AuthProviderConfig;
@@ -120,6 +133,7 @@ export type AuthProviderFactoryOptions = {
tokenIssuer: TokenIssuer;
discovery: PluginEndpointDiscovery;
catalogApi: CatalogApi;
identityResolver?: ExperimentalIdentityResolver;
};
export type AuthProviderFactory = (
+43
View File
@@ -1,5 +1,48 @@
# @backstage/plugin-catalog-backend
## 0.5.4
### Patch Changes
- def2307f3: Adds a `backstage.io/managed-by-origin-location` annotation to all entities. It links to the
location that was registered to the catalog and which emitted this entity. It has a different
semantic than the existing `backstage.io/managed-by-location` annotation, which tells the direct
parent location that created this entity.
Consider this example: The Backstage operator adds a location of type `github-org` in the
`app-config.yaml`. This setting will be added to a `bootstrap:boostrap` location. The processor
discovers the entities in the following branch
`Location bootstrap:bootstrap -> Location github-org:… -> User xyz`. The user `xyz` will be:
```yaml
apiVersion: backstage.io/v1alpha1
kind: User
metadata:
name: xyz
annotations:
# This entity was added by the 'github-org:…' location
backstage.io/managed-by-location: github-org:…
# The entity was added because the 'bootstrap:boostrap' was added to the catalog
backstage.io/managed-by-origin-location: bootstrap:bootstrap
# ...
spec:
# ...
```
- 318a6af9f: Change AWS Account type from Component to Resource
- ac7be581a: Refuse to remove the bootstrap location
- ad838c02f: Reduce log noise on locations refresh
- f9ba00a1c: Update the @azure/msal-node dependency to 1.0.0-beta.3.
- Updated dependencies [def2307f3]
- Updated dependencies [0b135e7e0]
- Updated dependencies [294a70cab]
- Updated dependencies [0ea032763]
- Updated dependencies [5345a1f98]
- Updated dependencies [09a370426]
- Updated dependencies [a93f42213]
- @backstage/catalog-model@0.7.0
- @backstage/backend-common@0.5.0
## 0.5.3
### Patch Changes
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-backend",
"version": "0.5.3",
"version": "0.5.4",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -31,8 +31,8 @@
"dependencies": {
"@aws-sdk/client-organizations": "^3.2.0",
"@azure/msal-node": "^1.0.0-beta.3",
"@backstage/backend-common": "^0.4.3",
"@backstage/catalog-model": "^0.6.1",
"@backstage/backend-common": "^0.5.0",
"@backstage/catalog-model": "^0.7.0",
"@backstage/config": "^0.1.2",
"@octokit/graphql": "^4.5.8",
"@types/express": "^4.17.6",
@@ -57,7 +57,7 @@
"yup": "^0.29.3"
},
"devDependencies": {
"@backstage/cli": "^0.4.6",
"@backstage/cli": "^0.4.7",
"@backstage/test-utils": "^0.1.6",
"@types/core-js": "^2.5.4",
"@types/git-url-parse": "^9.0.0",
@@ -78,13 +78,17 @@ export class LocationReaders implements LocationReader {
if (rulesEnforcer.isAllowed(item.entity, item.location)) {
const relations = Array<EntityRelationSpec>();
const entity = await this.handleEntity(item, emitResult => {
if (emitResult.type === 'relation') {
relations.push(emitResult.relation);
return;
}
emit(emitResult);
});
const entity = await this.handleEntity(
item,
emitResult => {
if (emitResult.type === 'relation') {
relations.push(emitResult.relation);
return;
}
emit(emitResult);
},
location,
);
if (entity) {
output.entities.push({
@@ -165,6 +169,7 @@ export class LocationReaders implements LocationReader {
private async handleEntity(
item: CatalogProcessorEntityResult,
emit: CatalogProcessorEmit,
originLocation: LocationSpec,
): Promise<Entity | undefined> {
const { processors, logger } = this.options;
@@ -185,6 +190,7 @@ export class LocationReaders implements LocationReader {
current,
item.location,
emit,
originLocation,
);
} catch (e) {
const message = `Processor ${processor.constructor.name} threw an error while preprocessing entity ${kind}:${namespace}/${name} at ${item.location.type} ${item.location.target}, ${e}`;
@@ -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 { Entity, LocationSpec } from '@backstage/catalog-model';
import { AnnotateLocationEntityProcessor } from './AnnotateLocationEntityProcessor';
describe('AnnotateLocationEntityProcessor', () => {
describe('preProcessEntity', () => {
it('adds annotations', async () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component',
},
};
const location: LocationSpec = {
type: 'url',
target: 'my-location',
};
const originLocation: LocationSpec = {
type: 'url',
target: 'my-origin-location',
};
const processor = new AnnotateLocationEntityProcessor();
expect(
await processor.preProcessEntity(
entity,
location,
() => {},
originLocation,
),
).toEqual({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component',
annotations: {
'backstage.io/managed-by-location': 'url:my-location',
'backstage.io/managed-by-origin-location': 'url:my-origin-location',
},
},
});
});
});
});
@@ -14,20 +14,28 @@
* limitations under the License.
*/
import { Entity, LocationSpec } from '@backstage/catalog-model';
import {
Entity,
LOCATION_ANNOTATION,
LocationSpec,
ORIGIN_LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
import lodash from 'lodash';
import { CatalogProcessor } from './types';
import { CatalogProcessor, CatalogProcessorEmit } from './types';
export class AnnotateLocationEntityProcessor implements CatalogProcessor {
async preProcessEntity(
entity: Entity,
location: LocationSpec,
_: CatalogProcessorEmit,
originLocation: LocationSpec,
): Promise<Entity> {
return lodash.merge(
{
metadata: {
annotations: {
'backstage.io/managed-by-location': `${location.type}:${location.target}`,
[LOCATION_ANNOTATION]: `${location.type}:${location.target}`,
[ORIGIN_LOCATION_ANNOTATION]: `${originLocation.type}:${originLocation.target}`,
},
},
},
@@ -45,7 +45,7 @@ describe('AwsOrganizationCloudAccountProcessor', () => {
location,
entity: {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
kind: 'Resource',
metadata: {
annotations: {
'amazonaws.com/arn':
@@ -58,7 +58,6 @@ describe('AwsOrganizationCloudAccountProcessor', () => {
},
spec: {
type: 'cloud-account',
lifecycle: 'unknown',
owner: 'unknown',
},
},
@@ -91,7 +90,7 @@ describe('AwsOrganizationCloudAccountProcessor', () => {
location,
entity: {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
kind: 'Resource',
metadata: {
annotations: {
'amazonaws.com/arn':
@@ -104,7 +103,6 @@ describe('AwsOrganizationCloudAccountProcessor', () => {
},
spec: {
type: 'cloud-account',
lifecycle: 'unknown',
owner: 'unknown',
},
},
@@ -13,10 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
ComponentEntityV1alpha1,
LocationSpec,
} from '@backstage/catalog-model';
import { LocationSpec, ResourceEntityV1alpha1 } from '@backstage/catalog-model';
import {
Account,
Organizations,
@@ -82,13 +79,13 @@ export class AwsOrganizationCloudAccountProcessor implements CatalogProcessor {
return awsAccounts;
}
mapAccountToComponent(account: Account): ComponentEntityV1alpha1 {
mapAccountToComponent(account: Account): ResourceEntityV1alpha1 {
const { accountId, organizationId } = this.extractInformationFromArn(
account.Arn as string,
);
return {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
kind: 'Resource',
metadata: {
annotations: {
[ACCOUNTID_ANNOTATION]: accountId,
@@ -100,7 +97,6 @@ export class AwsOrganizationCloudAccountProcessor implements CatalogProcessor {
},
spec: {
type: 'cloud-account',
lifecycle: 'unknown',
owner: 'unknown',
},
};
@@ -129,7 +125,7 @@ export class AwsOrganizationCloudAccountProcessor implements CatalogProcessor {
}
return true;
})
.forEach((entity: ComponentEntityV1alpha1) => {
.forEach((entity: ResourceEntityV1alpha1) => {
emit(results.entity(location, entity));
});
@@ -46,12 +46,16 @@ export type CatalogProcessor = {
* @param entity The (possibly partial) entity to process
* @param location The location that the entity came from
* @param emit A sink for auxiliary items resulting from the processing
* @param originLocation The location that the entity originally came from.
* While location resolves to the direct parent location, originLocation
* tells which location was used to start the ingestion loop.
* @returns The same entity or a modified version of it
*/
preProcessEntity?(
entity: Entity,
location: LocationSpec,
emit: CatalogProcessorEmit,
originLocation: LocationSpec,
): Promise<Entity>;
/**
+14
View File
@@ -1,5 +1,19 @@
# @backstage/plugin-catalog-graphql
## 0.2.6
### Patch Changes
- Updated dependencies [def2307f3]
- Updated dependencies [0b135e7e0]
- Updated dependencies [294a70cab]
- Updated dependencies [0ea032763]
- Updated dependencies [5345a1f98]
- Updated dependencies [09a370426]
- Updated dependencies [a93f42213]
- @backstage/catalog-model@0.7.0
- @backstage/backend-common@0.5.0
## 0.2.5
### Patch Changes
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-graphql",
"version": "0.2.5",
"version": "0.2.6",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -30,8 +30,8 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.4.1",
"@backstage/catalog-model": "^0.6.0",
"@backstage/backend-common": "^0.5.0",
"@backstage/catalog-model": "^0.7.0",
"@backstage/config": "^0.1.2",
"@graphql-modules/core": "^0.7.17",
"apollo-server": "^2.16.1",
@@ -42,7 +42,7 @@
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/cli": "^0.4.2",
"@backstage/cli": "^0.4.7",
"@backstage/test-utils": "^0.1.5",
"@graphql-codegen/cli": "^1.17.7",
"@graphql-codegen/typescript": "^1.17.7",
+20
View File
@@ -1,5 +1,25 @@
# @backstage/plugin-catalog-import
## 0.3.5
### Patch Changes
- 2b514d532: Modifying import functionality to register existing catalog-info.yaml if one exists in given GitHub repository
- Updated dependencies [def2307f3]
- Updated dependencies [efd6ef753]
- Updated dependencies [0b135e7e0]
- Updated dependencies [593632f07]
- Updated dependencies [33846acfc]
- Updated dependencies [fa8ba330a]
- Updated dependencies [a187b8ad0]
- Updated dependencies [ed6baab66]
- Updated dependencies [f04db53d7]
- Updated dependencies [a93f42213]
- @backstage/catalog-model@0.7.0
- @backstage/core@0.5.0
- @backstage/integration@0.3.0
- @backstage/plugin-catalog@0.2.12
## 0.3.4
### Patch Changes
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-import",
"version": "0.3.4",
"version": "0.3.5",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -30,10 +30,10 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.6.1",
"@backstage/core": "^0.4.4",
"@backstage/plugin-catalog": "^0.2.11",
"@backstage/integration": "^0.2.0",
"@backstage/catalog-model": "^0.7.0",
"@backstage/core": "^0.5.0",
"@backstage/integration": "^0.3.0",
"@backstage/plugin-catalog": "^0.2.12",
"@backstage/theme": "^0.2.2",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
@@ -49,8 +49,8 @@
"yaml": "^1.10.0"
},
"devDependencies": {
"@backstage/cli": "^0.4.6",
"@backstage/dev-utils": "^0.1.7",
"@backstage/cli": "^0.4.7",
"@backstage/dev-utils": "^0.1.8",
"@backstage/test-utils": "^0.1.6",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
@@ -30,6 +30,11 @@ export interface CatalogImportApi {
fileContent: string;
githubIntegrationConfig: GitHubIntegrationConfig;
}): Promise<{ link: string; location: string }>;
checkForExistingCatalogInfo(options: {
owner: string;
repo: string;
githubIntegrationConfig: GitHubIntegrationConfig;
}): Promise<{ exists: boolean; url?: string }>;
createRepositoryLocation(options: { location: string }): Promise<void>;
generateEntityDefinitions(options: {
repo: string;
@@ -0,0 +1,64 @@
/*
* 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 { CatalogImportClient } from './CatalogImportClient';
jest.mock('@octokit/rest', () => ({
Octokit: jest.fn().mockImplementation(() => {
return {
repos: {
get: () =>
Promise.resolve({
data: {
default_branch: 'main',
},
}),
},
search: {
code: () =>
Promise.resolve({
data: {
total_count: 2,
items: [
{ path: 'simple/path/catalog-info.yaml' },
{ path: 'co/mple/x/path/catalog-info.yaml' },
{ path: 'catalog-info.yaml' },
],
},
}),
},
};
}),
}));
describe('CatalogImportClient', () => {
describe('checkForExistingCatalogInfo', () => {
const cic = new CatalogImportClient({
discoveryApi: { getBaseUrl: () => Promise.resolve('base') },
githubAuthApi: { getAccessToken: (_, __) => Promise.resolve('token') },
configApi: {} as any,
});
it('should return the closest-to-root catalog-info from multiple responses', async () => {
const respo = await cic.checkForExistingCatalogInfo({
owner: 'test-user',
repo: 'rest-repo',
githubIntegrationConfig: { host: 'https://github.com' },
});
expect(respo.exists).toBe(true);
expect(respo.url).toBe('blob/main/catalog-info.yaml');
});
});
});
@@ -90,6 +90,52 @@ export class CatalogImportClient implements CatalogImportApi {
}
}
async checkForExistingCatalogInfo({
owner,
repo,
githubIntegrationConfig,
}: {
owner: string;
repo: string;
githubIntegrationConfig: GitHubIntegrationConfig;
}): Promise<{ exists: boolean; url?: string }> {
const token = await this.githubAuthApi.getAccessToken(['repo']);
const octo = new Octokit({
auth: token,
baseUrl: githubIntegrationConfig.apiBaseUrl,
});
const catalogFileName = 'catalog-info.yaml';
const query = `repo:${owner}/${repo}+filename:${catalogFileName}`;
const searchResult = await octo.search.code({ q: query }).catch(e => {
throw new Error(
formatHttpErrorMessage(
"Couldn't search repository for metadata file.",
e,
),
);
});
const exists = searchResult.data.total_count > 0;
if (exists) {
const repoInformation = await octo.repos.get({ owner, repo }).catch(e => {
throw new Error(formatHttpErrorMessage("Couldn't fetch repo data", e));
});
const defaultBranch = repoInformation.data.default_branch;
// Github search sorts returned values with 'best match' using 'multiple factors to boost the most relevant item',
// aka magic.
// Sorting to use the shortest item, closest to the repository root.
const catalogInfoItem = searchResult.data.items
.map(it => it.path)
.sort((a, b) => a.length - b.length)[0];
return {
url: `blob/${defaultBranch}/${catalogInfoItem}`,
exists,
};
}
return { exists };
}
async submitPrToRepo({
owner,
repo,
@@ -0,0 +1,89 @@
/*
* Copyright 2021 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 { renderInTestApp } from '@backstage/test-utils';
import { RegisterComponentForm } from './ImportComponentForm';
import {
ApiProvider,
ApiRegistry,
DiscoveryApi,
errorApiRef,
} from '@backstage/core';
import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog';
import { catalogImportApiRef, CatalogImportClient } from '../api';
import { fireEvent, waitFor, screen } from '@testing-library/react';
describe('<RegisterComponentForm />', () => {
let apis: ApiRegistry;
const mockErrorApi: jest.Mocked<typeof errorApiRef.T> = {
post: jest.fn(),
error$: jest.fn(),
};
beforeEach(() => {
apis = ApiRegistry.from([
[catalogApiRef, new CatalogClient({ discoveryApi: {} as DiscoveryApi })],
[
catalogImportApiRef,
new CatalogImportClient({
discoveryApi: { getBaseUrl: () => Promise.resolve('base') },
githubAuthApi: {
getAccessToken: (_, __) => Promise.resolve('token'),
},
configApi: {} as any,
}),
],
[errorApiRef, mockErrorApi],
]);
});
async function renderSUT(
nextStep: () => void = () => {},
saveConfig: () => void = () => {},
) {
return await renderInTestApp(
<ApiProvider apis={apis}>
<RegisterComponentForm
nextStep={nextStep}
saveConfig={saveConfig}
repository="GitHub"
/>
</ApiProvider>,
);
}
it('Renders without exploding', async () => {
await renderSUT();
expect(
screen.getByPlaceholderText('https://github.com/backstage/backstage'),
).toBeInTheDocument();
});
it('Should have basic URL validation for input', async () => {
await renderSUT();
await waitFor(() => {
fireEvent.input(
screen.getByPlaceholderText('https://github.com/backstage/backstage'),
{ target: { value: 'not a url' } },
);
});
await waitFor(() => {
fireEvent.click(screen.getByText('Next'));
});
expect(screen.getByText('Must start with https://.')).toBeInTheDocument();
});
});
@@ -64,27 +64,45 @@ export const RegisterComponentForm = ({
const isMounted = useMountedState();
const errorApi = useApi(errorApiRef);
const { generateEntityDefinitions } = useGithubRepos();
const {
generateEntityDefinitions,
checkForExistingCatalogInfo,
} = useGithubRepos();
const onSubmit = async (formData: Record<string, string>) => {
const { componentLocation: target } = formData;
try {
if (!isMounted()) return;
const type = urlType(target);
async function saveCatalogFileConfig(target: string) {
const data = await catalogApi.addLocation({ target });
saveConfig({
type: 'file',
location: data.location.target,
config: data.entities,
});
}
if (type === 'tree') {
async function trySaveRepositoryConfig(target: string) {
const existingCatalog = await checkForExistingCatalogInfo(target);
if (existingCatalog.exists) {
const targetUrl = target.endsWith('/')
? `${target}${existingCatalog.url}`
: `${target}/${existingCatalog.url}`;
await saveCatalogFileConfig(targetUrl);
} else {
saveConfig({
type,
type: 'tree',
location: target,
config: await generateEntityDefinitions(target),
});
}
}
try {
if (!isMounted()) return;
const type = urlType(target);
if (type === 'tree') {
await trySaveRepositoryConfig(target);
} else {
const data = await catalogApi.addLocation({ target });
saveConfig({
type,
location: data.location.target,
config: data.entities,
});
await saveCatalogFileConfig(target);
}
nextStep();
} catch (e) {
@@ -0,0 +1,267 @@
/*
* Copyright 2021 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 { msw, renderInTestApp } from '@backstage/test-utils';
import { ImportComponentPage } from './ImportComponentPage';
import {
ApiProvider,
ApiRegistry,
configApiRef,
errorApiRef,
} from '@backstage/core';
import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog';
import { catalogImportApiRef, CatalogImportClient } from '../api';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
let codeSearchMockResponse: () => Promise<{
data: {
total_count: number;
items: Array<{ path: string }>;
};
}>;
let findGithubConfigMockResponse = () => ({
host: 'test.localhost',
owner: 'someuser',
});
jest.mock('@backstage/integration', () => ({
readGitHubIntegrationConfigs: () => ({
find: findGithubConfigMockResponse,
}),
}));
jest.mock('@octokit/rest', () => ({
Octokit: jest.fn().mockImplementation(() => {
return {
repos: {
get: () =>
Promise.resolve({
data: {
default_branch: 'main',
},
}),
},
search: {
code: codeSearchMockResponse,
},
};
}),
}));
const OUR_GITHUB_TEST_REPO = 'https://github.com/someuser/somerepo';
describe('<ImportComponentPage />', () => {
const server = setupServer();
msw.setupDefaultHandlers(server);
beforeEach(() => {
server.use(
rest.post('https://backend.localhost/locations', (_, res, ctx) => {
return res(
ctx.status(201),
ctx.json(require('../mocks/locations-POST-response.json')),
);
}),
rest.post('https://backend.localhost/analyze-location', (_, res, ctx) => {
return res(
ctx.json(require('../mocks/analyze-location-POST-response.json')),
);
}),
);
});
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
let apis: ApiRegistry;
const mockErrorApi: jest.Mocked<typeof errorApiRef.T> = {
post: jest.fn(),
error$: jest.fn(),
};
beforeEach(() => {
const getBaseUrl = () => Promise.resolve('https://backend.localhost');
apis = ApiRegistry.from([
[
catalogApiRef,
new CatalogClient({
discoveryApi: { getBaseUrl },
}),
],
[
catalogImportApiRef,
new CatalogImportClient({
discoveryApi: { getBaseUrl },
githubAuthApi: {
getAccessToken: (_, __) => Promise.resolve('token'),
},
configApi: {} as any,
}),
],
[
configApiRef,
{
getOptional: () => 'Title',
getOptionalConfigArray: () => [],
has: () => true,
getConfig: () => ({
has: () => true,
}),
},
],
[errorApiRef, mockErrorApi],
]);
});
async function renderSUT() {
return await renderInTestApp(
<ApiProvider apis={apis}>
<ImportComponentPage catalogRouteRef={{ path: 'path', title: 'ttl' }} />
</ApiProvider>,
);
}
it('Should not explode on non-Github URLs', async () => {
findGithubConfigMockResponse = () => undefined!!;
await renderSUT();
await waitFor(() => {
fireEvent.input(
screen.getByPlaceholderText('https://github.com/backstage/backstage'),
{
target: {
value: 'https://test-git-provider.localhost/someuser/somerepo',
},
},
);
});
fireEvent.click(screen.getByText('Next'));
await waitFor(() => {
const firstStepInput = screen.queryByPlaceholderText(
'https://github.com/backstage/backstage',
);
expect(firstStepInput).not.toBeInTheDocument();
});
});
it('Should offer direct file import from non-Github URLs', async () => {
findGithubConfigMockResponse = () => undefined!!;
await renderSUT();
await waitFor(() => {
fireEvent.input(
screen.getByPlaceholderText('https://github.com/backstage/backstage'),
{
target: {
value:
'https://test-git-provider.localhost/someuser/somerepo/catalog-info.yaml',
},
},
);
});
fireEvent.click(screen.getByText('Next'));
await waitFor(() => {
const firstStepInput = screen.queryByPlaceholderText(
'https://github.com/backstage/backstage',
);
expect(firstStepInput).not.toBeInTheDocument();
});
expect(
screen.getByText(
'https://test-git-provider.localhost/someuser/somerepo/catalog-info.yaml',
),
).toBeInTheDocument();
});
it('Should use found yaml file directly and not create a pull request if GitHub api returns one', async () => {
findGithubConfigMockResponse = () => ({
host: 'test.localhost',
owner: 'someuser',
});
codeSearchMockResponse = () =>
Promise.resolve({
data: {
total_count: 3,
items: [
{ path: 'simple/path/catalog-info.yaml' },
{ path: 'co/mple/x/path/catalog-info.yaml' },
{ path: 'catalog-info.yaml' },
],
},
});
await renderSUT();
await waitFor(() => {
fireEvent.input(
screen.getByPlaceholderText('https://github.com/backstage/backstage'),
{ target: { value: OUR_GITHUB_TEST_REPO } },
);
});
fireEvent.click(screen.getByText('Next'));
await waitFor(() => {
expect(
screen.getByText(
'https://github.com/someusername/somerepo/blob/master/src/catalog-info.yaml',
),
).toBeInTheDocument();
const pullReqText = screen.queryByText('pull request');
expect(pullReqText).not.toBeInTheDocument();
});
});
it('Should indicate a pull request creation when no yaml file found in the repo', async () => {
findGithubConfigMockResponse = () => ({
host: 'test.localhost',
owner: 'someuser',
});
codeSearchMockResponse = () =>
Promise.resolve({
data: {
total_count: 0,
items: [],
},
});
const { container } = await renderSUT();
await waitFor(() => {
fireEvent.input(
screen.getByPlaceholderText('https://github.com/backstage/backstage'),
{ target: { value: OUR_GITHUB_TEST_REPO } },
);
});
fireEvent.click(screen.getByText('Next'));
await waitFor(() => {
expect(screen.getByText(OUR_GITHUB_TEST_REPO)).toBeInTheDocument();
});
const textNode = container
.querySelector('a[href="https://github.com/someuser/somerepo"]')
?.closest('p');
expect(textNode?.innerHTML).toContain(
'Following config object will be submitted in a pull request to the repository',
);
expect(
screen.queryByText(
'https://github.com/someusername/somerepo/blob/master/src/catalog-info.yaml',
),
).not.toBeInTheDocument();
});
});
@@ -109,10 +109,11 @@ export const ImportComponentPage = ({
<React.Fragment>
<Typography variant="h6">GitHub Repo</Typography>
<Typography variant="body2" paragraph>
If you already have code in a GitHub repository, enter the
full URL to your repo and a new pull request with a sample
Backstage metadata Entity File (
<code>catalog-info.yaml</code>) will be opened for you.
If you already have code in a GitHub repository without
Backstage metadata file set up for it, enter the full URL to
your repo and a new pull request with a sample Backstage
metadata Entity File (<code>catalog-info.yaml</code>) will
be opened for you.
</Typography>
</React.Fragment>
)}
@@ -0,0 +1,22 @@
{
"existingEntityFiles": [],
"generateEntities": [
{
"entity": {
"apiVersion": "backstage.io/v1alpha1",
"kind": "Component",
"metadata": {
"name": "somerepo",
"annotations": {
"github.com/project-slug": "someuser/somerepo"
}
},
"spec": {
"type": "other",
"lifecycle": "unknown"
}
},
"fields": []
}
]
}
@@ -0,0 +1,39 @@
{
"location": {
"id": "d4a64359-a709-4c91-a9de-0905a033bf22",
"type": "url",
"target": "https://github.com/someusername/somerepo/blob/master/src/catalog-info.yaml"
},
"entities": [
{
"metadata": {
"namespace": "default",
"annotations": {
"backstage.io/managed-by-location": "url:https://github.com/someusername/somerepo/blob/master/src/catalog-info.yaml",
"github.com/project-slug": "someusername/somerepo"
},
"name": "somerepo",
"uid": "e992d5ee-7c70-4316-90cf-325f1a0a5146",
"etag": "YWE2M2Q5MzgtNjdkNi00N2QwLWJkZjYtNDM0MTMzMDI4Y2I0",
"generation": 1
},
"apiVersion": "backstage.io/v1alpha1",
"kind": "Component",
"spec": {
"type": "other",
"lifecycle": "unknown",
"owner": "unknown"
},
"relations": [
{
"target": {
"kind": "group",
"namespace": "default",
"name": "unknown"
},
"type": "ownedBy"
}
]
}
]
}
+1
View File
@@ -15,3 +15,4 @@
*/
import '@testing-library/jest-dom';
import 'cross-fetch/polyfill';
@@ -22,18 +22,21 @@ import parseGitUrl from 'git-url-parse';
// TODO: (O5ten) Refactor into a core API instead of direct usage like this
// https://github.com/backstage/backstage/pull/3613#issuecomment-7408929430
import { readGitHubIntegrationConfigs } from '@backstage/integration';
import {
GitHubIntegrationConfig,
readGitHubIntegrationConfigs,
} from '@backstage/integration';
export function useGithubRepos() {
const api = useApi(catalogImportApiRef);
const config = useApi(configApiRef);
const submitPrToRepo = async (selectedRepo: ConfigSpec) => {
const getGithubIntegrationConfig = (location: string) => {
const {
name: repoName,
owner: ownerName,
resource: hostname,
} = parseGitUrl(selectedRepo.location);
} = parseGitUrl(location);
const configs = readGitHubIntegrationConfigs(
config.getOptionalConfigArray('integrations.github') ?? [],
@@ -41,9 +44,22 @@ export function useGithubRepos() {
const githubIntegrationConfig = configs.find(v => v.host === hostname);
if (!githubIntegrationConfig) {
throw new Error(
`Unable to locate github-integration for repo-location: ${selectedRepo.location}`,
`Unable to locate github-integration for repo-location, ${location}`,
);
}
return {
repoName,
ownerName,
githubIntegrationConfig,
};
};
const submitPrToRepo = async (selectedRepo: ConfigSpec) => {
const {
repoName,
ownerName,
githubIntegrationConfig,
} = getGithubIntegrationConfig(selectedRepo.location);
const submitPRResponse = await api
.submitPrToRepo({
owner: ownerName,
@@ -54,7 +70,7 @@ export function useGithubRepos() {
githubIntegrationConfig,
})
.catch(e => {
throw new Error(`Failed to submit PR to repo:\n${e.message}`);
throw new Error(`Failed to submit PR to repo, ${e.message}`);
});
await api
@@ -62,14 +78,41 @@ export function useGithubRepos() {
location: submitPRResponse.location,
})
.catch(e => {
throw new Error(`Failed to create repository location:\n${e.message}`);
throw new Error(`Failed to create repository location, ${e.message}`);
});
return submitPRResponse;
};
const checkForExistingCatalogInfo = async (
location: string,
): Promise<{ exists: boolean; url?: string }> => {
let githubConfig: {
repoName: string;
ownerName: string;
githubIntegrationConfig: GitHubIntegrationConfig;
};
try {
githubConfig = getGithubIntegrationConfig(location);
} catch (e) {
return { exists: false };
}
return await api
.checkForExistingCatalogInfo({
owner: githubConfig.ownerName,
repo: githubConfig.repoName,
githubIntegrationConfig: githubConfig.githubIntegrationConfig,
})
.catch(e => {
throw new Error(
`Failed to inspect repository for existing catalog-info.yaml, ${e.message}`,
);
});
};
return {
submitPrToRepo,
checkForExistingCatalogInfo,
generateEntityDefinitions: (repo: string) =>
api.generateEntityDefinitions({ repo }),
addLocation: (location: string) =>
+28
View File
@@ -1,5 +1,33 @@
# @backstage/plugin-catalog
## 0.2.13
### Patch Changes
- a4e636c8f: Hide the kind of owners in the about card if it's the default kind (group)
- 099c5cf4f: Show the parent component in the about card (via partOf relationship)
- a08db734c: Remove the `WelcomeBanner` that links to a plugin that is not longer wired into Backstage instances
## 0.2.12
### Patch Changes
- 593632f07: Derive the list of to-delete entities in the `UnregisterEntityDialog` from the `backstage.io/managed-by-origin-location` annotation.
The dialog also rejects deleting entities that are created by the `bootstrap:bootstrap` location.
- 33846acfc: Display the owner, system, and domain as links to the entity pages in the about card.
Only display fields in the about card that are applicable to the entity kind.
- f04db53d7: Display systems in catalog table and make both owner and system link to the entity pages.
The owner field is now taken from the relations of the entity instead of its spec.
- Updated dependencies [def2307f3]
- Updated dependencies [efd6ef753]
- Updated dependencies [a187b8ad0]
- Updated dependencies [ed6baab66]
- Updated dependencies [a93f42213]
- @backstage/catalog-model@0.7.0
- @backstage/core@0.5.0
- @backstage/plugin-scaffolder@0.4.0
- @backstage/catalog-client@0.3.5
## 0.2.11
### Patch Changes
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog",
"version": "0.2.11",
"version": "0.2.13",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -30,10 +30,10 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-client": "^0.3.4",
"@backstage/catalog-model": "^0.6.1",
"@backstage/core": "^0.4.4",
"@backstage/plugin-scaffolder": "^0.3.6",
"@backstage/catalog-client": "^0.3.5",
"@backstage/catalog-model": "^0.7.0",
"@backstage/core": "^0.5.0",
"@backstage/plugin-scaffolder": "^0.4.0",
"@backstage/theme": "^0.2.2",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
@@ -51,8 +51,8 @@
"swr": "^0.3.0"
},
"devDependencies": {
"@backstage/cli": "^0.4.6",
"@backstage/dev-utils": "^0.1.7",
"@backstage/cli": "^0.4.7",
"@backstage/dev-utils": "^0.1.8",
"@backstage/test-utils": "^0.1.6",
"@microsoft/microsoft-graph-types": "^1.25.0",
"@testing-library/jest-dom": "^5.10.1",
@@ -40,9 +40,17 @@ export const AboutContent = ({ entity }: Props) => {
const isSystem = entity.kind.toLowerCase() === 'system';
const isDomain = entity.kind.toLowerCase() === 'domain';
const isResource = entity.kind.toLowerCase() === 'resource';
const isComponent = entity.kind.toLowerCase() === 'component';
const [partOfSystemRelation] = getEntityRelations(entity, RELATION_PART_OF, {
kind: 'system',
});
const [partOfComponentRelation] = getEntityRelations(
entity,
RELATION_PART_OF,
{
kind: 'component',
},
);
const [partOfDomainRelation] = getEntityRelations(entity, RELATION_PART_OF, {
kind: 'domain',
});
@@ -59,7 +67,7 @@ export const AboutContent = ({ entity }: Props) => {
{ownedByRelations.map((t, i) => (
<React.Fragment key={i}>
{i > 0 && ', '}
<EntityRefLink entityRef={t} />
<EntityRefLink entityRef={t} defaultKind="group" />
</React.Fragment>
))}
</AboutField>
@@ -91,6 +99,18 @@ export const AboutContent = ({ entity }: Props) => {
)}
</AboutField>
)}
{isComponent && partOfComponentRelation && (
<AboutField
label="Parent Component"
value="No Parent Component"
gridSizes={{ xs: 12, sm: 6, lg: 4 }}
>
<EntityRefLink
entityRef={partOfComponentRelation}
defaultKind="component"
/>
</AboutField>
)}
{!isSystem && !isDomain && (
<AboutField
label="Type"
@@ -36,7 +36,6 @@ import { CatalogTable } from '../CatalogTable/CatalogTable';
import { ResultsFilter } from '../ResultsFilter/ResultsFilter';
import CatalogLayout from './CatalogLayout';
import { CatalogTabs, LabeledComponentType } from './CatalogTabs';
import { WelcomeBanner } from './WelcomeBanner';
import { useOwnUser } from '../useOwnUser';
import { isOwnerOf } from '../isOwnerOf';
@@ -157,7 +156,6 @@ const CatalogPageContents = () => {
onChange={({ label }) => setSelectedTab(label)}
/>
<Content>
<WelcomeBanner />
<ContentHeader title={selectedTab ?? ''}>
<Button
component={RouterLink}
@@ -1,54 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DismissableBanner } from '@backstage/core';
import { Link, makeStyles, Typography } from '@material-ui/core';
import React from 'react';
const useStyles = makeStyles(theme => ({
contentWrapper: {
display: 'grid',
gridTemplateAreas: "'filters' 'table'",
gridTemplateColumns: '250px 1fr',
gridColumnGap: theme.spacing(2),
},
emoji: {
fontSize: '125%',
marginRight: theme.spacing(2),
},
}));
export const WelcomeBanner = () => {
const classes = useStyles();
return (
<DismissableBanner
variant="info"
message={
<Typography>
<span role="img" aria-label="tada" className={classes.emoji}>
🎉
</span>
Welcome to Backstage! Take a look around and check out our{' '}
<Link href="/welcome" color="textPrimary">
getting started
</Link>{' '}
page.
</Typography>
}
id="catalog_page_welcome_banner"
/>
);
};
@@ -42,7 +42,7 @@
"Galician": "Boas noites",
"Georgian": "Saghamo mshvidobisa",
"German": "Guten Abend",
"Greek": "Kalinishta",
"Greek": "Kalinichta",
"Guarani": "Mba’éichapa ndepyhare",
"Hebrew": "Erev tov",
"Hungarian": "Jó estét",
@@ -14,8 +14,8 @@
* limitations under the License.
*/
import { Entity, LOCATION_ANNOTATION } from '@backstage/catalog-model';
import { alertApiRef, Progress, useApi } from '@backstage/core';
import { Entity, ORIGIN_LOCATION_ANNOTATION } from '@backstage/catalog-model';
import { alertApiRef, configApiRef, Progress, useApi } from '@backstage/core';
import {
Button,
Dialog,
@@ -32,6 +32,7 @@ import React from 'react';
import { useAsync } from 'react-use';
import { AsyncState } from 'react-use/lib/useAsync';
import { catalogApiRef } from '../../plugin';
import { formatEntityRefTitle } from '../EntityRefLink';
type Props = {
open: boolean;
@@ -40,15 +41,30 @@ type Props = {
entity: Entity;
};
class DeniedLocationException extends Error {
constructor(public readonly locationName: string) {
super(`You may not remove the location ${locationName}`);
this.name = 'DeniedLocationException';
}
}
function useColocatedEntities(entity: Entity): AsyncState<Entity[]> {
const catalogApi = useApi(catalogApiRef);
return useAsync(async () => {
const myLocation = entity.metadata.annotations?.[LOCATION_ANNOTATION];
const myLocation =
entity.metadata.annotations?.[ORIGIN_LOCATION_ANNOTATION];
if (!myLocation) {
return [];
}
if (myLocation === 'bootstrap:bootstrap') {
throw new DeniedLocationException(myLocation);
}
const response = await catalogApi.getEntities({
filter: { [LOCATION_ANNOTATION]: myLocation },
filter: {
[`metadata.annotations.${ORIGIN_LOCATION_ANNOTATION}`]: myLocation,
},
});
return response.items;
}, [catalogApi, entity]);
@@ -65,6 +81,7 @@ export const UnregisterEntityDialog = ({
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const catalogApi = useApi(catalogApiRef);
const alertApi = useApi(alertApiRef);
const configApi = useApi(configApiRef);
const removeEntity = async () => {
const uid = entity.metadata.uid;
@@ -82,13 +99,27 @@ export const UnregisterEntityDialog = ({
<DialogTitle id="responsive-dialog-title">
Are you sure you want to unregister this entity?
</DialogTitle>
<DialogContent>
{loading ? <Progress /> : null}
{error ? (
<Alert severity="error" style={{ wordBreak: 'break-word' }}>
{error.toString()}
{error.name === 'DeniedLocationException' ? (
<>
You cannot unregister this entity, since it originates from a
protected Backstage configuration (location
{`"${(error as DeniedLocationException).locationName}"`}). If
you believe this is in error, please contact the{' '}
{configApi.getOptionalString('app.title') ?? 'Backstage'}{' '}
integrator.
</>
) : (
error.toString()
)}
</Alert>
) : null}
{entities?.length ? (
<>
<DialogContentText>
@@ -96,9 +127,10 @@ export const UnregisterEntityDialog = ({
</DialogContentText>
<Typography component="div">
<ul>
{entities.map(e => (
<li key={e.metadata.name}>{e.metadata.name}</li>
))}
{entities.map(e => {
const fullName = formatEntityRefTitle(e);
return <li key={fullName}>{fullName}</li>;
})}
</ul>
</Typography>
<DialogContentText>
@@ -107,16 +139,21 @@ export const UnregisterEntityDialog = ({
<Typography component="div">
<ul style={{ wordBreak: 'break-word' }}>
<li>
{entities[0]?.metadata.annotations?.[LOCATION_ANNOTATION]}
{
entities[0]?.metadata.annotations?.[
ORIGIN_LOCATION_ANNOTATION
]
}
</li>
</ul>
</Typography>
<DialogContentText>
To undo, just re-register the entity in Backstage.
</DialogContentText>
</>
) : null}
<DialogContentText>
To undo, just re-register the entity in Backstage.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose} color="primary">
Cancel
+15
View File
@@ -1,5 +1,20 @@
# @backstage/plugin-circleci
## 0.2.6
### Patch Changes
- Updated dependencies [def2307f3]
- Updated dependencies [efd6ef753]
- Updated dependencies [593632f07]
- Updated dependencies [33846acfc]
- Updated dependencies [a187b8ad0]
- Updated dependencies [f04db53d7]
- Updated dependencies [a93f42213]
- @backstage/catalog-model@0.7.0
- @backstage/core@0.5.0
- @backstage/plugin-catalog@0.2.12
## 0.2.5
### Patch Changes
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-circleci",
"version": "0.2.5",
"version": "0.2.6",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -31,9 +31,9 @@
"postpack": "backstage-cli postpack"
},
"dependencies": {
"@backstage/catalog-model": "^0.6.0",
"@backstage/core": "^0.4.4",
"@backstage/plugin-catalog": "^0.2.7",
"@backstage/catalog-model": "^0.7.0",
"@backstage/core": "^0.5.0",
"@backstage/plugin-catalog": "^0.2.12",
"@backstage/theme": "^0.2.2",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
@@ -50,8 +50,8 @@
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.4.6",
"@backstage/dev-utils": "^0.1.7",
"@backstage/cli": "^0.4.7",
"@backstage/dev-utils": "^0.1.8",
"@backstage/test-utils": "^0.1.6",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
+15
View File
@@ -1,5 +1,20 @@
# @backstage/plugin-cloudbuild
## 0.2.7
### Patch Changes
- Updated dependencies [def2307f3]
- Updated dependencies [efd6ef753]
- Updated dependencies [593632f07]
- Updated dependencies [33846acfc]
- Updated dependencies [a187b8ad0]
- Updated dependencies [f04db53d7]
- Updated dependencies [a93f42213]
- @backstage/catalog-model@0.7.0
- @backstage/core@0.5.0
- @backstage/plugin-catalog@0.2.12
## 0.2.6
### Patch Changes
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-cloudbuild",
"version": "0.2.6",
"version": "0.2.7",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -30,9 +30,9 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.6.1",
"@backstage/core": "^0.4.4",
"@backstage/plugin-catalog": "^0.2.11",
"@backstage/catalog-model": "^0.7.0",
"@backstage/core": "^0.5.0",
"@backstage/plugin-catalog": "^0.2.12",
"@backstage/theme": "^0.2.2",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
@@ -47,8 +47,8 @@
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.4.6",
"@backstage/dev-utils": "^0.1.7",
"@backstage/cli": "^0.4.7",
"@backstage/dev-utils": "^0.1.8",
"@backstage/test-utils": "^0.1.6",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
+9
View File
@@ -1,5 +1,14 @@
# @backstage/plugin-cost-insights
## 0.5.7
### Patch Changes
- 8c2437c15: bug(cost-insights): Remove entity count when none present
- Updated dependencies [efd6ef753]
- Updated dependencies [a187b8ad0]
- @backstage/core@0.5.0
## 0.5.6
### Patch Changes
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-cost-insights",
"version": "0.5.6",
"version": "0.5.7",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -31,7 +31,7 @@
},
"dependencies": {
"@backstage/config": "^0.1.2",
"@backstage/core": "^0.4.4",
"@backstage/core": "^0.5.0",
"@backstage/theme": "^0.2.2",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
@@ -55,8 +55,8 @@
"yup": "^0.29.3"
},
"devDependencies": {
"@backstage/cli": "^0.4.6",
"@backstage/dev-utils": "^0.1.7",
"@backstage/cli": "^0.4.7",
"@backstage/dev-utils": "^0.1.8",
"@backstage/test-utils": "^0.1.6",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
@@ -13,10 +13,17 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import React, { useState } from 'react';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import { useTheme, Box } from '@material-ui/core';
import {
useTheme,
Box,
Typography,
Divider,
emphasize,
} from '@material-ui/core';
import { default as FullScreenIcon } from '@material-ui/icons/Fullscreen';
import {
AreaChart,
ContentRenderer,
@@ -59,9 +66,10 @@ export const CostOverviewByProductChart = ({
costsByProduct,
}: CostOverviewByProductChartProps) => {
const theme = useTheme<CostInsightsTheme>();
const styles = useStyles(theme);
const classes = useStyles(theme);
const lastCompleteBillingDate = useLastCompleteBillingDate();
const { duration } = useFilters(mapFiltersToProps);
const [isExpanded, setExpanded] = useState(false);
if (!costsByProduct) {
return null;
@@ -79,23 +87,27 @@ export const CostOverviewByProductChart = ({
lastCompleteBillingDate,
);
const currentPeriodTotal = totalCost - previousPeriodTotal;
const otherProducts: string[] = [];
const productsByDate = costsByProduct.reduce((prodByDate, product) => {
const productTotal = aggregationSum(product.aggregation);
// Group products with less than 10% of the total cost into "Other" category
// when there we have >= 5 products.
// when we have >= 8 products.
const isOtherProduct =
costsByProduct.length >= 5 &&
costsByProduct.length >= 8 &&
productTotal < totalCost * LOW_COST_THRESHOLD;
const productName = isOtherProduct ? 'Other' : product.id;
const updatedProdByDate = { ...prodByDate };
const updatedProdByDate = { ...prodByDate };
if (isOtherProduct) {
otherProducts.push(product.id);
}
product.aggregation.forEach(curAggregation => {
const productCostsForDate = updatedProdByDate[curAggregation.date] || {};
updatedProdByDate[curAggregation.date] = {
...productCostsForDate,
[productName]:
(productCostsForDate[productName] || 0) + curAggregation.amount,
[product.id]:
(productCostsForDate[product.id] || 0) + curAggregation.amount,
};
});
@@ -104,38 +116,63 @@ export const CostOverviewByProductChart = ({
const chartData: Record<string, number>[] = Object.keys(productsByDate).map(
date => {
const costsForDate = Object.keys(productsByDate[date]).reduce(
(dateCosts, product) => {
// Group costs for products that belong to 'Other' in the chart.
const cost = productsByDate[date][product];
const productCost =
!isExpanded && otherProducts.includes(product)
? { Other: (dateCosts.Other || 0) + cost }
: { [product]: cost };
return { ...dateCosts, ...productCost };
},
{} as Record<string, number>,
);
return {
...productsByDate[date],
...costsForDate,
date: Date.parse(date),
};
},
);
const sortedProducts = costsByProduct.sort(
(a, b) => aggregationSum(a.aggregation) - aggregationSum(b.aggregation),
);
const renderAreas = () => {
const productGroupNames = new Set(
Object.values(productsByDate)
.map(d => Object.keys(d))
.flat(),
);
const sortedProducts = costsByProduct
const separatedProducts = sortedProducts
// Check that product is a separate group and hasn't been added to 'Other'
.filter(
product => product.id !== 'Other' && productGroupNames.has(product.id),
)
.sort(
(a, b) => aggregationSum(a.aggregation) - aggregationSum(b.aggregation),
product =>
product.id !== 'Other' && !otherProducts.includes(product.id),
)
.map(product => product.id);
// Keep 'Other' category at the bottom of the stack
return ['Other', ...sortedProducts].map((product, i) => (
<Area
dataKey={product}
isAnimationActive={false}
stackId="1"
stroke={theme.palette.dataViz[sortedProducts.length - i]}
fill={theme.palette.dataViz[sortedProducts.length - i]}
/>
));
const productsToDisplay = isExpanded
? sortedProducts.map(product => product.id)
: ['Other', ...separatedProducts];
return productsToDisplay.map((product, i) => {
// Logic to handle case where there are more products than data viz colors.
const productColor =
theme.palette.dataViz[
(productsToDisplay.length - 1 - i) %
(theme.palette.dataViz.length - 1)
];
return (
<Area
dataKey={product}
isAnimationActive={false}
stackId="1"
stroke={productColor}
fill={productColor}
onClick={() => setExpanded(true)}
style={{
cursor: product === 'Other' && !isExpanded ? 'pointer' : null,
}}
/>
);
});
};
const tooltipRenderer: ContentRenderer<TooltipProps> = ({
@@ -144,18 +181,32 @@ export const CostOverviewByProductChart = ({
}) => {
if (isInvalid({ label, payload })) return null;
const title = dayjs(label).utc().format(DEFAULT_DATE_FORMAT);
const dateTitle = dayjs(label).utc().format(DEFAULT_DATE_FORMAT);
const items = payload.map(p => ({
label: p.dataKey as string,
value: formatGraphValue(p.value as number),
fill: p.fill!,
}));
const expandText = (
<Box>
<Divider
style={{
backgroundColor: emphasize(theme.palette.divider, 1),
margin: '10px 0',
}}
/>
<Box display="flex" justifyContent="space-between" alignItems="center">
<FullScreenIcon />
<Typography>Click to expand</Typography>
</Box>
</Box>
);
return (
<Tooltip title={title}>
<Tooltip title={dateTitle}>
{items.reverse().map((item, index) => (
<TooltipItem key={`${item.label}-${index}`} item={item} />
))}
{!isExpanded ? expandText : null}
</Tooltip>
);
};
@@ -176,8 +227,8 @@ export const CostOverviewByProductChart = ({
/>
</Box>
<ResponsiveContainer
width={styles.container.width}
height={styles.container.height}
width={classes.container.width}
height={classes.container.height}
>
<AreaChart
data={chartData}
@@ -187,20 +238,20 @@ export const CostOverviewByProductChart = ({
bottom: 40,
}}
>
<CartesianGrid stroke={styles.cartesianGrid.stroke} />
<CartesianGrid stroke={classes.cartesianGrid.stroke} />
<XAxis
dataKey="date"
domain={['dataMin', 'dataMax']}
tickFormatter={overviewGraphTickFormatter}
tickCount={6}
type="number"
stroke={styles.axis.fill}
stroke={classes.axis.fill}
/>
<YAxis
domain={[() => 0, 'dataMax']}
tick={{ fill: styles.axis.fill }}
tick={{ fill: classes.axis.fill }}
tickFormatter={formatGraphValue}
width={styles.yAxis.width}
width={classes.yAxis.width}
/>
{renderAreas()}
<RechartsTooltip content={tooltipRenderer} animationDuration={100} />
+8
View File
@@ -1,5 +1,13 @@
# @backstage/plugin-explore
## 0.2.3
### Patch Changes
- Updated dependencies [efd6ef753]
- Updated dependencies [a187b8ad0]
- @backstage/core@0.5.0
## 0.2.2
### Patch Changes
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-explore",
"version": "0.2.2",
"version": "0.2.3",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -30,7 +30,7 @@
"start": "backstage-cli plugin:serve"
},
"dependencies": {
"@backstage/core": "^0.4.4",
"@backstage/core": "^0.5.0",
"@backstage/theme": "^0.2.2",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
@@ -42,8 +42,8 @@
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.4.6",
"@backstage/dev-utils": "^0.1.7",
"@backstage/cli": "^0.4.7",
"@backstage/dev-utils": "^0.1.8",
"@backstage/test-utils": "^0.1.6",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
+14
View File
@@ -1,5 +1,19 @@
# @backstage/plugin-fossa
## 0.1.2
### Patch Changes
- 9f618bf16: Request a sorted response list to select the project with the correct title. The FOSSA API
matches title searches with "starts with" so previously it used the response for `my-project-part`
if you searched for `my-project`.
- Updated dependencies [def2307f3]
- Updated dependencies [efd6ef753]
- Updated dependencies [a187b8ad0]
- Updated dependencies [a93f42213]
- @backstage/catalog-model@0.7.0
- @backstage/core@0.5.0
## 0.1.1
### Patch Changes
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-fossa",
"version": "0.1.1",
"version": "0.1.2",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -31,8 +31,8 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.6.0",
"@backstage/core": "^0.4.4",
"@backstage/catalog-model": "^0.7.0",
"@backstage/core": "^0.5.0",
"@backstage/theme": "^0.2.2",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
@@ -43,8 +43,8 @@
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.4.6",
"@backstage/dev-utils": "^0.1.7",
"@backstage/cli": "^0.4.7",
"@backstage/dev-utils": "^0.1.8",
"@backstage/test-utils": "^0.1.6",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
+36 -4
View File
@@ -37,12 +37,13 @@ describe('FossaClient', () => {
server.use(
rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => {
expect(req.url.searchParams.toString()).toBe(
'count=1&title=our-service&organizationId=8736',
'count=1&sort=title+&title=our-service&organizationId=8736',
);
return res(
ctx.json([
{
locator: 'custom+8736/our-service',
title: 'our-service',
default_branch: 'develop',
revisions: [
{
@@ -73,12 +74,13 @@ describe('FossaClient', () => {
server.use(
rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => {
expect(req.url.searchParams.toString()).toBe(
'count=1&title=our-service&organizationId=8736',
'count=1&sort=title+&title=our-service&organizationId=8736',
);
return res(
ctx.json([
{
locator: 'custom+8736/our-service',
title: 'our-service',
default_branch: 'refs/master',
revisions: [
{
@@ -104,13 +106,43 @@ describe('FossaClient', () => {
} as FindingSummary);
});
it('should handle empty result', async () => {
server.use(
rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => {
expect(req.url.searchParams.toString()).toBe(
'count=1&sort=title+&title=our-service&organizationId=8736',
);
return res(ctx.json([]));
}),
);
const summary = await client.getFindingSummary('our-service');
expect(summary).toBeUndefined();
});
it('should ignore result with invalid title', async () => {
server.use(
rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => {
expect(req.url.searchParams.toString()).toBe(
'count=1&sort=title+&title=our-service&organizationId=8736',
);
return res(ctx.json([{ title: 'our-service-2' }]));
}),
);
const summary = await client.getFindingSummary('our-service');
expect(summary).toBeUndefined();
});
it('should skip organizationId', async () => {
client = new FossaClient({ discoveryApi });
server.use(
rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => {
expect(req.url.searchParams.toString()).toBe(
'count=1&title=our-service',
'count=1&sort=title+&title=our-service',
);
return res(ctx.status(404));
}),
@@ -125,7 +157,7 @@ describe('FossaClient', () => {
server.use(
rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => {
expect(req.url.searchParams.toString()).toBe(
'count=1&title=our-service&organizationId=8736',
'count=1&sort=title+&title=our-service&organizationId=8736',
);
return res(ctx.status(404));
}),
+3 -2
View File
@@ -46,11 +46,12 @@ export class FossaClient implements FossaApi {
projectTitle: string,
): Promise<FindingSummary | undefined> {
const project = await this.callApi(
`projects?count=1&title=${projectTitle}${
`projects?count=1&sort=title+&title=${projectTitle}${
this.organizationId ? `&organizationId=${this.organizationId}` : ''
}`,
);
if (!project) {
if (!project || project.length === 0 || project[0].title !== projectTitle) {
return undefined;
}
+8
View File
@@ -1,5 +1,13 @@
# @backstage/plugin-gcp-projects
## 0.2.3
### Patch Changes
- Updated dependencies [efd6ef753]
- Updated dependencies [a187b8ad0]
- @backstage/core@0.5.0
## 0.2.2
### Patch Changes
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-gcp-projects",
"version": "0.2.2",
"version": "0.2.3",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -30,7 +30,7 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/core": "^0.4.4",
"@backstage/core": "^0.5.0",
"@backstage/theme": "^0.2.2",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
@@ -41,8 +41,8 @@
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.4.6",
"@backstage/dev-utils": "^0.1.7",
"@backstage/cli": "^0.4.7",
"@backstage/dev-utils": "^0.1.8",
"@backstage/test-utils": "^0.1.6",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
+23
View File
@@ -1,5 +1,28 @@
# @backstage/plugin-github-actions
## 0.3.0
### Minor Changes
- 46bba09ea: Support GHE
### Patch Changes
- Updated dependencies [def2307f3]
- Updated dependencies [efd6ef753]
- Updated dependencies [0b135e7e0]
- Updated dependencies [593632f07]
- Updated dependencies [33846acfc]
- Updated dependencies [fa8ba330a]
- Updated dependencies [a187b8ad0]
- Updated dependencies [ed6baab66]
- Updated dependencies [f04db53d7]
- Updated dependencies [a93f42213]
- @backstage/catalog-model@0.7.0
- @backstage/core@0.5.0
- @backstage/integration@0.3.0
- @backstage/plugin-catalog@0.2.12
## 0.2.7
### Patch Changes
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-github-actions",
"version": "0.2.7",
"version": "0.3.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -32,10 +32,10 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.6.1",
"@backstage/core": "^0.4.4",
"@backstage/integration": "^0.2.0",
"@backstage/plugin-catalog": "^0.2.11",
"@backstage/catalog-model": "^0.7.0",
"@backstage/core": "^0.5.0",
"@backstage/integration": "^0.3.0",
"@backstage/plugin-catalog": "^0.2.12",
"@backstage/theme": "^0.2.2",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
@@ -50,8 +50,8 @@
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.4.6",
"@backstage/dev-utils": "^0.1.7",
"@backstage/cli": "^0.4.7",
"@backstage/dev-utils": "^0.1.8",
"@backstage/test-utils": "^0.1.6",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
+8
View File
@@ -1,5 +1,13 @@
# @backstage/plugin-gitops-profiles
## 0.2.3
### Patch Changes
- Updated dependencies [efd6ef753]
- Updated dependencies [a187b8ad0]
- @backstage/core@0.5.0
## 0.2.2
### Patch Changes
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-gitops-profiles",
"version": "0.2.2",
"version": "0.2.3",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -31,7 +31,7 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/core": "^0.4.4",
"@backstage/core": "^0.5.0",
"@backstage/theme": "^0.2.2",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
@@ -42,8 +42,8 @@
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.4.6",
"@backstage/dev-utils": "^0.1.7",
"@backstage/cli": "^0.4.7",
"@backstage/dev-utils": "^0.1.8",
"@backstage/test-utils": "^0.1.6",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
+8
View File
@@ -1,5 +1,13 @@
# @backstage/plugin-graphiql
## 0.2.6
### Patch Changes
- Updated dependencies [efd6ef753]
- Updated dependencies [a187b8ad0]
- @backstage/core@0.5.0
## 0.2.5
### Patch Changes
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/plugin-graphiql",
"description": "Backstage plugin for browsing GraphQL APIs",
"version": "0.2.5",
"version": "0.2.6",
"private": false,
"publishConfig": {
"access": "public",
@@ -31,7 +31,7 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/core": "^0.4.4",
"@backstage/core": "^0.5.0",
"@backstage/theme": "^0.2.2",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
@@ -43,8 +43,8 @@
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.4.6",
"@backstage/dev-utils": "^0.1.7",
"@backstage/cli": "^0.4.7",
"@backstage/dev-utils": "^0.1.8",
"@backstage/test-utils": "^0.1.6",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
+12
View File
@@ -1,5 +1,17 @@
# @backstage/plugin-graphql-backend
## 0.1.5
### Patch Changes
- Updated dependencies [0b135e7e0]
- Updated dependencies [294a70cab]
- Updated dependencies [0ea032763]
- Updated dependencies [5345a1f98]
- Updated dependencies [09a370426]
- @backstage/backend-common@0.5.0
- @backstage/plugin-catalog-graphql@0.2.6
## 0.1.4
### Patch Changes
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-graphql-backend",
"version": "0.1.4",
"version": "0.1.5",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -29,9 +29,9 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.4.0",
"@backstage/backend-common": "^0.5.0",
"@backstage/config": "^0.1.2",
"@backstage/plugin-catalog-graphql": "^0.2.4",
"@backstage/plugin-catalog-graphql": "^0.2.6",
"@graphql-modules/core": "^0.7.17",
"@types/express": "^4.17.6",
"apollo-server": "^2.16.1",
@@ -45,7 +45,7 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.4.1",
"@backstage/cli": "^0.4.7",
"@types/supertest": "^2.0.8",
"eslint-plugin-graphql": "^4.0.0",
"msw": "^0.20.5",
+25
View File
@@ -1,5 +1,30 @@
# @backstage/plugin-jenkins
## 0.3.7
### Patch Changes
- 9f8faed25: Update Jenkins card with build date, duration, and rename card to "latest" build.
- Updated dependencies [a4e636c8f]
- Updated dependencies [099c5cf4f]
- Updated dependencies [a08db734c]
- @backstage/plugin-catalog@0.2.13
## 0.3.6
### Patch Changes
- Updated dependencies [def2307f3]
- Updated dependencies [efd6ef753]
- Updated dependencies [593632f07]
- Updated dependencies [33846acfc]
- Updated dependencies [a187b8ad0]
- Updated dependencies [f04db53d7]
- Updated dependencies [a93f42213]
- @backstage/catalog-model@0.7.0
- @backstage/core@0.5.0
- @backstage/plugin-catalog@0.2.12
## 0.3.5
### Patch Changes
+2 -3
View File
@@ -84,6 +84,5 @@ YWRtaW46MTFlYzI1NmU0Mzg1MDFjM2Y1Yzc2Yjc1MWE3ZTQ3YWY4Mw== is the base64 of user a
## Limitations
- Only works with projects that use the Git SCM
- It requires jobs to be organised into folders
- No pagination support currently - don't run this on a Jenkins with lots of builds
- Only works with organization folder jobs backed by GitHub
- No pagination support currently, limited to 50 jobs - don't run this on a Jenkins with lots of builds
+7 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-jenkins",
"version": "0.3.5",
"version": "0.3.7",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -31,14 +31,15 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.6.1",
"@backstage/core": "^0.4.4",
"@backstage/plugin-catalog": "^0.2.11",
"@backstage/catalog-model": "^0.7.0",
"@backstage/core": "^0.5.0",
"@backstage/plugin-catalog": "^0.2.13",
"@backstage/theme": "^0.2.2",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"jenkins": "^0.28.0",
"luxon": "^1.25.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router": "6.0.0-beta.0",
@@ -46,8 +47,8 @@
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.4.6",
"@backstage/dev-utils": "^0.1.7",
"@backstage/cli": "^0.4.7",
"@backstage/dev-utils": "^0.1.8",
"@backstage/test-utils": "^0.1.6",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
+21 -11
View File
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import React from 'react';
import { DateTime, Duration } from 'luxon';
import { Link, Theme, makeStyles, LinearProgress } from '@material-ui/core';
import { InfoCard, StructuredMetadataTable } from '@backstage/core';
import ExternalLinkIcon from '@material-ui/icons/Launch';
@@ -30,28 +31,37 @@ const useStyles = makeStyles<Theme>({
const WidgetContent = ({
loading,
lastRun,
latestRun,
}: {
loading?: boolean;
lastRun: any;
latestRun: any;
branch: string;
}) => {
const classes = useStyles();
if (loading || !lastRun) return <LinearProgress />;
if (loading || !latestRun) return <LinearProgress />;
const displayDate = DateTime.fromMillis(latestRun.timestamp).toRelative();
// TODO This works, but hard codes as minutes. Would prefer something smarter/relative.
const durationInMinutes = Math.round(
Duration.fromMillis(latestRun.duration).as('minutes'),
);
const displayDuration = `${durationInMinutes} minute${
durationInMinutes > 1 ? 's' : ''
}`;
return (
<StructuredMetadataTable
metadata={{
status: (
<>
<JenkinsRunStatus
status={lastRun.building ? 'running' : lastRun.result}
status={latestRun.building ? 'running' : latestRun.result}
/>
</>
),
build: lastRun.fullDisplayName,
url: (
<Link href={lastRun.url} target="_blank">
build: latestRun.fullDisplayName,
'latest run': displayDate,
duration: displayDuration,
link: (
<Link href={latestRun.url} target="_blank">
See more on Jenkins{' '}
<ExternalLinkIcon className={classes.externalLinkIcon} />
</Link>
@@ -70,10 +80,10 @@ export const LatestRunCard = ({
}) => {
const { owner, repo } = useProjectSlugFromEntity();
const [{ builds, loading }] = useBuilds(owner, repo, branch);
const lastRun = builds ?? {};
const latestRun = builds ?? {};
return (
<InfoCard title={`Last ${branch} build`} variant={variant}>
<WidgetContent loading={loading} branch={branch} lastRun={lastRun} />
<InfoCard title={`Latest ${branch} build`} variant={variant}>
<WidgetContent loading={loading} branch={branch} latestRun={latestRun} />
</InfoCard>
);
};
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
};
+15
View File
@@ -0,0 +1,15 @@
# @backstage/plugin-kafka-backend
## 0.1.1
### Patch Changes
- Updated dependencies [def2307f3]
- Updated dependencies [0b135e7e0]
- Updated dependencies [294a70cab]
- Updated dependencies [0ea032763]
- Updated dependencies [5345a1f98]
- Updated dependencies [09a370426]
- Updated dependencies [a93f42213]
- @backstage/catalog-model@0.7.0
- @backstage/backend-common@0.5.0
+28
View File
@@ -0,0 +1,28 @@
# Kafka Backend
This is the backend part of the Kafka plugin. It responds to Kafka requests from the frontend.
## Configuration
This configures how to connect to the brokers in your Kafka cluster.
### clientId
The name of the client to use when connecting to the cluster.
### brokers
A list of the brokers' host names and ports to connect to.
### SSL (optional)
Configure TLS connection to the Kafka cluster. The options are passed directly to [tls.connect] and used to create the TLS secure context. Normally these would include `key` and `cert`.
Example:
```yaml
kafka:
clientId: backstage
brokers:
- localhost:9092
```
+38
View File
@@ -0,0 +1,38 @@
/*
* 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 Config {
kafka?: {
/**
* Client ID used to Backstage uses to identify when connecting to the Kafka cluster.
*/
clientId: string;
/**
* List of brokers in the Kafka cluster to connect to.
*/
brokers: string[];
/**
* Optional SSL connection parameters to connect to the cluster. Passed directly to Node tls.connect.
* See https://nodejs.org/dist/latest-v8.x/docs/api/tls.html#tls_tls_createsecurecontext_options
*/
ssl?: {
ca: string[];
/** @visibility secret */
key: string;
cert: string;
};
};
}
+55
View File
@@ -0,0 +1,55 @@
{
"name": "@backstage/plugin-kafka-backend",
"version": "0.1.1",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/kafka-backend"
},
"keywords": [
"backstage",
"kafka"
],
"configSchema": "config.d.ts",
"scripts": {
"start": "backstage-cli backend:dev",
"build": "backstage-cli backend:build",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.5.0",
"@backstage/catalog-model": "^0.7.0",
"@backstage/config": "^0.1.2",
"@types/express": "^4.17.6",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"kafkajs": "^1.16.0-beta.6",
"lodash": "^4.17.15",
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/cli": "^0.4.7",
"@types/jest-when": "^2.7.2",
"@types/lodash": "^4.14.151",
"jest-when": "^3.1.0",
"supertest": "^4.0.2"
},
"files": [
"dist",
"schema.d.ts"
]
}
@@ -13,10 +13,5 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export type RemoteProtocol =
| 'file'
| 'github'
| 'gitlab'
| 'gitlab/api'
| 'azure/api'
| 'bitbucket';
export { createRouter } from './service/router';
@@ -0,0 +1,97 @@
/*
* 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 { Kafka, SeekEntry } from 'kafkajs';
import { Logger } from 'winston';
import { ConnectionOptions } from 'tls';
export type PartitionOffset = {
id: number;
offset: string;
};
export type TopicOffset = {
topic: string;
partitions: PartitionOffset[];
};
export type Options = {
clientId: string;
brokers: string[];
ssl?: ConnectionOptions;
logger: Logger;
};
export interface KafkaApi {
fetchTopicOffsets(topic: string): Promise<Array<PartitionOffset>>;
fetchGroupOffsets(groupId: string): Promise<Array<TopicOffset>>;
}
export class KafkaJsApiImpl implements KafkaApi {
private readonly kafka: Kafka;
private readonly logger: Logger;
constructor(options: Options) {
options.logger.debug(
`creating kafka client with clientId=${options.clientId} and brokers=${options.brokers}`,
);
this.kafka = new Kafka(options);
this.logger = options.logger;
}
async fetchTopicOffsets(topic: string): Promise<Array<PartitionOffset>> {
this.logger.debug(`fetching topic offsets for ${topic}`);
const admin = this.kafka.admin();
await admin.connect();
try {
return KafkaJsApiImpl.toPartitionOffsets(
await admin.fetchTopicOffsets(topic),
);
} finally {
await admin.disconnect();
}
}
async fetchGroupOffsets(groupId: string): Promise<Array<TopicOffset>> {
this.logger.debug(`fetching consumer group offsets for ${groupId}`);
const admin = this.kafka.admin();
await admin.connect();
try {
const groupOffsets = await admin.fetchOffsets({ groupId });
return groupOffsets.map(topicOffset => ({
topic: topicOffset.topic,
partitions: KafkaJsApiImpl.toPartitionOffsets(topicOffset.partitions),
}));
} finally {
await admin.disconnect();
}
}
private static toPartitionOffsets(
result: Array<SeekEntry>,
): Array<PartitionOffset> {
return result.map(seekEntry => ({
id: seekEntry.partition,
offset: seekEntry.offset,
}));
}
}
@@ -0,0 +1,108 @@
/*
* 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 request from 'supertest';
import express from 'express';
import { makeRouter } from './router';
import { getVoidLogger } from '@backstage/backend-common';
import { KafkaApi } from './KafkaApi';
import { when } from 'jest-when';
describe('router', () => {
let app: express.Express;
let kafkaApi: jest.Mocked<KafkaApi>;
beforeAll(async () => {
kafkaApi = {
fetchTopicOffsets: jest.fn(),
fetchGroupOffsets: jest.fn(),
};
const router = makeRouter(getVoidLogger(), kafkaApi);
app = express().use(router);
});
beforeEach(() => {
jest.resetAllMocks();
});
describe('get /consumer/:consumerId/offsets', () => {
it('returns topic and group offsets', async () => {
const topic1Offsets = [
{ id: 1, offset: '500' },
{ id: 2, offset: '1000' },
];
const topic2Offsets = [{ id: 1, offset: '456' }];
const groupOffsets = [
{
topic: 'topic1',
partitions: [
{ id: 1, offset: '100' },
{ id: 2, offset: '213' },
],
},
{
topic: 'topic2',
partitions: [{ id: 1, offset: '456' }],
},
];
when(kafkaApi.fetchTopicOffsets)
.calledWith('topic1')
.mockResolvedValue(topic1Offsets);
when(kafkaApi.fetchTopicOffsets)
.calledWith('topic2')
.mockResolvedValue(topic2Offsets);
kafkaApi.fetchGroupOffsets.mockResolvedValue(groupOffsets);
const response = await request(app).get('/consumer/hey/offsets');
expect(response.status).toEqual(200);
expect(response.body.consumerId).toEqual('hey');
// Note the Set comparison here since there's no guarantee on the order of the elements in the list.
expect(new Set(response.body.offsets)).toStrictEqual(
new Set([
{
topic: 'topic1',
partitionId: 1,
groupOffset: '100',
topicOffset: '500',
},
{
topic: 'topic1',
partitionId: 2,
groupOffset: '213',
topicOffset: '1000',
},
{
topic: 'topic2',
partitionId: 1,
groupOffset: '456',
topicOffset: '456',
},
]),
);
});
it('handles internal error correctly', async () => {
kafkaApi.fetchGroupOffsets.mockRejectedValue(Error('oh no'));
const response = await request(app).get('/consumer/hey/offsets');
expect(response.status).toEqual(500);
});
});
});
@@ -0,0 +1,81 @@
/*
* 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 Router from 'express-promise-router';
import { Logger } from 'winston';
import { Config } from '@backstage/config';
import { KafkaApi, KafkaJsApiImpl } from './KafkaApi';
import _ from 'lodash';
import { ConnectionOptions } from 'tls';
export interface RouterOptions {
logger: Logger;
config: Config;
}
export const makeRouter = (
logger: Logger,
kafkaApi: KafkaApi,
): express.Router => {
const router = Router();
router.use(express.json());
router.get('/consumer/:consumerId/offsets', async (req, res) => {
const consumerId = req.params.consumerId;
logger.debug(`Fetch consumer group ${consumerId} offsets`);
const groupOffsets = await kafkaApi.fetchGroupOffsets(consumerId);
const groupWithTopicOffsets = await Promise.all(
groupOffsets.map(async ({ topic, partitions }) => {
const topicOffsets = _.keyBy(
await kafkaApi.fetchTopicOffsets(topic),
partition => partition.id,
);
return partitions.map(partition => ({
topic: topic,
partitionId: partition.id,
groupOffset: partition.offset,
topicOffset: topicOffsets[partition.id].offset,
}));
}),
);
res.send({ consumerId, offsets: groupWithTopicOffsets.flat() });
});
return router;
};
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const logger = options.logger;
logger.info('Initializing Kafka backend');
const clientId = options.config.getString('kafka.clientId');
const brokers = options.config.getStringArray('kafka.brokers');
const sslConfig = options.config.getOptional('kafka.ssl');
const ssl = sslConfig ? (sslConfig as ConnectionOptions) : undefined;
const kafkaApi = new KafkaJsApiImpl({ clientId, brokers, logger, ssl });
return makeRouter(logger, kafkaApi);
}
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export {};
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+16
View File
@@ -0,0 +1,16 @@
# @backstage/plugin-kafka
## 0.1.1
### Patch Changes
- Updated dependencies [def2307f3]
- Updated dependencies [efd6ef753]
- Updated dependencies [593632f07]
- Updated dependencies [33846acfc]
- Updated dependencies [a187b8ad0]
- Updated dependencies [f04db53d7]
- Updated dependencies [a93f42213]
- @backstage/catalog-model@0.7.0
- @backstage/core@0.5.0
- @backstage/plugin-catalog@0.2.12
+87
View File
@@ -0,0 +1,87 @@
# Kafka Plugin
<img src="./src/assets/screenshot-1.png">
## Setup
1. Run:
```bash
yarn add @backstage/plugin-kafka @backstage/plugin-kafka-backend
```
2. Add the plugin backend:
In a new file named `kafka.js` under `backend/src/plugins`:
```js
import { createRouter } from '@backstage/plugin-kafka-backend';
import { PluginEnvironment } from '../types';
export default async function createPlugin({
logger,
config,
}: PluginEnvironment) {
return await createRouter({ logger, config });
}
```
And then add to `packages/backend/src/index.ts`:
```js
async function main() {
// ...
const kafkaEnv = useHotMemoize(module, () => createEnv('kafka'));
// ...
const apiRouter = Router();
// ...
apiRouter.use('/kafka', await kafka(kafkaEnv));
// ...
```
3. Add the plugin frontend to `packages/app/src/plugin.ts`:
```js
export { plugin as Kafka } from '@backstage/plugin-kafka';
```
4. Register the plugin frontend router in `packages/app/src/components/catalog/EntityPage.tsx`:
```jsx
import { Router as KafkaRouter } from '@backstage/plugin-kafka';
// Then, somewhere inside <EntityPageLayout>
<EntityPageLayout.Content
path="/kafka/*"
title="Kafka"
element={<KafkaRouter entity={entity} />}
/>;
```
5. Add broker configs for the backend in your `app-config.yaml`:
```yaml
kafka:
clientId: backstage
brokers:
- localhost:9092
```
6. Add `kafka.apache.org/consumer-groups` annotation to your services:
```yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
# ...
annotations:
kafka.apache.org/consumer-groups: consumer-group-name
spec:
type: service
```
## Features
- List topics offsets and consumer group offsets for configured services.
+19
View File
@@ -0,0 +1,19 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createDevApp } from '@backstage/dev-utils';
import { plugin } from '../src/plugin';
createDevApp().registerPlugin(plugin).render();
+52
View File
@@ -0,0 +1,52 @@
{
"name": "@backstage/plugin-kafka",
"version": "0.1.1",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.7.0",
"@backstage/core": "^0.5.0",
"@backstage/plugin-catalog": "^0.2.12",
"@backstage/theme": "^0.2.2",
"@material-ui/core": "^4.11.0",
"@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": "6.0.0-beta.0",
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.4.7",
"@backstage/dev-utils": "^0.1.8",
"@backstage/test-utils": "^0.1.6",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/react-hooks": "^3.4.2",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"cross-fetch": "^3.0.6",
"jest-when": "^3.1.0",
"msw": "^0.21.2"
},
"files": [
"dist"
]
}
+40
View File
@@ -0,0 +1,40 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import React from 'react';
import { Route, Routes } from 'react-router';
import { rootCatalogKafkaRouteRef } from './plugin';
import { KAFKA_CONSUMER_GROUP_ANNOTATION } from './constants';
import { KafkaTopicsForConsumer } from './components/ConsumerGroupOffsets/ConsumerGroupOffsets';
import { MissingAnnotationEmptyState } from '@backstage/core';
export const isPluginApplicableToEntity = (entity: Entity) =>
Boolean(entity.metadata.annotations?.[KAFKA_CONSUMER_GROUP_ANNOTATION]);
export const Router = ({ entity }: { entity: Entity }) => {
return !isPluginApplicableToEntity(entity) ? (
<MissingAnnotationEmptyState annotation={KAFKA_CONSUMER_GROUP_ANNOTATION} />
) : (
<Routes>
<Route
path={`${rootCatalogKafkaRouteRef.path}`}
element={<KafkaTopicsForConsumer />}
/>
</Routes>
);
};
@@ -0,0 +1,50 @@
/*
* 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 { DiscoveryApi } from '@backstage/core';
import { KafkaApi, ConsumerGroupOffsetsResponse } from './types';
export class KafkaBackendClient implements KafkaApi {
private readonly discoveryApi: DiscoveryApi;
constructor(options: { discoveryApi: DiscoveryApi }) {
this.discoveryApi = options.discoveryApi;
}
private async internalGet(path: string): Promise<any> {
const url = `${await this.discoveryApi.getBaseUrl('kafka')}${path}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const payload = await response.text();
const message = `Request failed with ${response.status} ${response.statusText}, ${payload}`;
throw new Error(message);
}
return await response.json();
}
async getConsumerGroupOffsets(
consumerGroup: string,
): Promise<ConsumerGroupOffsetsResponse> {
return await this.internalGet(`/consumer/${consumerGroup}/offsets`);
}
}
+39
View File
@@ -0,0 +1,39 @@
/*
* 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';
export const kafkaApiRef = createApiRef<KafkaApi>({
id: 'plugin.kafka.service',
description:
'Used by the Kafka plugin to make requests to accompanying backend',
});
export type ConsumerGroupOffsetsResponse = {
consumerId: string;
offsets: {
topic: string;
partitionId: number;
topicOffset: string;
groupOffset: string;
}[];
};
export interface KafkaApi {
getConsumerGroupOffsets(
consumerGroup: string,
): Promise<ConsumerGroupOffsetsResponse>;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

@@ -0,0 +1,40 @@
/*
* 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 { renderInTestApp } from '@backstage/test-utils';
import { ConsumerGroupOffsets } from './ConsumerGroupOffsets';
import * as data from './__fixtures__/consumer-group-offsets.json';
import { ConsumerGroupOffsetsResponse } from '../../api/types';
const consumerGroupOffsets = data as ConsumerGroupOffsetsResponse;
describe('ConsumerGroupOffsets', () => {
it('should render consumer group table', async () => {
const rendered = await renderInTestApp(
<ConsumerGroupOffsets
consumerGroup={consumerGroupOffsets.consumerId}
topics={consumerGroupOffsets.offsets}
loading={false}
retry={() => {}}
/>,
);
expect(rendered.getByText(/consumer/)).toBeInTheDocument();
expect(rendered.getByText('topic1')).toBeInTheDocument();
expect(rendered.getByText('topic2')).toBeInTheDocument();
});
});
@@ -0,0 +1,103 @@
/*
* 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 { Table, TableColumn } from '@backstage/core';
import { Box, Typography } from '@material-ui/core';
import RetryIcon from '@material-ui/icons/Replay';
import React from 'react';
import { useConsumerGroupsOffsetsForEntity } from './useConsumerGroupsOffsetsForEntity';
export type TopicPartitionInfo = {
topic: string;
partitionId: number;
topicOffset: string;
groupOffset: string;
};
const generatedColumns: TableColumn[] = [
{
title: 'Topic',
field: 'topic',
highlight: true,
render: (row: Partial<TopicPartitionInfo>) => {
return <>{row.topic ?? ''}</>;
},
},
{
title: 'Partition',
field: 'partitionId',
render: (row: Partial<TopicPartitionInfo>) => {
return <>{row.partitionId ?? ''}</>;
},
},
{
title: 'Topic Offset',
field: 'topicOffset',
render: (row: Partial<TopicPartitionInfo>) => {
return <>{row.topicOffset ?? ''}</>;
},
},
{
title: 'Group Offset',
field: 'groupOffset',
render: (row: Partial<TopicPartitionInfo>) => {
return <>{row.groupOffset ?? ''}</>;
},
},
];
type Props = {
loading: boolean;
retry: () => void;
consumerGroup: string;
topics?: TopicPartitionInfo[];
};
export const ConsumerGroupOffsets = ({
loading,
topics,
consumerGroup,
retry,
}: Props) => {
return (
<Table
isLoading={loading}
actions={[
{
icon: () => <RetryIcon />,
tooltip: 'Refresh Data',
isFreeAction: true,
onClick: () => retry(),
},
]}
data={topics ?? []}
title={
<Box display="flex" alignItems="center">
<Typography variant="h6">
Consumed Topics for {consumerGroup}
</Typography>
</Box>
}
columns={generatedColumns}
/>
);
};
export const KafkaTopicsForConsumer = () => {
const [tableProps, { retry }] = useConsumerGroupsOffsetsForEntity();
return <ConsumerGroupOffsets {...tableProps} retry={retry} />;
};
@@ -0,0 +1,17 @@
{
"consumerId": "consumer",
"offsets": [
{
"topic": "topic1",
"partitionId": 1,
"topicOffset": "100",
"groupOffset": "50"
},
{
"topic": "topic2",
"partitionId": 1,
"topicOffset": "2340",
"groupOffset": "1234"
}
]
}
@@ -0,0 +1,53 @@
/*
* 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, { PropsWithChildren } from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { useConsumerGroupsForEntity } from './useConsumerGroupsForEntity';
import { EntityContext } from '@backstage/plugin-catalog';
import { Entity } from '@backstage/catalog-model';
describe('useConsumerGroupOffsets', () => {
const entity: Entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'test',
annotations: {
'kafka.apache.org/consumer-groups': 'consumer',
},
},
spec: {
owner: 'guest',
type: 'Website',
lifecycle: 'development',
},
};
const wrapper = ({ children }: PropsWithChildren<{}>) => {
return (
<EntityContext.Provider value={{ entity: entity, loading: false }}>
{children}
</EntityContext.Provider>
);
};
const subject = () => renderHook(useConsumerGroupsForEntity, { wrapper });
it('returns correct consumer group for annotation', async () => {
const { result } = subject();
expect(result.current).toBe('consumer');
});
});
@@ -0,0 +1,24 @@
/*
* 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 { useEntity } from '@backstage/plugin-catalog';
import { KAFKA_CONSUMER_GROUP_ANNOTATION } from '../../constants';
export const useConsumerGroupsForEntity = () => {
const { entity } = useEntity();
return entity.metadata.annotations?.[KAFKA_CONSUMER_GROUP_ANNOTATION] ?? '';
};

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