Merge branch 'master' of github.com:backstage/backstage into blam/move-scaffolder-to-integrations

* 'master' of github.com:backstage/backstage: (75 commits)
  backend-common: Move definition of etag property inside constructor
  docs: Fix url links in well known annotations
  bitbucket casing
  integration: take over the config
  More review comments
  Address some final review coments about cross-fetch and type-casting
  chore(deps): bump sucrase from 3.16.0 to 3.17.0
  chore(deps-dev): bump @types/webpack from 4.41.22 to 4.41.26
  core-api: remove deprecated registerRoute and deprecate addRoute
  Update README
  Support GHE
  app,create-app: switch to using FlatRoutes
  Make breaking 'minor' change'
  Add codefences to changeset
  Add changeset
  Remove InfoCard :height100" variant
  scripts/check-if-release: fix diff and tweak to handle new and removed packages
  Update with review comments
  docs: Run prettier on techdocs ci/cd tutorial
  remove adr status header
  ...
This commit is contained in:
blam
2021-01-20 16:22:58 +01:00
122 changed files with 2857 additions and 1030 deletions
+7 -1
View File
@@ -16,6 +16,11 @@
"url": "https://github.com/backstage/backstage",
"directory": "plugins/auth-backend"
},
"jest": {
"moduleNameMapper": {
"^jose/(.*)$": "<rootDir>/../../../node_modules/jose/dist/node/cjs/$1"
}
},
"keywords": [
"backstage"
],
@@ -44,11 +49,12 @@
"fs-extra": "^9.0.0",
"got": "^11.5.2",
"helmet": "^4.0.0",
"jose": "^1.27.1",
"jose": "^3.5.1",
"jwt-decode": "^3.1.0",
"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",
@@ -13,12 +13,23 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TextDecoder, TextEncoder } from 'util';
// These two statements are structured like this because Jest doesn't include these in the default
// test environment, even though they exist in Node.
global.TextEncoder = TextEncoder;
// @ts-ignore
global.TextDecoder = TextDecoder;
import { utc } from 'moment';
import { TokenFactory } from './TokenFactory';
import { getVoidLogger } from '@backstage/backend-common';
import { KeyStore, AnyJWK, StoredKey } from './types';
import { JWKS, JSONWebKey, JWT } from 'jose';
import { AnyJWK, KeyStore, StoredKey } from './types';
import jwtVerify from 'jose/jwt/verify';
import parseJwk from 'jose/jwk/parse';
import decodeProtectedHeader, {
ProtectedHeaderParameters,
} from 'jose/util/decode_protected_header';
const logger = getVoidLogger();
@@ -52,10 +63,8 @@ class MemoryKeyStore implements KeyStore {
}
function jwtKid(jwt: string): string {
const { header } = JWT.decode(jwt, { complete: true }) as {
header: { kid: string };
};
return header.kid;
const header = decodeProtectedHeader(jwt) as ProtectedHeaderParameters;
return header.kid as string;
}
describe('TokenFactory', () => {
@@ -72,14 +81,14 @@ describe('TokenFactory', () => {
const token = await factory.issueToken({ claims: { sub: 'foo' } });
const { keys } = await factory.listPublicKeys();
const keyStore = JWKS.asKeyStore({
keys: keys.map(key => key as JSONWebKey),
});
const keyMap = Object.fromEntries(keys.map(key => [key.kid, key]));
const payload = JWT.verify(token, keyStore) as object & {
iat: number;
exp: number;
};
const payload = (
await jwtVerify(token, async header => {
const kid = header.kid as string;
return await parseJwk(keyMap[kid]);
})
).payload;
expect(payload).toEqual({
iss: 'my-issuer',
aud: 'backstage',
@@ -87,7 +96,7 @@ describe('TokenFactory', () => {
iat: expect.any(Number),
exp: expect.any(Number),
});
expect(payload.exp).toBe(payload.iat + keyDurationSeconds);
expect(payload.exp).toBe(Number(payload.iat) + keyDurationSeconds);
});
it('should generate new signing keys when the current one expires', async () => {
@@ -16,7 +16,10 @@
import moment from 'moment';
import { TokenIssuer, TokenParams, KeyStore, AnyJWK } from './types';
import { JSONWebKey, JWK, JWS } from 'jose';
import parseJwk, { JWK } from 'jose/jwk/parse';
import SignJWT from 'jose/jwt/sign';
import generateKeyPair from 'jose/util/generate_key_pair';
import fromKeyLike from 'jose/jwk/from_key_like';
import { Logger } from 'winston';
import { v4 as uuid } from 'uuid';
@@ -53,7 +56,7 @@ export class TokenFactory implements TokenIssuer {
private readonly keyDurationSeconds: number;
private keyExpiry?: moment.Moment;
private privateKeyPromise?: Promise<JSONWebKey>;
private privateKeyPromise?: Promise<JWK>;
constructor(options: Options) {
this.issuer = options.issuer;
@@ -64,7 +67,7 @@ export class TokenFactory implements TokenIssuer {
async issueToken(params: TokenParams): Promise<string> {
const key = await this.getKey();
const keyLike = await parseJwk(key);
const iss = this.issuer;
const sub = params.claims.sub;
const aud = 'backstage';
@@ -72,11 +75,9 @@ export class TokenFactory implements TokenIssuer {
const exp = iat + this.keyDurationSeconds;
this.logger.info(`Issuing token for ${sub}`);
return JWS.sign({ iss, sub, aud, iat, exp }, key, {
alg: key.alg,
kid: key.kid,
});
return new SignJWT({ iss, sub, aud, iat, exp })
.setProtectedHeader({ alg: key.alg, typ: 'JWT', kid: key.kid })
.sign(keyLike);
}
// This will be called by other services that want to verify ID tokens.
@@ -114,7 +115,7 @@ export class TokenFactory implements TokenIssuer {
return { keys: validKeys.map(({ key }) => key) };
}
private async getKey(): Promise<JSONWebKey> {
private async getKey(): Promise<JWK> {
// Make sure that we only generate one key at a time
if (this.privateKeyPromise) {
if (this.keyExpiry?.isAfter()) {
@@ -127,23 +128,30 @@ export class TokenFactory implements TokenIssuer {
this.keyExpiry = moment().add(this.keyDurationSeconds, 'seconds');
const promise = (async () => {
// This generates a new signing key to be used to sign tokens until the next key rotation
const key = await JWK.generate('EC', 'P-256', {
use: 'sig',
kid: uuid(),
alg: 'ES256',
});
const key = await generateKeyPair('ES256');
const kid = uuid();
const jwk = await fromKeyLike(key.privateKey);
// JOSE Library provides optional for most fields - and TS does not distinguish between missing/undefined.
// Because AnyJWK requires keys to have type "string", this throws a TypeError - though in practice, if the field
// is undefined, JOSE will not send it back as key.
const storedJwk: AnyJWK = {
...jwk,
alg: 'ES256',
kid: kid,
use: 'sig',
} as AnyJWK;
// We're not allowed to use the key until it has been successfully stored
// TODO: some token verification implementations aggressively cache the list of keys, and
// don't attempt to fetch new ones even if they encounter an unknown kid. Therefore we
// may want to keep using the existing key for some period of time until we switch to
// the new one. This also needs to be implemented cross-service though, meaning new services
// that boot up need to be able to grab an existing key to use for signing.
this.logger.info(`Created new signing key ${key.kid}`);
await this.keyStore.addKey((key.toJWK(false) as unknown) as AnyJWK);
this.logger.info(`Created new signing key ${jwk.kid}`);
await this.keyStore.addKey(storedJwk);
// At this point we are allowed to start using the new key
return key as JSONWebKey;
return storedJwk;
})();
this.privateKeyPromise = promise;
@@ -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,192 @@
/*
* 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 * as jwtVerify from 'jose/jwt/verify';
import { AwsAlbAuthProvider } from './provider';
import { AuthResponse } from '../types';
const mockedJwtVerify = jwtVerify as jest.Mocked<any>;
const mockKey = async () => {
return `-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEnuN4LlaJhaUpx+qZFTzYCrSBLk0I
yOlxJ2VW88mLAQGJ7HPAvOdylxZsItMnzCuqNzZvie8m/NJsOjhDncVkrw==
-----END PUBLIC KEY-----
`;
};
jest.mock('cross-fetch', () => ({
__esModule: true,
default: async () => {
return {
json: async () => {
return mockKey();
},
};
},
}));
jest.mock('jose/jwt/verify', () => {
return {
__esModule: true,
default: jest.fn(),
};
});
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',
});
mockedJwtVerify.default.mockImplementationOnce(async () => {
return {
payload: {
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',
});
mockedJwtVerify.default.mockImplementationOnce(async () => {
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',
});
mockedJwtVerify.default.mockImplementationOnce(async () => {
return {};
});
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',
});
mockedJwtVerify.default.mockImplementationOnce(async () => {
return {};
});
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 jwtVerify from 'jose/jwt/verify';
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 verifiedToken = await jwtVerify(jwt, key, {});
if (
this.options.issuer !== '' &&
headers.issuer !== this.options.issuer
) {
throw new Error('issuer mismatch on JWT');
}
const resolvedEntity = await this.options.identityResolutionCallback(
verifiedToken.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.json());
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,
};
@@ -13,13 +13,17 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TextDecoder, TextEncoder } from 'util';
// @ts-ignore
global.TextDecoder = TextDecoder;
global.TextEncoder = TextEncoder;
import express from 'express';
import { Session } from 'express-session';
import nock from 'nock';
import { ClientMetadata, IssuerMetadata } from 'openid-client';
import { createOidcProvider, OidcAuthProvider } from './provider';
import { JWT, JWK } from 'jose';
import UnsecuredJWT from 'jose/jwt/unsecured';
import { AuthProviderFactoryOptions } from '../types';
import { Config } from '@backstage/config';
import { OAuthAdapter } from '../../lib/oauth';
@@ -71,6 +75,7 @@ describe('OidcAuthProvider', () => {
const jwt = {
sub: 'alice',
iss: 'https://oidc.test',
iat: Date.now(),
aud: clientMetadata.clientId,
exp: Date.now() + 10000,
};
@@ -79,7 +84,7 @@ describe('OidcAuthProvider', () => {
.reply(200, issuerMetadata)
.post('/as/token.oauth2')
.reply(200, {
id_token: JWT.sign(jwt, JWK.None),
id_token: new UnsecuredJWT(jwt).encode(),
access_token: 'test',
authorization_signed_response_alg: 'HS256',
})
@@ -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 = (
@@ -78,13 +78,17 @@ export class LocationReaders implements LocationReader {
if (rulesEnforcer.isAllowed(item.entity, item.location)) {
const relations = Array<EntityRelationSpec>();
const entity = await this.handleEntity(item, emitResult => {
if (emitResult.type === 'relation') {
relations.push(emitResult.relation);
return;
}
emit(emitResult);
});
const entity = await this.handleEntity(
item,
emitResult => {
if (emitResult.type === 'relation') {
relations.push(emitResult.relation);
return;
}
emit(emitResult);
},
location,
);
if (entity) {
output.entities.push({
@@ -165,6 +169,7 @@ export class LocationReaders implements LocationReader {
private async handleEntity(
item: CatalogProcessorEntityResult,
emit: CatalogProcessorEmit,
originLocation: LocationSpec,
): Promise<Entity | undefined> {
const { processors, logger } = this.options;
@@ -185,6 +190,7 @@ export class LocationReaders implements LocationReader {
current,
item.location,
emit,
originLocation,
);
} catch (e) {
const message = `Processor ${processor.constructor.name} threw an error while preprocessing entity ${kind}:${namespace}/${name} at ${item.location.type} ${item.location.target}, ${e}`;
@@ -0,0 +1,62 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity, LocationSpec } from '@backstage/catalog-model';
import { AnnotateLocationEntityProcessor } from './AnnotateLocationEntityProcessor';
describe('AnnotateLocationEntityProcessor', () => {
describe('preProcessEntity', () => {
it('adds annotations', async () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component',
},
};
const location: LocationSpec = {
type: 'url',
target: 'my-location',
};
const originLocation: LocationSpec = {
type: 'url',
target: 'my-origin-location',
};
const processor = new AnnotateLocationEntityProcessor();
expect(
await processor.preProcessEntity(
entity,
location,
() => {},
originLocation,
),
).toEqual({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component',
annotations: {
'backstage.io/managed-by-location': 'url:my-location',
'backstage.io/managed-by-origin-location': 'url:my-origin-location',
},
},
});
});
});
});
@@ -14,20 +14,28 @@
* limitations under the License.
*/
import { Entity, LocationSpec } from '@backstage/catalog-model';
import {
Entity,
LOCATION_ANNOTATION,
LocationSpec,
ORIGIN_LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
import lodash from 'lodash';
import { CatalogProcessor } from './types';
import { CatalogProcessor, CatalogProcessorEmit } from './types';
export class AnnotateLocationEntityProcessor implements CatalogProcessor {
async preProcessEntity(
entity: Entity,
location: LocationSpec,
_: CatalogProcessorEmit,
originLocation: LocationSpec,
): Promise<Entity> {
return lodash.merge(
{
metadata: {
annotations: {
'backstage.io/managed-by-location': `${location.type}:${location.target}`,
[LOCATION_ANNOTATION]: `${location.type}:${location.target}`,
[ORIGIN_LOCATION_ANNOTATION]: `${originLocation.type}:${originLocation.target}`,
},
},
},
@@ -27,13 +27,18 @@ import {
} from './types';
describe('UrlReaderProcessor', () => {
const mockApiOrigin = 'http://localhost:23000';
const mockApiOrigin = 'http://localhost';
const server = setupServer();
msw.setupDefaultHandlers(server);
it('should load from url', async () => {
const logger = getVoidLogger();
const reader = UrlReaders.default({ logger, config: new ConfigReader({}) });
const reader = UrlReaders.default({
logger,
config: new ConfigReader({
backend: { reading: { allow: [{ host: 'localhost' }] } },
}),
});
const processor = new UrlReaderProcessor({ reader, logger });
const spec = {
type: 'url',
@@ -57,7 +62,12 @@ describe('UrlReaderProcessor', () => {
it('should fail load from url with error', async () => {
const logger = getVoidLogger();
const reader = UrlReaders.default({ logger, config: new ConfigReader({}) });
const reader = UrlReaders.default({
logger,
config: new ConfigReader({
backend: { reading: { allow: [{ host: 'localhost' }] } },
}),
});
const processor = new UrlReaderProcessor({ reader, logger });
const spec = {
type: 'url',
@@ -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>;
/**
@@ -13,7 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import {
Entity,
EntityName,
RELATION_OWNED_BY,
RELATION_PART_OF,
} from '@backstage/catalog-model';
import { Table, TableColumn, TableProps } from '@backstage/core';
import { Chip, Link } from '@material-ui/core';
import Edit from '@material-ui/icons/Edit';
@@ -22,20 +27,31 @@ import { Alert } from '@material-ui/lab';
import React from 'react';
import { generatePath, Link as RouterLink } from 'react-router-dom';
import { findLocationForEntityMeta } from '../../data/utils';
import { createEditLink } from '../createEditLink';
import { useStarredEntities } from '../../hooks/useStarredEntities';
import { entityRoute, entityRouteParams } from '../../routes';
import { createEditLink } from '../createEditLink';
import { EntityRefLink, formatEntityRefTitle } from '../EntityRefLink';
import {
favouriteEntityIcon,
favouriteEntityTooltip,
} from '../FavouriteEntity/FavouriteEntity';
import { getEntityRelations } from '../getEntityRelations';
const columns: TableColumn<Entity>[] = [
type EntityRow = Entity & {
row: {
partOfSystemRelationTitle?: string;
partOfSystemRelation?: EntityName;
ownedByRelationsTitle?: string;
ownedByRelations: EntityName[];
};
};
const columns: TableColumn<EntityRow>[] = [
{
title: 'Name',
field: 'metadata.name',
highlight: true,
render: (entity: any) => (
render: entity => (
<Link
component={RouterLink}
to={generatePath(entityRoute.path, {
@@ -47,9 +63,33 @@ const columns: TableColumn<Entity>[] = [
</Link>
),
},
{
title: 'System',
field: 'row.partOfSystemRelationTitle',
render: entity => (
<>
{entity.row.partOfSystemRelation && (
<EntityRefLink
entityRef={entity.row.partOfSystemRelation}
defaultKind="system"
/>
)}
</>
),
},
{
title: 'Owner',
field: 'spec.owner',
field: 'row.ownedByRelationsTitle',
render: entity => (
<>
{entity.row.ownedByRelations.map((t, i) => (
<React.Fragment key={i}>
{i > 0 && ', '}
<EntityRefLink entityRef={t} defaultKind="group" />
</React.Fragment>
))}
</>
),
},
{
title: 'Lifecycle',
@@ -65,7 +105,7 @@ const columns: TableColumn<Entity>[] = [
cellStyle: {
padding: '0px 16px 0px 20px',
},
render: (entity: Entity) => (
render: entity => (
<>
{entity.metadata.tags &&
entity.metadata.tags.map(t => (
@@ -141,8 +181,31 @@ export const CatalogTable = ({
},
];
const rows = entities.map(e => {
const [partOfSystemRelation] = getEntityRelations(e, RELATION_PART_OF, {
kind: 'system',
});
const ownedByRelations = getEntityRelations(e, RELATION_OWNED_BY);
return {
...e,
row: {
ownedByRelationsTitle: ownedByRelations
.map(r => formatEntityRefTitle(r, { defaultKind: 'group' }))
.join(', '),
ownedByRelations,
partOfSystemRelationTitle: partOfSystemRelation
? formatEntityRefTitle(partOfSystemRelation, {
defaultKind: 'system',
})
: undefined,
partOfSystemRelation,
},
};
});
return (
<Table<Entity>
<Table<EntityRow>
isLoading={loading}
columns={columns}
options={{
@@ -155,7 +218,7 @@ export const CatalogTable = ({
pageSizeOptions: [20, 50, 100],
}}
title={`${titlePreamble} (${(entities && entities.length) || 0})`}
data={entities}
data={rows}
actions={actions}
/>
);
@@ -37,7 +37,10 @@ describe('<EntityRefLink />', () => {
wrapper: MemoryRouter,
});
expect(getByText('component:software')).toBeInTheDocument();
expect(getByText('component:software')).toHaveAttribute(
'href',
'/catalog/default/component/software',
);
});
it('renders link for entity in other namespace', () => {
@@ -57,7 +60,10 @@ describe('<EntityRefLink />', () => {
const { getByText } = render(<EntityRefLink entityRef={entity} />, {
wrapper: MemoryRouter,
});
expect(getByText('component:test/software')).toBeInTheDocument();
expect(getByText('component:test/software')).toHaveAttribute(
'href',
'/catalog/test/component/software',
);
});
it('renders link for entity and hides default kind', () => {
@@ -80,7 +86,10 @@ describe('<EntityRefLink />', () => {
wrapper: MemoryRouter,
},
);
expect(getByText('test/software')).toBeInTheDocument();
expect(getByText('test/software')).toHaveAttribute(
'href',
'/catalog/test/component/software',
);
});
it('renders link for entity name in default namespace', () => {
@@ -92,7 +101,10 @@ describe('<EntityRefLink />', () => {
const { getByText } = render(<EntityRefLink entityRef={entityName} />, {
wrapper: MemoryRouter,
});
expect(getByText('component:software')).toBeInTheDocument();
expect(getByText('component:software')).toHaveAttribute(
'href',
'/catalog/default/component/software',
);
});
it('renders link for entity name in other namespace', () => {
@@ -104,7 +116,10 @@ describe('<EntityRefLink />', () => {
const { getByText } = render(<EntityRefLink entityRef={entityName} />, {
wrapper: MemoryRouter,
});
expect(getByText('component:test/software')).toBeInTheDocument();
expect(getByText('component:test/software')).toHaveAttribute(
'href',
'/catalog/test/component/software',
);
});
it('renders link for entity name and hides default kind', () => {
@@ -119,6 +134,29 @@ describe('<EntityRefLink />', () => {
wrapper: MemoryRouter,
},
);
expect(getByText('test/software')).toBeInTheDocument();
expect(getByText('test/software')).toHaveAttribute(
'href',
'/catalog/test/component/software',
);
});
it('renders link with custom children', () => {
const entityName = {
kind: 'Component',
namespace: 'test',
name: 'software',
};
const { getByText } = render(
<EntityRefLink entityRef={entityName} defaultKind="component">
Custom Children
</EntityRefLink>,
{
wrapper: MemoryRouter,
},
);
expect(getByText('Custom Children')).toHaveAttribute(
'href',
'/catalog/test/component/software',
);
});
});
@@ -17,17 +17,18 @@ import {
Entity,
EntityName,
ENTITY_DEFAULT_NAMESPACE,
serializeEntityRef,
} from '@backstage/catalog-model';
import { Link } from '@material-ui/core';
import React from 'react';
import { generatePath } from 'react-router';
import { Link as RouterLink } from 'react-router-dom';
import { entityRoute } from '../../routes';
import { formatEntityRefTitle } from './format';
type EntityRefLinkProps = {
entityRef: Entity | EntityName;
defaultKind?: string;
children?: React.ReactNode;
};
// TODO: This component is private for now, as it should probably belong into
@@ -36,6 +37,7 @@ type EntityRefLinkProps = {
export const EntityRefLink = ({
entityRef,
defaultKind,
children,
}: EntityRefLinkProps) => {
let kind;
let namespace;
@@ -51,17 +53,8 @@ export const EntityRefLink = ({
name = entityRef.name;
}
if (namespace === ENTITY_DEFAULT_NAMESPACE) {
namespace = undefined;
}
kind = kind.toLowerCase();
const title = `${serializeEntityRef({
kind: defaultKind && defaultKind.toLowerCase() === kind ? undefined : kind,
name,
namespace,
})}`;
const routeParams = {
kind,
namespace: namespace?.toLowerCase() ?? ENTITY_DEFAULT_NAMESPACE,
@@ -74,7 +67,8 @@ export const EntityRefLink = ({
component={RouterLink}
to={generatePath(`/catalog/${entityRoute.path}`, routeParams)}
>
{title}
{children}
{!children && formatEntityRefTitle(entityRef, { defaultKind })}
</Link>
);
};
@@ -0,0 +1,106 @@
/*
* 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 { formatEntityRefTitle } from './format';
describe('formatEntityRefTitle', () => {
it('formats entity in default namespace', () => {
const entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'software',
},
spec: {
owner: 'guest',
type: 'service',
lifecycle: 'production',
},
};
const title = formatEntityRefTitle(entity);
expect(title).toEqual('component:software');
});
it('formats entity in other namespace', () => {
const entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'software',
namespace: 'test',
},
spec: {
owner: 'guest',
type: 'service',
lifecycle: 'production',
},
};
const title = formatEntityRefTitle(entity);
expect(title).toEqual('component:test/software');
});
it('formats entity and hides default kind', () => {
const entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'software',
namespace: 'test',
},
spec: {
owner: 'guest',
type: 'service',
lifecycle: 'production',
},
};
const title = formatEntityRefTitle(entity, { defaultKind: 'Component' });
expect(title).toEqual('test/software');
});
it('formats entity name in default namespace', () => {
const entityName = {
kind: 'Component',
namespace: 'default',
name: 'software',
};
const title = formatEntityRefTitle(entityName);
expect(title).toEqual('component:software');
});
it('formats entity name in other namespace', () => {
const entityName = {
kind: 'Component',
namespace: 'test',
name: 'software',
};
const title = formatEntityRefTitle(entityName);
expect(title).toEqual('component:test/software');
});
it('renders link for entity name and hides default kind', () => {
const entityName = {
kind: 'Component',
namespace: 'test',
name: 'software',
};
const title = formatEntityRefTitle(entityName, {
defaultKind: 'component',
});
expect(title).toEqual('test/software');
});
});
@@ -0,0 +1,54 @@
/*
* 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,
EntityName,
ENTITY_DEFAULT_NAMESPACE,
serializeEntityRef,
} from '@backstage/catalog-model';
export function formatEntityRefTitle(
entityRef: Entity | EntityName,
opts?: { defaultKind?: string },
) {
const defaultKind = opts?.defaultKind;
let kind;
let namespace;
let name;
if ('metadata' in entityRef) {
kind = entityRef.kind;
namespace = entityRef.metadata.namespace;
name = entityRef.metadata.name;
} else {
kind = entityRef.kind;
namespace = entityRef.namespace;
name = entityRef.name;
}
if (namespace === ENTITY_DEFAULT_NAMESPACE) {
namespace = undefined;
}
kind = kind.toLowerCase();
return `${serializeEntityRef({
kind: defaultKind && defaultKind.toLowerCase() === kind ? undefined : kind,
name,
namespace,
})}`;
}
@@ -14,3 +14,4 @@
* limitations under the License.
*/
export { EntityRefLink } from './EntityRefLink';
export { formatEntityRefTitle } from './format';
@@ -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 -14
View File
@@ -43,25 +43,12 @@ If you didn't clone this repo you have to do some extra work.
yarn add @backstage/plugin-github-actions
```
```js
// packages/app/src/api.ts
import { ApiRegistry } from '@backstage/core';
import { GithubActionsClient, githubActionsApiRef } from '@backstage/plugin-github-actions';
const builder = ApiRegistry.builder();
builder.add(githubActionsApiRef, new GithubActionsClient());
export default builder.build() as ApiHolder;
```
2. Add plugin itself:
```js
// packages/app/src/plugins.ts
export { plugin as GithubActions } from '@backstage/plugin-github-actions';
```
3. Run the app with `yarn start` and the backend with `yarn --cwd packages/backend start`, navigate to `/github-actions/`.
2. Run the app with `yarn start` and the backend with `yarn --cwd packages/backend start`, navigate to `/github-actions/`.
## Features
+1
View File
@@ -34,6 +34,7 @@
"dependencies": {
"@backstage/catalog-model": "^0.6.1",
"@backstage/core": "^0.4.4",
"@backstage/integration": "^0.2.0",
"@backstage/plugin-catalog": "^0.2.11",
"@backstage/theme": "^0.2.2",
"@material-ui/core": "^4.11.0",
@@ -24,14 +24,14 @@ export const githubActionsApiRef = createApiRef<GithubActionsApi>({
export type GithubActionsApi = {
listWorkflowRuns: ({
token,
hostname,
owner,
repo,
pageSize,
page,
branch,
}: {
token: string;
hostname?: string;
owner: string;
repo: string;
pageSize?: number;
@@ -41,12 +41,12 @@ export type GithubActionsApi = {
RestEndpointMethodTypes['actions']['listWorkflowRuns']['response']['data']
>;
getWorkflow: ({
token,
hostname,
owner,
repo,
id,
}: {
token: string;
hostname?: string;
owner: string;
repo: string;
id: number;
@@ -54,12 +54,12 @@ export type GithubActionsApi = {
RestEndpointMethodTypes['actions']['getWorkflow']['response']['data']
>;
getWorkflowRun: ({
token,
hostname,
owner,
repo,
id,
}: {
token: string;
hostname?: string;
owner: string;
repo: string;
id: number;
@@ -67,23 +67,23 @@ export type GithubActionsApi = {
RestEndpointMethodTypes['actions']['getWorkflowRun']['response']['data']
>;
reRunWorkflow: ({
token,
hostname,
owner,
repo,
runId,
}: {
token: string;
hostname?: string;
owner: string;
repo: string;
runId: number;
}) => Promise<any>;
downloadJobLogsForWorkflowRun: ({
token,
hostname,
owner,
repo,
runId,
}: {
token: string;
hostname?: string;
owner: string;
repo: string;
runId: number;
@@ -14,36 +14,60 @@
* limitations under the License.
*/
import { ConfigApi, OAuthApi } from '@backstage/core';
import { readGitHubIntegrationConfigs } from '@backstage/integration';
import { GithubActionsApi } from './GithubActionsApi';
import { Octokit, RestEndpointMethodTypes } from '@octokit/rest';
export class GithubActionsClient implements GithubActionsApi {
private readonly configApi: ConfigApi;
private readonly githubAuthApi: OAuthApi;
constructor(options: { configApi: ConfigApi; githubAuthApi: OAuthApi }) {
this.configApi = options.configApi;
this.githubAuthApi = options.githubAuthApi;
}
private async getOctokit(hostname?: string): Promise<Octokit> {
// TODO: Get access token for the specified hostname
const token = await this.githubAuthApi.getAccessToken(['repo']);
const configs = readGitHubIntegrationConfigs(
this.configApi.getOptionalConfigArray('integrations.github') ?? [],
);
const githubIntegrationConfig = configs.find(
v => v.host === hostname ?? 'github.com',
);
const baseUrl = githubIntegrationConfig?.apiBaseUrl;
return new Octokit({ auth: token, baseUrl });
}
async reRunWorkflow({
token,
hostname,
owner,
repo,
runId,
}: {
token: string;
hostname?: string;
owner: string;
repo: string;
runId: number;
}): Promise<any> {
return new Octokit({ auth: token }).actions.reRunWorkflow({
const octokit = await this.getOctokit(hostname);
return octokit.actions.reRunWorkflow({
owner,
repo,
run_id: runId,
});
}
async listWorkflowRuns({
token,
hostname,
owner,
repo,
pageSize = 100,
page = 0,
branch,
}: {
token: string;
hostname?: string;
owner: string;
repo: string;
pageSize?: number;
@@ -52,9 +76,8 @@ export class GithubActionsClient implements GithubActionsApi {
}): Promise<
RestEndpointMethodTypes['actions']['listWorkflowRuns']['response']['data']
> {
const workflowRuns = await new Octokit({
auth: token,
}).actions.listWorkflowRunsForRepo({
const octokit = await this.getOctokit(hostname);
const workflowRuns = await octokit.actions.listWorkflowRunsForRepo({
owner,
repo,
per_page: pageSize,
@@ -64,19 +87,20 @@ export class GithubActionsClient implements GithubActionsApi {
return workflowRuns.data;
}
async getWorkflow({
token,
hostname,
owner,
repo,
id,
}: {
token: string;
hostname?: string;
owner: string;
repo: string;
id: number;
}): Promise<
RestEndpointMethodTypes['actions']['getWorkflow']['response']['data']
> {
const workflow = await new Octokit({ auth: token }).actions.getWorkflow({
const octokit = await this.getOctokit(hostname);
const workflow = await octokit.actions.getWorkflow({
owner,
repo,
workflow_id: id,
@@ -84,19 +108,20 @@ export class GithubActionsClient implements GithubActionsApi {
return workflow.data;
}
async getWorkflowRun({
token,
hostname,
owner,
repo,
id,
}: {
token: string;
hostname?: string;
owner: string;
repo: string;
id: number;
}): Promise<
RestEndpointMethodTypes['actions']['getWorkflowRun']['response']['data']
> {
const run = await new Octokit({ auth: token }).actions.getWorkflowRun({
const octokit = await this.getOctokit(hostname);
const run = await octokit.actions.getWorkflowRun({
owner,
repo,
run_id: id,
@@ -104,21 +129,20 @@ export class GithubActionsClient implements GithubActionsApi {
return run.data;
}
async downloadJobLogsForWorkflowRun({
token,
hostname,
owner,
repo,
runId,
}: {
token: string;
hostname?: string;
owner: string;
repo: string;
runId: number;
}): Promise<
RestEndpointMethodTypes['actions']['downloadJobLogsForWorkflowRun']['response']['data']
> {
const workflow = await new Octokit({
auth: token,
}).actions.downloadJobLogsForWorkflowRun({
const octokit = await this.getOctokit(hostname);
const workflow = await octokit.actions.downloadJobLogsForWorkflowRun({
owner,
repo,
job_id: runId,
@@ -17,6 +17,7 @@ import React, { useEffect } from 'react';
import { useWorkflowRuns } from '../useWorkflowRuns';
import { WorkflowRun, WorkflowRunsTable } from '../WorkflowRunsTable';
import { Entity } from '@backstage/catalog-model';
import { readGitHubIntegrationConfigs } from '@backstage/integration';
import { WorkflowRunStatus } from '../WorkflowRunStatus';
import {
Link,
@@ -28,6 +29,7 @@ import {
import {
InfoCard,
StructuredMetadataTable,
configApiRef,
errorApiRef,
useApi,
} from '@backstage/core';
@@ -84,11 +86,17 @@ export const LatestWorkflowRunCard = ({
// Display the card full height suitable for
variant,
}: Props) => {
const config = useApi(configApiRef);
const errorApi = useApi(errorApiRef);
// TODO: Get github hostname from metadata annotation
const hostname = readGitHubIntegrationConfigs(
config.getOptionalConfigArray('integrations.github') ?? [],
)[0].host;
const [owner, repo] = (
entity?.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION] ?? '/'
).split('/');
const [{ runs, loading, error }] = useWorkflowRuns({
hostname,
owner,
repo,
branch,
@@ -14,7 +14,14 @@
* limitations under the License.
*/
import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
import {
ApiProvider,
ApiRegistry,
errorApiRef,
configApiRef,
ConfigApi,
ConfigReader,
} from '@backstage/core';
import { lightTheme } from '@backstage/theme';
import { ThemeProvider } from '@material-ui/core';
import { render } from '@testing-library/react';
@@ -33,6 +40,8 @@ const mockErrorApi: jest.Mocked<typeof errorApiRef.T> = {
error$: jest.fn(),
};
const configApi: ConfigApi = new ConfigReader({});
describe('<RecentWorkflowRunsCard />', () => {
const entity = {
apiVersion: 'v1',
@@ -69,7 +78,12 @@ describe('<RecentWorkflowRunsCard />', () => {
render(
<ThemeProvider theme={lightTheme}>
<MemoryRouter>
<ApiProvider apis={ApiRegistry.with(errorApiRef, mockErrorApi)}>
<ApiProvider
apis={ApiRegistry.with(errorApiRef, mockErrorApi).with(
configApiRef,
configApi,
)}
>
<RecentWorkflowRunsCard {...props} />
</ApiProvider>
</MemoryRouter>
@@ -15,12 +15,14 @@
*/
import { Entity } from '@backstage/catalog-model';
import {
configApiRef,
EmptyState,
errorApiRef,
InfoCard,
Table,
useApi,
} from '@backstage/core';
import { readGitHubIntegrationConfigs } from '@backstage/integration';
import { Button, Link } from '@material-ui/core';
import React, { useEffect } from 'react';
import { generatePath, Link as RouterLink } from 'react-router-dom';
@@ -45,11 +47,17 @@ export const RecentWorkflowRunsCard = ({
limit = 5,
variant,
}: Props) => {
const config = useApi(configApiRef);
const errorApi = useApi(errorApiRef);
// TODO: Get github hostname from metadata annotation
const hostname = readGitHubIntegrationConfigs(
config.getOptionalConfigArray('integrations.github') ?? [],
)[0].host;
const [owner, repo] = (
entity?.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION] ?? '/'
).split('/');
const [{ runs = [], loading, error }] = useWorkflowRuns({
hostname,
owner,
repo,
branch,
@@ -14,7 +14,8 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { Link } from '@backstage/core';
import { configApiRef, Link, useApi } from '@backstage/core';
import { readGitHubIntegrationConfigs } from '@backstage/integration';
import {
Accordion,
AccordionDetails,
@@ -159,10 +160,15 @@ const JobListItem = ({
};
export const WorkflowRunDetails = ({ entity }: { entity: Entity }) => {
const config = useApi(configApiRef);
const projectName = useProjectName(entity);
// TODO: Get github hostname from metadata annotation
const hostname = readGitHubIntegrationConfigs(
config.getOptionalConfigArray('integrations.github') ?? [],
)[0].host;
const [owner, repo] = projectName.value ? projectName.value.split('/') : [];
const details = useWorkflowRunsDetails(repo, owner);
const details = useWorkflowRunsDetails({ hostname, owner, repo });
const jobs = useWorkflowRunJobs(details.value?.jobs_url);
const error = projectName.error || (projectName.value && details.error);
@@ -13,20 +13,26 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useApi, githubAuthApiRef } from '@backstage/core';
import { useApi } from '@backstage/core';
import { useParams } from 'react-router-dom';
import { useAsync } from 'react-use';
import { githubActionsApiRef } from '../../api';
export const useWorkflowRunsDetails = (repo: string, owner: string) => {
export const useWorkflowRunsDetails = ({
hostname,
owner,
repo,
}: {
hostname?: string;
owner: string;
repo: string;
}) => {
const api = useApi(githubActionsApiRef);
const auth = useApi(githubAuthApiRef);
const { id } = useParams();
const details = useAsync(async () => {
const token = await auth.getAccessToken(['repo']);
return repo && owner
? api.getWorkflowRun({
token,
hostname,
owner,
repo,
id: parseInt(id, 10),
@@ -34,6 +34,8 @@ import { useProjectName } from '../useProjectName';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import DescriptionIcon from '@material-ui/icons/Description';
import { Entity } from '@backstage/catalog-model';
import { configApiRef, useApi } from '@backstage/core';
import { readGitHubIntegrationConfigs } from '@backstage/integration';
const LazyLog = React.lazy(() => import('react-lazylog/build/LazyLog'));
const LinePart = React.lazy(() => import('react-lazylog/build/LinePart'));
@@ -107,11 +109,21 @@ export const WorkflowRunLogs = ({
runId: string;
inProgress: boolean;
}) => {
const config = useApi(configApiRef);
const classes = useStyles();
const projectName = useProjectName(entity);
// TODO: Get github hostname from metadata annotation
const hostname = readGitHubIntegrationConfigs(
config.getOptionalConfigArray('integrations.github') ?? [],
)[0].host;
const [owner, repo] = projectName.value ? projectName.value.split('/') : [];
const jobLogs = useDownloadWorkflowRunLogs(repo, owner, runId);
const jobLogs = useDownloadWorkflowRunLogs({
hostname,
owner,
repo,
id: runId,
});
const [open, setOpen] = React.useState(false);
const handleOpen = () => {
@@ -14,22 +14,26 @@
* limitations under the License.
*/
import { useApi, githubAuthApiRef } from '@backstage/core';
import { useApi } from '@backstage/core';
import { useAsync } from 'react-use';
import { githubActionsApiRef } from '../../api';
export const useDownloadWorkflowRunLogs = (
repo: string,
owner: string,
id: string,
) => {
export const useDownloadWorkflowRunLogs = ({
hostname,
owner,
repo,
id,
}: {
hostname?: string;
owner: string;
repo: string;
id: string;
}) => {
const api = useApi(githubActionsApiRef);
const auth = useApi(githubAuthApiRef);
const details = useAsync(async () => {
const token = await auth.getAccessToken(['repo']);
return repo && owner
? api.downloadJobLogsForWorkflowRun({
token,
hostname,
owner,
repo,
runId: parseInt(id, 10),
@@ -25,13 +25,20 @@ import {
import RetryIcon from '@material-ui/icons/Replay';
import GitHubIcon from '@material-ui/icons/GitHub';
import { Link as RouterLink, generatePath } from 'react-router-dom';
import { EmptyState, Table, TableColumn } from '@backstage/core';
import {
EmptyState,
Table,
TableColumn,
configApiRef,
useApi,
} from '@backstage/core';
import { useWorkflowRuns } from '../useWorkflowRuns';
import { WorkflowRunStatus } from '../WorkflowRunStatus';
import SyncIcon from '@material-ui/icons/Sync';
import { buildRouteRef } from '../../plugin';
import { useProjectName } from '../useProjectName';
import { Entity } from '@backstage/catalog-model';
import { readGitHubIntegrationConfigs } from '@backstage/integration';
export type WorkflowRun = {
id: string;
@@ -162,12 +169,18 @@ export const WorkflowRunsTable = ({
entity: Entity;
branch?: string;
}) => {
const config = useApi(configApiRef);
const { value: projectName, loading } = useProjectName(entity);
// TODO: Get github hostname from metadata annotation
const hostname = readGitHubIntegrationConfigs(
config.getOptionalConfigArray('integrations.github') ?? [],
)[0].host;
const [owner, repo] = (projectName ?? '/').split('/');
const [
{ runs, ...tableProps },
{ retry, setPage, setPageSize },
] = useWorkflowRuns({
hostname,
owner,
repo,
branch,
@@ -17,21 +17,22 @@ import { useState } from 'react';
import { useAsyncRetry } from 'react-use';
import { WorkflowRun } from './WorkflowRunsTable/WorkflowRunsTable';
import { githubActionsApiRef } from '../api/GithubActionsApi';
import { useApi, githubAuthApiRef, errorApiRef } from '@backstage/core';
import { useApi, errorApiRef } from '@backstage/core';
export function useWorkflowRuns({
hostname,
owner,
repo,
branch,
initialPageSize = 5,
}: {
hostname?: string;
owner: string;
repo: string;
branch?: string;
initialPageSize?: number;
}) {
const api = useApi(githubActionsApiRef);
const auth = useApi(githubAuthApiRef);
const errorApi = useApi(errorApiRef);
@@ -42,12 +43,11 @@ export function useWorkflowRuns({
const { loading, value: runs, retry, error } = useAsyncRetry<
WorkflowRun[]
>(async () => {
const token = await auth.getAccessToken(['repo']);
return (
api
// GitHub API pagination count starts from 1
.listWorkflowRuns({
token,
hostname,
owner,
repo,
pageSize,
@@ -63,7 +63,7 @@ export function useWorkflowRuns({
onReRunClick: async () => {
try {
await api.reRunWorkflow({
token,
hostname,
owner,
repo,
runId: run.id,
+10 -1
View File
@@ -15,9 +15,11 @@
*/
import {
configApiRef,
createPlugin,
createRouteRef,
createApiFactory,
githubAuthApiRef,
} from '@backstage/core';
import { githubActionsApiRef, GithubActionsClient } from './api';
@@ -34,5 +36,12 @@ export const buildRouteRef = createRouteRef({
export const plugin = createPlugin({
id: 'github-actions',
apis: [createApiFactory(githubActionsApiRef, new GithubActionsClient())],
apis: [
createApiFactory({
api: githubActionsApiRef,
deps: { configApi: configApiRef, githubAuthApi: githubAuthApiRef },
factory: ({ configApi, githubAuthApi }) =>
new GithubActionsClient({ configApi, githubAuthApi }),
}),
],
});
@@ -38,7 +38,7 @@ const websiteListResponse = data as WebsiteListResponse;
let entityWebsite = websiteListResponse.items[2];
describe('<LastLighthouseAuditCard />', () => {
const asPercentage = (fraction: number) => `${fraction * 100}%`;
const asPercentage = (fraction: number) => `${Math.round(fraction * 100)}%`;
beforeEach(() => {
(useWebsiteForEntity as jest.Mock).mockReturnValue({
@@ -27,7 +27,7 @@ import { useWebsiteForEntity } from '../../hooks/useWebsiteForEntity';
import AuditStatusIcon from '../AuditStatusIcon';
const LighthouseCategoryScoreStatus = ({ score }: { score: number }) => {
const scoreAsPercentage = score * 100;
const scoreAsPercentage = Math.round(score * 100);
switch (true) {
case scoreAsPercentage >= 90:
return (
@@ -161,7 +161,7 @@ export const JobStage = ({ endedAt, startedAt, name, log, status }: Props) => {
</AccordionDetails>
<AccordionActions>
<Button color="primary" onClick={toggleLogsFullScreen}>
Open in fullcreen
Open in fullscreen
</Button>
</AccordionActions>
</Accordion>