Merge branch 'master' into feature/AwsIamAuthForKubernetes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
+3
-3
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
+62
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+11
-3
@@ -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}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+2
-4
@@ -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',
|
||||
},
|
||||
},
|
||||
|
||||
+4
-8
@@ -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>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
+89
-38
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
|
||||
};
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
Vendored
+38
@@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
+2
-7
@@ -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);
|
||||
}
|
||||
@@ -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 {};
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint')],
|
||||
};
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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} />;
|
||||
};
|
||||
+17
@@ -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
Reference in New Issue
Block a user