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:
@@ -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}`;
|
||||
|
||||
+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}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
+13
-9
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user