From 6437a7d9ad5bee911894bd73b2c88f063929938b Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Tue, 18 Aug 2020 20:00:49 -0600 Subject: [PATCH 01/94] Update CODEOWNERS for internal clone --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index de0655cdbc..20e574382b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -6,6 +6,7 @@ * @spotify/backstage-core /docs/features/techdocs @spotify/techdocs-core +/plugins/cost-engineering @cost-engineering/silver-lining /plugins/techdocs @spotify/techdocs-core /plugins/techdocs-backend @spotify/techdocs-core /packages/techdocs-cli @spotify/techdocs-core From 1ae5a74c3ae8eb01c917d51d9631cccd08745c3c Mon Sep 17 00:00:00 2001 From: "J Shamsul Bahri (jibone))" Date: Fri, 4 Sep 2020 13:56:54 +0800 Subject: [PATCH 02/94] WIP: Got the SAML login working TODO: Cleanup, remove hardcoded url. --- app-config.yaml | 136 +++++++++--------- packages/app/src/App.tsx | 5 +- packages/app/src/apis.ts | 10 ++ packages/app/src/identityProviders.ts | 7 + .../core-api/src/apis/definitions/auth.ts | 23 +++ .../src/apis/implementations/auth/index.ts | 1 + .../implementations/auth/saml/SamlAuth.ts | 126 ++++++++++++++++ .../apis/implementations/auth/saml/index.ts | 16 +++ .../apis/implementations/auth/saml/types.ts | 22 +++ packages/core-api/src/app/AppIdentity.ts | 4 + .../lib/AuthConnector/SamlAuthConnector.ts | 105 ++++++++++++++ .../core-api/src/lib/AuthConnector/index.ts | 1 + .../SamlAuthSessionManager.ts | 64 +++++++++ .../SamlAuthSessionStore.ts | 96 +++++++++++++ .../src/lib/AuthSessionManager/index.ts | 2 + .../src/lib/AuthSessionManager/types.ts | 7 + .../core/src/layout/Sidebar/UserSettings.tsx | 8 +- .../src/layout/SignInPage/commonProvider.tsx | 5 + .../core/src/layout/SignInPage/providers.tsx | 2 + .../src/layout/SignInPage/samlProvider.tsx | 92 ++++++++++++ packages/core/src/layout/SignInPage/types.ts | 9 +- .../auth-backend/src/providers/factories.ts | 1 + .../src/providers/saml/provider.ts | 29 +++- .../src/ingestion/HigherOrderOperations.ts | 6 +- yarn.lock | 5 +- 25 files changed, 698 insertions(+), 84 deletions(-) create mode 100644 packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts create mode 100644 packages/core-api/src/apis/implementations/auth/saml/index.ts create mode 100644 packages/core-api/src/apis/implementations/auth/saml/types.ts create mode 100644 packages/core-api/src/lib/AuthConnector/SamlAuthConnector.ts create mode 100644 packages/core-api/src/lib/AuthSessionManager/SamlAuthSessionManager.ts create mode 100644 packages/core-api/src/lib/AuthSessionManager/SamlAuthSessionStore.ts create mode 100644 packages/core/src/layout/SignInPage/samlProvider.tsx diff --git a/app-config.yaml b/app-config.yaml index f578fb3fca..e65df93969 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -28,72 +28,74 @@ sentry: auth: providers: - google: - development: - appOrigin: "http://localhost:3000/" - secure: false - clientId: - $secret: - env: AUTH_GOOGLE_CLIENT_ID - clientSecret: - $secret: - env: AUTH_GOOGLE_CLIENT_SECRET - github: - development: - appOrigin: "http://localhost:3000/" - secure: false - clientId: - $secret: - env: AUTH_GITHUB_CLIENT_ID - clientSecret: - $secret: - env: AUTH_GITHUB_CLIENT_SECRET - enterpriseInstanceUrl: - $secret: - env: AUTH_GITHUB_ENTERPRISE_INSTANCE_URL - gitlab: - development: - appOrigin: "http://localhost:3000/" - secure: false - clientId: - $secret: - env: AUTH_GITLAB_CLIENT_ID - clientSecret: - $secret: - env: AUTH_GITLAB_CLIENT_SECRET - audience: - $secret: - env: GITLAB_BASE_URL - # saml: + # google: # development: - # entryPoint: "http://localhost:7001/" - # issuer: "passport-saml" - okta: + # appOrigin: 'http://localhost:3000/' + # secure: false + # clientId: + # $secret: + # env: AUTH_GOOGLE_CLIENT_ID + # clientSecret: + # $secret: + # env: AUTH_GOOGLE_CLIENT_SECRET + # github: + # development: + # appOrigin: 'http://localhost:3000/' + # secure: false + # clientId: + # $secret: + # env: AUTH_GITHUB_CLIENT_ID + # clientSecret: + # $secret: + # env: AUTH_GITHUB_CLIENT_SECRET + # enterpriseInstanceUrl: + # $secret: + # env: AUTH_GITHUB_ENTERPRISE_INSTANCE_URL + # gitlab: + # development: + # appOrigin: 'http://localhost:3000/' + # secure: false + # clientId: + # $secret: + # env: AUTH_GITLAB_CLIENT_ID + # clientSecret: + # $secret: + # env: AUTH_GITLAB_CLIENT_SECRET + # audience: + # $secret: + # env: GITLAB_BASE_URL + saml: development: - appOrigin: "http://localhost:3000/" - secure: false - clientId: - $secret: - env: AUTH_OKTA_CLIENT_ID - clientSecret: - $secret: - env: AUTH_OKTA_CLIENT_SECRET - audience: - $secret: - env: AUTH_OKTA_AUDIENCE - oauth2: - development: - appOrigin: "http://localhost:3000/" - secure: false - clientId: - $secret: - env: AUTH_OAUTH2_CLIENT_ID - clientSecret: - $secret: - env: AUTH_OAUTH2_CLIENT_SECRET - authorizationURL: - $secret: - env: AUTH_OAUTH2_AUTH_URL - tokenURL: - $secret: - env: AUTH_OAUTH2_TOKEN_URL + entryPoint: 'https://sso.jumpcloud.com/saml2/backstage-test' + issuer: 'passport-saml' + # entryPoint: 'http://localhost:7001/' + # issuer: 'passport-saml' + # okta + # development: + # appOrigin: 'http://localhost:3000/' + # secure: false + # clientId: + # $secret: + # env: AUTH_OKTA_CLIENT_ID + # clientSecret: + # $secret: + # env: AUTH_OKTA_CLIENT_SECRET + # audience: + # $secret: + # env: AUTH_OKTA_AUDIENCE + # oauth2: + # development: + # appOrigin: 'http://localhost:3000/' + # secure: false + # clientId: + # $secret: + # env: AUTH_OAUTH2_CLIENT_ID + # clientSecret: + # $secret: + # env: AUTH_OAUTH2_CLIENT_SECRET + # authorizationURL: + # $secret: + # env: AUTH_OAUTH2_AUTH_URL + # tokenURL: + # $secret: + # env: AUTH_OAUTH2_TOKEN_URL diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 9a9fdfe5c4..29e371b469 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -33,7 +33,10 @@ const app = createApp({ components: { SignInPage: props => { return ( - + ); }, }, diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index cac3d6e171..0c097450bc 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -38,6 +38,8 @@ import { gitlabAuthApiRef, storageApiRef, WebStorage, + SamlAuth, + samlAuthApiRef, } from '@backstage/core'; import { @@ -139,6 +141,14 @@ export const apis = (config: ConfigApi) => { }), ); + builder.add( + samlAuthApiRef, + SamlAuth.create({ + apiOrigin: backendUrl, + basePath: '/auth/', + }), + ); + builder.add( techRadarApiRef, new TechRadar({ diff --git a/packages/app/src/identityProviders.ts b/packages/app/src/identityProviders.ts index e80b1d6fea..e229bb7671 100644 --- a/packages/app/src/identityProviders.ts +++ b/packages/app/src/identityProviders.ts @@ -19,6 +19,7 @@ import { gitlabAuthApiRef, oktaAuthApiRef, githubAuthApiRef, + samlAuthApiRef, } from '@backstage/core'; export const providers = [ @@ -46,4 +47,10 @@ export const providers = [ message: 'Sign In using Okta', apiRef: oktaAuthApiRef, }, + { + id: 'saml-auth-provider', + title: 'SAML', + message: 'Sign In using SAML', + apiRef: samlAuthApiRef, + }, ]; diff --git a/packages/core-api/src/apis/definitions/auth.ts b/packages/core-api/src/apis/definitions/auth.ts index ec7aba81c4..6dfe9595b0 100644 --- a/packages/core-api/src/apis/definitions/auth.ts +++ b/packages/core-api/src/apis/definitions/auth.ts @@ -97,6 +97,21 @@ export type OAuthApi = { logout(): Promise; }; +/** + * This API provides access to SAML 2 credentials. Verify user access with identity provider. + */ +export type SamlApi = { + // Not sure what Promise call back should have. + getBackstageIdentity( + options?: AuthRequestOptions, + ): Promise; + + getProfile(options?: AuthRequestOptions): Promise; + + // Not sure if this is needed. + logout(): Promise; +}; + /** * This API provides access to OpenID Connect credentials. It lets you request ID tokens, * which can be passed to backend services to prove the user's identity. @@ -273,3 +288,11 @@ export const oauth2ApiRef = createApiRef< id: 'core.auth.oauth2', description: 'Example of how to use oauth2 custom provider', }); + +/** + * Provides authentication for saml based identity providers + */ +export const samlAuthApiRef = createApiRef({ + id: 'core.auth.saml', + description: 'provides authentication towards saml based provider', +}); diff --git a/packages/core-api/src/apis/implementations/auth/index.ts b/packages/core-api/src/apis/implementations/auth/index.ts index 786e9fa771..0906e34149 100644 --- a/packages/core-api/src/apis/implementations/auth/index.ts +++ b/packages/core-api/src/apis/implementations/auth/index.ts @@ -19,3 +19,4 @@ export * from './gitlab'; export * from './google'; export * from './oauth2'; export * from './okta'; +export * from './saml'; diff --git a/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts b/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts new file mode 100644 index 0000000000..75aefda7f5 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts @@ -0,0 +1,126 @@ +/* + * 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 SamlIcon from '@material-ui/icons/AcUnit'; +import { SamlAuthConnector } from '../../../../lib/AuthConnector'; +import { SessionManager } from '../../../../lib/AuthSessionManager/types'; +import { Observable } from '../../../../types'; +import { + ProfileInfo, + BackstageIdentity, + SessionState, + AuthRequestOptions, + SamlApi, +} from '../../../definitions/auth'; +import { AuthProvider } from '../../../definitions'; +import { SamlSession } from './types'; +import { + SamlAuthSessionManager, + SamlAuthSessionStore, +} from '../../../../lib/AuthSessionManager'; + +type CreateOptions = { + apiOrigin: string; + basePath: string; + + // oauthRequestApi?: OAuthRequestApi; + + environment?: string; + provider?: AuthProvider & { id: string }; +}; + +export type SamlAuthResponse = { + userId: string; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; + +const DEFAULT_PROVIDER = { + id: 'saml', + title: 'SAML', + icon: SamlIcon, +}; + +class SamlAuth implements SamlApi { + static create({ + apiOrigin, + basePath, + environment = 'development', + provider = DEFAULT_PROVIDER, + }: CreateOptions) { + // eslint-disable-next-line no-console + console.log('this is from SamlAuth'); + + const connector = new SamlAuthConnector({ + apiOrigin, + basePath, + environment, + provider, + }); + + const sessionManager = new SamlAuthSessionManager({ + connector, + }); + + const authSessionStore = new SamlAuthSessionStore({ + manager: sessionManager, + storageKey: 'samlSession', + }); + + // return new SamlAuth(authSessionStore); + return new SamlAuth(authSessionStore); + } + + sessionState$(): Observable { + return this.sessionManager.sessionState$(); + } + // constructor(private readonly sessionManager: any) {} + // eslint-disable-next-line @typescript-eslint/no-useless-constructor + constructor(private readonly sessionManager: SessionManager) { + // eslint-disable-next-line no-console + console.log('this is the constructor'); + } + + async getBackstageIdentity( + options: AuthRequestOptions, + ): Promise { + // eslint-disable-next-line no-console + console.log('===> Saml getBackstageIdentity()'); + const session = await this.sessionManager.getSession(options); + // eslint-disable-next-line no-console + console.log('this this thish lkkdfkkfkfji'); + // eslint-disable-next-line no-console + console.log(session); + return session?.backstageIdentity; + } + + async getProfile(options: AuthRequestOptions = {}) { + // eslint-disable-next-line no-console + console.log('==> samlauth getprofile()'); + const session = await this.sessionManager.getSession(options); + // eslint-disable-next-line no-console + console.log('+++ this is the session from getProfile()'); + // eslint-disable-next-line no-console + console.log(session); + return session?.profile; + } + + async logout() { + await this.sessionManager.removeSession(); + } +} + +export default SamlAuth; diff --git a/packages/core-api/src/apis/implementations/auth/saml/index.ts b/packages/core-api/src/apis/implementations/auth/saml/index.ts new file mode 100644 index 0000000000..c2436ab435 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/saml/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { default as SamlAuth } from './SamlAuth'; diff --git a/packages/core-api/src/apis/implementations/auth/saml/types.ts b/packages/core-api/src/apis/implementations/auth/saml/types.ts new file mode 100644 index 0000000000..296f70b0ea --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/saml/types.ts @@ -0,0 +1,22 @@ +/* + * 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 { ProfileInfo, BackstageIdentity } from '../../../definitions'; + +export type SamlSession = { + userId: string; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; diff --git a/packages/core-api/src/app/AppIdentity.ts b/packages/core-api/src/app/AppIdentity.ts index 69ee5d28ac..1a6cdac3e6 100644 --- a/packages/core-api/src/app/AppIdentity.ts +++ b/packages/core-api/src/app/AppIdentity.ts @@ -67,6 +67,10 @@ export class AppIdentity implements IdentityApi { // This is indirectly called by the sign-in page to continue into the app. setSignInResult(result: SignInResult) { + // eslint-disable-next-line no-console + console.log('this is from setSignInResult'); + // eslint-disable-next-line no-console + console.log(result); if (this.hasIdentity) { return; } diff --git a/packages/core-api/src/lib/AuthConnector/SamlAuthConnector.ts b/packages/core-api/src/lib/AuthConnector/SamlAuthConnector.ts new file mode 100644 index 0000000000..9dc0fca0a0 --- /dev/null +++ b/packages/core-api/src/lib/AuthConnector/SamlAuthConnector.ts @@ -0,0 +1,105 @@ +/* + * 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 { + AuthProvider, + ProfileInfo, + BackstageIdentity, +} from '../../apis/definitions'; +import { AuthConnector } from './types'; +import { showLoginPopup } from '../loginPopup'; + +const DEFAULT_BASE_PATH = '/api/auth'; + +type Options = { + apiOrigin?: string; + basePath?: string; + environment?: string; + provider: AuthProvider & { id: string }; +}; + +export type SamlResponse = { + userId: string; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; + +export class SamlAuthConnector implements AuthConnector { + private readonly apiOrigin: string; + private readonly basePath: string; + private readonly environment: string | undefined; + private readonly provider: AuthProvider & { id: string }; + + constructor(options: Options) { + const { + apiOrigin = window.location.origin, + basePath = DEFAULT_BASE_PATH, + environment, + provider, + } = options; + + this.apiOrigin = apiOrigin; + this.basePath = basePath; + this.environment = environment; + this.provider = provider; + } + + async createSession(): Promise { + // eslint-disable-next-line no-console + console.log('==> from SamlAuthConnector createSession'); + + const payload = await showLoginPopup({ + url: 'http://localhost:7000/auth/saml/start', // FIXME: this should be from app.config or somewhere + name: 'SAML Login', // FIXME: change this to provider name? and not hardcode the name + origin: this.apiOrigin, + width: 450, + height: 730, + }); + + // eslint-disable-next-line no-console + console.log(payload); + + return { + ...payload, + id: payload.profile.email, + }; + } + + // TODO: do we need this for SAML? + async refreshSession(): Promise { + // eslint-disable-next-line no-console + console.log('==> this is refresh session'); + } + + async removeSession(): Promise { + // eslint-disable-next-line no-console + console.log('this removes the session'); + const res = await fetch('http://localhost:7000/auth/saml/logout', { + method: 'POST', + headers: { + 'x-requested-with': 'XMLHttpRequest', + }, + credentials: 'include', + }).catch(error => { + throw new Error(`Logout request failed, ${error}`); + }); + + if (!res.ok) { + const error: any = new Error(`Logout request failed, ${res.statusText}`); + error.status = res.status; + throw error; + } + } +} diff --git a/packages/core-api/src/lib/AuthConnector/index.ts b/packages/core-api/src/lib/AuthConnector/index.ts index db5c582328..84f5067443 100644 --- a/packages/core-api/src/lib/AuthConnector/index.ts +++ b/packages/core-api/src/lib/AuthConnector/index.ts @@ -15,4 +15,5 @@ */ export { DefaultAuthConnector } from './DefaultAuthConnector'; +export { SamlAuthConnector } from './SamlAuthConnector'; export * from './types'; diff --git a/packages/core-api/src/lib/AuthSessionManager/SamlAuthSessionManager.ts b/packages/core-api/src/lib/AuthSessionManager/SamlAuthSessionManager.ts new file mode 100644 index 0000000000..9439684cf4 --- /dev/null +++ b/packages/core-api/src/lib/AuthSessionManager/SamlAuthSessionManager.ts @@ -0,0 +1,64 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { SessionManager, GetSessionOptions } from './types'; +import { SamlAuthConnector, SamlResponse } from '../AuthConnector/SamlAuthConnector'; +import { SessionStateTracker } from './SessionStateTracker'; + + +type Options = { + connector: SamlAuthConnector; +}; + +export class SamlAuthSessionManager implements SessionManager { + private readonly connector: SamlAuthConnector; + private readonly stateTracker = new SessionStateTracker(); + + private currentSession: any | undefined; // FIXME: proper typing here + + constructor(options: Options) { + const { connector } = options; + + this.connector = connector; + // this.helper = new SessionScopeHelper + } + + async getSession(options: GetSessionOptions): Promise { + // eslint-disable-next-line no-console + console.log('==> this is from SamlAuthSessionManager getSession()'); + if (this.currentSession) { + return this.currentSession; + } + + if (options.optional) { + return undefined; + } + + this.currentSession = await this.connector.createSession(); + this.stateTracker.setIsSignedIn(true); + return this.currentSession; + } + + async removeSession() { + this.currentSession = undefined; + await this.connector.removeSession(); + this.stateTracker.setIsSignedIn(false); + } + + sessionState$() { + return this.stateTracker.sessionState$(); + } +} diff --git a/packages/core-api/src/lib/AuthSessionManager/SamlAuthSessionStore.ts b/packages/core-api/src/lib/AuthSessionManager/SamlAuthSessionStore.ts new file mode 100644 index 0000000000..53fba09b54 --- /dev/null +++ b/packages/core-api/src/lib/AuthSessionManager/SamlAuthSessionStore.ts @@ -0,0 +1,96 @@ +/* + * 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 { SamlAuthSessionManager, GetSessionOptions } from './types'; + +type Options = { + manager: SamlAuthSessionManager; + storageKey: string; +}; + +export class SamlAuthSessionStore implements SamlAuthSessionManager { + private readonly manager: SamlAuthSessionManager; + private readonly storageKey: string; + + constructor(options: Options) { + const { manager, storageKey } = options; + + this.manager = manager; + this.storageKey = storageKey; + } + + async getSession(options: GetSessionOptions): Promise { + // eslint-disable-next-line no-console + console.log('==>> this is from SamlAuthSessionStore getSession()'); + const session = this.loadSession(); + + if (session) { + return session!; + } + + const newSession = await this.manager.getSession(options); + this.saveSession(newSession); + return newSession; + } + + async removeSession() { + localStorage.removeItem(this.storageKey); + await this.manager.removeSession(); + } + + sessionState$() { + return this.manager.sessionState$(); + } + + private saveSession(session: T | undefined) { + if (session === undefined) { + localStorage.removeItem(this.storageKey); + } else { + localStorage.setItem( + this.storageKey, + JSON.stringify(session, (_key, value) => { + if (value instanceof Set) { + return { + __type: 'Set', + __value: Array.from(value), + }; + } + return value; + }), + ); + } + } + + private loadSession(): T | undefined { + try { + const sessionJson = localStorage.getItem(this.storageKey); + if (sessionJson) { + const session = JSON.parse(sessionJson, (_key, value) => { + if (value?.__type === 'Set') { + return new Set(value.__value); + } + return value; + }); + return session; + } + + return undefined; + } catch (error) { + localStorage.removeItem(this.storageKey); + return undefined; + } + } +} diff --git a/packages/core-api/src/lib/AuthSessionManager/index.ts b/packages/core-api/src/lib/AuthSessionManager/index.ts index 5f4dde8662..a452ac0055 100644 --- a/packages/core-api/src/lib/AuthSessionManager/index.ts +++ b/packages/core-api/src/lib/AuthSessionManager/index.ts @@ -16,5 +16,7 @@ export { RefreshingAuthSessionManager } from './RefreshingAuthSessionManager'; export { StaticAuthSessionManager } from './StaticAuthSessionManager'; +export { SamlAuthSessionManager } from './SamlAuthSessionManager'; export { AuthSessionStore } from './AuthSessionStore'; +export { SamlAuthSessionStore } from './SamlAuthSessionStore'; export * from './types'; diff --git a/packages/core-api/src/lib/AuthSessionManager/types.ts b/packages/core-api/src/lib/AuthSessionManager/types.ts index 804c7121e1..1be439a840 100644 --- a/packages/core-api/src/lib/AuthSessionManager/types.ts +++ b/packages/core-api/src/lib/AuthSessionManager/types.ts @@ -36,6 +36,13 @@ export type SessionManager = { sessionState$(): Observable; }; +// FIXME do we need this? +export type SamlAuthSessionManager = { + getSession(options: GetSessionOptions): Promise; + removeSession(): Promise; + sessionState$(): Observable; +}; + /** * A function called to determine the scopes of a session. */ diff --git a/packages/core/src/layout/Sidebar/UserSettings.tsx b/packages/core/src/layout/Sidebar/UserSettings.tsx index e69d63186d..b08a3b82d8 100644 --- a/packages/core/src/layout/Sidebar/UserSettings.tsx +++ b/packages/core/src/layout/Sidebar/UserSettings.tsx @@ -49,6 +49,12 @@ export function SidebarUserSettings() { if (!sidebarOpen && open) setOpen(false); }, [open, sidebarOpen]); + // FIXME: Change this and remove the session storage stuff + const handleLogout = () => { + identityApi.logout(); + window.sessionStorage.clear(); + }; + return ( <> @@ -91,7 +97,7 @@ export function SidebarUserSettings() { identityApi.logout()} + onClick={handleLogout} /> diff --git a/packages/core/src/layout/SignInPage/commonProvider.tsx b/packages/core/src/layout/SignInPage/commonProvider.tsx index f9611342a2..88a53ec358 100644 --- a/packages/core/src/layout/SignInPage/commonProvider.tsx +++ b/packages/core/src/layout/SignInPage/commonProvider.tsx @@ -36,6 +36,11 @@ const Component: ProviderComponent = ({ config, onResult }) => { instantPopup: true, }); + // eslint-disable-next-line no-console + console.log('====++++++++++++++++++++++++++> handleLogin for commonProvider'); + // eslint-disable-next-line no-console + console.log(identity); + const profile = await authApi.getProfile(); onResult({ userId: identity!.id, diff --git a/packages/core/src/layout/SignInPage/providers.tsx b/packages/core/src/layout/SignInPage/providers.tsx index ff60eba228..436078e49c 100644 --- a/packages/core/src/layout/SignInPage/providers.tsx +++ b/packages/core/src/layout/SignInPage/providers.tsx @@ -26,6 +26,7 @@ import { SignInConfig, IdentityProviders, SignInProvider } from './types'; import { commonProvider } from './commonProvider'; import { guestProvider } from './guestProvider'; import { customProvider } from './customProvider'; +import { samlProvider } from './samlProvider'; const PROVIDER_STORAGE_KEY = '@backstage/core:SignInPage:provider'; @@ -41,6 +42,7 @@ const signInProviders: { [key: string]: SignInProvider } = { guest: guestProvider, custom: customProvider, common: commonProvider, + saml: samlProvider, }; function validateIDs(id: string, providers: SignInProviderType): void { diff --git a/packages/core/src/layout/SignInPage/samlProvider.tsx b/packages/core/src/layout/SignInPage/samlProvider.tsx new file mode 100644 index 0000000000..e9673bca8c --- /dev/null +++ b/packages/core/src/layout/SignInPage/samlProvider.tsx @@ -0,0 +1,92 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { Grid, Typography, Button } from '@material-ui/core'; +import { ProviderComponent, ProviderLoader, SignInProvider } from './types'; +import { useApi, errorApiRef /* samlAuthApiRef */ } from '@backstage/core-api'; +import { InfoCard } from '../InfoCard/InfoCard'; + +const Component: ProviderComponent = ({ onResult }) => { + // const samlApi = useApi(samlAuthApiRef); + const errorApi = useApi(errorApiRef); + + const handleSSORedirect = () => { + // FIXME: this shoudl be from identity API or something. + window.open('http://localhost:7000/auth/saml/start'); + }; + + const receiveMessage = (event: any) => { + // FIXME: Should use the backstage identity API and not create this session storage. + window.sessionStorage.setItem( + 'helixSession', + JSON.stringify(event.data.response), + ); + + onResult({ + userId: event.data.response.backstageIdentity, + profile: { + email: event.data.response.profile.email, + displayName: event.data.response.profile.displayName, + }, + }); + }; + + const handleLogin = async () => { + try { + // FIXME: this should be handle through backstage API or something. + handleSSORedirect(); + window.addEventListener('message', receiveMessage, false); + } catch (error) { + errorApi.post(error); + } + }; + + return ( + + + Sign In + + } + > + Sign In using SAML lolololol + + + ); +}; + +const loader: ProviderLoader = async () => { + // FIXME: should get the profile from identiy API not from storage session. + const sessionRaw = window.sessionStorage.getItem('helixSession'); + + if (!sessionRaw) { + return undefined; + } + + const session = JSON.parse(sessionRaw); + + return { + userId: session.backstageIdentity, + profile: { + email: session.profile.email, + displayName: session.profile.displayName, + }, + }; +}; + +export const samlProvider: SignInProvider = { Component, loader }; diff --git a/packages/core/src/layout/SignInPage/types.ts b/packages/core/src/layout/SignInPage/types.ts index 9c501e8095..74463f93b4 100644 --- a/packages/core/src/layout/SignInPage/types.ts +++ b/packages/core/src/layout/SignInPage/types.ts @@ -24,18 +24,19 @@ import { ProfileInfoApi, BackstageIdentityApi, SessionStateApi, + SamlApi, } from '@backstage/core-api'; export type SignInConfig = { id: string; title: string; message: string; - apiRef: ApiRef< - OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionStateApi - >; + apiRef: + | ApiRef + | ApiRef; }; -export type IdentityProviders = ('guest' | 'custom' | SignInConfig)[]; +export type IdentityProviders = ('guest' | 'custom' | 'saml' | SignInConfig)[]; export type ProviderComponent = ComponentType< SignInPageProps & { config: SignInConfig } diff --git a/plugins/auth-backend/src/providers/factories.ts b/plugins/auth-backend/src/providers/factories.ts index 701eef81af..3311bfc836 100644 --- a/plugins/auth-backend/src/providers/factories.ts +++ b/plugins/auth-backend/src/providers/factories.ts @@ -62,6 +62,7 @@ export const createAuthProviderRouter = ( for (const env of envs) { const envConfig = providerConfig.getConfig(env); + console.log(envConfig); const provider = factory(globalConfig, env, envConfig, logger, issuer); if (provider) { envProviders[env] = provider; diff --git a/plugins/auth-backend/src/providers/saml/provider.ts b/plugins/auth-backend/src/providers/saml/provider.ts index 1ccee1a9cb..fe6bb87b6d 100644 --- a/plugins/auth-backend/src/providers/saml/provider.ts +++ b/plugins/auth-backend/src/providers/saml/provider.ts @@ -55,8 +55,21 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers { // for non-oauth auth flows. // TODO: This flow doesn't issue an identity token that can be used to validate // the identity of the user in other backends, which we need in some form. + console.log('===>'); + console.log('===>'); + console.log('===>'); + console.log('===>'); + console.log('===>'); + console.log('===>'); + console.log('===>'); + console.log('===>'); + console.log('===>'); + console.log('===>'); + console.log('===>'); + console.log('===> SamlAuthProvider constructor'); + console.log(profile); done(undefined, { - userId: profile.ID!, + userId: profile.nameID!, profile: { email: profile.email!, displayName: profile.displayName as string, @@ -76,9 +89,9 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers { ): Promise { try { const { - response: { userId, profile }, + response: { userId, profile }, } = await executeFrameHandlerStrategy(req, this.strategy); - + const id = userId; const idToken = await this.tokenIssuer.issueToken({ claims: { sub: id }, @@ -107,8 +120,14 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers { res.send('noop'); } - identifyEnv(): string | undefined { - return undefined; + identifyEnv(req: express.Request): string | undefined { + const reqEnv = req.query.env?.toString(); + if (reqEnv) { + return reqEnv; + } + + // FIXME : do we want to always to return 'development' ? + return 'development'; } } diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts index 0224dc7f1d..2b446c9cd6 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts +++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts @@ -116,14 +116,14 @@ export class HigherOrderOperations implements HigherOrderOperation { * Entities are read from their respective sources, are parsed and validated * according to the entity policy, and get inserted or updated in the catalog. * Entities that have disappeared from their location are left orphaned, - * without changes. + * without changes.i */ async refreshAllLocations(): Promise { const startTimestamp = new Date().valueOf(); - this.logger.info('Beginning locations refresh'); + // this.logger.info('Beginning locations refresh'); const locations = await this.locationsCatalog.locations(); - this.logger.info(`Visiting ${locations.length} locations`); + // this.logger.info(`Visiting ${locations.length} locations`); for (const { data: location } of locations) { this.logger.debug( diff --git a/yarn.lock b/yarn.lock index 0354b1844f..f873b2cdaf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9848,9 +9848,8 @@ file-loader@^4.2.0: loader-utils "^1.2.3" schema-utils "^2.5.0" -"file-saver@github:eligrey/FileSaver.js#1.3.8": +file-saver@eligrey/FileSaver.js#1.3.8: version "1.3.8" - uid e865e37af9f9947ddcced76b549e27dc45c1cb2e resolved "https://codeload.github.com/eligrey/FileSaver.js/tar.gz/e865e37af9f9947ddcced76b549e27dc45c1cb2e" file-system-cache@^1.0.5: @@ -12977,7 +12976,7 @@ jspdf@1.5.3: integrity sha512-J9X76xnncMw+wIqb15HeWfPMqPwYxSpPY8yWPJ7rAZN/ZDzFkjCSZObryCyUe8zbrVRNiuCnIeQteCzMn7GnWw== dependencies: canvg "1.5.3" - file-saver "github:eligrey/FileSaver.js#1.3.8" + file-saver eligrey/FileSaver.js#1.3.8 html2canvas "1.0.0-alpha.12" omggif "1.0.7" promise-polyfill "8.1.0" From 667b42cfb7dbaffbb9488512b4a79847f567d773 Mon Sep 17 00:00:00 2001 From: "J Shamsul Bahri (jibone))" Date: Fri, 4 Sep 2020 16:18:32 +0800 Subject: [PATCH 03/94] Remove console.log debug stuff. --- packages/app/src/App.tsx | 2 +- .../core-api/src/apis/definitions/auth.ts | 3 + .../implementations/auth/saml/SamlAuth.ts | 32 ++----- packages/core-api/src/app/AppIdentity.ts | 4 - .../lib/AuthConnector/SamlAuthConnector.ts | 21 ++--- .../SamlAuthSessionManager.ts | 11 +-- .../SamlAuthSessionStore.ts | 2 - .../src/layout/SignInPage/commonProvider.tsx | 5 - .../core/src/layout/SignInPage/providers.tsx | 2 - .../src/layout/SignInPage/samlProvider.tsx | 92 ------------------- packages/core/src/layout/SignInPage/types.ts | 8 +- .../src/providers/saml/provider.ts | 11 --- 12 files changed, 27 insertions(+), 166 deletions(-) delete mode 100644 packages/core/src/layout/SignInPage/samlProvider.tsx diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 29e371b469..6ff4da0fe3 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -35,7 +35,7 @@ const app = createApp({ return ( ); }, diff --git a/packages/core-api/src/apis/definitions/auth.ts b/packages/core-api/src/apis/definitions/auth.ts index 6dfe9595b0..8f0a4b6595 100644 --- a/packages/core-api/src/apis/definitions/auth.ts +++ b/packages/core-api/src/apis/definitions/auth.ts @@ -108,6 +108,9 @@ export type SamlApi = { getProfile(options?: AuthRequestOptions): Promise; + // FIXME: is this needed? + getAccessToken(options?: AuthRequestOptions): Promise; + // Not sure if this is needed. logout(): Promise; }; diff --git a/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts b/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts index 75aefda7f5..cb93dcea97 100644 --- a/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts @@ -35,9 +35,6 @@ import { type CreateOptions = { apiOrigin: string; basePath: string; - - // oauthRequestApi?: OAuthRequestApi; - environment?: string; provider?: AuthProvider & { id: string }; }; @@ -61,9 +58,6 @@ class SamlAuth implements SamlApi { environment = 'development', provider = DEFAULT_PROVIDER, }: CreateOptions) { - // eslint-disable-next-line no-console - console.log('this is from SamlAuth'); - const connector = new SamlAuthConnector({ apiOrigin, basePath, @@ -87,37 +81,27 @@ class SamlAuth implements SamlApi { sessionState$(): Observable { return this.sessionManager.sessionState$(); } - // constructor(private readonly sessionManager: any) {} - // eslint-disable-next-line @typescript-eslint/no-useless-constructor - constructor(private readonly sessionManager: SessionManager) { - // eslint-disable-next-line no-console - console.log('this is the constructor'); - } + + constructor(private readonly sessionManager: SessionManager) {} async getBackstageIdentity( options: AuthRequestOptions, ): Promise { - // eslint-disable-next-line no-console - console.log('===> Saml getBackstageIdentity()'); const session = await this.sessionManager.getSession(options); - // eslint-disable-next-line no-console - console.log('this this thish lkkdfkkfkfji'); - // eslint-disable-next-line no-console - console.log(session); return session?.backstageIdentity; } async getProfile(options: AuthRequestOptions = {}) { - // eslint-disable-next-line no-console - console.log('==> samlauth getprofile()'); const session = await this.sessionManager.getSession(options); - // eslint-disable-next-line no-console - console.log('+++ this is the session from getProfile()'); - // eslint-disable-next-line no-console - console.log(session); return session?.profile; } + // FIXME: Is this needed?... + async getAccessToken(options: AuthRequestOptions) { + const session = await this.sessionManager.getSession(options); + return session?.userId ?? ''; + } + async logout() { await this.sessionManager.removeSession(); } diff --git a/packages/core-api/src/app/AppIdentity.ts b/packages/core-api/src/app/AppIdentity.ts index 1a6cdac3e6..69ee5d28ac 100644 --- a/packages/core-api/src/app/AppIdentity.ts +++ b/packages/core-api/src/app/AppIdentity.ts @@ -67,10 +67,6 @@ export class AppIdentity implements IdentityApi { // This is indirectly called by the sign-in page to continue into the app. setSignInResult(result: SignInResult) { - // eslint-disable-next-line no-console - console.log('this is from setSignInResult'); - // eslint-disable-next-line no-console - console.log(result); if (this.hasIdentity) { return; } diff --git a/packages/core-api/src/lib/AuthConnector/SamlAuthConnector.ts b/packages/core-api/src/lib/AuthConnector/SamlAuthConnector.ts index 9dc0fca0a0..4a6babe224 100644 --- a/packages/core-api/src/lib/AuthConnector/SamlAuthConnector.ts +++ b/packages/core-api/src/lib/AuthConnector/SamlAuthConnector.ts @@ -36,7 +36,8 @@ export type SamlResponse = { backstageIdentity: BackstageIdentity; }; -export class SamlAuthConnector implements AuthConnector { +export class SamlAuthConnector + implements AuthConnector { private readonly apiOrigin: string; private readonly basePath: string; private readonly environment: string | undefined; @@ -57,35 +58,25 @@ export class SamlAuthConnector implements AuthConnector { - // eslint-disable-next-line no-console - console.log('==> from SamlAuthConnector createSession'); - const payload = await showLoginPopup({ - url: 'http://localhost:7000/auth/saml/start', // FIXME: this should be from app.config or somewhere + url: 'http://localhost:7000/auth/saml/start', // FIXME: remove hardcoded here. should do something like buildUrl name: 'SAML Login', // FIXME: change this to provider name? and not hardcode the name origin: this.apiOrigin, width: 450, height: 730, }); - // eslint-disable-next-line no-console - console.log(payload); - return { ...payload, id: payload.profile.email, }; } - // TODO: do we need this for SAML? - async refreshSession(): Promise { - // eslint-disable-next-line no-console - console.log('==> this is refresh session'); - } + // FIXME: do we need this for SAML? + async refreshSession(): Promise {} async removeSession(): Promise { - // eslint-disable-next-line no-console - console.log('this removes the session'); + // FIXME: remove hardcoded url here... should do something like buildUrl const res = await fetch('http://localhost:7000/auth/saml/logout', { method: 'POST', headers: { diff --git a/packages/core-api/src/lib/AuthSessionManager/SamlAuthSessionManager.ts b/packages/core-api/src/lib/AuthSessionManager/SamlAuthSessionManager.ts index 9439684cf4..8a21bc05b9 100644 --- a/packages/core-api/src/lib/AuthSessionManager/SamlAuthSessionManager.ts +++ b/packages/core-api/src/lib/AuthSessionManager/SamlAuthSessionManager.ts @@ -15,10 +15,12 @@ */ import { SessionManager, GetSessionOptions } from './types'; -import { SamlAuthConnector, SamlResponse } from '../AuthConnector/SamlAuthConnector'; +import { + SamlAuthConnector, + SamlResponse, +} from '../AuthConnector/SamlAuthConnector'; import { SessionStateTracker } from './SessionStateTracker'; - type Options = { connector: SamlAuthConnector; }; @@ -27,18 +29,15 @@ export class SamlAuthSessionManager implements SessionManager { private readonly connector: SamlAuthConnector; private readonly stateTracker = new SessionStateTracker(); - private currentSession: any | undefined; // FIXME: proper typing here + private currentSession: any | undefined; // FIXME: proper typing here? constructor(options: Options) { const { connector } = options; this.connector = connector; - // this.helper = new SessionScopeHelper } async getSession(options: GetSessionOptions): Promise { - // eslint-disable-next-line no-console - console.log('==> this is from SamlAuthSessionManager getSession()'); if (this.currentSession) { return this.currentSession; } diff --git a/packages/core-api/src/lib/AuthSessionManager/SamlAuthSessionStore.ts b/packages/core-api/src/lib/AuthSessionManager/SamlAuthSessionStore.ts index 53fba09b54..3afb07c4df 100644 --- a/packages/core-api/src/lib/AuthSessionManager/SamlAuthSessionStore.ts +++ b/packages/core-api/src/lib/AuthSessionManager/SamlAuthSessionStore.ts @@ -33,8 +33,6 @@ export class SamlAuthSessionStore implements SamlAuthSessionManager { } async getSession(options: GetSessionOptions): Promise { - // eslint-disable-next-line no-console - console.log('==>> this is from SamlAuthSessionStore getSession()'); const session = this.loadSession(); if (session) { diff --git a/packages/core/src/layout/SignInPage/commonProvider.tsx b/packages/core/src/layout/SignInPage/commonProvider.tsx index 88a53ec358..f9611342a2 100644 --- a/packages/core/src/layout/SignInPage/commonProvider.tsx +++ b/packages/core/src/layout/SignInPage/commonProvider.tsx @@ -36,11 +36,6 @@ const Component: ProviderComponent = ({ config, onResult }) => { instantPopup: true, }); - // eslint-disable-next-line no-console - console.log('====++++++++++++++++++++++++++> handleLogin for commonProvider'); - // eslint-disable-next-line no-console - console.log(identity); - const profile = await authApi.getProfile(); onResult({ userId: identity!.id, diff --git a/packages/core/src/layout/SignInPage/providers.tsx b/packages/core/src/layout/SignInPage/providers.tsx index 436078e49c..ff60eba228 100644 --- a/packages/core/src/layout/SignInPage/providers.tsx +++ b/packages/core/src/layout/SignInPage/providers.tsx @@ -26,7 +26,6 @@ import { SignInConfig, IdentityProviders, SignInProvider } from './types'; import { commonProvider } from './commonProvider'; import { guestProvider } from './guestProvider'; import { customProvider } from './customProvider'; -import { samlProvider } from './samlProvider'; const PROVIDER_STORAGE_KEY = '@backstage/core:SignInPage:provider'; @@ -42,7 +41,6 @@ const signInProviders: { [key: string]: SignInProvider } = { guest: guestProvider, custom: customProvider, common: commonProvider, - saml: samlProvider, }; function validateIDs(id: string, providers: SignInProviderType): void { diff --git a/packages/core/src/layout/SignInPage/samlProvider.tsx b/packages/core/src/layout/SignInPage/samlProvider.tsx deleted file mode 100644 index e9673bca8c..0000000000 --- a/packages/core/src/layout/SignInPage/samlProvider.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React from 'react'; -import { Grid, Typography, Button } from '@material-ui/core'; -import { ProviderComponent, ProviderLoader, SignInProvider } from './types'; -import { useApi, errorApiRef /* samlAuthApiRef */ } from '@backstage/core-api'; -import { InfoCard } from '../InfoCard/InfoCard'; - -const Component: ProviderComponent = ({ onResult }) => { - // const samlApi = useApi(samlAuthApiRef); - const errorApi = useApi(errorApiRef); - - const handleSSORedirect = () => { - // FIXME: this shoudl be from identity API or something. - window.open('http://localhost:7000/auth/saml/start'); - }; - - const receiveMessage = (event: any) => { - // FIXME: Should use the backstage identity API and not create this session storage. - window.sessionStorage.setItem( - 'helixSession', - JSON.stringify(event.data.response), - ); - - onResult({ - userId: event.data.response.backstageIdentity, - profile: { - email: event.data.response.profile.email, - displayName: event.data.response.profile.displayName, - }, - }); - }; - - const handleLogin = async () => { - try { - // FIXME: this should be handle through backstage API or something. - handleSSORedirect(); - window.addEventListener('message', receiveMessage, false); - } catch (error) { - errorApi.post(error); - } - }; - - return ( - - - Sign In - - } - > - Sign In using SAML lolololol - - - ); -}; - -const loader: ProviderLoader = async () => { - // FIXME: should get the profile from identiy API not from storage session. - const sessionRaw = window.sessionStorage.getItem('helixSession'); - - if (!sessionRaw) { - return undefined; - } - - const session = JSON.parse(sessionRaw); - - return { - userId: session.backstageIdentity, - profile: { - email: session.profile.email, - displayName: session.profile.displayName, - }, - }; -}; - -export const samlProvider: SignInProvider = { Component, loader }; diff --git a/packages/core/src/layout/SignInPage/types.ts b/packages/core/src/layout/SignInPage/types.ts index 74463f93b4..513fe9f674 100644 --- a/packages/core/src/layout/SignInPage/types.ts +++ b/packages/core/src/layout/SignInPage/types.ts @@ -36,7 +36,7 @@ export type SignInConfig = { | ApiRef; }; -export type IdentityProviders = ('guest' | 'custom' | 'saml' | SignInConfig)[]; +export type IdentityProviders = ('guest' | 'custom' | SignInConfig)[]; export type ProviderComponent = ComponentType< SignInPageProps & { config: SignInConfig } @@ -44,9 +44,9 @@ export type ProviderComponent = ComponentType< export type ProviderLoader = ( apis: ApiHolder, - apiRef: ApiRef< - OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionStateApi - >, + apiRef: + | ApiRef + | ApiRef, ) => Promise; export type SignInProvider = { diff --git a/plugins/auth-backend/src/providers/saml/provider.ts b/plugins/auth-backend/src/providers/saml/provider.ts index fe6bb87b6d..f790228107 100644 --- a/plugins/auth-backend/src/providers/saml/provider.ts +++ b/plugins/auth-backend/src/providers/saml/provider.ts @@ -55,17 +55,6 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers { // for non-oauth auth flows. // TODO: This flow doesn't issue an identity token that can be used to validate // the identity of the user in other backends, which we need in some form. - console.log('===>'); - console.log('===>'); - console.log('===>'); - console.log('===>'); - console.log('===>'); - console.log('===>'); - console.log('===>'); - console.log('===>'); - console.log('===>'); - console.log('===>'); - console.log('===>'); console.log('===> SamlAuthProvider constructor'); console.log(profile); done(undefined, { From 66b816cdf00153e215515efd140174dc4de32164 Mon Sep 17 00:00:00 2001 From: "J Shamsul Bahri (jibone))" Date: Fri, 4 Sep 2020 18:11:02 +0800 Subject: [PATCH 04/94] clean up config --- app-config.yaml | 134 ++++++++++++++++++++++++------------------------ 1 file changed, 66 insertions(+), 68 deletions(-) diff --git a/app-config.yaml b/app-config.yaml index e65df93969..d6c745a6c2 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -28,74 +28,72 @@ sentry: auth: providers: - # google: - # development: - # appOrigin: 'http://localhost:3000/' - # secure: false - # clientId: - # $secret: - # env: AUTH_GOOGLE_CLIENT_ID - # clientSecret: - # $secret: - # env: AUTH_GOOGLE_CLIENT_SECRET - # github: - # development: - # appOrigin: 'http://localhost:3000/' - # secure: false - # clientId: - # $secret: - # env: AUTH_GITHUB_CLIENT_ID - # clientSecret: - # $secret: - # env: AUTH_GITHUB_CLIENT_SECRET - # enterpriseInstanceUrl: - # $secret: - # env: AUTH_GITHUB_ENTERPRISE_INSTANCE_URL - # gitlab: - # development: - # appOrigin: 'http://localhost:3000/' - # secure: false - # clientId: - # $secret: - # env: AUTH_GITLAB_CLIENT_ID - # clientSecret: - # $secret: - # env: AUTH_GITLAB_CLIENT_SECRET - # audience: - # $secret: - # env: GITLAB_BASE_URL + google: + development: + appOrigin: 'http://localhost:3000/' + secure: false + clientId: + $secret: + env: AUTH_GOOGLE_CLIENT_ID + clientSecret: + $secret: + env: AUTH_GOOGLE_CLIENT_SECRET + github: + development: + appOrigin: 'http://localhost:3000/' + secure: false + clientId: + $secret: + env: AUTH_GITHUB_CLIENT_ID + clientSecret: + $secret: + env: AUTH_GITHUB_CLIENT_SECRET + enterpriseInstanceUrl: + $secret: + env: AUTH_GITHUB_ENTERPRISE_INSTANCE_URL + gitlab: + development: + appOrigin: 'http://localhost:3000/' + secure: false + clientId: + $secret: + env: AUTH_GITLAB_CLIENT_ID + clientSecret: + $secret: + env: AUTH_GITLAB_CLIENT_SECRET + audience: + $secret: + env: GITLAB_BASE_URL saml: development: - entryPoint: 'https://sso.jumpcloud.com/saml2/backstage-test' + entryPoint: 'http://localhost:7001/' issuer: 'passport-saml' - # entryPoint: 'http://localhost:7001/' - # issuer: 'passport-saml' - # okta - # development: - # appOrigin: 'http://localhost:3000/' - # secure: false - # clientId: - # $secret: - # env: AUTH_OKTA_CLIENT_ID - # clientSecret: - # $secret: - # env: AUTH_OKTA_CLIENT_SECRET - # audience: - # $secret: - # env: AUTH_OKTA_AUDIENCE - # oauth2: - # development: - # appOrigin: 'http://localhost:3000/' - # secure: false - # clientId: - # $secret: - # env: AUTH_OAUTH2_CLIENT_ID - # clientSecret: - # $secret: - # env: AUTH_OAUTH2_CLIENT_SECRET - # authorizationURL: - # $secret: - # env: AUTH_OAUTH2_AUTH_URL - # tokenURL: - # $secret: - # env: AUTH_OAUTH2_TOKEN_URL + okta: + development: + appOrigin: 'http://localhost:3000/' + secure: false + clientId: + $secret: + env: AUTH_OKTA_CLIENT_ID + clientSecret: + $secret: + env: AUTH_OKTA_CLIENT_SECRET + audience: + $secret: + env: AUTH_OKTA_AUDIENCE + oauth2: + development: + appOrigin: 'http://localhost:3000/' + secure: false + clientId: + $secret: + env: AUTH_OAUTH2_CLIENT_ID + clientSecret: + $secret: + env: AUTH_OAUTH2_CLIENT_SECRET + authorizationURL: + $secret: + env: AUTH_OAUTH2_AUTH_URL + tokenURL: + $secret: + env: AUTH_OAUTH2_TOKEN_URL From 1cd640dc16c1169e85813fb8e041d271105de100 Mon Sep 17 00:00:00 2001 From: "J Shamsul Bahri (jibone))" Date: Fri, 4 Sep 2020 18:33:13 +0800 Subject: [PATCH 05/94] fix merge conflicts --- app-config.yaml | 34 ++----------------- packages/app/src/App.tsx | 3 -- packages/app/src/identityProviders.ts | 3 -- .../src/apis/implementations/auth/index.ts | 3 -- .../auth-backend/src/providers/factories.ts | 22 ------------ 5 files changed, 2 insertions(+), 63 deletions(-) diff --git a/app-config.yaml b/app-config.yaml index 7eb6023f61..2620f6b9b7 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -92,11 +92,6 @@ auth: providers: google: development: -<<<<<<< HEAD - appOrigin: 'http://localhost:3000/' - secure: false -======= ->>>>>>> master clientId: $secret: env: AUTH_GOOGLE_CLIENT_ID @@ -105,11 +100,6 @@ auth: env: AUTH_GOOGLE_CLIENT_SECRET github: development: -<<<<<<< HEAD - appOrigin: 'http://localhost:3000/' - secure: false -======= ->>>>>>> master clientId: $secret: env: AUTH_GITHUB_CLIENT_ID @@ -121,11 +111,6 @@ auth: env: AUTH_GITHUB_ENTERPRISE_INSTANCE_URL gitlab: development: -<<<<<<< HEAD - appOrigin: 'http://localhost:3000/' - secure: false -======= ->>>>>>> master clientId: $secret: env: AUTH_GITLAB_CLIENT_ID @@ -136,20 +121,10 @@ auth: $secret: env: GITLAB_BASE_URL saml: -<<<<<<< HEAD - development: - entryPoint: 'http://localhost:7001/' - issuer: 'passport-saml' + entryPoint: 'http://localhost:7001/' + issuer: 'passport-saml' okta: development: - appOrigin: 'http://localhost:3000/' - secure: false -======= - entryPoint: "http://localhost:7001/" - issuer: "passport-saml" - okta: - development: ->>>>>>> master clientId: $secret: env: AUTH_OKTA_CLIENT_ID @@ -161,11 +136,6 @@ auth: env: AUTH_OKTA_AUDIENCE oauth2: development: -<<<<<<< HEAD - appOrigin: 'http://localhost:3000/' - secure: false -======= ->>>>>>> master clientId: $secret: env: AUTH_OAUTH2_CLIENT_ID diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 53f7d93d3c..cb38726716 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -40,11 +40,8 @@ const app = createApp({ >>>>>> master /> ); }, diff --git a/packages/app/src/identityProviders.ts b/packages/app/src/identityProviders.ts index 767229eed4..388bc8d8b6 100644 --- a/packages/app/src/identityProviders.ts +++ b/packages/app/src/identityProviders.ts @@ -19,11 +19,8 @@ import { gitlabAuthApiRef, oktaAuthApiRef, githubAuthApiRef, -<<<<<<< HEAD samlAuthApiRef, -======= microsoftAuthApiRef, ->>>>>>> master } from '@backstage/core'; export const providers = [ diff --git a/packages/core-api/src/apis/implementations/auth/index.ts b/packages/core-api/src/apis/implementations/auth/index.ts index 9c6b8ff565..39223e8e15 100644 --- a/packages/core-api/src/apis/implementations/auth/index.ts +++ b/packages/core-api/src/apis/implementations/auth/index.ts @@ -19,9 +19,6 @@ export * from './gitlab'; export * from './google'; export * from './oauth2'; export * from './okta'; -<<<<<<< HEAD export * from './saml'; -======= export * from './auth0'; export * from './microsoft'; ->>>>>>> master diff --git a/plugins/auth-backend/src/providers/factories.ts b/plugins/auth-backend/src/providers/factories.ts index 948cc3eb8f..6c83dffda3 100644 --- a/plugins/auth-backend/src/providers/factories.ts +++ b/plugins/auth-backend/src/providers/factories.ts @@ -53,29 +53,7 @@ export const createAuthProviderRouter = ( const router = Router(); -<<<<<<< HEAD - for (const env of envs) { - const envConfig = providerConfig.getConfig(env); - console.log(envConfig); - const provider = factory(globalConfig, env, envConfig, logger, issuer); - if (provider) { - envProviders[env] = provider; - envIdentifier = provider.identifyEnv; - } - } - - if (typeof envIdentifier === 'undefined') { - throw Error(`No envIdentifier provided for '${providerId}'`); - } - - const handler = new EnvironmentHandler( - providerId, - envProviders, - envIdentifier, - ); -======= const handler = factory({ globalConfig, config, logger, tokenIssuer }); ->>>>>>> master router.get('/start', handler.start.bind(handler)); router.get('/handler/frame', handler.frameHandler.bind(handler)); From 41edd22db2dccdf99a8e4c2e25a03f4a29229256 Mon Sep 17 00:00:00 2001 From: "J Shamsul Bahri (jibone))" Date: Fri, 4 Sep 2020 18:44:30 +0800 Subject: [PATCH 06/94] Remove commented out log code. --- .../catalog-backend/src/ingestion/HigherOrderOperations.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts index 2b446c9cd6..0224dc7f1d 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts +++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts @@ -116,14 +116,14 @@ export class HigherOrderOperations implements HigherOrderOperation { * Entities are read from their respective sources, are parsed and validated * according to the entity policy, and get inserted or updated in the catalog. * Entities that have disappeared from their location are left orphaned, - * without changes.i + * without changes. */ async refreshAllLocations(): Promise { const startTimestamp = new Date().valueOf(); - // this.logger.info('Beginning locations refresh'); + this.logger.info('Beginning locations refresh'); const locations = await this.locationsCatalog.locations(); - // this.logger.info(`Visiting ${locations.length} locations`); + this.logger.info(`Visiting ${locations.length} locations`); for (const { data: location } of locations) { this.logger.debug( From cc2afdb9e327911bbd0d749274e104b087543abd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2020 04:19:00 +0000 Subject: [PATCH 07/94] chore(deps): bump @types/webpack-env from 1.15.2 to 1.15.3 Bumps [@types/webpack-env](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/webpack-env) from 1.15.2 to 1.15.3. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/webpack-env) Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 449148e487..a59bbf3ac2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4865,9 +4865,9 @@ "@types/webpack" "*" "@types/webpack-env@^1.15.2": - version "1.15.2" - resolved "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.15.2.tgz#927997342bb9f4a5185a86e6579a0a18afc33b0a" - integrity sha512-67ZgZpAlhIICIdfQrB5fnDvaKFcDxpKibxznfYRVAT4mQE41Dido/3Ty+E3xGBmTogc5+0Qb8tWhna+5B8z1iQ== + version "1.15.3" + resolved "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.15.3.tgz#fb602cd4c2f0b7c0fb857e922075fdf677d25d84" + integrity sha512-5oiXqR7kwDGZ6+gmzIO2lTC+QsriNuQXZDWNYRV3l2XRN/zmPgnC21DLSx2D05zvD8vnXW6qUg7JnXZ4I6qLVQ== "@types/webpack-node-externals@^2.5.0": version "2.5.0" From bb8bc7dd42d1d07bd47a40e58351ad932e558b71 Mon Sep 17 00:00:00 2001 From: "J Shamsul Bahri (jibone))" Date: Mon, 21 Sep 2020 15:00:56 +0800 Subject: [PATCH 08/94] merge master and start using discoveryApi --- app-config.yaml | 5 +-- .../core-api/src/apis/definitions/auth.ts | 2 +- .../implementations/auth/saml/SamlAuth.ts | 12 +++---- .../lib/AuthConnector/SamlAuthConnector.ts | 32 ++++++++----------- packages/core/src/api-wrappers/defaultApis.ts | 9 ++++++ 5 files changed, 31 insertions(+), 29 deletions(-) diff --git a/app-config.yaml b/app-config.yaml index 5b704f9b8e..562a2b0e2e 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -131,8 +131,9 @@ auth: $secret: env: GITLAB_BASE_URL saml: - entryPoint: 'http://localhost:7001/' - issuer: 'passport-saml' + development: + entryPoint: 'http://localhost:7001/' + issuer: 'passport-saml' okta: development: clientId: diff --git a/packages/core-api/src/apis/definitions/auth.ts b/packages/core-api/src/apis/definitions/auth.ts index ec99bae6b2..933a78b693 100644 --- a/packages/core-api/src/apis/definitions/auth.ts +++ b/packages/core-api/src/apis/definitions/auth.ts @@ -332,5 +332,5 @@ export const oauth2ApiRef = createApiRef< */ export const samlAuthApiRef = createApiRef({ id: 'core.auth.saml', - description: 'provides authentication towards saml based provider', + description: 'Example of how to use SAML custom provider', }); diff --git a/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts b/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts index cb93dcea97..aeede356ef 100644 --- a/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts @@ -25,7 +25,7 @@ import { AuthRequestOptions, SamlApi, } from '../../../definitions/auth'; -import { AuthProvider } from '../../../definitions'; +import { AuthProvider, DiscoveryApi } from '../../../definitions'; import { SamlSession } from './types'; import { SamlAuthSessionManager, @@ -33,8 +33,7 @@ import { } from '../../../../lib/AuthSessionManager'; type CreateOptions = { - apiOrigin: string; - basePath: string; + discoveryApi: DiscoveryApi; environment?: string; provider?: AuthProvider & { id: string }; }; @@ -53,14 +52,12 @@ const DEFAULT_PROVIDER = { class SamlAuth implements SamlApi { static create({ - apiOrigin, - basePath, + discoveryApi, environment = 'development', provider = DEFAULT_PROVIDER, }: CreateOptions) { const connector = new SamlAuthConnector({ - apiOrigin, - basePath, + discoveryApi, environment, provider, }); @@ -74,7 +71,6 @@ class SamlAuth implements SamlApi { storageKey: 'samlSession', }); - // return new SamlAuth(authSessionStore); return new SamlAuth(authSessionStore); } diff --git a/packages/core-api/src/lib/AuthConnector/SamlAuthConnector.ts b/packages/core-api/src/lib/AuthConnector/SamlAuthConnector.ts index 4a6babe224..47d8f25820 100644 --- a/packages/core-api/src/lib/AuthConnector/SamlAuthConnector.ts +++ b/packages/core-api/src/lib/AuthConnector/SamlAuthConnector.ts @@ -17,15 +17,13 @@ import { AuthProvider, ProfileInfo, BackstageIdentity, + DiscoveryApi, } from '../../apis/definitions'; import { AuthConnector } from './types'; import { showLoginPopup } from '../loginPopup'; -const DEFAULT_BASE_PATH = '/api/auth'; - type Options = { - apiOrigin?: string; - basePath?: string; + discoveryApi: DiscoveryApi; environment?: string; provider: AuthProvider & { id: string }; }; @@ -38,30 +36,24 @@ export type SamlResponse = { export class SamlAuthConnector implements AuthConnector { - private readonly apiOrigin: string; - private readonly basePath: string; + private readonly discoveryApi: DiscoveryApi; private readonly environment: string | undefined; private readonly provider: AuthProvider & { id: string }; constructor(options: Options) { - const { - apiOrigin = window.location.origin, - basePath = DEFAULT_BASE_PATH, - environment, - provider, - } = options; + const { discoveryApi, environment, provider } = options; - this.apiOrigin = apiOrigin; - this.basePath = basePath; + this.discoveryApi = discoveryApi; this.environment = environment; this.provider = provider; } async createSession(): Promise { + const popupUrl = await this.buildUrl('/start'); const payload = await showLoginPopup({ - url: 'http://localhost:7000/auth/saml/start', // FIXME: remove hardcoded here. should do something like buildUrl + url: popupUrl, name: 'SAML Login', // FIXME: change this to provider name? and not hardcode the name - origin: this.apiOrigin, + origin: new URL(popupUrl).origin, width: 450, height: 730, }); @@ -76,8 +68,7 @@ export class SamlAuthConnector async refreshSession(): Promise {} async removeSession(): Promise { - // FIXME: remove hardcoded url here... should do something like buildUrl - const res = await fetch('http://localhost:7000/auth/saml/logout', { + const res = await fetch(await this.buildUrl('/logout'), { method: 'POST', headers: { 'x-requested-with': 'XMLHttpRequest', @@ -93,4 +84,9 @@ export class SamlAuthConnector throw error; } } + + private async buildUrl(path: string): Promise { + const baseUrl = await this.discoveryApi.getBaseUrl('auth'); + return `${baseUrl}/${this.provider.id}${path}?env=${this.environment}`; + } } diff --git a/packages/core/src/api-wrappers/defaultApis.ts b/packages/core/src/api-wrappers/defaultApis.ts index a3f0cb0251..dedadf0456 100644 --- a/packages/core/src/api-wrappers/defaultApis.ts +++ b/packages/core/src/api-wrappers/defaultApis.ts @@ -44,6 +44,8 @@ import { createApiFactory, configApiRef, UrlPatternDiscovery, + samlAuthApiRef, + SamlAuth, } from '@backstage/core-api'; export const defaultApis = [ @@ -132,4 +134,11 @@ export const defaultApis = [ factory: ({ discoveryApi, oauthRequestApi }) => OAuth2.create({ discoveryApi, oauthRequestApi }), }), + createApiFactory({ + api: samlAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + }, + factory: ({ discoveryApi }) => SamlAuth.create({ discoveryApi }), + }), ]; From 5288ce0deeddfce29857a39a01c71864fc84b296 Mon Sep 17 00:00:00 2001 From: "J Shamsul Bahri (jibone))" Date: Mon, 21 Sep 2020 17:43:05 +0800 Subject: [PATCH 09/94] Remove getAccessToken func --- app-config.yaml | 5 ++--- packages/core-api/src/apis/definitions/auth.ts | 4 ---- .../src/apis/implementations/auth/saml/SamlAuth.ts | 6 ------ .../core-api/src/lib/AuthConnector/SamlAuthConnector.ts | 9 ++------- 4 files changed, 4 insertions(+), 20 deletions(-) diff --git a/app-config.yaml b/app-config.yaml index 6fda32f175..60ecb813f7 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -160,9 +160,8 @@ auth: $secret: env: GITLAB_BASE_URL saml: - development: - entryPoint: 'http://localhost:7001/' - issuer: 'passport-saml' + entryPoint: 'http://localhost:7001/' + issuer: 'passport-saml' okta: development: clientId: diff --git a/packages/core-api/src/apis/definitions/auth.ts b/packages/core-api/src/apis/definitions/auth.ts index 933a78b693..b536d74801 100644 --- a/packages/core-api/src/apis/definitions/auth.ts +++ b/packages/core-api/src/apis/definitions/auth.ts @@ -101,16 +101,12 @@ export type OAuthApi = { * This API provides access to SAML 2 credentials. Verify user access with identity provider. */ export type SamlApi = { - // Not sure what Promise call back should have. getBackstageIdentity( options?: AuthRequestOptions, ): Promise; getProfile(options?: AuthRequestOptions): Promise; - // FIXME: is this needed? - getAccessToken(options?: AuthRequestOptions): Promise; - // Not sure if this is needed. logout(): Promise; }; diff --git a/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts b/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts index aeede356ef..c06a6274fd 100644 --- a/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts @@ -92,12 +92,6 @@ class SamlAuth implements SamlApi { return session?.profile; } - // FIXME: Is this needed?... - async getAccessToken(options: AuthRequestOptions) { - const session = await this.sessionManager.getSession(options); - return session?.userId ?? ''; - } - async logout() { await this.sessionManager.removeSession(); } diff --git a/packages/core-api/src/lib/AuthConnector/SamlAuthConnector.ts b/packages/core-api/src/lib/AuthConnector/SamlAuthConnector.ts index 47d8f25820..30de81536d 100644 --- a/packages/core-api/src/lib/AuthConnector/SamlAuthConnector.ts +++ b/packages/core-api/src/lib/AuthConnector/SamlAuthConnector.ts @@ -19,7 +19,6 @@ import { BackstageIdentity, DiscoveryApi, } from '../../apis/definitions'; -import { AuthConnector } from './types'; import { showLoginPopup } from '../loginPopup'; type Options = { @@ -34,8 +33,7 @@ export type SamlResponse = { backstageIdentity: BackstageIdentity; }; -export class SamlAuthConnector - implements AuthConnector { +export class SamlAuthConnector { private readonly discoveryApi: DiscoveryApi; private readonly environment: string | undefined; private readonly provider: AuthProvider & { id: string }; @@ -52,7 +50,7 @@ export class SamlAuthConnector const popupUrl = await this.buildUrl('/start'); const payload = await showLoginPopup({ url: popupUrl, - name: 'SAML Login', // FIXME: change this to provider name? and not hardcode the name + name: `${this.provider.title} Login`, origin: new URL(popupUrl).origin, width: 450, height: 730, @@ -64,9 +62,6 @@ export class SamlAuthConnector }; } - // FIXME: do we need this for SAML? - async refreshSession(): Promise {} - async removeSession(): Promise { const res = await fetch(await this.buildUrl('/logout'), { method: 'POST', From 70dc0627aa770680c7abb57dc01f2a58491e6744 Mon Sep 17 00:00:00 2001 From: "J Shamsul Bahri (jibone))" Date: Mon, 21 Sep 2020 17:53:18 +0800 Subject: [PATCH 10/94] remove the console.log --- plugins/auth-backend/src/providers/saml/provider.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/plugins/auth-backend/src/providers/saml/provider.ts b/plugins/auth-backend/src/providers/saml/provider.ts index aac42d6bd9..2163d50f37 100644 --- a/plugins/auth-backend/src/providers/saml/provider.ts +++ b/plugins/auth-backend/src/providers/saml/provider.ts @@ -53,8 +53,6 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers { // for non-oauth auth flows. // TODO: This flow doesn't issue an identity token that can be used to validate // the identity of the user in other backends, which we need in some form. - console.log('===> SamlAuthProvider constructor'); - console.log(profile); done(undefined, { userId: profile.nameID!, profile: { @@ -76,9 +74,9 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers { ): Promise { try { const { - response: { userId, profile }, + response: { userId, profile }, } = await executeFrameHandlerStrategy(req, this.strategy); - + const id = userId; const idToken = await this.tokenIssuer.issueToken({ claims: { sub: id }, From f77333c35171a38c2cfc79376a78c390affe0ff4 Mon Sep 17 00:00:00 2001 From: Tim Jacomb Date: Mon, 21 Sep 2020 11:01:22 +0100 Subject: [PATCH 11/94] Update docker base image to buster for node --- contrib/docker/multi-stage-frontend/Dockerfile | 2 +- packages/backend/Dockerfile | 2 +- .../templates/default-app/packages/backend/Dockerfile | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contrib/docker/multi-stage-frontend/Dockerfile b/contrib/docker/multi-stage-frontend/Dockerfile index 0c492478d1..3190687c52 100644 --- a/contrib/docker/multi-stage-frontend/Dockerfile +++ b/contrib/docker/multi-stage-frontend/Dockerfile @@ -1,4 +1,4 @@ -FROM node:12 AS build +FROM node:12-buster AS build RUN mkdir /app COPY . /app diff --git a/packages/backend/Dockerfile b/packages/backend/Dockerfile index a7bd814b19..93929ceb86 100644 --- a/packages/backend/Dockerfile +++ b/packages/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM node:12 +FROM node:12-buster WORKDIR /usr/src/app diff --git a/packages/create-app/templates/default-app/packages/backend/Dockerfile b/packages/create-app/templates/default-app/packages/backend/Dockerfile index a7bd814b19..93929ceb86 100644 --- a/packages/create-app/templates/default-app/packages/backend/Dockerfile +++ b/packages/create-app/templates/default-app/packages/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM node:12 +FROM node:12-buster WORKDIR /usr/src/app From e2d719f502eaf166f6473ef14a5d9d02c9e0495c Mon Sep 17 00:00:00 2001 From: "J Shamsul Bahri (jibone))" Date: Mon, 21 Sep 2020 18:48:13 +0800 Subject: [PATCH 12/94] switch to use generic sessionmanager and storage --- .../implementations/auth/saml/SamlAuth.ts | 8 +- .../AuthSessionManager/AuthSessionStore.ts | 2 +- .../SamlAuthSessionManager.ts | 63 ------------- .../SamlAuthSessionStore.ts | 94 ------------------- .../StaticAuthSessionManager.ts | 2 +- .../src/lib/AuthSessionManager/common.ts | 7 +- .../src/lib/AuthSessionManager/index.ts | 2 - 7 files changed, 11 insertions(+), 167 deletions(-) delete mode 100644 packages/core-api/src/lib/AuthSessionManager/SamlAuthSessionManager.ts delete mode 100644 packages/core-api/src/lib/AuthSessionManager/SamlAuthSessionStore.ts diff --git a/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts b/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts index c06a6274fd..6e0bd0af10 100644 --- a/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts @@ -28,8 +28,8 @@ import { import { AuthProvider, DiscoveryApi } from '../../../definitions'; import { SamlSession } from './types'; import { - SamlAuthSessionManager, - SamlAuthSessionStore, + AuthSessionStore, + StaticAuthSessionManager, } from '../../../../lib/AuthSessionManager'; type CreateOptions = { @@ -62,11 +62,11 @@ class SamlAuth implements SamlApi { provider, }); - const sessionManager = new SamlAuthSessionManager({ + const sessionManager = new StaticAuthSessionManager({ connector, }); - const authSessionStore = new SamlAuthSessionStore({ + const authSessionStore = new AuthSessionStore({ manager: sessionManager, storageKey: 'samlSession', }); diff --git a/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts b/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts index f2b8558f74..e82557b1ce 100644 --- a/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts +++ b/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts @@ -28,7 +28,7 @@ type Options = { /** Storage key to use to store sessions */ storageKey: string; /** Used to get the scope of the session */ - sessionScopes: SessionScopesFunc; + sessionScopes?: SessionScopesFunc; /** Used to check if the session needs to be refreshed, defaults to never refresh */ sessionShouldRefresh?: SessionShouldRefreshFunc; }; diff --git a/packages/core-api/src/lib/AuthSessionManager/SamlAuthSessionManager.ts b/packages/core-api/src/lib/AuthSessionManager/SamlAuthSessionManager.ts deleted file mode 100644 index 8a21bc05b9..0000000000 --- a/packages/core-api/src/lib/AuthSessionManager/SamlAuthSessionManager.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { SessionManager, GetSessionOptions } from './types'; -import { - SamlAuthConnector, - SamlResponse, -} from '../AuthConnector/SamlAuthConnector'; -import { SessionStateTracker } from './SessionStateTracker'; - -type Options = { - connector: SamlAuthConnector; -}; - -export class SamlAuthSessionManager implements SessionManager { - private readonly connector: SamlAuthConnector; - private readonly stateTracker = new SessionStateTracker(); - - private currentSession: any | undefined; // FIXME: proper typing here? - - constructor(options: Options) { - const { connector } = options; - - this.connector = connector; - } - - async getSession(options: GetSessionOptions): Promise { - if (this.currentSession) { - return this.currentSession; - } - - if (options.optional) { - return undefined; - } - - this.currentSession = await this.connector.createSession(); - this.stateTracker.setIsSignedIn(true); - return this.currentSession; - } - - async removeSession() { - this.currentSession = undefined; - await this.connector.removeSession(); - this.stateTracker.setIsSignedIn(false); - } - - sessionState$() { - return this.stateTracker.sessionState$(); - } -} diff --git a/packages/core-api/src/lib/AuthSessionManager/SamlAuthSessionStore.ts b/packages/core-api/src/lib/AuthSessionManager/SamlAuthSessionStore.ts deleted file mode 100644 index 3afb07c4df..0000000000 --- a/packages/core-api/src/lib/AuthSessionManager/SamlAuthSessionStore.ts +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { SamlAuthSessionManager, GetSessionOptions } from './types'; - -type Options = { - manager: SamlAuthSessionManager; - storageKey: string; -}; - -export class SamlAuthSessionStore implements SamlAuthSessionManager { - private readonly manager: SamlAuthSessionManager; - private readonly storageKey: string; - - constructor(options: Options) { - const { manager, storageKey } = options; - - this.manager = manager; - this.storageKey = storageKey; - } - - async getSession(options: GetSessionOptions): Promise { - const session = this.loadSession(); - - if (session) { - return session!; - } - - const newSession = await this.manager.getSession(options); - this.saveSession(newSession); - return newSession; - } - - async removeSession() { - localStorage.removeItem(this.storageKey); - await this.manager.removeSession(); - } - - sessionState$() { - return this.manager.sessionState$(); - } - - private saveSession(session: T | undefined) { - if (session === undefined) { - localStorage.removeItem(this.storageKey); - } else { - localStorage.setItem( - this.storageKey, - JSON.stringify(session, (_key, value) => { - if (value instanceof Set) { - return { - __type: 'Set', - __value: Array.from(value), - }; - } - return value; - }), - ); - } - } - - private loadSession(): T | undefined { - try { - const sessionJson = localStorage.getItem(this.storageKey); - if (sessionJson) { - const session = JSON.parse(sessionJson, (_key, value) => { - if (value?.__type === 'Set') { - return new Set(value.__value); - } - return value; - }); - return session; - } - - return undefined; - } catch (error) { - localStorage.removeItem(this.storageKey); - return undefined; - } - } -} diff --git a/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts b/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts index e59c11421c..e02b600828 100644 --- a/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts +++ b/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts @@ -23,7 +23,7 @@ type Options = { /** The connector used for acting on the auth session */ connector: AuthConnector; /** Used to get the scope of the session */ - sessionScopes: (session: T) => Set; + sessionScopes?: (session: T) => Set; /** The default scopes that should always be present in a session, defaults to none. */ defaultScopes?: Set; }; diff --git a/packages/core-api/src/lib/AuthSessionManager/common.ts b/packages/core-api/src/lib/AuthSessionManager/common.ts index 83b81bc81a..ff2897535d 100644 --- a/packages/core-api/src/lib/AuthSessionManager/common.ts +++ b/packages/core-api/src/lib/AuthSessionManager/common.ts @@ -29,7 +29,7 @@ export function hasScopes( } type ScopeHelperOptions = { - sessionScopes: SessionScopesFunc; + sessionScopes: SessionScopesFunc | undefined; defaultScopes?: Set; }; @@ -46,13 +46,16 @@ export class SessionScopeHelper { if (!scopes) { return true; } + if (this.options.sessionScopes === undefined) { + return true; + } const sessionScopes = this.options.sessionScopes(session); return hasScopes(sessionScopes, scopes); } getExtendedScope(session: T | undefined, scopes?: Set) { const newScope = new Set(this.options.defaultScopes); - if (session) { + if (session && this.options.sessionScopes !== undefined) { const sessionScopes = this.options.sessionScopes(session); for (const scope of sessionScopes) { newScope.add(scope); diff --git a/packages/core-api/src/lib/AuthSessionManager/index.ts b/packages/core-api/src/lib/AuthSessionManager/index.ts index a452ac0055..5f4dde8662 100644 --- a/packages/core-api/src/lib/AuthSessionManager/index.ts +++ b/packages/core-api/src/lib/AuthSessionManager/index.ts @@ -16,7 +16,5 @@ export { RefreshingAuthSessionManager } from './RefreshingAuthSessionManager'; export { StaticAuthSessionManager } from './StaticAuthSessionManager'; -export { SamlAuthSessionManager } from './SamlAuthSessionManager'; export { AuthSessionStore } from './AuthSessionStore'; -export { SamlAuthSessionStore } from './SamlAuthSessionStore'; export * from './types'; From eccef941eabfabcd26d10108cc9b983c495d519c Mon Sep 17 00:00:00 2001 From: "J Shamsul Bahri (jibone))" Date: Mon, 21 Sep 2020 18:51:24 +0800 Subject: [PATCH 13/94] remove type that is not used --- packages/core-api/src/lib/AuthSessionManager/types.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/core-api/src/lib/AuthSessionManager/types.ts b/packages/core-api/src/lib/AuthSessionManager/types.ts index 1be439a840..804c7121e1 100644 --- a/packages/core-api/src/lib/AuthSessionManager/types.ts +++ b/packages/core-api/src/lib/AuthSessionManager/types.ts @@ -36,13 +36,6 @@ export type SessionManager = { sessionState$(): Observable; }; -// FIXME do we need this? -export type SamlAuthSessionManager = { - getSession(options: GetSessionOptions): Promise; - removeSession(): Promise; - sessionState$(): Observable; -}; - /** * A function called to determine the scopes of a session. */ From a6178e4aff9d0f2a1fc6852f24180e8259218113 Mon Sep 17 00:00:00 2001 From: Samira Mokaram Date: Mon, 21 Sep 2020 14:14:19 +0200 Subject: [PATCH 14/94] fix banner position and color --- .../DismissableBanner.stories.tsx | 12 ++++++ .../DismissableBanner/DismissableBanner.tsx | 37 ++++++++++++++++--- packages/theme/src/themes.ts | 4 ++ packages/theme/src/types.ts | 2 + 4 files changed, 49 insertions(+), 6 deletions(-) diff --git a/packages/core/src/components/DismissableBanner/DismissableBanner.stories.tsx b/packages/core/src/components/DismissableBanner/DismissableBanner.stories.tsx index 62760f72fb..148e7b6803 100644 --- a/packages/core/src/components/DismissableBanner/DismissableBanner.stories.tsx +++ b/packages/core/src/components/DismissableBanner/DismissableBanner.stories.tsx @@ -100,3 +100,15 @@ export const WithLink = () => ( ); +export const Fixed = () => ( +
+ + + +
+); diff --git a/packages/core/src/components/DismissableBanner/DismissableBanner.tsx b/packages/core/src/components/DismissableBanner/DismissableBanner.tsx index 747ed06b37..2496d26d71 100644 --- a/packages/core/src/components/DismissableBanner/DismissableBanner.tsx +++ b/packages/core/src/components/DismissableBanner/DismissableBanner.tsx @@ -27,12 +27,19 @@ import Close from '@material-ui/icons/Close'; const useStyles = makeStyles((theme: BackstageTheme) => ({ root: { - position: 'relative', + // position: 'relative', padding: theme.spacing(0), - marginBottom: theme.spacing(6), - marginTop: -theme.spacing(3), + marginBottom: theme.spacing(0), + marginTop: theme.spacing(0), display: 'flex', flexFlow: 'row nowrap', + // zIndex: 'unset' + }, + // showing on top + topPosition: { + position: 'relative', + marginBottom: theme.spacing(6), + marginTop: -theme.spacing(3), zIndex: 'unset', }, icon: { @@ -45,6 +52,10 @@ const useStyles = makeStyles((theme: BackstageTheme) => ({ message: { display: 'flex', alignItems: 'center', + color: theme.palette.banner.textColor, + '& a': { + color: theme.palette.banner.linkColor, + }, }, info: { backgroundColor: theme.palette.banner.info, @@ -58,9 +69,15 @@ type Props = { variant: 'info' | 'error'; message: ReactNode; id: string; + fixed?: boolean; }; -export const DismissableBanner: FC = ({ variant, message, id }) => { +export const DismissableBanner: FC = ({ + variant, + message, + id, + fixed = false, +}) => { const classes = useStyles(); const storageApi = useApi(storageApiRef); const notificationsStore = storageApi.forBucket('notifications'); @@ -88,9 +105,17 @@ export const DismissableBanner: FC = ({ variant, message, id }) => { return ( Date: Mon, 21 Sep 2020 14:39:16 +0200 Subject: [PATCH 15/94] remove comments --- .../core/src/components/DismissableBanner/DismissableBanner.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/core/src/components/DismissableBanner/DismissableBanner.tsx b/packages/core/src/components/DismissableBanner/DismissableBanner.tsx index 2496d26d71..537ad285ea 100644 --- a/packages/core/src/components/DismissableBanner/DismissableBanner.tsx +++ b/packages/core/src/components/DismissableBanner/DismissableBanner.tsx @@ -27,13 +27,11 @@ import Close from '@material-ui/icons/Close'; const useStyles = makeStyles((theme: BackstageTheme) => ({ root: { - // position: 'relative', padding: theme.spacing(0), marginBottom: theme.spacing(0), marginTop: theme.spacing(0), display: 'flex', flexFlow: 'row nowrap', - // zIndex: 'unset' }, // showing on top topPosition: { From 113ac17c2e5410844892a09e8805b8dd147e0cf2 Mon Sep 17 00:00:00 2001 From: "J Shamsul Bahri (jibone))" Date: Tue, 22 Sep 2020 12:23:09 +0800 Subject: [PATCH 16/94] fix tsc error --- packages/core-api/src/lib/AuthConnector/SamlAuthConnector.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core-api/src/lib/AuthConnector/SamlAuthConnector.ts b/packages/core-api/src/lib/AuthConnector/SamlAuthConnector.ts index 30de81536d..e8ab412756 100644 --- a/packages/core-api/src/lib/AuthConnector/SamlAuthConnector.ts +++ b/packages/core-api/src/lib/AuthConnector/SamlAuthConnector.ts @@ -62,6 +62,8 @@ export class SamlAuthConnector { }; } + async refreshSession(): Promise {} + async removeSession(): Promise { const res = await fetch(await this.buildUrl('/logout'), { method: 'POST', From 40a6a4ee23c0bd0e864b49027fe7621fffb71e01 Mon Sep 17 00:00:00 2001 From: "J Shamsul Bahri (jibone))" Date: Tue, 22 Sep 2020 14:47:16 +0800 Subject: [PATCH 17/94] add sidebar option --- .../src/apis/implementations/auth/saml/SamlAuth.ts | 5 +++++ .../core/src/layout/Sidebar/DefaultProviderSettings.tsx | 8 ++++++++ .../src/layout/Sidebar/Settings/OIDCProviderSettings.tsx | 2 +- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts b/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts index 6e0bd0af10..2c8c98ecf6 100644 --- a/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts @@ -87,6 +87,11 @@ class SamlAuth implements SamlApi { return session?.backstageIdentity; } + async getIdToken(options: AuthRequestOptions = {}) { + const session = await this.sessionManager.getSession(options); + return session?.backstageIdentity ?? ''; + } + async getProfile(options: AuthRequestOptions = {}) { const session = await this.sessionManager.getSession(options); return session?.profile; diff --git a/packages/core/src/layout/Sidebar/DefaultProviderSettings.tsx b/packages/core/src/layout/Sidebar/DefaultProviderSettings.tsx index 293cefb19a..246ce722a1 100644 --- a/packages/core/src/layout/Sidebar/DefaultProviderSettings.tsx +++ b/packages/core/src/layout/Sidebar/DefaultProviderSettings.tsx @@ -21,6 +21,7 @@ import { oauth2ApiRef, oktaAuthApiRef, microsoftAuthApiRef, + samlAuthApiRef, useApi, } from '@backstage/core-api'; import Star from '@material-ui/icons/Star'; @@ -69,6 +70,13 @@ export const DefaultProviderSettings = () => { icon={Star} /> )} + {providers.includes('saml') && ( + + )} {providers.includes('oauth2') && ( ; + apiRef: ApiRef | ApiRef; }; export const OIDCProviderSettings: FC = ({ From 60fdb3eda1ff6604fb4510a0476823faf41051bf Mon Sep 17 00:00:00 2001 From: "J Shamsul Bahri (jibone))" Date: Tue, 22 Sep 2020 16:02:00 +0800 Subject: [PATCH 18/94] remove comment --- plugins/auth-backend/src/providers/saml/provider.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/auth-backend/src/providers/saml/provider.ts b/plugins/auth-backend/src/providers/saml/provider.ts index 2163d50f37..82e0acbdcc 100644 --- a/plugins/auth-backend/src/providers/saml/provider.ts +++ b/plugins/auth-backend/src/providers/saml/provider.ts @@ -111,7 +111,6 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers { return reqEnv; } - // FIXME : do we want to always to return 'development' ? return 'development'; } } From e52a6daae83e1c7798d27891dcbd03d4d306b031 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 19 Sep 2020 13:17:08 +0200 Subject: [PATCH 19/94] core-api/routing: initial RouteRefRegistry implementation --- .../src/routing/RouteRefRegistry.test.ts | 67 ++++++++++++ .../core-api/src/routing/RouteRefRegistry.ts | 103 ++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 packages/core-api/src/routing/RouteRefRegistry.test.ts create mode 100644 packages/core-api/src/routing/RouteRefRegistry.ts diff --git a/packages/core-api/src/routing/RouteRefRegistry.test.ts b/packages/core-api/src/routing/RouteRefRegistry.test.ts new file mode 100644 index 0000000000..addf7dec80 --- /dev/null +++ b/packages/core-api/src/routing/RouteRefRegistry.test.ts @@ -0,0 +1,67 @@ +/* + * 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 { RouteRefRegistry } from './RouteRefRegistry'; + +const ref1 = {}; +const ref11 = {}; +const ref12 = {}; +const ref121 = {}; +const ref2 = {}; + +describe('RouteRefRegistry', () => { + it('should be constructed with a root route', () => { + const registry = new RouteRefRegistry(); + expect(registry.resolveRoute([], [])).toBe(''); + }); + + it('should register and resolve some routes', () => { + const registry = new RouteRefRegistry(); + expect(registry.registerRoute([ref1], '1')).toBe(true); + expect(registry.registerRoute([ref1, ref11], '11')).toBe(true); + expect(registry.registerRoute([ref1, ref12], '12')).toBe(true); + expect(registry.registerRoute([ref1, ref12, ref121], '121')).toBe(true); + expect(registry.registerRoute([ref1, ref12, ref121], 'duplicate')).toBe( + false, + ); + expect(registry.registerRoute([ref1, ref12], 'duplicate')).toBe(false); + expect(registry.registerRoute([ref2], '2')).toBe(true); + expect(registry.registerRoute([ref2], 'duplicate')).toBe(false); + + expect(registry.resolveRoute([], [ref1])).toBe('1'); + expect(registry.resolveRoute([], [ref11])).toBe(undefined); + expect(registry.resolveRoute([], [ref1, ref11])).toBe('11'); + expect(registry.resolveRoute([ref1], [ref11])).toBe('11'); + expect(registry.resolveRoute([ref1], [ref2])).toBe('2'); + expect(registry.resolveRoute([ref1, ref12, ref121], [])).toBe('121'); + expect(registry.resolveRoute([ref1, ref12, ref121], [ref121])).toBe('121'); + expect(registry.resolveRoute([ref1, ref12, ref121], [ref12, ref121])).toBe( + '121', + ); + expect(registry.resolveRoute([ref1, ref12, ref121], [ref12])).toBe('12'); + expect(registry.resolveRoute([ref1, ref12, ref121], [ref1])).toBe('1'); + }); + + it('should throw when registering routes incorrectly', () => { + const registry = new RouteRefRegistry(); + expect(() => { + registry.registerRoute([ref1, ref11], '11'); + }).toThrow('Could not find parent for new routing node'); + expect(() => { + registry.registerRoute([], '11'); + }).toThrow('Must provide at least 1 route to add routing node'); + }); +}); diff --git a/packages/core-api/src/routing/RouteRefRegistry.ts b/packages/core-api/src/routing/RouteRefRegistry.ts new file mode 100644 index 0000000000..3b6cb9e768 --- /dev/null +++ b/packages/core-api/src/routing/RouteRefRegistry.ts @@ -0,0 +1,103 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type RouteRefResolver = { + resolveRoute(from: unknown[], to: unknown[]): string; +}; + +class Node { + readonly children = new Map(); + + constructor(readonly path: string, readonly parent: Node | undefined) {} + + /** + * Look up a node in the tree given a path. + */ + findNode(routes: unknown[]): Node | undefined { + // eslint-disable-next-line consistent-this + let node: Node | undefined = this; + + for (let i = 0; i < routes.length; i++) { + node = node?.children.get(routes[i]); + } + + return node; + } + + /** + * Assigns a path to a leaf node in the routing tree. All ancestor + * nodes of the new leaf node must already exist, or an error will be thrown. + * + * Returns true if the node was added, or false if the node already existed. + */ + addNode(routes: unknown[], path: string): boolean { + if (routes.length === 0) { + throw new Error('Must provide at least 1 route to add routing node'); + } + + const parentNode = this.findNode(routes.slice(0, -1)); + if (!parentNode) { + throw new Error('Could not find parent for new routing node'); + } + + const lastRoute = routes[routes.length - 1]; + if (parentNode.children.has(lastRoute)) { + return false; + } + + parentNode.children.set(lastRoute, new Node(path, parentNode)); + return true; + } +} + +/** + * A registry for resolving route refs into concrete string routes. + */ +export class RouteRefRegistry { + private readonly root = new Node('', undefined); + + /** + * Register a new leaf path for a sequence of routes. All ancestor + * routes must already exist. + */ + registerRoute(routes: unknown[], path: string): boolean { + return this.root.addNode(routes, path); + } + + /** + * Resolve a route from a point in the routing tree. + * + * The route referenced by `from` must exist, and is the starting + * point for the search, walking up the tree until a subtree that + * matches the routes reference in `to` are found. + * + * If `from` is empty, the search starts and ends at the root node. + * If `to` is empty, the route referenced by `from` will always be returned. + */ + resolveRoute(from: unknown[], to: unknown[]): string | undefined { + let fromNode = this.root.findNode(from); + + while (fromNode) { + const resolvedNode = fromNode.findNode(to); + if (resolvedNode) { + return resolvedNode.path; + } + fromNode = fromNode.parent; + } + + return undefined; + } +} From 558ae5593ecf1f2d823b37df1da25342cd40062a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 19 Sep 2020 14:56:27 +0200 Subject: [PATCH 20/94] core-api/routing: implement parameterized and absolute path resolution --- .../src/routing/RouteRefRegistry.test.ts | 34 +++++----- .../core-api/src/routing/RouteRefRegistry.ts | 67 ++++++++++++++++--- 2 files changed, 74 insertions(+), 27 deletions(-) diff --git a/packages/core-api/src/routing/RouteRefRegistry.test.ts b/packages/core-api/src/routing/RouteRefRegistry.test.ts index addf7dec80..7547de1eb4 100644 --- a/packages/core-api/src/routing/RouteRefRegistry.test.ts +++ b/packages/core-api/src/routing/RouteRefRegistry.test.ts @@ -14,13 +14,13 @@ * limitations under the License. */ -import { RouteRefRegistry } from './RouteRefRegistry'; +import { RouteRefRegistry, resolveRoute } from './RouteRefRegistry'; -const ref1 = {}; -const ref11 = {}; -const ref12 = {}; -const ref121 = {}; -const ref2 = {}; +const ref1 = { [resolveRoute]: (path: string) => path }; +const ref11 = { [resolveRoute]: (path: string) => path }; +const ref12 = { [resolveRoute]: (path: string) => path }; +const ref121 = { [resolveRoute]: (path: string) => path }; +const ref2 = { [resolveRoute]: (path: string) => path }; describe('RouteRefRegistry', () => { it('should be constructed with a root route', () => { @@ -41,18 +41,20 @@ describe('RouteRefRegistry', () => { expect(registry.registerRoute([ref2], '2')).toBe(true); expect(registry.registerRoute([ref2], 'duplicate')).toBe(false); - expect(registry.resolveRoute([], [ref1])).toBe('1'); + expect(registry.resolveRoute([], [ref1])).toBe('/1'); expect(registry.resolveRoute([], [ref11])).toBe(undefined); - expect(registry.resolveRoute([], [ref1, ref11])).toBe('11'); - expect(registry.resolveRoute([ref1], [ref11])).toBe('11'); - expect(registry.resolveRoute([ref1], [ref2])).toBe('2'); - expect(registry.resolveRoute([ref1, ref12, ref121], [])).toBe('121'); - expect(registry.resolveRoute([ref1, ref12, ref121], [ref121])).toBe('121'); - expect(registry.resolveRoute([ref1, ref12, ref121], [ref12, ref121])).toBe( - '121', + expect(registry.resolveRoute([], [ref1, ref11])).toBe('/1/11'); + expect(registry.resolveRoute([ref1], [ref11])).toBe('/1/11'); + expect(registry.resolveRoute([ref1], [ref2])).toBe('/2'); + expect(registry.resolveRoute([ref1, ref12, ref121], [])).toBe('/1/12/121'); + expect(registry.resolveRoute([ref1, ref12, ref121], [ref121])).toBe( + '/1/12/121', ); - expect(registry.resolveRoute([ref1, ref12, ref121], [ref12])).toBe('12'); - expect(registry.resolveRoute([ref1, ref12, ref121], [ref1])).toBe('1'); + expect(registry.resolveRoute([ref1, ref12, ref121], [ref12, ref121])).toBe( + '/1/12/121', + ); + expect(registry.resolveRoute([ref1, ref12, ref121], [ref12])).toBe('/1/12'); + expect(registry.resolveRoute([ref1, ref12, ref121], [ref1])).toBe('/1'); }); it('should throw when registering routes incorrectly', () => { diff --git a/packages/core-api/src/routing/RouteRefRegistry.ts b/packages/core-api/src/routing/RouteRefRegistry.ts index 3b6cb9e768..7d6260f78a 100644 --- a/packages/core-api/src/routing/RouteRefRegistry.ts +++ b/packages/core-api/src/routing/RouteRefRegistry.ts @@ -14,21 +14,30 @@ * limitations under the License. */ +export const resolveRoute = Symbol('resolve-route'); + +type ConcreteRoute = { + [resolveRoute](path: string): string; +}; + +const rootRoute: ConcreteRoute = { + [resolveRoute]: () => '', +}; + export type RouteRefResolver = { - resolveRoute(from: unknown[], to: unknown[]): string; + resolveRoute(from: ConcreteRoute[], to: ConcreteRoute[]): string; }; class Node { - readonly children = new Map(); + readonly children = new Map(); constructor(readonly path: string, readonly parent: Node | undefined) {} /** * Look up a node in the tree given a path. */ - findNode(routes: unknown[]): Node | undefined { - // eslint-disable-next-line consistent-this - let node: Node | undefined = this; + findNode(routes: ConcreteRoute[]): Node | undefined { + let node = this as Node | undefined; for (let i = 0; i < routes.length; i++) { node = node?.children.get(routes[i]); @@ -43,7 +52,7 @@ class Node { * * Returns true if the node was added, or false if the node already existed. */ - addNode(routes: unknown[], path: string): boolean { + addNode(routes: ConcreteRoute[], path: string): boolean { if (routes.length === 0) { throw new Error('Must provide at least 1 route to add routing node'); } @@ -61,6 +70,35 @@ class Node { parentNode.children.set(lastRoute, new Node(path, parentNode)); return true; } + + /** + * Resolve an absolute URL that represents this node in the routing tree, using + * using the supplied concrete routes and ancestors of this node. + * + * The length of the provided routes array must match the depth of + * the routing tree that this node is at, or an error will be thrown. + */ + resolve(routes: ConcreteRoute[]) { + const parts = Array(routes.length); + + let node = this as Node | undefined; + for (let i = routes.length - 1; i >= 0; i--) { + if (!node) { + throw new Error('Route resolve missing required parent'); + } + + const route = routes[i]; + parts[i] = route[resolveRoute](node.path); + + node = node.parent; + } + + if (node) { + throw new Error('Route resolve did not reach root'); + } + + return parts.join('/'); + } } /** @@ -73,12 +111,12 @@ export class RouteRefRegistry { * Register a new leaf path for a sequence of routes. All ancestor * routes must already exist. */ - registerRoute(routes: unknown[], path: string): boolean { + registerRoute(routes: ConcreteRoute[], path: string): boolean { return this.root.addNode(routes, path); } /** - * Resolve a route from a point in the routing tree. + * Resolve an absolute path from a point in the routing tree. * * The route referenced by `from` must exist, and is the starting * point for the search, walking up the tree until a subtree that @@ -87,14 +125,21 @@ export class RouteRefRegistry { * If `from` is empty, the search starts and ends at the root node. * If `to` is empty, the route referenced by `from` will always be returned. */ - resolveRoute(from: unknown[], to: unknown[]): string | undefined { - let fromNode = this.root.findNode(from); + resolveRoute(from: ConcreteRoute[], to: ConcreteRoute[]): string | undefined { + // Keep track of the `from` routes and pop the last ones as we traverse up + // the routing tree. The list of concrete routes that we're passing to + // `node.resolve()` should only include the ones in the resolve path. + const concreteStack = from.slice(); + let fromNode = this.root.findNode(from); while (fromNode) { const resolvedNode = fromNode.findNode(to); if (resolvedNode) { - return resolvedNode.path; + return resolvedNode.resolve([rootRoute].concat(concreteStack, to)); } + + // Search at this level of the tree failed, move up to parent + concreteStack.pop(); fromNode = fromNode.parent; } From 988ebe7139d332b648a5a7d77d9b71be07373b26 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 19 Sep 2020 15:16:24 +0200 Subject: [PATCH 21/94] core-api/routing: make RouteRefs immutable + move out some types --- packages/core-api/src/routing/RouteRef.ts | 21 +++++++------------ .../src/routing/RouteRefRegistry.test.ts | 3 ++- .../core-api/src/routing/RouteRefRegistry.ts | 6 +----- packages/core-api/src/routing/index.ts | 3 +-- packages/core-api/src/routing/types.ts | 13 ++++++------ 5 files changed, 19 insertions(+), 27 deletions(-) diff --git a/packages/core-api/src/routing/RouteRef.ts b/packages/core-api/src/routing/RouteRef.ts index 9f9980ed0c..8a03680791 100644 --- a/packages/core-api/src/routing/RouteRef.ts +++ b/packages/core-api/src/routing/RouteRef.ts @@ -14,30 +14,25 @@ * limitations under the License. */ -import type { RouteRefConfig, RouteRefOverrideConfig } from './types'; - -export class MutableRouteRef { - private effectiveConfig: RouteRefConfig = this.config; +import type { RouteRefConfig } from './types'; +export class AbsoluteRouteRef { constructor(private readonly config: RouteRefConfig) {} - override(overrideConfig: RouteRefOverrideConfig) { - this.effectiveConfig = { ...this.config, ...overrideConfig }; - } - get icon() { - return this.effectiveConfig.icon; + return this.config.icon; } + // TODO(Rugvip): Remove this, routes are looked up via the registry instead get path() { - return this.effectiveConfig.path; + return this.config.path; } get title() { - return this.effectiveConfig.title; + return this.config.title; } } -export function createRouteRef(config: RouteRefConfig): MutableRouteRef { - return new MutableRouteRef(config); +export function createRouteRef(config: RouteRefConfig): AbsoluteRouteRef { + return new AbsoluteRouteRef(config); } diff --git a/packages/core-api/src/routing/RouteRefRegistry.test.ts b/packages/core-api/src/routing/RouteRefRegistry.test.ts index 7547de1eb4..76272cb9f7 100644 --- a/packages/core-api/src/routing/RouteRefRegistry.test.ts +++ b/packages/core-api/src/routing/RouteRefRegistry.test.ts @@ -14,7 +14,8 @@ * limitations under the License. */ -import { RouteRefRegistry, resolveRoute } from './RouteRefRegistry'; +import { RouteRefRegistry } from './RouteRefRegistry'; +import { resolveRoute } from './types'; const ref1 = { [resolveRoute]: (path: string) => path }; const ref11 = { [resolveRoute]: (path: string) => path }; diff --git a/packages/core-api/src/routing/RouteRefRegistry.ts b/packages/core-api/src/routing/RouteRefRegistry.ts index 7d6260f78a..3b9bf4a7a9 100644 --- a/packages/core-api/src/routing/RouteRefRegistry.ts +++ b/packages/core-api/src/routing/RouteRefRegistry.ts @@ -14,11 +14,7 @@ * limitations under the License. */ -export const resolveRoute = Symbol('resolve-route'); - -type ConcreteRoute = { - [resolveRoute](path: string): string; -}; +import { ConcreteRoute, resolveRoute } from './types'; const rootRoute: ConcreteRoute = { [resolveRoute]: () => '', diff --git a/packages/core-api/src/routing/index.ts b/packages/core-api/src/routing/index.ts index 67d4c82167..1b80c0838e 100644 --- a/packages/core-api/src/routing/index.ts +++ b/packages/core-api/src/routing/index.ts @@ -14,6 +14,5 @@ * limitations under the License. */ -export * from './types'; +export type { RouteRef, RouteRefConfig, ConcreteRoute } from './types'; export { createRouteRef } from './RouteRef'; -export type { MutableRouteRef } from './RouteRef'; diff --git a/packages/core-api/src/routing/types.ts b/packages/core-api/src/routing/types.ts index 515dc31de6..9e5e2bb3a7 100644 --- a/packages/core-api/src/routing/types.ts +++ b/packages/core-api/src/routing/types.ts @@ -16,7 +16,14 @@ import { IconComponent } from '../icons'; +export const resolveRoute = Symbol('resolve-route'); + +export type ConcreteRoute = { + [resolveRoute](path: string): string; +}; + export type RouteRef = { + // TODO(Rugvip): Remove path, look up via registry instead path: string; icon?: IconComponent; title: string; @@ -27,9 +34,3 @@ export type RouteRefConfig = { icon?: IconComponent; title: string; }; - -export type RouteRefOverrideConfig = { - path?: string; - icon?: IconComponent; - title?: string; -}; From 9155138168be2014c2b17ed831f00c6345b65718 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 19 Sep 2020 15:21:43 +0200 Subject: [PATCH 22/94] core-api/routing: make RouteRef implement ConcreteRoute and use in registry test --- packages/core-api/src/routing/RouteRef.ts | 8 ++++++-- .../core-api/src/routing/RouteRefRegistry.test.ts | 13 +++++++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/core-api/src/routing/RouteRef.ts b/packages/core-api/src/routing/RouteRef.ts index 8a03680791..cc36446f8b 100644 --- a/packages/core-api/src/routing/RouteRef.ts +++ b/packages/core-api/src/routing/RouteRef.ts @@ -14,9 +14,9 @@ * limitations under the License. */ -import type { RouteRefConfig } from './types'; +import { ConcreteRoute, resolveRoute, RouteRefConfig } from './types'; -export class AbsoluteRouteRef { +export class AbsoluteRouteRef implements ConcreteRoute { constructor(private readonly config: RouteRefConfig) {} get icon() { @@ -31,6 +31,10 @@ export class AbsoluteRouteRef { get title() { return this.config.title; } + + [resolveRoute](path: string) { + return path; + } } export function createRouteRef(config: RouteRefConfig): AbsoluteRouteRef { diff --git a/packages/core-api/src/routing/RouteRefRegistry.test.ts b/packages/core-api/src/routing/RouteRefRegistry.test.ts index 76272cb9f7..91bbc70117 100644 --- a/packages/core-api/src/routing/RouteRefRegistry.test.ts +++ b/packages/core-api/src/routing/RouteRefRegistry.test.ts @@ -15,13 +15,14 @@ */ import { RouteRefRegistry } from './RouteRefRegistry'; -import { resolveRoute } from './types'; +import { createRouteRef } from './RouteRef'; -const ref1 = { [resolveRoute]: (path: string) => path }; -const ref11 = { [resolveRoute]: (path: string) => path }; -const ref12 = { [resolveRoute]: (path: string) => path }; -const ref121 = { [resolveRoute]: (path: string) => path }; -const ref2 = { [resolveRoute]: (path: string) => path }; +const dummyConfig = { path: '/', icon: null, title: 'my-title' }; +const ref1 = createRouteRef(dummyConfig); +const ref11 = createRouteRef(dummyConfig); +const ref12 = createRouteRef(dummyConfig); +const ref121 = createRouteRef(dummyConfig); +const ref2 = createRouteRef(dummyConfig); describe('RouteRefRegistry', () => { it('should be constructed with a root route', () => { From 0f3e9b29d44e1add0693a79abdcdbed11051be96 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 19 Sep 2020 16:32:41 +0200 Subject: [PATCH 23/94] core-api/routing: initial subroute implementation --- packages/core-api/src/routing/RouteRef.ts | 41 ++++++++++++++++++- .../src/routing/RouteRefRegistry.test.ts | 39 +++++++++++++++++- .../core-api/src/routing/RouteRefRegistry.ts | 20 +++++---- packages/core-api/src/routing/types.ts | 7 +++- 4 files changed, 95 insertions(+), 12 deletions(-) diff --git a/packages/core-api/src/routing/RouteRef.ts b/packages/core-api/src/routing/RouteRef.ts index cc36446f8b..51bb9ee1a4 100644 --- a/packages/core-api/src/routing/RouteRef.ts +++ b/packages/core-api/src/routing/RouteRef.ts @@ -14,7 +14,36 @@ * limitations under the License. */ -import { ConcreteRoute, resolveRoute, RouteRefConfig } from './types'; +import { ConcreteRoute, ref, resolveRoute, RouteRefConfig } from './types'; +import { generatePath } from 'react-router-dom'; + +type SubRouteConfig = { + path: string; +}; + +export class SubRouteRef { + constructor( + private readonly parent: ConcreteRoute, + private readonly config: SubRouteConfig, + ) {} + + [ref]() { + return this; + } + + link(params: T): ConcreteRoute { + const selfRef = this as unknown; + + return { + [ref]: () => selfRef, + [resolveRoute]: (path: string) => { + const ownPart = generatePath(this.config.path, params); + const parentPart = this.parent[resolveRoute](path); + return parentPart + ownPart; + }, + }; + } +} export class AbsoluteRouteRef implements ConcreteRoute { constructor(private readonly config: RouteRefConfig) {} @@ -32,6 +61,16 @@ export class AbsoluteRouteRef implements ConcreteRoute { return this.config.title; } + createSubRoute( + config: SubRouteConfig, + ) { + return new SubRouteRef(this, config); + } + + [ref]() { + return this; + } + [resolveRoute](path: string) { return path; } diff --git a/packages/core-api/src/routing/RouteRefRegistry.test.ts b/packages/core-api/src/routing/RouteRefRegistry.test.ts index 91bbc70117..2849bcf32b 100644 --- a/packages/core-api/src/routing/RouteRefRegistry.test.ts +++ b/packages/core-api/src/routing/RouteRefRegistry.test.ts @@ -17,12 +17,14 @@ import { RouteRefRegistry } from './RouteRefRegistry'; import { createRouteRef } from './RouteRef'; -const dummyConfig = { path: '/', icon: null, title: 'my-title' }; +const dummyConfig = { path: '/', icon: () => null, title: 'my-title' }; const ref1 = createRouteRef(dummyConfig); const ref11 = createRouteRef(dummyConfig); const ref12 = createRouteRef(dummyConfig); const ref121 = createRouteRef(dummyConfig); const ref2 = createRouteRef(dummyConfig); +const ref2a = ref2.createSubRoute({ path: '/a' }); +const ref2b = ref2.createSubRoute<{ id: string }>({ path: '/b/:id' }); describe('RouteRefRegistry', () => { it('should be constructed with a root route', () => { @@ -30,7 +32,7 @@ describe('RouteRefRegistry', () => { expect(registry.resolveRoute([], [])).toBe(''); }); - it('should register and resolve some routes', () => { + it('should register and resolve some absolute routes', () => { const registry = new RouteRefRegistry(); expect(registry.registerRoute([ref1], '1')).toBe(true); expect(registry.registerRoute([ref1, ref11], '11')).toBe(true); @@ -59,6 +61,39 @@ describe('RouteRefRegistry', () => { expect(registry.resolveRoute([ref1, ref12, ref121], [ref1])).toBe('/1'); }); + it('should register and resolve with sub routes', () => { + const registry = new RouteRefRegistry(); + expect(registry.registerRoute([ref1], '1')).toBe(true); + expect(registry.registerRoute([ref2], '2')).toBe(true); + expect(registry.registerRoute([ref2a], '2')).toBe(true); + expect(registry.registerRoute([ref2a, ref1], '1')).toBe(true); + expect(registry.registerRoute([ref2a, ref2], '2')).toBe(true); + expect(registry.registerRoute([ref2b], '2')).toBe(true); + expect(registry.registerRoute([ref2b, ref1], '1')).toBe(true); + expect(registry.registerRoute([ref2b, ref2], '2')).toBe(true); + + expect(registry.resolveRoute([], [ref1])).toBe('/1'); + expect(registry.resolveRoute([], [ref2])).toBe('/2'); + expect(registry.resolveRoute([], [ref2a.link({}), ref1])).toBe('/2/a/1'); + expect(registry.resolveRoute([], [ref2a.link({}), ref2])).toBe('/2/a/2'); + expect(registry.resolveRoute([ref2a.link({})], [ref2])).toBe('/2/a/2'); + expect(registry.resolveRoute([ref2a.link({}), ref1], [ref2])).toBe( + '/2/a/2', + ); + expect(registry.resolveRoute([], [ref2b.link({ id: 'abc' }), ref1])).toBe( + '/2/b/abc/1', + ); + expect(registry.resolveRoute([], [ref2b.link({ id: 'xyz' }), ref2])).toBe( + '/2/b/xyz/2', + ); + expect(registry.resolveRoute([ref2b.link({ id: 'abc' })], [ref2])).toBe( + '/2/b/abc/2', + ); + expect( + registry.resolveRoute([ref2b.link({ id: 'abc' }), ref1], [ref2]), + ).toBe('/2/b/abc/2'); + }); + it('should throw when registering routes incorrectly', () => { const registry = new RouteRefRegistry(); expect(() => { diff --git a/packages/core-api/src/routing/RouteRefRegistry.ts b/packages/core-api/src/routing/RouteRefRegistry.ts index 3b9bf4a7a9..7377a90672 100644 --- a/packages/core-api/src/routing/RouteRefRegistry.ts +++ b/packages/core-api/src/routing/RouteRefRegistry.ts @@ -14,9 +14,12 @@ * limitations under the License. */ -import { ConcreteRoute, resolveRoute } from './types'; +import { ConcreteRoute, ref, resolveRoute, ReferencedRoute } from './types'; const rootRoute: ConcreteRoute = { + [ref]() { + return this; + }, [resolveRoute]: () => '', }; @@ -25,18 +28,18 @@ export type RouteRefResolver = { }; class Node { - readonly children = new Map(); + readonly children = new Map(); constructor(readonly path: string, readonly parent: Node | undefined) {} /** * Look up a node in the tree given a path. */ - findNode(routes: ConcreteRoute[]): Node | undefined { + findNode(routes: ReferencedRoute[]): Node | undefined { let node = this as Node | undefined; for (let i = 0; i < routes.length; i++) { - node = node?.children.get(routes[i]); + node = node?.children.get(routes[i][ref]()); } return node; @@ -48,7 +51,7 @@ class Node { * * Returns true if the node was added, or false if the node already existed. */ - addNode(routes: ConcreteRoute[], path: string): boolean { + addNode(routes: ReferencedRoute[], path: string): boolean { if (routes.length === 0) { throw new Error('Must provide at least 1 route to add routing node'); } @@ -59,11 +62,12 @@ class Node { } const lastRoute = routes[routes.length - 1]; - if (parentNode.children.has(lastRoute)) { + const lastRouteRef = lastRoute[ref](); + if (parentNode.children.has(lastRouteRef)) { return false; } - parentNode.children.set(lastRoute, new Node(path, parentNode)); + parentNode.children.set(lastRouteRef, new Node(path, parentNode)); return true; } @@ -107,7 +111,7 @@ export class RouteRefRegistry { * Register a new leaf path for a sequence of routes. All ancestor * routes must already exist. */ - registerRoute(routes: ConcreteRoute[], path: string): boolean { + registerRoute(routes: ReferencedRoute[], path: string): boolean { return this.root.addNode(routes, path); } diff --git a/packages/core-api/src/routing/types.ts b/packages/core-api/src/routing/types.ts index 9e5e2bb3a7..291029c3f2 100644 --- a/packages/core-api/src/routing/types.ts +++ b/packages/core-api/src/routing/types.ts @@ -17,8 +17,13 @@ import { IconComponent } from '../icons'; export const resolveRoute = Symbol('resolve-route'); +export const ref = Symbol('route-ref'); -export type ConcreteRoute = { +export type ReferencedRoute = { + [ref](): unknown; +}; + +export type ConcreteRoute = ReferencedRoute & { [resolveRoute](path: string): string; }; From 735c1277d1c327b299c5c4c90599e734e5be64c8 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 20 Sep 2020 13:21:55 +0200 Subject: [PATCH 24/94] core-api/routing: switch ref to prop and rename to routeReference --- packages/core-api/src/routing/RouteRef.ts | 19 ++++++++++++------- .../core-api/src/routing/RouteRefRegistry.ts | 13 +++++++++---- packages/core-api/src/routing/types.ts | 4 ++-- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/packages/core-api/src/routing/RouteRef.ts b/packages/core-api/src/routing/RouteRef.ts index 51bb9ee1a4..5e7a4294d9 100644 --- a/packages/core-api/src/routing/RouteRef.ts +++ b/packages/core-api/src/routing/RouteRef.ts @@ -14,28 +14,33 @@ * limitations under the License. */ -import { ConcreteRoute, ref, resolveRoute, RouteRefConfig } from './types'; +import { + ConcreteRoute, + routeReference, + ReferencedRoute, + resolveRoute, + RouteRefConfig, +} from './types'; import { generatePath } from 'react-router-dom'; type SubRouteConfig = { path: string; }; -export class SubRouteRef { +export class SubRouteRef + implements ReferencedRoute { constructor( private readonly parent: ConcreteRoute, private readonly config: SubRouteConfig, ) {} - [ref]() { + get [routeReference]() { return this; } link(params: T): ConcreteRoute { - const selfRef = this as unknown; - return { - [ref]: () => selfRef, + [routeReference]: this, [resolveRoute]: (path: string) => { const ownPart = generatePath(this.config.path, params); const parentPart = this.parent[resolveRoute](path); @@ -67,7 +72,7 @@ export class AbsoluteRouteRef implements ConcreteRoute { return new SubRouteRef(this, config); } - [ref]() { + get [routeReference]() { return this; } diff --git a/packages/core-api/src/routing/RouteRefRegistry.ts b/packages/core-api/src/routing/RouteRefRegistry.ts index 7377a90672..4bc824ac3f 100644 --- a/packages/core-api/src/routing/RouteRefRegistry.ts +++ b/packages/core-api/src/routing/RouteRefRegistry.ts @@ -14,10 +14,15 @@ * limitations under the License. */ -import { ConcreteRoute, ref, resolveRoute, ReferencedRoute } from './types'; +import { + ConcreteRoute, + routeReference, + resolveRoute, + ReferencedRoute, +} from './types'; const rootRoute: ConcreteRoute = { - [ref]() { + get [routeReference]() { return this; }, [resolveRoute]: () => '', @@ -39,7 +44,7 @@ class Node { let node = this as Node | undefined; for (let i = 0; i < routes.length; i++) { - node = node?.children.get(routes[i][ref]()); + node = node?.children.get(routes[i][routeReference]); } return node; @@ -62,7 +67,7 @@ class Node { } const lastRoute = routes[routes.length - 1]; - const lastRouteRef = lastRoute[ref](); + const lastRouteRef = lastRoute[routeReference]; if (parentNode.children.has(lastRouteRef)) { return false; } diff --git a/packages/core-api/src/routing/types.ts b/packages/core-api/src/routing/types.ts index 291029c3f2..162ac74bde 100644 --- a/packages/core-api/src/routing/types.ts +++ b/packages/core-api/src/routing/types.ts @@ -17,10 +17,10 @@ import { IconComponent } from '../icons'; export const resolveRoute = Symbol('resolve-route'); -export const ref = Symbol('route-ref'); +export const routeReference = Symbol('route-ref'); export type ReferencedRoute = { - [ref](): unknown; + [routeReference]: unknown; }; export type ConcreteRoute = ReferencedRoute & { From 240366572ca5d4c37e9da05f5b964c66a831872c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 20 Sep 2020 14:41:42 +0200 Subject: [PATCH 25/94] core-api/routing: allow duplicate registration if paths match --- packages/core-api/src/routing/RouteRefRegistry.test.ts | 1 + packages/core-api/src/routing/RouteRefRegistry.ts | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/core-api/src/routing/RouteRefRegistry.test.ts b/packages/core-api/src/routing/RouteRefRegistry.test.ts index 2849bcf32b..91d730de40 100644 --- a/packages/core-api/src/routing/RouteRefRegistry.test.ts +++ b/packages/core-api/src/routing/RouteRefRegistry.test.ts @@ -44,6 +44,7 @@ describe('RouteRefRegistry', () => { expect(registry.registerRoute([ref1, ref12], 'duplicate')).toBe(false); expect(registry.registerRoute([ref2], '2')).toBe(true); expect(registry.registerRoute([ref2], 'duplicate')).toBe(false); + expect(registry.registerRoute([ref2], '2')).toBe(true); expect(registry.resolveRoute([], [ref1])).toBe('/1'); expect(registry.resolveRoute([], [ref11])).toBe(undefined); diff --git a/packages/core-api/src/routing/RouteRefRegistry.ts b/packages/core-api/src/routing/RouteRefRegistry.ts index 4bc824ac3f..7e55cbe8f7 100644 --- a/packages/core-api/src/routing/RouteRefRegistry.ts +++ b/packages/core-api/src/routing/RouteRefRegistry.ts @@ -68,8 +68,10 @@ class Node { const lastRoute = routes[routes.length - 1]; const lastRouteRef = lastRoute[routeReference]; - if (parentNode.children.has(lastRouteRef)) { - return false; + + const existingNode = parentNode.children.get(lastRouteRef); + if (existingNode) { + return existingNode.path === path; } parentNode.children.set(lastRouteRef, new Node(path, parentNode)); From 8804c5e338b19ef27eeea37d47cf567415a62353 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 20 Sep 2020 14:49:46 +0200 Subject: [PATCH 26/94] core-api/routing: only require args in link call if routeRef has params --- packages/core-api/src/routing/RouteRef.ts | 8 ++++---- packages/core-api/src/routing/RouteRefRegistry.test.ts | 10 ++++------ 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/core-api/src/routing/RouteRef.ts b/packages/core-api/src/routing/RouteRef.ts index 5e7a4294d9..c8b0855de7 100644 --- a/packages/core-api/src/routing/RouteRef.ts +++ b/packages/core-api/src/routing/RouteRef.ts @@ -27,7 +27,7 @@ type SubRouteConfig = { path: string; }; -export class SubRouteRef +export class SubRouteRef implements ReferencedRoute { constructor( private readonly parent: ConcreteRoute, @@ -38,11 +38,11 @@ export class SubRouteRef return this; } - link(params: T): ConcreteRoute { + link(...args: Args): ConcreteRoute { return { [routeReference]: this, [resolveRoute]: (path: string) => { - const ownPart = generatePath(this.config.path, params); + const ownPart = generatePath(this.config.path, args[0] ?? {}); const parentPart = this.parent[resolveRoute](path); return parentPart + ownPart; }, @@ -66,7 +66,7 @@ export class AbsoluteRouteRef implements ConcreteRoute { return this.config.title; } - createSubRoute( + createSubRoute( config: SubRouteConfig, ) { return new SubRouteRef(this, config); diff --git a/packages/core-api/src/routing/RouteRefRegistry.test.ts b/packages/core-api/src/routing/RouteRefRegistry.test.ts index 91d730de40..fa1ef584f1 100644 --- a/packages/core-api/src/routing/RouteRefRegistry.test.ts +++ b/packages/core-api/src/routing/RouteRefRegistry.test.ts @@ -75,12 +75,10 @@ describe('RouteRefRegistry', () => { expect(registry.resolveRoute([], [ref1])).toBe('/1'); expect(registry.resolveRoute([], [ref2])).toBe('/2'); - expect(registry.resolveRoute([], [ref2a.link({}), ref1])).toBe('/2/a/1'); - expect(registry.resolveRoute([], [ref2a.link({}), ref2])).toBe('/2/a/2'); - expect(registry.resolveRoute([ref2a.link({})], [ref2])).toBe('/2/a/2'); - expect(registry.resolveRoute([ref2a.link({}), ref1], [ref2])).toBe( - '/2/a/2', - ); + expect(registry.resolveRoute([], [ref2a.link(), ref1])).toBe('/2/a/1'); + expect(registry.resolveRoute([], [ref2a.link(), ref2])).toBe('/2/a/2'); + expect(registry.resolveRoute([ref2a.link()], [ref2])).toBe('/2/a/2'); + expect(registry.resolveRoute([ref2a.link(), ref1], [ref2])).toBe('/2/a/2'); expect(registry.resolveRoute([], [ref2b.link({ id: 'abc' }), ref1])).toBe( '/2/b/abc/1', ); From d7cd977ce7ed451934ba6dd4f6e9fc634163974b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 22 Sep 2020 18:01:04 +0200 Subject: [PATCH 27/94] core-api: add back MutableRouteRef for backwards compatibility and export AbsoluteRouteRef --- packages/core-api/src/routing/RouteRef.ts | 5 +++++ packages/core-api/src/routing/index.ts | 1 + 2 files changed, 6 insertions(+) diff --git a/packages/core-api/src/routing/RouteRef.ts b/packages/core-api/src/routing/RouteRef.ts index c8b0855de7..c33335cb38 100644 --- a/packages/core-api/src/routing/RouteRef.ts +++ b/packages/core-api/src/routing/RouteRef.ts @@ -84,3 +84,8 @@ export class AbsoluteRouteRef implements ConcreteRoute { export function createRouteRef(config: RouteRefConfig): AbsoluteRouteRef { return new AbsoluteRouteRef(config); } + +// TODO(Rugvip): Added for backwards compatibility, remove once old usage is gone +// We may want to avoid exporting the AbsoluteRouteRef itself though, and consider +// a different model for how to create sub routes, just avoid this +export type MutableRouteRef = AbsoluteRouteRef; diff --git a/packages/core-api/src/routing/index.ts b/packages/core-api/src/routing/index.ts index 1b80c0838e..29de34ec42 100644 --- a/packages/core-api/src/routing/index.ts +++ b/packages/core-api/src/routing/index.ts @@ -15,4 +15,5 @@ */ export type { RouteRef, RouteRefConfig, ConcreteRoute } from './types'; +export type { MutableRouteRef, AbsoluteRouteRef } from './RouteRef'; export { createRouteRef } from './RouteRef'; From 9959558d2f41c9a470d3cbd9007530f1c3601a59 Mon Sep 17 00:00:00 2001 From: Samira Mokaram Date: Wed, 23 Sep 2020 14:06:23 +0200 Subject: [PATCH 28/94] fix dense in metadata table --- .../StructuredMetadataTable/MetadataTable.tsx | 2 +- .../StructuredMetadataTable.stories.tsx | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/core/src/components/StructuredMetadataTable/MetadataTable.tsx b/packages/core/src/components/StructuredMetadataTable/MetadataTable.tsx index 1d863db764..ad468f623c 100644 --- a/packages/core/src/components/StructuredMetadataTable/MetadataTable.tsx +++ b/packages/core/src/components/StructuredMetadataTable/MetadataTable.tsx @@ -71,7 +71,7 @@ export const MetadataTable = ({ dense?: boolean; children: React.ReactNode; }) => ( - +
{!dense && ( diff --git a/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.stories.tsx b/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.stories.tsx index bdf0f65375..9a9a4b42b6 100644 --- a/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.stories.tsx +++ b/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.stories.tsx @@ -56,3 +56,16 @@ export const Default = () => ( ); + +export const DenseTable = () => ( + + +
+ +
+
+
+); From 091ab63d19cb743fdbe56e6a873d4792f43534d9 Mon Sep 17 00:00:00 2001 From: Samira Mokaram Date: Wed, 23 Sep 2020 14:56:46 +0200 Subject: [PATCH 29/94] set dense as default value --- .../components/StructuredMetadataTable/MetadataTable.tsx | 6 ------ .../StructuredMetadataTable.stories.tsx | 6 +++--- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/core/src/components/StructuredMetadataTable/MetadataTable.tsx b/packages/core/src/components/StructuredMetadataTable/MetadataTable.tsx index ad468f623c..b34e535a00 100644 --- a/packages/core/src/components/StructuredMetadataTable/MetadataTable.tsx +++ b/packages/core/src/components/StructuredMetadataTable/MetadataTable.tsx @@ -72,12 +72,6 @@ export const MetadataTable = ({ children: React.ReactNode; }) => (
- {!dense && ( - - - - - )} {children}
); diff --git a/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.stories.tsx b/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.stories.tsx index 9a9a4b42b6..9db7385872 100644 --- a/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.stories.tsx +++ b/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.stories.tsx @@ -57,14 +57,14 @@ export const Default = () => ( ); -export const DenseTable = () => ( +export const NotDenseTable = () => (
- +
From 54e937ee90cd5c70fd2d8544f39bfd0b5528520b Mon Sep 17 00:00:00 2001 From: Samira Mokaram Date: Wed, 23 Sep 2020 14:57:55 +0200 Subject: [PATCH 30/94] change class component to functional component --- .../StructuredMetadataTable.tsx | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.tsx b/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.tsx index f966bd570f..52ad9a320a 100644 --- a/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.tsx +++ b/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { Component, Fragment, ReactElement } from 'react'; +import React, { Fragment, ReactElement } from 'react'; import { withStyles, createStyles, WithStyles, Theme } from '@material-ui/core'; import startCase from 'lodash/startCase'; @@ -142,17 +142,17 @@ const TableItem = ({ ); }; -interface ComponentProps { +type Props = { metadata: { [key: string]: any }; dense?: boolean; options?: any; -} +}; -export class StructuredMetadataTable extends Component { - render() { - const { metadata, dense, options } = this.props; - const metadataItems = mapToItems(metadata, options || {}); - - return {metadataItems}; - } -} +export const StructuredMetadataTable = ({ + metadata, + dense = true, + options, +}: Props) => { + const metadataItems = mapToItems(metadata, options || {}); + return {metadataItems}; +}; From e0102ec73aba5552ee82d39bf14640ee41c0eb25 Mon Sep 17 00:00:00 2001 From: Samira Mokaram Date: Wed, 23 Sep 2020 17:13:22 +0200 Subject: [PATCH 31/94] apply comments --- .../DismissableBanner/DismissableBanner.tsx | 14 +++++--------- packages/theme/src/types.ts | 4 ++-- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/core/src/components/DismissableBanner/DismissableBanner.tsx b/packages/core/src/components/DismissableBanner/DismissableBanner.tsx index 537ad285ea..b33a1c4704 100644 --- a/packages/core/src/components/DismissableBanner/DismissableBanner.tsx +++ b/packages/core/src/components/DismissableBanner/DismissableBanner.tsx @@ -50,9 +50,9 @@ const useStyles = makeStyles((theme: BackstageTheme) => ({ message: { display: 'flex', alignItems: 'center', - color: theme.palette.banner.textColor, + color: theme.palette.banner.text, '& a': { - color: theme.palette.banner.linkColor, + color: theme.palette.banner.link, }, }, info: { @@ -70,12 +70,12 @@ type Props = { fixed?: boolean; }; -export const DismissableBanner: FC = ({ +export const DismissableBanner = ({ variant, message, id, fixed = false, -}) => { +}: Props) => { const classes = useStyles(); const storageApi = useApi(storageApiRef); const notificationsStore = storageApi.forBucket('notifications'); @@ -109,11 +109,7 @@ export const DismissableBanner: FC = ({ : { vertical: 'top', horizontal: 'center' } } open={!dismissedBanners.has(id)} - classes={ - fixed - ? { root: classes.root } - : { root: classNames(classes.topPosition, classes.root) } - } + classes={{ root: classNames(classes.root, fixed && classes.topPosition) }} > Date: Wed, 23 Sep 2020 23:25:46 +0800 Subject: [PATCH 32/94] refactor to use sessionApi --- packages/core-api/src/apis/definitions/auth.ts | 18 +++--------------- .../apis/implementations/auth/saml/SamlAuth.ts | 17 +++++++++++------ 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/packages/core-api/src/apis/definitions/auth.ts b/packages/core-api/src/apis/definitions/auth.ts index 076a7479d6..109f15b26e 100644 --- a/packages/core-api/src/apis/definitions/auth.ts +++ b/packages/core-api/src/apis/definitions/auth.ts @@ -92,20 +92,6 @@ export type OAuthApi = { ): Promise; }; -/** - * This API provides access to SAML 2 credentials. Verify user access with identity provider. - */ -export type SamlApi = { - getBackstageIdentity( - options?: AuthRequestOptions, - ): Promise; - - getProfile(options?: AuthRequestOptions): Promise; - - // Not sure if this is needed. - logout(): Promise; -}; - /** * This API provides access to OpenID Connect credentials. It lets you request ID tokens, * which can be passed to backend services to prove the user's identity. @@ -328,7 +314,9 @@ export const oauth2ApiRef = createApiRef< /** * Provides authentication for saml based identity providers */ -export const samlAuthApiRef = createApiRef({ +export const samlAuthApiRef = createApiRef< + ProfileInfoApi & BackstageIdentityApi & SessionApi +>({ id: 'core.auth.saml', description: 'Example of how to use SAML custom provider', }); diff --git a/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts b/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts index 2c8c98ecf6..433d3befe8 100644 --- a/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts @@ -23,7 +23,9 @@ import { BackstageIdentity, SessionState, AuthRequestOptions, - SamlApi, + ProfileInfoApi, + BackstageIdentityApi, + SessionApi, } from '../../../definitions/auth'; import { AuthProvider, DiscoveryApi } from '../../../definitions'; import { SamlSession } from './types'; @@ -50,7 +52,7 @@ const DEFAULT_PROVIDER = { icon: SamlIcon, }; -class SamlAuth implements SamlApi { +class SamlAuth implements ProfileInfoApi, BackstageIdentityApi, SessionApi { static create({ discoveryApi, environment = 'development', @@ -80,6 +82,13 @@ class SamlAuth implements SamlApi { constructor(private readonly sessionManager: SessionManager) {} + async signIn() { + await this.getBackstageIdentity({}); + } + async signOut() { + await this.sessionManager.removeSession(); + } + async getBackstageIdentity( options: AuthRequestOptions, ): Promise { @@ -96,10 +105,6 @@ class SamlAuth implements SamlApi { const session = await this.sessionManager.getSession(options); return session?.profile; } - - async logout() { - await this.sessionManager.removeSession(); - } } export default SamlAuth; From 32def99071323d4dd681af37cbe965349f84845b Mon Sep 17 00:00:00 2001 From: "J Shamsul Bahri (jibone))" Date: Thu, 24 Sep 2020 10:45:11 +0800 Subject: [PATCH 33/94] Remove getTokenId and userId --- .../core-api/src/apis/implementations/auth/saml/SamlAuth.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts b/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts index 433d3befe8..dbd9368842 100644 --- a/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts @@ -41,7 +41,6 @@ type CreateOptions = { }; export type SamlAuthResponse = { - userId: string; profile: ProfileInfo; backstageIdentity: BackstageIdentity; }; @@ -96,11 +95,6 @@ class SamlAuth implements ProfileInfoApi, BackstageIdentityApi, SessionApi { return session?.backstageIdentity; } - async getIdToken(options: AuthRequestOptions = {}) { - const session = await this.sessionManager.getSession(options); - return session?.backstageIdentity ?? ''; - } - async getProfile(options: AuthRequestOptions = {}) { const session = await this.sessionManager.getSession(options); return session?.profile; From e3b504798af83337c3b1c88d6f2fc26f0f3be540 Mon Sep 17 00:00:00 2001 From: "J Shamsul Bahri (jibone))" Date: Thu, 24 Sep 2020 11:28:43 +0800 Subject: [PATCH 34/94] revert identifyEnv implementation --- plugins/auth-backend/src/providers/saml/provider.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/plugins/auth-backend/src/providers/saml/provider.ts b/plugins/auth-backend/src/providers/saml/provider.ts index 82e0acbdcc..a90f3e3053 100644 --- a/plugins/auth-backend/src/providers/saml/provider.ts +++ b/plugins/auth-backend/src/providers/saml/provider.ts @@ -105,13 +105,8 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers { res.send('noop'); } - identifyEnv(req: express.Request): string | undefined { - const reqEnv = req.query.env?.toString(); - if (reqEnv) { - return reqEnv; - } - - return 'development'; + identifyEnv(): string | undefined { + return undefined; } } From 63d3a4d3dac92878918d8bc7282aaeb8ed8ae4ea Mon Sep 17 00:00:00 2001 From: Iain Billett Date: Mon, 21 Sep 2020 16:06:57 +0100 Subject: [PATCH 35/94] Add widget to show recent git workflow runs Adds a widget to show recent git workflow runs to the github actions plugin. The default setting is the last 5 runs across all branches but both branch and the number of runs are configurable. --- .../app/src/components/catalog/EntityPage.tsx | 20 +-- .../Cards/RecentWorkflowRunsCard.test.tsx | 127 ++++++++++++++++++ .../Cards/RecentWorkflowRunsCard.tsx | 88 ++++++++++++ .../src/components/Cards/index.ts | 1 + .../src/components/useWorkflowRuns.ts | 4 +- 5 files changed, 230 insertions(+), 10 deletions(-) create mode 100644 plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.test.tsx create mode 100644 plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index d96fe9841f..28fb012c12 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -14,17 +14,18 @@ * limitations under the License. */ import { - Router as GitHubActionsRouter, isPluginApplicableToEntity as isGitHubActionsAvailable, + RecentWorkflowRunsCard, + Router as GitHubActionsRouter, } from '@backstage/plugin-github-actions'; import { - Router as JenkinsRouter, isPluginApplicableToEntity as isJenkinsAvailable, LatestRunCard as JenkinsLatestRunCard, + Router as JenkinsRouter, } from '@backstage/plugin-jenkins'; import { - Router as CircleCIRouter, isPluginApplicableToEntity as isCircleCIAvailable, + Router as CircleCIRouter, } from '@backstage/plugin-circleci'; import { Router as ApiDocsRouter } from '@backstage/plugin-api-docs'; import { Router as SentryRouter } from '@backstage/plugin-sentry'; @@ -62,14 +63,15 @@ const CICDSwitcher = ({ entity }: { entity: Entity }) => { const OverviewContent = ({ entity }: { entity: Entity }) => ( - + + {isJenkinsAvailable(entity) && } + {isGitHubActionsAvailable(entity) && ( + + )} + + - {isJenkinsAvailable(entity) && ( - - - - )} ); diff --git a/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.test.tsx b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.test.tsx new file mode 100644 index 0000000000..e5cb931ec1 --- /dev/null +++ b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.test.tsx @@ -0,0 +1,127 @@ +/* + * 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 type { Props as RecentWorkflowRunsCardProps } from './RecentWorkflowRunsCard'; +import React from 'react'; +import { render } from '@testing-library/react'; +import { RecentWorkflowRunsCard } from './RecentWorkflowRunsCard'; +import { useApi } from '@backstage/core-api'; +import { useWorkflowRuns } from '../useWorkflowRuns'; +import { ThemeProvider } from '@material-ui/core'; +import { lightTheme } from '@backstage/theme'; +import { MemoryRouter } from 'react-router'; + +jest.mock('../useWorkflowRuns', () => ({ + useWorkflowRuns: jest.fn(), +})); +jest.mock('@backstage/core-api'); + +describe('', () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + annotations: { + 'github.com/project-slug': 'theorg/the-service', + }, + }, + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + + const workflowRuns = [1, 2, 3, 4, 5].map(n => ({ + id: `run-id-${n}`, + message: `Commit message for workflow ${n}`, + source: { branchName: `branch-${n}` }, + status: 'completed', + })); + const mockErrorApi = { post: jest.fn() }; + + beforeEach(() => { + (useWorkflowRuns as jest.Mock).mockReturnValue([{ runs: workflowRuns }]); + (useApi as jest.Mock).mockReturnValue(mockErrorApi); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + const renderSubject = (props: RecentWorkflowRunsCardProps = { entity }) => + render( + + + + + , + ); + + it('renders a table with a row for each workflow', async () => { + const subject = renderSubject(); + + workflowRuns.forEach(run => { + expect(subject.getByText(run.message)).toBeInTheDocument(); + }); + }); + + it('renders a workflow row correctly', async () => { + const subject = renderSubject(); + const [run] = workflowRuns; + expect(subject.getByText(run.message).closest('a')).toHaveAttribute( + 'href', + `/ci-cd/${run.id}`, + ); + expect(subject.getByText(run.source.branchName)).toBeInTheDocument(); + }); + + it('requests only the required number of workflow runs', async () => { + const limit = 3; + renderSubject({ entity, limit }); + expect(useWorkflowRuns).toHaveBeenCalledWith( + expect.objectContaining({ initialPageSize: limit }), + ); + }); + + it('uses the github repo and owner from the entity annotation', async () => { + renderSubject(); + expect(useWorkflowRuns).toHaveBeenCalledWith( + expect.objectContaining({ owner: 'theorg', repo: 'the-service' }), + ); + }); + + it('filters workflows by branch if one is specified', async () => { + const branch = 'master'; + renderSubject({ entity, branch }); + expect(useWorkflowRuns).toHaveBeenCalledWith( + expect.objectContaining({ branch }), + ); + }); + + describe('where there is an error fetching workflows', () => { + const error = 'error getting workflows'; + beforeEach(() => { + (useWorkflowRuns as jest.Mock).mockReturnValue([{ runs: [], error }]); + }); + + it('sends the error to the errorApi', async () => { + renderSubject(); + expect(mockErrorApi.post).toHaveBeenCalledWith(error); + }); + }); +}); diff --git a/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx new file mode 100644 index 0000000000..014aed1633 --- /dev/null +++ b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx @@ -0,0 +1,88 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Entity } from '@backstage/catalog-model'; +import { errorApiRef, useApi } from '@backstage/core-api'; +import { GITHUB_ACTIONS_ANNOTATION } from '../useProjectName'; +import { useWorkflowRuns } from '../useWorkflowRuns'; +import React, { useEffect } from 'react'; +import { Table } from '@backstage/core'; +import { WorkflowRunStatus } from '../WorkflowRunStatus'; +import { Card, Link, TableContainer } from '@material-ui/core'; +import { generatePath, Link as RouterLink } from 'react-router-dom'; + +const firstLine = (message: string): string => message.split('\n')[0]; + +export type Props = { + entity: Entity; + branch?: string; + dense?: boolean; + limit?: number; +}; + +export const RecentWorkflowRunsCard = ({ + entity, + branch, + dense = false, + limit = 5, +}: Props) => { + const errorApi = useApi(errorApiRef); + const [owner, repo] = ( + entity?.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION] ?? '/' + ).split('/'); + const [{ runs = [], loading, error }] = useWorkflowRuns({ + owner, + repo, + branch, + initialPageSize: limit, + }); + useEffect(() => { + if (error) { + errorApi.post(error); + } + }, [error, errorApi]); + + return ( + + ( + + {firstLine(data.message)} + + ), + }, + { title: 'Branch', field: 'source.branchName' }, + { title: 'Status', field: 'status', render: WorkflowRunStatus }, + ]} + data={runs} + /> + + ); +}; diff --git a/plugins/github-actions/src/components/Cards/index.ts b/plugins/github-actions/src/components/Cards/index.ts index 8c987ea1d5..ab918d3b77 100644 --- a/plugins/github-actions/src/components/Cards/index.ts +++ b/plugins/github-actions/src/components/Cards/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export { LatestWorkflowRunCard, LatestWorkflowsForBranchCard } from './Cards'; +export { RecentWorkflowRunsCard } from './RecentWorkflowRunsCard'; diff --git a/plugins/github-actions/src/components/useWorkflowRuns.ts b/plugins/github-actions/src/components/useWorkflowRuns.ts index f935d57810..25ede3f997 100644 --- a/plugins/github-actions/src/components/useWorkflowRuns.ts +++ b/plugins/github-actions/src/components/useWorkflowRuns.ts @@ -24,10 +24,12 @@ export function useWorkflowRuns({ owner, repo, branch, + initialPageSize = 5, }: { owner: string; repo: string; branch?: string; + initialPageSize?: number; }) { const api = useApi(githubActionsApiRef); const auth = useApi(githubAuthApiRef); @@ -36,7 +38,7 @@ export function useWorkflowRuns({ const [total, setTotal] = useState(0); const [page, setPage] = useState(0); - const [pageSize, setPageSize] = useState(5); + const [pageSize, setPageSize] = useState(initialPageSize); const { loading, value: runs, retry, error } = useAsyncRetry< WorkflowRun[] From f910491c58e5c8db2f63338c0ef9df67537fb148 Mon Sep 17 00:00:00 2001 From: Iain Billett Date: Wed, 23 Sep 2020 09:43:13 +0100 Subject: [PATCH 36/94] Add widget to show recent git workflow runs * Fix Entity Page layout * Fix mock api injection --- .../app/src/components/catalog/EntityPage.tsx | 16 ++++++++++------ .../Cards/RecentWorkflowRunsCard.test.tsx | 14 +++++++++----- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 28fb012c12..931979d2d6 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -63,15 +63,19 @@ const CICDSwitcher = ({ entity }: { entity: Entity }) => { const OverviewContent = ({ entity }: { entity: Entity }) => ( - - {isJenkinsAvailable(entity) && } - {isGitHubActionsAvailable(entity) && ( - - )} - + {isJenkinsAvailable(entity) && ( + + + + )} + {isGitHubActionsAvailable(entity) && ( + + + + )} ); diff --git a/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.test.tsx b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.test.tsx index e5cb931ec1..1d6001e038 100644 --- a/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.test.tsx +++ b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.test.tsx @@ -18,7 +18,7 @@ import type { Props as RecentWorkflowRunsCardProps } from './RecentWorkflowRunsC import React from 'react'; import { render } from '@testing-library/react'; import { RecentWorkflowRunsCard } from './RecentWorkflowRunsCard'; -import { useApi } from '@backstage/core-api'; +import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core-api'; import { useWorkflowRuns } from '../useWorkflowRuns'; import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; @@ -27,7 +27,11 @@ import { MemoryRouter } from 'react-router'; jest.mock('../useWorkflowRuns', () => ({ useWorkflowRuns: jest.fn(), })); -jest.mock('@backstage/core-api'); + +const mockErrorApi: jest.Mocked = { + post: jest.fn(), + error$: jest.fn(), +}; describe('', () => { const entity = { @@ -52,11 +56,9 @@ describe('', () => { source: { branchName: `branch-${n}` }, status: 'completed', })); - const mockErrorApi = { post: jest.fn() }; beforeEach(() => { (useWorkflowRuns as jest.Mock).mockReturnValue([{ runs: workflowRuns }]); - (useApi as jest.Mock).mockReturnValue(mockErrorApi); }); afterEach(() => { @@ -67,7 +69,9 @@ describe('', () => { render( - + + + , ); From 3b2e301c52258447ec58f688a4f93a6c68777823 Mon Sep 17 00:00:00 2001 From: Iain Billett Date: Wed, 23 Sep 2020 16:38:52 +0100 Subject: [PATCH 37/94] Add widget to show recent git workflow runs * Add switcher for recent CI/CD widgets --- .../app/src/components/catalog/EntityPage.tsx | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 931979d2d6..79ca074c35 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -31,7 +31,7 @@ import { Router as ApiDocsRouter } from '@backstage/plugin-api-docs'; import { Router as SentryRouter } from '@backstage/plugin-sentry'; import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs'; import { Router as KubernetesRouter } from '@backstage/plugin-kubernetes'; -import React from 'react'; +import React, { ReactNode } from 'react'; import { AboutCard, EntityPageLayout, @@ -61,21 +61,34 @@ const CICDSwitcher = ({ entity }: { entity: Entity }) => { } }; +const RecentCICDRunsSwitcher = ({ entity }: { entity: Entity }) => { + let content: ReactNode; + switch (true) { + case isJenkinsAvailable(entity): + content = ; + break; + case isGitHubActionsAvailable(entity): + content = ; + break; + default: + content = null; + } + if (!content) { + return null; + } + return ( + + {content} + + ); +}; + const OverviewContent = ({ entity }: { entity: Entity }) => ( - {isJenkinsAvailable(entity) && ( - - - - )} - {isGitHubActionsAvailable(entity) && ( - - - - )} + ); From ab4de802318307f787c7b3876a30c5bac80bf7c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 23 Sep 2020 14:01:09 +0200 Subject: [PATCH 38/94] feat(catalog): entirely case insensitive read path of entities --- packages/catalog-model/src/entity/Entity.ts | 7 +- .../catalog-model/src/entity/constants.ts | 29 +++++++ packages/catalog-model/src/entity/index.ts | 12 ++- .../DefaultNamespaceEntityPolicy.test.ts | 6 +- .../policies/DefaultNamespaceEntityPolicy.ts | 3 +- packages/catalog-model/src/entity/ref.test.ts | 11 +-- packages/catalog-model/src/entity/ref.ts | 19 ++++- .../catalog-model/src/location/annotation.ts | 1 + .../20200923104503_case_insensitivity.js | 32 +++++++ .../catalog/CoalescedEntitiesCatalog.test.ts | 28 ++++-- .../src/catalog/CoalescedEntitiesCatalog.ts | 10 +-- .../catalog/DatabaseEntitiesCatalog.test.ts | 28 +++--- .../src/catalog/DatabaseEntitiesCatalog.ts | 45 ++-------- .../src/catalog/StaticEntitiesCatalog.ts | 22 +++-- plugins/catalog-backend/src/catalog/types.ts | 8 +- .../src/database/CommonDatabase.test.ts | 85 +++++++++++++++++++ .../src/database/CommonDatabase.ts | 62 ++++++++------ .../src/database/search.test.ts | 20 ++++- .../catalog-backend/src/database/search.ts | 23 ++++- plugins/catalog-backend/src/database/types.ts | 8 +- .../ingestion/HigherOrderOperations.test.ts | 18 ++-- .../src/ingestion/HigherOrderOperations.ts | 11 ++- .../src/service/router.test.ts | 12 ++- plugins/catalog-backend/src/service/router.ts | 4 +- 24 files changed, 351 insertions(+), 153 deletions(-) create mode 100644 packages/catalog-model/src/entity/constants.ts create mode 100644 plugins/catalog-backend/migrations/20200923104503_case_insensitivity.js diff --git a/packages/catalog-model/src/entity/Entity.ts b/packages/catalog-model/src/entity/Entity.ts index 7f323043b1..9e0aec7de6 100644 --- a/packages/catalog-model/src/entity/Entity.ts +++ b/packages/catalog-model/src/entity/Entity.ts @@ -87,7 +87,7 @@ export type EntityMeta = JsonObject & { /** * The name of the entity. * - * Must be uniqe within the catalog at any given point in time, for any + * Must be unique within the catalog at any given point in time, for any * given namespace + kind pair. */ name: string; @@ -120,8 +120,3 @@ export type EntityMeta = JsonObject & { */ tags?: string[]; }; - -/** - * The keys of EntityMeta that are auto-generated. - */ -export const entityMetaGeneratedFields = ['uid', 'etag', 'generation'] as const; diff --git a/packages/catalog-model/src/entity/constants.ts b/packages/catalog-model/src/entity/constants.ts new file mode 100644 index 0000000000..42ea2ae8ba --- /dev/null +++ b/packages/catalog-model/src/entity/constants.ts @@ -0,0 +1,29 @@ +/* + * 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. + */ + +/** + * The namespace that entities without an explicit namespace fall into. + */ +export const ENTITY_DEFAULT_NAMESPACE = 'default'; + +/** + * The keys of EntityMeta that are auto-generated. + */ +export const ENTITY_META_GENERATED_FIELDS = [ + 'uid', + 'etag', + 'generation', +] as const; diff --git a/packages/catalog-model/src/entity/index.ts b/packages/catalog-model/src/entity/index.ts index a6e90e6975..759a94ca67 100644 --- a/packages/catalog-model/src/entity/index.ts +++ b/packages/catalog-model/src/entity/index.ts @@ -14,10 +14,18 @@ * limitations under the License. */ -export { entityMetaGeneratedFields } from './Entity'; +export { + ENTITY_DEFAULT_NAMESPACE, + ENTITY_META_GENERATED_FIELDS, +} from './constants'; export type { Entity, EntityMeta } from './Entity'; export * from './policies'; -export { parseEntityName, serializeEntityRef } from './ref'; +export { + getEntityName, + parseEntityName, + parseEntityRef, + serializeEntityRef, +} from './ref'; export { entityHasChanges, generateEntityEtag, diff --git a/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.test.ts index 68f1cec649..c6bda864cb 100644 --- a/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.test.ts +++ b/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.test.ts @@ -15,6 +15,7 @@ */ import yaml from 'yaml'; +import { ENTITY_DEFAULT_NAMESPACE } from '../constants'; import { DefaultNamespaceEntityPolicy } from './DefaultNamespaceEntityPolicy'; describe('DefaultNamespaceEntityPolicy', () => { @@ -54,7 +55,10 @@ describe('DefaultNamespaceEntityPolicy', () => { await expect(result).resolves.not.toBe(withoutNamespace); await expect(result).resolves.toEqual( expect.objectContaining({ - metadata: { name: 'my-component-yay', namespace: 'default' }, + metadata: { + name: 'my-component-yay', + namespace: ENTITY_DEFAULT_NAMESPACE, + }, }), ); }); diff --git a/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.ts b/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.ts index e5aba745c7..3119164454 100644 --- a/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.ts +++ b/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.ts @@ -16,6 +16,7 @@ import lodash from 'lodash'; import { EntityPolicy } from '../../types'; +import { ENTITY_DEFAULT_NAMESPACE } from '../constants'; import { Entity } from '../Entity'; /** @@ -24,7 +25,7 @@ import { Entity } from '../Entity'; export class DefaultNamespaceEntityPolicy implements EntityPolicy { private readonly namespace: string; - constructor(namespace: string = 'default') { + constructor(namespace: string = ENTITY_DEFAULT_NAMESPACE) { this.namespace = namespace; } diff --git a/packages/catalog-model/src/entity/ref.test.ts b/packages/catalog-model/src/entity/ref.test.ts index b76f01f1f9..d9787fe675 100644 --- a/packages/catalog-model/src/entity/ref.test.ts +++ b/packages/catalog-model/src/entity/ref.test.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { ENTITY_DEFAULT_NAMESPACE } from './constants'; import { parseEntityName, parseEntityRef, serializeEntityRef } from './ref'; describe('ref', () => { @@ -27,7 +28,7 @@ describe('ref', () => { expect(() => parseEntityName('b/c')).toThrow(/kind/); expect(parseEntityName('a:c')).toEqual({ kind: 'a', - namespace: 'default', + namespace: ENTITY_DEFAULT_NAMESPACE, name: 'c', }); expect(() => parseEntityName('c')).toThrow(/kind/); @@ -116,7 +117,7 @@ describe('ref', () => { ).toEqual({ kind: 'a', namespace: 'y', name: 'c' }); expect(parseEntityName('a:c', { defaultKind: 'x' })).toEqual({ kind: 'a', - namespace: 'default', + namespace: ENTITY_DEFAULT_NAMESPACE, name: 'c', }); expect( @@ -124,7 +125,7 @@ describe('ref', () => { ).toEqual({ kind: 'x', namespace: 'y', name: 'c' }); expect(parseEntityName('c', { defaultKind: 'x' })).toEqual({ kind: 'x', - namespace: 'default', + namespace: ENTITY_DEFAULT_NAMESPACE, name: 'c', }); }); @@ -150,7 +151,7 @@ describe('ref', () => { ).toEqual({ kind: 'a', namespace: 'y', name: 'c' }); expect( parseEntityName({ kind: 'a', name: 'c' }, { defaultKind: 'x' }), - ).toEqual({ kind: 'a', namespace: 'default', name: 'c' }); + ).toEqual({ kind: 'a', namespace: ENTITY_DEFAULT_NAMESPACE, name: 'c' }); expect( parseEntityName( { name: 'c' }, @@ -159,7 +160,7 @@ describe('ref', () => { ).toEqual({ kind: 'x', namespace: 'y', name: 'c' }); expect(parseEntityName({ name: 'c' }, { defaultKind: 'x' })).toEqual({ kind: 'x', - namespace: 'default', + namespace: ENTITY_DEFAULT_NAMESPACE, name: 'c', }); // empty strings are errors, not defaults diff --git a/packages/catalog-model/src/entity/ref.ts b/packages/catalog-model/src/entity/ref.ts index b9320a5ac7..c4c86b176c 100644 --- a/packages/catalog-model/src/entity/ref.ts +++ b/packages/catalog-model/src/entity/ref.ts @@ -15,6 +15,23 @@ */ import { EntityName, EntityRef } from '../types'; +import { ENTITY_DEFAULT_NAMESPACE } from './constants'; +import { Entity } from './Entity'; + +/** + * Extracts the kind, namespace and name that form the name triplet of the + * given entity. + * + * @param entity An entity + * @returns The complete entity name + */ +export function getEntityName(entity: Entity): EntityName { + return { + kind: entity.kind, + namespace: entity.metadata.namespace || ENTITY_DEFAULT_NAMESPACE, + name: entity.metadata.name, + }; +} /** * The context of defaults that entity reference parsing happens within. @@ -43,7 +60,7 @@ export function parseEntityName( context: EntityRefContext = {}, ): EntityName { const { kind, namespace, name } = parseEntityRef(ref, { - defaultNamespace: 'default', + defaultNamespace: ENTITY_DEFAULT_NAMESPACE, ...context, }); diff --git a/packages/catalog-model/src/location/annotation.ts b/packages/catalog-model/src/location/annotation.ts index ab7a90249a..371d095685 100644 --- a/packages/catalog-model/src/location/annotation.ts +++ b/packages/catalog-model/src/location/annotation.ts @@ -13,4 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + export const LOCATION_ANNOTATION = 'backstage.io/managed-by-location'; diff --git a/plugins/catalog-backend/migrations/20200923104503_case_insensitivity.js b/plugins/catalog-backend/migrations/20200923104503_case_insensitivity.js new file mode 100644 index 0000000000..eeec950d7f --- /dev/null +++ b/plugins/catalog-backend/migrations/20200923104503_case_insensitivity.js @@ -0,0 +1,32 @@ +/* + * 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. + */ + +// @ts-check + +/** + * @param {import('knex')} knex + */ +exports.up = async function up(knex) { + await knex('entities') + .where({ namespace: null }) + .update({ namespace: 'default' }); + await knex('entities_search').update({ + key: knex.raw('LOWER(key)'), + value: knex.raw('LOWER(value)'), + }); +}; + +exports.down = async function down() {}; diff --git a/plugins/catalog-backend/src/catalog/CoalescedEntitiesCatalog.test.ts b/plugins/catalog-backend/src/catalog/CoalescedEntitiesCatalog.test.ts index bcd1248a66..1cfea23e43 100644 --- a/plugins/catalog-backend/src/catalog/CoalescedEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/catalog/CoalescedEntitiesCatalog.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; +import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import { Logger } from 'winston'; import { CoalescedEntitiesCatalog } from './CoalescedEntitiesCatalog'; import { EntitiesCatalog } from './types'; @@ -115,9 +115,13 @@ describe('CoalescedEntitiesCatalog', () => { c1.entityByName.mockResolvedValueOnce(undefined); c2.entityByName.mockResolvedValueOnce(e2); const catalog = new CoalescedEntitiesCatalog([c1, c2], logger); - await expect(catalog.entityByName('k', undefined, 'n2')).resolves.toBe( - e2, - ); + await expect( + catalog.entityByName({ + kind: 'k', + namespace: ENTITY_DEFAULT_NAMESPACE, + name: 'n2', + }), + ).resolves.toBe(e2); expect(c1.entityByName).toBeCalledTimes(1); expect(c2.entityByName).toBeCalledTimes(1); }); @@ -127,7 +131,11 @@ describe('CoalescedEntitiesCatalog', () => { c2.entityByName.mockResolvedValueOnce(undefined); const catalog = new CoalescedEntitiesCatalog([c1, c2], logger); await expect( - catalog.entityByName('k', undefined, 'n2'), + catalog.entityByName({ + kind: 'k', + namespace: ENTITY_DEFAULT_NAMESPACE, + name: 'n2', + }), ).resolves.toBeUndefined(); expect(c1.entityByName).toBeCalledTimes(1); expect(c2.entityByName).toBeCalledTimes(1); @@ -137,9 +145,13 @@ describe('CoalescedEntitiesCatalog', () => { c1.entityByName.mockResolvedValueOnce(e1); c2.entityByName.mockRejectedValueOnce(new Error('boo')); const catalog = new CoalescedEntitiesCatalog([c1, c2], logger); - await expect(catalog.entityByName('k', undefined, 'n2')).resolves.toBe( - e1, - ); + await expect( + catalog.entityByName({ + kind: 'k', + namespace: ENTITY_DEFAULT_NAMESPACE, + name: 'n2', + }), + ).resolves.toBe(e1); expect(c1.entityByName).toBeCalledTimes(1); expect(c2.entityByName).toBeCalledTimes(1); expect(mockLogger.warn).toBeCalledWith(expect.stringMatching(/boo/)); diff --git a/plugins/catalog-backend/src/catalog/CoalescedEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/CoalescedEntitiesCatalog.ts index 5a0922495e..7eaf1868cb 100644 --- a/plugins/catalog-backend/src/catalog/CoalescedEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/catalog/CoalescedEntitiesCatalog.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; +import { Entity, EntityName } from '@backstage/catalog-model'; import { Logger } from 'winston'; import { EntityFilters } from '../database'; import { EntitiesCatalog } from './types'; @@ -72,14 +72,10 @@ export class CoalescedEntitiesCatalog implements EntitiesCatalog { return results.find(Boolean); } - async entityByName( - kind: string, - namespace: string | undefined, - name: string, - ): Promise { + async entityByName(name: EntityName): Promise { const ops = this.inner.map(async catalog => { try { - return await catalog.entityByName(kind, namespace, name); + return await catalog.entityByName(name); } catch (e) { this.logger.warn(`Inner entityByName call failed, ${e}`); return undefined; diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts index 5d35fe814e..b94365a9ba 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts @@ -27,7 +27,7 @@ describe('DatabaseEntitiesCatalog', () => { addEntity: jest.fn(), updateEntity: jest.fn(), entities: jest.fn(), - entity: jest.fn(), + entityByName: jest.fn(), entityByUid: jest.fn(), removeEntity: jest.fn(), addLocation: jest.fn(), @@ -61,12 +61,12 @@ describe('DatabaseEntitiesCatalog', () => { const catalog = new DatabaseEntitiesCatalog(db); const result = await catalog.addOrUpdateEntity(entity); - expect(db.entities).toHaveBeenCalledTimes(1); - expect(db.entities).toHaveBeenCalledWith(expect.anything(), [ - { key: 'kind', values: ['b'] }, - { key: 'name', values: ['c'] }, - { key: 'namespace', values: ['d'] }, - ]); + expect(db.entityByName).toHaveBeenCalledTimes(1); + expect(db.entityByName).toHaveBeenCalledWith(expect.anything(), { + kind: 'b', + namespace: 'd', + name: 'c', + }); expect(db.addEntity).toHaveBeenCalledTimes(1); expect(result).toBe(entity); }); @@ -146,18 +146,18 @@ describe('DatabaseEntitiesCatalog', () => { }, }; - db.entities.mockResolvedValue([{ entity: existing }]); + db.entityByName.mockResolvedValue({ entity: existing }); db.updateEntity.mockResolvedValue({ entity: existing }); const catalog = new DatabaseEntitiesCatalog(db); const result = await catalog.addOrUpdateEntity(added); - expect(db.entities).toHaveBeenCalledTimes(1); - expect(db.entities).toHaveBeenCalledWith(expect.anything(), [ - { key: 'kind', values: ['b'] }, - { key: 'name', values: ['c'] }, - { key: 'namespace', values: ['d'] }, - ]); + expect(db.entityByName).toHaveBeenCalledTimes(1); + expect(db.entityByName).toHaveBeenCalledWith(expect.anything(), { + kind: 'b', + namespace: 'd', + name: 'c', + }); expect(db.updateEntity).toHaveBeenCalledTimes(1); expect(db.updateEntity).toHaveBeenCalledWith( expect.anything(), diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts index d6809e468f..f6aec0c268 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts @@ -16,7 +16,9 @@ import { NotFoundError } from '@backstage/backend-common'; import { + EntityName, generateUpdatedEntity, + getEntityName, LOCATION_ANNOTATION, } from '@backstage/catalog-model'; import type { Entity } from '@backstage/catalog-model'; @@ -34,20 +36,15 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { } async entityByUid(uid: string): Promise { - const matches = await this.database.transaction(tx => - this.database.entities(tx, [{ key: 'uid', values: [uid] }]), + const response = await this.database.transaction(tx => + this.database.entityByUid(tx, uid), ); - - return matches.length ? matches[0].entity : undefined; + return response?.entity; } - async entityByName( - kind: string, - namespace: string | undefined, - name: string, - ): Promise { + async entityByName(name: EntityName): Promise { const response = await this.database.transaction(tx => - this.entityByNameInternal(tx, kind, name, namespace), + this.database.entityByName(tx, name), ); return response?.entity; } @@ -61,12 +58,7 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { // entity) existing entity, to know whether to update or add const existing = entity.metadata.uid ? await this.database.entityByUid(tx, entity.metadata.uid) - : await this.entityByNameInternal( - tx, - entity.kind, - entity.metadata.name, - entity.metadata.namespace, - ); + : await this.database.entityByName(tx, getEntityName(entity)); // If it's an update, run the algorithm for annotation merging, updating // etag/generation, etc. @@ -113,25 +105,4 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { return undefined; }); } - - private async entityByNameInternal( - tx: unknown, - kind: string, - name: string, - namespace: string | undefined, - ): Promise { - const matches = await this.database.entities(tx, [ - { key: 'kind', values: [kind] }, - { key: 'name', values: [name] }, - { - key: 'namespace', - values: - !namespace || namespace === 'default' - ? [null, 'default'] - : [namespace], - }, - ]); - - return matches.length ? matches[0] : undefined; - } } diff --git a/plugins/catalog-backend/src/catalog/StaticEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/StaticEntitiesCatalog.ts index 22bbd2e1a3..65fbfb9071 100644 --- a/plugins/catalog-backend/src/catalog/StaticEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/catalog/StaticEntitiesCatalog.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { Entity } from '@backstage/catalog-model'; +import { Entity, EntityName, getEntityName } from '@backstage/catalog-model'; import lodash from 'lodash'; import type { EntitiesCatalog } from './types'; @@ -34,17 +34,15 @@ export class StaticEntitiesCatalog implements EntitiesCatalog { return item ? lodash.cloneDeep(item) : undefined; } - async entityByName( - kind: string, - name: string, - namespace: string | undefined, - ): Promise { - const item = this._entities.find( - e => - kind === e.kind && - name === e.metadata.name && - namespace === e.metadata.namespace, - ); + async entityByName(name: EntityName): Promise { + const item = this._entities.find(e => { + const candidate = getEntityName(e); + return ( + name.kind.toLowerCase() === candidate.kind.toLowerCase() && + name.namespace.toLowerCase() === candidate.namespace.toLowerCase() && + name.name.toLowerCase() === candidate.name.toLowerCase() + ); + }); return item ? lodash.cloneDeep(item) : undefined; } diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index b0d3dde0fb..37618a2440 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Entity, Location } from '@backstage/catalog-model'; +import { Entity, EntityName, Location } from '@backstage/catalog-model'; import type { EntityFilters } from '../database'; // @@ -24,11 +24,7 @@ import type { EntityFilters } from '../database'; export type EntitiesCatalog = { entities(filters?: EntityFilters): Promise; entityByUid(uid: string): Promise; - entityByName( - kind: string, - namespace: string | undefined, - name: string, - ): Promise; + entityByName(name: EntityName): Promise; addOrUpdateEntity(entity: Entity, locationId?: string): Promise; removeEntityByUid(uid: string): Promise; }; diff --git a/plugins/catalog-backend/src/database/CommonDatabase.test.ts b/plugins/catalog-backend/src/database/CommonDatabase.test.ts index b86b4ef959..8b8067f641 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.test.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.test.ts @@ -395,5 +395,90 @@ describe('CommonDatabase', () => { ]), ); }); + + it('can get all specific entities for matching filters case insensitively)', async () => { + const entities: Entity[] = [ + { apiVersion: 'A', kind: 'K1', metadata: { name: 'N' } }, + { + apiVersion: 'a', + kind: 'k2', + metadata: { name: 'n' }, + spec: { c: 'Some' }, + }, + { + apiVersion: 'a', + kind: 'k3', + metadata: { name: 'n' }, + spec: { c: null }, + }, + ]; + + await db.transaction(async tx => { + for (const entity of entities) { + await db.addEntity(tx, { entity }); + } + }); + + const rows = await db.transaction(async tx => + db.entities(tx, [ + { key: 'ApiVersioN', values: ['A'] }, + { key: 'spEc.C', values: [null, 'some'] }, + ]), + ); + + expect(rows.length).toEqual(3); + expect(rows).toEqual( + expect.arrayContaining([ + { + locationId: undefined, + entity: expect.objectContaining({ kind: 'K1' }), + }, + { + locationId: undefined, + entity: expect.objectContaining({ kind: 'k2' }), + }, + { + locationId: undefined, + entity: expect.objectContaining({ kind: 'k3' }), + }, + ]), + ); + }); + }); + + describe('entityByName', () => { + it('can get entities case insensitively', async () => { + const entities: Entity[] = [ + { + apiVersion: 'a', + kind: 'k1', + metadata: { name: 'n' }, + }, + { + apiVersion: 'B', + kind: 'K2', + metadata: { name: 'N', namespace: 'NS' }, + }, + ]; + + await db.transaction(async tx => { + for (const entity of entities) { + await db.addEntity(tx, { entity }); + } + }); + + const e1 = await db.transaction(async tx => + db.entityByName(tx, { kind: 'k1', namespace: 'default', name: 'n' }), + ); + expect(e1!.entity.metadata.name).toEqual('n'); + const e2 = await db.transaction(async tx => + db.entityByName(tx, { kind: 'k2', namespace: 'nS', name: 'n' }), + ); + expect(e2!.entity.metadata.name).toEqual('N'); + const e3 = await db.transaction(async tx => + db.entityByName(tx, { kind: 'unknown', namespace: 'nS', name: 'n' }), + ); + expect(e3).toBeUndefined(); + }); }); }); diff --git a/plugins/catalog-backend/src/database/CommonDatabase.ts b/plugins/catalog-backend/src/database/CommonDatabase.ts index 4ad5362fb6..9ac1fff951 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.ts @@ -22,9 +22,12 @@ import { import { Entity, EntityMeta, - entityMetaGeneratedFields, + EntityName, + ENTITY_DEFAULT_NAMESPACE, + ENTITY_META_GENERATED_FIELDS, generateEntityEtag, generateEntityUid, + getEntityName, Location, } from '@backstage/catalog-model'; import Knex from 'knex'; @@ -172,7 +175,7 @@ export class CommonDatabase implements Database { let builder = tx('entities'); for (const [indexU, filter] of (filters ?? []).entries()) { const index = Number(indexU); - const key = filter.key.replace('*', '%'); + const key = filter.key.toLowerCase().replace('*', '%'); const keyOp = filter.key.includes('*') ? 'like' : '='; let matchNulls = false; @@ -183,9 +186,9 @@ export class CommonDatabase implements Database { if (!value) { matchNulls = true; } else if (value.includes('*')) { - matchLike.push(value.replace('*', '%')); + matchLike.push(value.toLowerCase().replace('*', '%')); } else { - matchIn.push(value); + matchIn.push(value.toLowerCase()); } } @@ -219,16 +222,19 @@ export class CommonDatabase implements Database { return rows.map(row => this.toEntityResponse(row)); } - async entity( + async entityByName( txOpaque: unknown, - kind: string, - name: string, - namespace?: string, + name: EntityName, ): Promise { const tx = txOpaque as Knex.Transaction; const rows = await tx('entities') - .where({ kind, name, namespace: namespace || null }) + .whereRaw( + tx.raw( + 'LOWER(kind) = LOWER(?) AND LOWER(namespace) = LOWER(?) AND LOWER(name) = LOWER(?)', + [name.kind, name.namespace, name.name], + ), + ) .select(); if (rows.length !== 1) { @@ -240,11 +246,13 @@ export class CommonDatabase implements Database { async entityByUid( txOpaque: unknown, - id: string, + uid: string, ): Promise { const tx = txOpaque as Knex.Transaction; - const rows = await tx('entities').where({ id }).select(); + const rows = await tx('entities') + .where({ id: uid }) + .select(); if (rows.length !== 1) { return undefined; @@ -381,36 +389,40 @@ export class CommonDatabase implements Database { tx: Knex.Transaction, data: Entity, ): Promise { - const newKind = data.kind; - const newName = data.metadata.name; - const newNamespace = data.metadata.namespace; + const { + kind: newKind, + namespace: newNamespace, + name: newName, + } = getEntityName(data); const newKindNorm = this.normalize(newKind); + const newNamespaceNorm = this.normalize(newNamespace); const newNameNorm = this.normalize(newName); - const newNamespaceNorm = this.normalize(newNamespace || ''); for (const item of await this.entities(tx)) { if (data.metadata.uid === item.entity.metadata.uid) { continue; } - const oldKind = item.entity.kind; - const oldName = item.entity.metadata.name; - const oldNamespace = item.entity.metadata.namespace; + const { + kind: oldKind, + namespace: oldNamespace, + name: oldName, + } = getEntityName(item.entity); const oldKindNorm = this.normalize(oldKind); + const oldNamespaceNorm = this.normalize(oldNamespace); const oldNameNorm = this.normalize(oldName); - const oldNamespaceNorm = this.normalize(oldNamespace || ''); if ( oldKindNorm === newKindNorm && - oldNameNorm === newNameNorm && - oldNamespaceNorm === newNamespaceNorm + oldNamespaceNorm === newNamespaceNorm && + oldNameNorm === newNameNorm ) { // Only throw if things were actually different - for completely equal // things, we let the database handle the conflict if ( oldKind !== newKind || - oldName !== newName || - oldNamespace !== newNamespace + oldNamespace !== newNamespace || + oldName !== newName ) { const message = `Kind, namespace, name are too similar to an existing entity`; throw new ConflictError(message); @@ -431,9 +443,9 @@ export class CommonDatabase implements Database { api_version: entity.apiVersion, kind: entity.kind, name: entity.metadata.name, - namespace: entity.metadata.namespace || null, + namespace: entity.metadata.namespace || ENTITY_DEFAULT_NAMESPACE, metadata: JSON.stringify( - lodash.omit(entity.metadata, ...entityMetaGeneratedFields), + lodash.omit(entity.metadata, ...ENTITY_META_GENERATED_FIELDS), ), spec: entity.spec ? JSON.stringify(entity.spec) : null, }; diff --git a/plugins/catalog-backend/src/database/search.test.ts b/plugins/catalog-backend/src/database/search.test.ts index 38a2d40e74..6da87f9150 100644 --- a/plugins/catalog-backend/src/database/search.test.ts +++ b/plugins/catalog-backend/src/database/search.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { Entity } from '@backstage/catalog-model'; +import { ENTITY_DEFAULT_NAMESPACE, Entity } from '@backstage/catalog-model'; import { buildEntitySearch, visitEntityPart } from './search'; import type { DbEntitiesSearchRow } from './types'; @@ -95,6 +95,16 @@ describe('search', () => { { entity_id: 'eid', key: 'root.list.a', value: '2' }, ]); }); + + it('emits lowercase version of keys and values', () => { + const input = { theRoot: { listItems: [{ a: 'One' }, { a: 2 }] } }; + const output: DbEntitiesSearchRow[] = []; + visitEntityPart('eid', '', input, output); + expect(output).toEqual([ + { entity_id: 'eid', key: 'theroot.listitems.a', value: 'one' }, + { entity_id: 'eid', key: 'theroot.listitems.a', value: '2' }, + ]); + }); }); describe('buildEntitySearch', () => { @@ -108,11 +118,17 @@ describe('search', () => { { entity_id: 'eid', key: 'metadata.name', value: 'n' }, { entity_id: 'eid', key: 'metadata.namespace', value: null }, { entity_id: 'eid', key: 'metadata.uid', value: null }, - { entity_id: 'eid', key: 'apiVersion', value: 'a' }, + { + entity_id: 'eid', + key: 'metadata.namespace', + value: ENTITY_DEFAULT_NAMESPACE, + }, + { entity_id: 'eid', key: 'apiversion', value: 'a' }, { entity_id: 'eid', key: 'kind', value: 'b' }, { entity_id: 'eid', key: 'name', value: 'n' }, { entity_id: 'eid', key: 'namespace', value: null }, { entity_id: 'eid', key: 'uid', value: null }, + { entity_id: 'eid', key: 'namespace', value: ENTITY_DEFAULT_NAMESPACE }, ]); }); diff --git a/plugins/catalog-backend/src/database/search.ts b/plugins/catalog-backend/src/database/search.ts index c14acb6661..cbcd01a5d0 100644 --- a/plugins/catalog-backend/src/database/search.ts +++ b/plugins/catalog-backend/src/database/search.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { Entity } from '@backstage/catalog-model'; +import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import type { DbEntitiesSearchRow } from './types'; // Search entries that start with these prefixes, also get a shorthand without @@ -26,7 +26,7 @@ const SHORTHAND_KEY_PREFIXES = [ 'spec.', ]; -// These are exluded in the generic loop, either because they do not make sense +// These are excluded in the generic loop, either because they do not make sense // to index, or because they are special-case always inserted whether they are // null or not const SPECIAL_KEYS = [ @@ -42,7 +42,7 @@ function toValue(current: any): string | null { return null; } - return String(current); + return String(current).toLowerCase(); } // Helper for iterating through a nested structure and outputting a list of @@ -106,7 +106,12 @@ export function visitEntityPart( // object for (const [key, value] of Object.entries(current)) { - visitEntityPart(entityId, path ? `${path}.${key}` : key, value, output); + visitEntityPart( + entityId, + (path ? `${path}.${key}` : key).toLowerCase(), + value, + output, + ); } } @@ -141,6 +146,16 @@ export function buildEntitySearch( }, ]; + // Namespace not specified has the default value "default", so we want to + // match on that as well + if (!entity.metadata.namespace) { + result.push({ + entity_id: entityId, + key: 'metadata.namespace', + value: toValue(ENTITY_DEFAULT_NAMESPACE), + }); + } + // Visit the entire structure recursively visitEntityPart(entityId, '', entity, result); diff --git a/plugins/catalog-backend/src/database/types.ts b/plugins/catalog-backend/src/database/types.ts index 0537b87800..8aaf212583 100644 --- a/plugins/catalog-backend/src/database/types.ts +++ b/plugins/catalog-backend/src/database/types.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { Entity, Location } from '@backstage/catalog-model'; +import type { Entity, EntityName, Location } from '@backstage/catalog-model'; export type DbEntitiesRow = { id: string; @@ -129,11 +129,9 @@ export type Database = { entities(tx: unknown, filters?: EntityFilters): Promise; - entity( + entityByName( tx: unknown, - kind: string, - name: string, - namespace?: string, + name: EntityName, ): Promise; entityByUid(tx: unknown, uid: string): Promise; diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts index de68a16e9c..32fcc27909 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts +++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts @@ -15,7 +15,12 @@ */ import { getVoidLogger } from '@backstage/backend-common'; -import { Entity, Location, LocationSpec } from '@backstage/catalog-model'; +import { + Entity, + ENTITY_DEFAULT_NAMESPACE, + Location, + LocationSpec, +} from '@backstage/catalog-model'; import { EntitiesCatalog, LocationsCatalog } from '../catalog'; import { LocationUpdateStatus } from '../catalog/types'; import { DatabaseLocationUpdateLogStatus } from '../database/types'; @@ -200,12 +205,11 @@ describe('HigherOrderOperations', () => { target: 'thing', }); expect(entitiesCatalog.entityByName).toHaveBeenCalledTimes(1); - expect(entitiesCatalog.entityByName).toHaveBeenNthCalledWith( - 1, - 'Component', - undefined, - 'c1', - ); + expect(entitiesCatalog.entityByName).toHaveBeenNthCalledWith(1, { + kind: 'Component', + namespace: ENTITY_DEFAULT_NAMESPACE, + name: 'c1', + }); expect(entitiesCatalog.addOrUpdateEntity).toHaveBeenCalledTimes(1); expect(entitiesCatalog.addOrUpdateEntity).toHaveBeenNthCalledWith( 1, diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts index 0224dc7f1d..1b60666c8e 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts +++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts @@ -18,6 +18,7 @@ import { InputError } from '@backstage/backend-common'; import { Entity, entityHasChanges, + getEntityName, Location, LocationSpec, } from '@backstage/catalog-model'; @@ -162,16 +163,14 @@ export class HigherOrderOperations implements HigherOrderOperation { const { entity } = item; this.logger.debug( - `Read entity kind="${entity.kind}" name="${ - entity.metadata.name - }" namespace="${entity.metadata.namespace || ''}"`, + `Read entity kind="${entity.kind}" namespace="${ + entity.metadata.namespace || '' + }" name="${entity.metadata.name}"`, ); try { const previous = await this.entitiesCatalog.entityByName( - entity.kind, - entity.metadata.namespace, - entity.metadata.name, + getEntityName(entity), ); if (!previous) { diff --git a/plugins/catalog-backend/src/service/router.test.ts b/plugins/catalog-backend/src/service/router.test.ts index 29b3f7ce04..32d7b6aa63 100644 --- a/plugins/catalog-backend/src/service/router.test.ts +++ b/plugins/catalog-backend/src/service/router.test.ts @@ -136,7 +136,11 @@ describe('createRouter', () => { const response = await request(app).get('/entities/by-name/k/ns/n'); expect(entitiesCatalog.entityByName).toHaveBeenCalledTimes(1); - expect(entitiesCatalog.entityByName).toHaveBeenCalledWith('k', 'ns', 'n'); + expect(entitiesCatalog.entityByName).toHaveBeenCalledWith({ + kind: 'k', + namespace: 'ns', + name: 'n', + }); expect(response.status).toEqual(200); expect(response.body).toEqual(expect.objectContaining(entity)); }); @@ -147,7 +151,11 @@ describe('createRouter', () => { const response = await request(app).get('/entities/by-name/b/d/c'); expect(entitiesCatalog.entityByName).toHaveBeenCalledTimes(1); - expect(entitiesCatalog.entityByName).toHaveBeenCalledWith('b', 'd', 'c'); + expect(entitiesCatalog.entityByName).toHaveBeenCalledWith({ + kind: 'b', + namespace: 'd', + name: 'c', + }); expect(response.status).toEqual(404); expect(response.text).toMatch(/name/); }); diff --git a/plugins/catalog-backend/src/service/router.ts b/plugins/catalog-backend/src/service/router.ts index 2919e73396..8da589c721 100644 --- a/plugins/catalog-backend/src/service/router.ts +++ b/plugins/catalog-backend/src/service/router.ts @@ -67,11 +67,11 @@ export async function createRouter( }) .get('/entities/by-name/:kind/:namespace/:name', async (req, res) => { const { kind, namespace, name } = req.params; - const entity = await entitiesCatalog.entityByName( + const entity = await entitiesCatalog.entityByName({ kind, namespace, name, - ); + }); if (!entity) { res .status(404) From 8272bec5bde660f9b8d9d0121edbb7d3d7e0f0cf Mon Sep 17 00:00:00 2001 From: Samira Mokaram Date: Fri, 25 Sep 2020 10:07:31 +0200 Subject: [PATCH 39/94] review --- packages/theme/src/themes.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/theme/src/themes.ts b/packages/theme/src/themes.ts index b2eabfe460..5c0c3c04f7 100644 --- a/packages/theme/src/themes.ts +++ b/packages/theme/src/themes.ts @@ -102,8 +102,8 @@ export const darkTheme = createTheme({ banner: { info: '#2E77D0', error: '#E22134', - textColor: '#FFFFFF', - linkColor: '#000000', + text: '#FFFFFF', + link: '#000000', }, border: '#E6E6E6', textContrast: '#FFFFFF', From c7b8d9ed7e4d3388a06178f81a90db65167ac665 Mon Sep 17 00:00:00 2001 From: Samira Mokaram Date: Fri, 25 Sep 2020 10:10:07 +0200 Subject: [PATCH 40/94] review --- .../src/components/DismissableBanner/DismissableBanner.tsx | 2 +- packages/theme/src/themes.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/components/DismissableBanner/DismissableBanner.tsx b/packages/core/src/components/DismissableBanner/DismissableBanner.tsx index b33a1c4704..181eddcad2 100644 --- a/packages/core/src/components/DismissableBanner/DismissableBanner.tsx +++ b/packages/core/src/components/DismissableBanner/DismissableBanner.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC, ReactNode, useState, useEffect } from 'react'; +import React, { ReactNode, useState, useEffect } from 'react'; import { useApi, storageApiRef } from '@backstage/core-api'; import { useObservable } from 'react-use'; import classNames from 'classnames'; diff --git a/packages/theme/src/themes.ts b/packages/theme/src/themes.ts index 5c0c3c04f7..76c0660efb 100644 --- a/packages/theme/src/themes.ts +++ b/packages/theme/src/themes.ts @@ -44,8 +44,8 @@ export const lightTheme = createTheme({ banner: { info: '#2E77D0', error: '#E22134', - textColor: '#FFFFFF', - linkColor: '#000000', + text: '#FFFFFF', + link: '#000000', }, border: '#E6E6E6', textContrast: '#000000', From b0565777e98d2864bf1566c0000c4d63a559043d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 24 Sep 2020 23:37:52 +0200 Subject: [PATCH 41/94] backend-common: add initial discovery API and simple single host implementation --- .../src/discovery/SingleHostDiscovery.ts | 80 +++++++++++++++++++ .../backend-common/src/discovery/index.ts | 18 +++++ .../backend-common/src/discovery/types.ts | 61 ++++++++++++++ packages/backend-common/src/index.ts | 1 + .../src/service/lib/ServiceBuilderImpl.ts | 2 +- .../backend-common/src/service/lib/config.ts | 12 ++- 6 files changed, 166 insertions(+), 8 deletions(-) create mode 100644 packages/backend-common/src/discovery/SingleHostDiscovery.ts create mode 100644 packages/backend-common/src/discovery/index.ts create mode 100644 packages/backend-common/src/discovery/types.ts diff --git a/packages/backend-common/src/discovery/SingleHostDiscovery.ts b/packages/backend-common/src/discovery/SingleHostDiscovery.ts new file mode 100644 index 0000000000..1970583e5f --- /dev/null +++ b/packages/backend-common/src/discovery/SingleHostDiscovery.ts @@ -0,0 +1,80 @@ +/* + * 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 { Config } from '@backstage/config'; +import { PluginEndpointDiscovery } from './types'; +import { readBaseOptions } from '../service/lib/config'; +import { DEFAULT_PORT } from '../service/lib/ServiceBuilderImpl'; + +/** + * SingleHostDiscovery is a basic PluginEndpointDiscovery implementation + * that assumes that all plugins are hosted in a single deployment. + * + * The deployment may be scaled horizontally, as long as the external URL + * is the same for all instances. However, internal URLs will always be + * resolved to the same host, so there won't be any balancing of internal traffic. + */ +export class SingleHostDiscovery implements PluginEndpointDiscovery { + /** + * Creates a new SingleHostDiscovery discovery instance by reading + * from the `backend` config section, specifically the `.baseUrl` for + * discovering the external URL, and the `.listen` and `.https` config + * for the internal one. + * + * The basePath defaults to `/api`, meaning the default full internal + * path for the `catalog` plugin will be `http://localhost:7000/api/catalog`. + */ + static fromConfig(config: Config, options?: { basePath?: string }) { + const basePath = options?.basePath ?? '/api'; + const externalBaseUrl = config.getString('backend.baseUrl'); + + const { listenHost = '::', listenPort = DEFAULT_PORT } = readBaseOptions( + config.getConfig('backend'), + ); + const protocol = config.has('backend.https') ? 'https' : 'http'; + + // Translate bind-all to localhost, and support IPv6 + let host = listenHost; + if (host === '::') { + host = '::1'; + } else if (host === '0.0.0.0') { + host = '127.0.0.1'; + } + if (host.includes(':')) { + host = `[${host}]`; + } + + const internalBaseUrl = `${protocol}://${host}:${listenPort}`; + + return new SingleHostDiscovery( + internalBaseUrl + basePath, + externalBaseUrl + basePath, + ); + } + + private constructor( + private readonly internalBaseUrl: string, + private readonly externalBaseUrl: string, + ) {} + + async getBaseUrl(pluginId: string): Promise { + return `${this.internalBaseUrl}/${pluginId}`; + } + + async getExternalBaseUrl(pluginId: string): Promise { + return `${this.externalBaseUrl}/${pluginId}`; + } +} diff --git a/packages/backend-common/src/discovery/index.ts b/packages/backend-common/src/discovery/index.ts new file mode 100644 index 0000000000..7fe320c1e5 --- /dev/null +++ b/packages/backend-common/src/discovery/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { SingleHostDiscovery } from './SingleHostDiscovery'; +export type { PluginEndpointDiscovery } from './types'; diff --git a/packages/backend-common/src/discovery/types.ts b/packages/backend-common/src/discovery/types.ts new file mode 100644 index 0000000000..22eb4b23d4 --- /dev/null +++ b/packages/backend-common/src/discovery/types.ts @@ -0,0 +1,61 @@ +/* + * 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. + */ + +/** + * The PluginEndpointDiscovery is used to provide a mechanism for backend + * plugins to discover the endpoints for itself or other backend plugins. + * + * The purpose of the discovery API is to allow for many different deployment + * setups and routing methods through a central configuration, instead + * of letting each individual plugin manage that configuration. + * + * Implementations of the discovery API can be as simple as a URL pattern + * using the pluginId, but could also have overrides for individual plugins, + * or query a separate discovery service. + */ +export type PluginEndpointDiscovery = { + /** + * Returns the internal HTTP base URL for a given plugin, without a trailing slash. + * + * The returned URL should point to an internal endpoint for the plugin, with + * the shortest route possible. The URL should be used for service-to-service + * communication within a Backstage backend deployment. + * + * This method must always be called just before making a request, as opposed to + * fetching the URL when constructing an API client. That is to ensure that more + * flexible routing patterns can be supported. + * + * For example, asking for the URL for `catalog` may return something + * like `http://10.1.2.3/api/catalog` + */ + getBaseUrl(pluginId: string): Promise; + + /** + * Returns the external HTTP base backend URL for a given plugin, without a trailing slash. + * + * The returned URL should point to an external endpoint for the plugin, such that + * it is reachable from the Backstage frontend and other external services. The returned + * URL should be usable for example as a callback / webhook URL. + * + * The returned URL should be stable and in general not change unless other static + * or external configuration is changed. Changes should not come as a surprise + * to an operator of the Backstage backend. + * + * For example, asking for the URL for `catalog` may return something + * like `https://backstage.example.com/api/catalog` + */ + getExternalBaseUrl(pluginId: string): Promise; +}; diff --git a/packages/backend-common/src/index.ts b/packages/backend-common/src/index.ts index c527f5fea1..7074040655 100644 --- a/packages/backend-common/src/index.ts +++ b/packages/backend-common/src/index.ts @@ -16,6 +16,7 @@ export * from './config'; export * from './database'; +export * from './discovery'; export * from './errors'; export * from './logging'; export * from './middleware'; diff --git a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts index 10f9ca4d9b..f3bc59335b 100644 --- a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts +++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts @@ -39,7 +39,7 @@ import { import { createHttpServer, createHttpsServer } from './hostFactory'; import { metricsHandler } from './metrics'; -const DEFAULT_PORT = 7000; +export const DEFAULT_PORT = 7000; // '' is express default, which listens to all interfaces const DEFAULT_HOST = ''; diff --git a/packages/backend-common/src/service/lib/config.ts b/packages/backend-common/src/service/lib/config.ts index e257bd4111..3b053c7555 100644 --- a/packages/backend-common/src/service/lib/config.ts +++ b/packages/backend-common/src/service/lib/config.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ConfigReader } from '@backstage/config'; +import { Config } from '@backstage/config'; import { CorsOptions } from 'cors'; export type BaseOptions = { @@ -71,7 +71,7 @@ export type CertificateAttributes = { * } * ``` */ -export function readBaseOptions(config: ConfigReader): BaseOptions { +export function readBaseOptions(config: Config): BaseOptions { if (typeof config.get('listen') === 'string') { // TODO(freben): Expand this to support more addresses and perhaps optional const { host, port } = parseListenAddress(config.getString('listen')); @@ -105,7 +105,7 @@ export function readBaseOptions(config: ConfigReader): BaseOptions { * } * ``` */ -export function readCorsOptions(config: ConfigReader): CorsOptions | undefined { +export function readCorsOptions(config: Config): CorsOptions | undefined { const cc = config.getOptionalConfig('cors'); if (!cc) { return undefined; @@ -138,9 +138,7 @@ export function readCorsOptions(config: ConfigReader): CorsOptions | undefined { * } * ``` */ -export function readHttpsSettings( - config: ConfigReader, -): HttpsSettings | undefined { +export function readHttpsSettings(config: Config): HttpsSettings | undefined { const cc = config.getOptionalConfig('https'); if (!cc) { @@ -157,7 +155,7 @@ export function readHttpsSettings( } function getOptionalStringOrStrings( - config: ConfigReader, + config: Config, key: string, ): string | string[] | undefined { const value = config.getOptional(key); From 9433565fe7734b17c8577c2740f83e15f3ea0a77 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Thu, 24 Sep 2020 18:25:16 +0200 Subject: [PATCH 42/94] feat: add ApiDefinitionAtLocationProcessor that allows to load a API definition from another location Resolves #2572 --- .../well-known-annotations.md | 21 +++ packages/catalog-model/examples/all-apis.yaml | 2 + .../catalog-model/examples/spotify-api.yaml | 14 ++ .../src/ingestion/LocationReaders.ts | 30 +++- .../ApiDefinitionAtLocationProcessor.test.ts | 132 ++++++++++++++++++ .../ApiDefinitionAtLocationProcessor.ts | 70 ++++++++++ .../src/ingestion/processors/types.ts | 6 + 7 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 packages/catalog-model/examples/spotify-api.yaml create mode 100644 plugins/catalog-backend/src/ingestion/processors/ApiDefinitionAtLocationProcessor.test.ts create mode 100644 plugins/catalog-backend/src/ingestion/processors/ApiDefinitionAtLocationProcessor.ts diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md index ba5b491e07..e5b66bb3b2 100644 --- a/docs/features/software-catalog/well-known-annotations.md +++ b/docs/features/software-catalog/well-known-annotations.md @@ -41,6 +41,27 @@ expecting a two-item array out of it. The format of the target part is type-dependent and could conceivably even be an empty string, but the separator colon is always present. +### backstage.io/definition-at-location + +```yaml +# Example +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: petstore + annotations: + backstage.io/definition-at-location: 'url:https://petstore.swagger.io/v2/swagger.json' +spec: + type: openapi +``` + +This annotation allows to fetch an API definition from another location, instead +of wrapping the API definition inside the definition field. This allows to +easitly consume existing API definition. The definition is fetched during +ingestion by a processor and included in the entity. It is updated on every +refresh. The annotation contains a location reference string that contains the +location processor type and the target. + ### backstage.io/techdocs-ref ```yaml diff --git a/packages/catalog-model/examples/all-apis.yaml b/packages/catalog-model/examples/all-apis.yaml index 33b000d1ae..3c4f7804bf 100644 --- a/packages/catalog-model/examples/all-apis.yaml +++ b/packages/catalog-model/examples/all-apis.yaml @@ -8,3 +8,5 @@ spec: targets: - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/hello-world-api.yaml - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/streetlights-api.yaml + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/spotify-api.yaml + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/swapi-graphql.yaml diff --git a/packages/catalog-model/examples/spotify-api.yaml b/packages/catalog-model/examples/spotify-api.yaml new file mode 100644 index 0000000000..30524dfdd4 --- /dev/null +++ b/packages/catalog-model/examples/spotify-api.yaml @@ -0,0 +1,14 @@ +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: spotify + description: The Spotify web API + tags: + - spotify + - rest + annotations: + backstage.io/definition-at-location: 'url:https://raw.githubusercontent.com/APIs-guru/openapi-directory/master/APIs/spotify.com/v1/swagger.yaml' +spec: + type: openapi + lifecycle: production + owner: spotify@example.com diff --git a/plugins/catalog-backend/src/ingestion/LocationReaders.ts b/plugins/catalog-backend/src/ingestion/LocationReaders.ts index 8da6e20ab3..11558feae0 100644 --- a/plugins/catalog-backend/src/ingestion/LocationReaders.ts +++ b/plugins/catalog-backend/src/ingestion/LocationReaders.ts @@ -47,6 +47,7 @@ import { import { YamlProcessor } from './processors/YamlProcessor'; import { LocationReader, ReadLocationResult } from './types'; import { CatalogRulesEnforcer } from './CatalogRules'; +import { ApiDefinitionAtLocationProcessor } from './processors/ApiDefinitionAtLocationProcessor'; // The max amount of nesting depth of generated work items const MAX_DEPTH = 10; @@ -85,6 +86,7 @@ export class LocationReaders implements LocationReader { new AzureApiReaderProcessor(config), new UrlReaderProcessor(), new YamlProcessor(), + new ApiDefinitionAtLocationProcessor(), new EntityPolicyProcessor(entityPolicy), new LocationRefProcessor(), new AnnotateLocationEntityProcessor(), @@ -218,7 +220,12 @@ export class LocationReaders implements LocationReader { for (const processor of this.processors) { if (processor.processEntity) { try { - current = await processor.processEntity(current, item.location, emit); + current = await processor.processEntity( + current, + item.location, + emit, + this.readLocation.bind(this), + ); } catch (e) { const message = `Processor ${processor.constructor.name} threw an error while processing entity at ${item.location.type} ${item.location.target}, ${e}`; emit(result.generalError(item.location, message)); @@ -248,4 +255,25 @@ export class LocationReaders implements LocationReader { } } } + + private async readLocation( + location: LocationSpec, + ): Promise { + let locationResult: LocationProcessorResult | undefined; + + await this.handleLocation( + { + type: 'location', + location, + optional: false, + }, + r => (locationResult = r), + ); + + if (!locationResult) { + throw new Error('No location loaded'); + } + + return locationResult; + } } diff --git a/plugins/catalog-backend/src/ingestion/processors/ApiDefinitionAtLocationProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/ApiDefinitionAtLocationProcessor.test.ts new file mode 100644 index 0000000000..ef6d348ad5 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/ApiDefinitionAtLocationProcessor.test.ts @@ -0,0 +1,132 @@ +import { ApiEntity, Entity, LocationSpec } from '@backstage/catalog-model'; +/* + * 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 { ApiDefinitionAtLocationProcessor } from './ApiDefinitionAtLocationProcessor'; +import { LocationProcessorResult } from './types'; + +describe('ApiDefinitionAtLocationProcessor', () => { + let processor: ApiDefinitionAtLocationProcessor; + let entity: Entity; + let location: LocationSpec; + + beforeEach(() => { + processor = new ApiDefinitionAtLocationProcessor(); + entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'API', + metadata: { + name: 'test', + }, + spec: { + lifecycle: 'production', + owner: 'info@example.com', + type: 'openapi', + definition: 'Hello', + }, + }; + location = { + type: 'url', + target: `http://example.com/api.yaml`, + }; + }); + + it('should skip entities without annotation', async () => { + const read = jest.fn( + (): Promise => { + throw new Error(); + }, + ); + + const generated = (await processor.processEntity( + entity, + location, + () => {}, + read, + )) as ApiEntity; + + expect(generated.spec.definition).toBe('Hello'); + }); + + it('should load from location', async () => { + entity.metadata.annotations = { + 'backstage.io/definition-at-location': + 'url:http://example.com/openapi.yaml', + }; + + const read = jest.fn( + (l: LocationSpec): Promise => + Promise.resolve({ + type: 'data', + data: Buffer.from('Hello'), + location: l, + }), + ); + + const generated = (await processor.processEntity( + entity, + location, + () => {}, + read, + )) as ApiEntity; + + expect(generated.spec.definition).toBe('Hello'); + expect(read.mock.calls[0][0]).toStrictEqual({ + type: 'url', + target: 'http://example.com/openapi.yaml', + }); + }); + + it('should throw errors while loading', async () => { + entity.metadata.annotations = { + 'backstage.io/definition-at-location': 'missing', + }; + + const read = jest.fn( + (l: LocationSpec): Promise => + Promise.resolve({ + type: 'error', + error: new Error('Failed to load location'), + location: l, + }), + ); + + await expect( + processor.processEntity(entity, location, () => {}, read), + ).rejects.toThrow('Failed to read location: Failed to load location'); + }); + + it('should throw errors if location read has wrong type', async () => { + entity.metadata.annotations = { + 'backstage.io/definition-at-location': 'wrong', + }; + + const read = jest.fn( + (l: LocationSpec): Promise => + Promise.resolve({ + type: 'location', + optional: false, + location: l, + }), + ); + + await expect( + processor.processEntity(entity, location, () => {}, read), + ).rejects.toThrow( + `Only supports location processor results of type 'data', but got 'location'`, + ); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/ApiDefinitionAtLocationProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/ApiDefinitionAtLocationProcessor.ts new file mode 100644 index 0000000000..c359e78292 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/ApiDefinitionAtLocationProcessor.ts @@ -0,0 +1,70 @@ +/* + * 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 { ApiEntity, Entity, LocationSpec } from '@backstage/catalog-model'; +import { + LocationProcessor, + LocationProcessorEmit, + LocationProcessorRead, +} from './types'; + +const DEFINITION_AT_LOCATION_ANNOTATION = 'backstage.io/definition-at-location'; + +export class ApiDefinitionAtLocationProcessor implements LocationProcessor { + async processEntity( + entity: Entity, + _location: LocationSpec, + _emit: LocationProcessorEmit, + read: LocationProcessorRead, + ): Promise { + if ( + entity.kind !== 'API' || + !entity.metadata.annotations || + !entity.metadata.annotations[DEFINITION_AT_LOCATION_ANNOTATION] + ) { + return entity; + } + + const reference = + entity.metadata.annotations[DEFINITION_AT_LOCATION_ANNOTATION]; + const { type, target } = extractReference(reference); + const result = await read({ type, target }); + + if (result.type === 'error') { + throw new Error(`Failed to read location: ${result.error.message}`); + } + + if (result.type !== 'data') { + throw new Error( + `Only supports location processor results of type 'data', but got '${result.type}'`, + ); + } + + const definition = result.data.toString(); + const apiEntity = entity as ApiEntity; + apiEntity.spec.definition = definition; + + return entity; + } +} + +function extractReference(reference: string): { type: string; target: string } { + const delimiterIndex = reference.indexOf(':'); + const type = reference.slice(0, delimiterIndex); + const target = reference.slice(delimiterIndex + 1); + + return { type, target }; +} diff --git a/plugins/catalog-backend/src/ingestion/processors/types.ts b/plugins/catalog-backend/src/ingestion/processors/types.ts index c8f3f6f482..3aaa2d21f9 100644 --- a/plugins/catalog-backend/src/ingestion/processors/types.ts +++ b/plugins/catalog-backend/src/ingestion/processors/types.ts @@ -50,6 +50,7 @@ export type LocationProcessor = { * * @param entity The entity to process * @param location The location that the entity came from + * @param read Reads the contents of a location * @param emit A sink for auxiliary items resulting from the processing * @returns The same entity or a modifid version of it */ @@ -57,6 +58,7 @@ export type LocationProcessor = { entity: Entity, location: LocationSpec, emit: LocationProcessorEmit, + read: LocationProcessorRead, ): Promise; /** @@ -107,3 +109,7 @@ export type LocationProcessorResult = | LocationProcessorDataResult | LocationProcessorEntityResult | LocationProcessorErrorResult; + +export type LocationProcessorRead = ( + location: LocationSpec, +) => Promise; From 0e76ab787334e50ca924a3f6b1681a24215edd42 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 24 Sep 2020 23:43:08 +0200 Subject: [PATCH 43/94] backend: add and use for external URL discovery --- packages/backend/src/index.ts | 8 +++++--- packages/backend/src/plugins/auth.ts | 12 +++++++----- packages/backend/src/plugins/proxy.ts | 11 ++++++----- packages/backend/src/types.ts | 2 ++ plugins/auth-backend/src/service/router.ts | 11 ++++++----- .../auth-backend/src/service/standaloneServer.ts | 3 +++ plugins/proxy-backend/src/service/router.test.ts | 8 ++++++-- plugins/proxy-backend/src/service/router.ts | 14 ++++++-------- .../proxy-backend/src/service/standaloneServer.ts | 4 +++- 9 files changed, 44 insertions(+), 29 deletions(-) diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 9d4a738ecf..16cb35cade 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -30,6 +30,7 @@ import { getRootLogger, useHotMemoize, notFoundHandler, + SingleHostDiscovery, } from '@backstage/backend-common'; import { ConfigReader, AppConfig } from '@backstage/config'; import healthcheck from './plugins/healthcheck'; @@ -59,7 +60,8 @@ function makeCreateEnv(loadedConfigs: AppConfig[]) { }, }, ); - return { logger, database, config }; + const discovery = SingleHostDiscovery.fromConfig(config); + return { logger, database, config, discovery }; }; } @@ -85,11 +87,11 @@ async function main() { apiRouter.use('/rollbar', await rollbar(rollbarEnv)); apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv)); apiRouter.use('/sentry', await sentry(sentryEnv)); - apiRouter.use('/auth', await auth(authEnv, '/api/auth')); + apiRouter.use('/auth', await auth(authEnv)); apiRouter.use('/identity', await identity(identityEnv)); apiRouter.use('/techdocs', await techdocs(techdocsEnv)); apiRouter.use('/kubernetes', await kubernetes(kubernetesEnv)); - apiRouter.use('/proxy', await proxy(proxyEnv, '/api/proxy')); + apiRouter.use('/proxy', await proxy(proxyEnv)); apiRouter.use('/graphql', await graphql(graphqlEnv)); apiRouter.use(notFoundHandler()); diff --git a/packages/backend/src/plugins/auth.ts b/packages/backend/src/plugins/auth.ts index 60baaff6b7..913c8b783d 100644 --- a/packages/backend/src/plugins/auth.ts +++ b/packages/backend/src/plugins/auth.ts @@ -17,9 +17,11 @@ import { createRouter } from '@backstage/plugin-auth-backend'; import { PluginEnvironment } from '../types'; -export default async function createPlugin( - { logger, database, config }: PluginEnvironment, - basePath: string, -) { - return await createRouter({ logger, config, database, basePath }); +export default async function createPlugin({ + logger, + database, + config, + discovery, +}: PluginEnvironment) { + return await createRouter({ logger, config, database, discovery }); } diff --git a/packages/backend/src/plugins/proxy.ts b/packages/backend/src/plugins/proxy.ts index e96acf69d3..867e742dc0 100644 --- a/packages/backend/src/plugins/proxy.ts +++ b/packages/backend/src/plugins/proxy.ts @@ -18,9 +18,10 @@ import { createRouter } from '@backstage/plugin-proxy-backend'; import { PluginEnvironment } from '../types'; -export default async function createPlugin( - { logger, config }: PluginEnvironment, - pathPrefix: string, -) { - return await createRouter({ logger, config, pathPrefix }); +export default async function createPlugin({ + logger, + config, + discovery, +}: PluginEnvironment) { + return await createRouter({ logger, config, discovery }); } diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index f7df3d05c6..3709fc8d9a 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -17,9 +17,11 @@ import Knex from 'knex'; import { Logger } from 'winston'; import { Config } from '@backstage/config'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; export type PluginEnvironment = { logger: Logger; database: Knex; config: Config; + discovery: PluginEndpointDiscovery; }; diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index e70e687879..56ac587126 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -22,13 +22,16 @@ import { Logger } from 'winston'; import { createAuthProviderRouter } from '../providers'; import { Config } from '@backstage/config'; import { DatabaseKeyStore, TokenFactory, createOidcRouter } from '../identity'; -import { NotFoundError } from '@backstage/backend-common'; +import { + NotFoundError, + PluginEndpointDiscovery, +} from '@backstage/backend-common'; export interface RouterOptions { logger: Logger; database: Knex; config: Config; - basePath?: string; + discovery: PluginEndpointDiscovery; } export async function createRouter( @@ -38,9 +41,7 @@ export async function createRouter( const logger = options.logger.child({ plugin: 'auth' }); const appUrl = options.config.getString('app.baseUrl'); - const backendUrl = options.config.getString('backend.baseUrl'); - // TODO(Rugvip): Replace with service discovery of external URL - const authUrl = backendUrl + (options.basePath ?? '/api/auth'); + const authUrl = await options.discovery.getExternalBaseUrl('auth'); const keyDurationSeconds = 3600; diff --git a/plugins/auth-backend/src/service/standaloneServer.ts b/plugins/auth-backend/src/service/standaloneServer.ts index dc91c92631..71771340a5 100644 --- a/plugins/auth-backend/src/service/standaloneServer.ts +++ b/plugins/auth-backend/src/service/standaloneServer.ts @@ -23,6 +23,7 @@ import { createServiceBuilder, useHotMemoize, loadBackendConfig, + SingleHostDiscovery, } from '@backstage/backend-common'; export interface ServerOptions { @@ -34,6 +35,7 @@ export async function startStandaloneServer( ): Promise { const logger = options.logger.child({ service: 'auth-backend' }); const config = ConfigReader.fromConfigs(await loadBackendConfig()); + const discovery = SingleHostDiscovery.fromConfig(config); const database = useHotMemoize(module, () => { const knex = Knex({ @@ -52,6 +54,7 @@ export async function startStandaloneServer( logger, config, database, + discovery, }); const service = createServiceBuilder(module) diff --git a/plugins/proxy-backend/src/service/router.test.ts b/plugins/proxy-backend/src/service/router.test.ts index c7d7fff1d4..07737e9ea8 100644 --- a/plugins/proxy-backend/src/service/router.test.ts +++ b/plugins/proxy-backend/src/service/router.test.ts @@ -17,16 +17,20 @@ import { createRouter } from './router'; import * as winston from 'winston'; import { ConfigReader } from '@backstage/config'; -import { loadBackendConfig } from '@backstage/backend-common'; +import { + loadBackendConfig, + SingleHostDiscovery, +} from '@backstage/backend-common'; describe('createRouter', () => { it('works', async () => { const logger = winston.createLogger(); const config = ConfigReader.fromConfigs(await loadBackendConfig()); + const discovery = SingleHostDiscovery.fromConfig(config); const router = await createRouter({ config, logger, - pathPrefix: '/proxy', + discovery, }); expect(router).toBeDefined(); }); diff --git a/plugins/proxy-backend/src/service/router.ts b/plugins/proxy-backend/src/service/router.ts index 7cd5f37598..8cec31719c 100644 --- a/plugins/proxy-backend/src/service/router.ts +++ b/plugins/proxy-backend/src/service/router.ts @@ -23,12 +23,12 @@ import createProxyMiddleware, { } from 'http-proxy-middleware'; import { Logger } from 'winston'; import http from 'http'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; export interface RouterOptions { logger: Logger; config: Config; - // The URL path prefix that the router itself is mounted as, commonly "/proxy" - pathPrefix: string; + discovery: PluginEndpointDiscovery; } export interface ProxyConfig extends ProxyMiddlewareConfig { @@ -76,16 +76,14 @@ export async function createRouter( ): Promise { const router = Router(); + const externalUrl = await options.discovery.getExternalBaseUrl('proxy'); + const { pathname: pathPrefix } = new URL(externalUrl); + const proxyConfig = options.config.getOptional('proxy') ?? {}; Object.entries(proxyConfig).forEach(([route, proxyRouteConfig]) => { router.use( route, - buildMiddleware( - options.pathPrefix, - options.logger, - route, - proxyRouteConfig, - ), + buildMiddleware(pathPrefix, options.logger, route, proxyRouteConfig), ); }); diff --git a/plugins/proxy-backend/src/service/standaloneServer.ts b/plugins/proxy-backend/src/service/standaloneServer.ts index 68e9ade183..83980729d8 100644 --- a/plugins/proxy-backend/src/service/standaloneServer.ts +++ b/plugins/proxy-backend/src/service/standaloneServer.ts @@ -17,6 +17,7 @@ import { createServiceBuilder, loadBackendConfig, + SingleHostDiscovery, } from '@backstage/backend-common'; import { Server } from 'http'; import { Logger } from 'winston'; @@ -37,10 +38,11 @@ export async function startStandaloneServer( logger.debug('Creating application...'); const config = ConfigReader.fromConfigs(await loadBackendConfig()); + const discovery = SingleHostDiscovery.fromConfig(config); const router = await createRouter({ config, logger, - pathPrefix: '/proxy', + discovery, }); const service = createServiceBuilder(module) .enableCors({ origin: 'http://localhost:3000' }) From 2a443ce68de13c92c5eaff347cb44f403e9e38f1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 24 Sep 2020 23:59:38 +0200 Subject: [PATCH 44/94] techdocs-backend: use endpoint discovery to look up catalog url --- packages/backend/src/plugins/techdocs.ts | 2 ++ .../techdocs-backend/src/service/router.ts | 25 +++++++++++++------ .../src/service/standaloneServer.ts | 7 +++++- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/packages/backend/src/plugins/techdocs.ts b/packages/backend/src/plugins/techdocs.ts index 58dca83b43..9f66cbe2ff 100644 --- a/packages/backend/src/plugins/techdocs.ts +++ b/packages/backend/src/plugins/techdocs.ts @@ -29,6 +29,7 @@ import Docker from 'dockerode'; export default async function createPlugin({ logger, config, + discovery, }: PluginEnvironment) { const generators = new Generators(); const techdocsGenerator = new TechdocsGenerator(logger, config); @@ -51,5 +52,6 @@ export default async function createPlugin({ dockerClient, logger, config, + discovery, }); } diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index 14820da4e9..bd9e71cb8b 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -26,7 +26,10 @@ import { PublisherBase, LocalPublish, } from '../techdocs'; -import { resolvePackagePath } from '@backstage/backend-common'; +import { + PluginEndpointDiscovery, + resolvePackagePath, +} from '@backstage/backend-common'; import { Entity } from '@backstage/catalog-model'; import { DocsBuilder } from './helpers'; @@ -35,6 +38,7 @@ type RouterOptions = { generators: GeneratorBuilder; publisher: PublisherBase; logger: Logger; + discovery: PluginEndpointDiscovery; database?: Knex; // TODO: Make database required when we're implementing database stuff. config: Config; dockerClient: Docker; @@ -52,20 +56,25 @@ export async function createRouter({ config, dockerClient, logger, + discovery, }: RouterOptions): Promise { const router = Router(); router.get('/docs/:kind/:namespace/:name/*', async (req, res) => { - const baseUrl = config.getString('backend.baseUrl'); const storageUrl = config.getString('techdocs.storageUrl'); const { kind, namespace, name } = req.params; - const entity = (await ( - await fetch( - `${baseUrl}/api/catalog/entities/by-name/${kind}/${namespace}/${name}`, - ) - ).json()) as Entity; + const catalogUrl = await discovery.getBaseUrl('catalog'); + const triple = [kind, namespace, name].map(encodeURIComponent).join('/'); + + const catalogRes = await fetch(`${catalogUrl}/entities/by-name/${triple}`); + if (!catalogRes.ok) { + catalogRes.body.pipe(res.status(catalogRes.status)); + return; + } + + const entity: Entity = await catalogRes.json(); const docsBuilder = new DocsBuilder({ preparers, @@ -80,7 +89,7 @@ export async function createRouter({ await docsBuilder.build(); } - return res.redirect(`${storageUrl}${req.path.replace('/docs', '')}`); + res.redirect(`${storageUrl}${req.path.replace('/docs', '')}`); }); if (publisher instanceof LocalPublish) { diff --git a/plugins/techdocs-backend/src/service/standaloneServer.ts b/plugins/techdocs-backend/src/service/standaloneServer.ts index 7a39742b80..79e8b0b945 100644 --- a/plugins/techdocs-backend/src/service/standaloneServer.ts +++ b/plugins/techdocs-backend/src/service/standaloneServer.ts @@ -14,7 +14,10 @@ * limitations under the License. */ -import { createServiceBuilder } from '@backstage/backend-common'; +import { + createServiceBuilder, + SingleHostDiscovery, +} from '@backstage/backend-common'; import { Server } from 'http'; import { Logger } from 'winston'; import { createRouter } from './router'; @@ -39,6 +42,7 @@ export async function startStandaloneServer( ): Promise { const logger = options.logger.child({ service: 'techdocs-backend' }); const config = ConfigReader.fromConfigs([]); + const discovery = SingleHostDiscovery.fromConfig(config); logger.debug('Creating application...'); const preparers = new Preparers(); @@ -61,6 +65,7 @@ export async function startStandaloneServer( publisher, dockerClient, config, + discovery, }); const service = createServiceBuilder(module) .enableCors({ origin: 'http://localhost:3000' }) From aa970ab49b136509f197df921ad245be80a6ca50 Mon Sep 17 00:00:00 2001 From: "J Shamsul Bahri (jibone))" Date: Fri, 25 Sep 2020 17:02:17 +0800 Subject: [PATCH 45/94] rename SamlAuthConnector to DirectAuthConnector --- .../core-api/src/apis/implementations/auth/saml/SamlAuth.ts | 4 ++-- .../{SamlAuthConnector.ts => DirectAuthConnector.ts} | 6 +++--- packages/core-api/src/lib/AuthConnector/index.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) rename packages/core-api/src/lib/AuthConnector/{SamlAuthConnector.ts => DirectAuthConnector.ts} (94%) diff --git a/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts b/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts index dbd9368842..973b402756 100644 --- a/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts @@ -15,7 +15,7 @@ */ import SamlIcon from '@material-ui/icons/AcUnit'; -import { SamlAuthConnector } from '../../../../lib/AuthConnector'; +import { DirectAuthConnector } from '../../../../lib/AuthConnector'; import { SessionManager } from '../../../../lib/AuthSessionManager/types'; import { Observable } from '../../../../types'; import { @@ -57,7 +57,7 @@ class SamlAuth implements ProfileInfoApi, BackstageIdentityApi, SessionApi { environment = 'development', provider = DEFAULT_PROVIDER, }: CreateOptions) { - const connector = new SamlAuthConnector({ + const connector = new DirectAuthConnector({ discoveryApi, environment, provider, diff --git a/packages/core-api/src/lib/AuthConnector/SamlAuthConnector.ts b/packages/core-api/src/lib/AuthConnector/DirectAuthConnector.ts similarity index 94% rename from packages/core-api/src/lib/AuthConnector/SamlAuthConnector.ts rename to packages/core-api/src/lib/AuthConnector/DirectAuthConnector.ts index e8ab412756..517bf82ae7 100644 --- a/packages/core-api/src/lib/AuthConnector/SamlAuthConnector.ts +++ b/packages/core-api/src/lib/AuthConnector/DirectAuthConnector.ts @@ -27,13 +27,13 @@ type Options = { provider: AuthProvider & { id: string }; }; -export type SamlResponse = { +export type DirectAuthResponse = { userId: string; profile: ProfileInfo; backstageIdentity: BackstageIdentity; }; -export class SamlAuthConnector { +export class DirectAuthConnector { private readonly discoveryApi: DiscoveryApi; private readonly environment: string | undefined; private readonly provider: AuthProvider & { id: string }; @@ -46,7 +46,7 @@ export class SamlAuthConnector { this.provider = provider; } - async createSession(): Promise { + async createSession(): Promise { const popupUrl = await this.buildUrl('/start'); const payload = await showLoginPopup({ url: popupUrl, diff --git a/packages/core-api/src/lib/AuthConnector/index.ts b/packages/core-api/src/lib/AuthConnector/index.ts index 84f5067443..388619e2c1 100644 --- a/packages/core-api/src/lib/AuthConnector/index.ts +++ b/packages/core-api/src/lib/AuthConnector/index.ts @@ -15,5 +15,5 @@ */ export { DefaultAuthConnector } from './DefaultAuthConnector'; -export { SamlAuthConnector } from './SamlAuthConnector'; +export { DirectAuthConnector } from './DirectAuthConnector'; export * from './types'; From 4e5dd5f506ddbca42914c15862452b389a12138d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 25 Sep 2020 11:03:20 +0200 Subject: [PATCH 46/94] CHANGELOG: add entry for backend service discovery --- CHANGELOG.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d360dd995d..0d6ac3a063 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,13 +8,27 @@ If you encounter issues while upgrading to a newer version, don't hesitate to re > Collect changes for the next release below +### Backend (example-backend, or backends created with @backstage/create-app) + +- The default mount point for backend plugins have been changed to `/api`. These changes are done in the backend package itself, so it is recommended that you sync up existing backend packages with this new pattern. [#2562](https://github.com/spotify/backstage/pull/2562) +- A service discovery mechanism for backend plugins has been added, and is now a requirement for several backend plugins. See [packages/backend/src/index.ts](./packages/backend/src/index.ts) for how to set it up using `SingleHostDiscovery` from `@backstage/backend-common`. Note that the default base path for plugins is set to `/api` to that change, but it can be set to use the old behavior via the `basePath` option. [#2600](https://github.com/spotify/backstage/pull/2600) + ### @backstage/core - Renamed `SessionStateApi` to `SessionApi` and `logout` to `signOut`. Custom implementations of the `SingInPage` app-component will need to rename their `logout` function. The different auth provider items for the `UserSettingsMenu` have been consolidated into a single `ProviderSettingsItem`, meaning you need to replace existing usages of `OAuthProviderSettings` and `OIDCProviderSettings`. [#2555](https://github.com/spotify/backstage/pull/2555). ### @backstage/auth-backend -- The default mount path of backend plugins was changed to `/api/:pluginId`, and as part of that it was needed to enable configuration of the base path of the auth backend, so that it can construct redirect URLs correctly. The default base path is `/api/auth`, but you need to set this to `/auth` if you want to keep using the old path. Note that you will also need to reconfigure any allowed redirect URLs to include `/api` if you switch to the new recommended pattern. [#2562](https://github.com/spotify/backstage/pull/2562) +- The default mount path of backend plugins was changed to `/api/:pluginId`, and as part of that it was needed to enable configuration of the base path of the auth backend, so that it can construct redirect URLs correctly. Note that you will also need to reconfigure any allowed redirect URLs to include `/api` if you switch to the new recommended pattern. [#2562](https://github.com/spotify/backstage/pull/2562) +- The auth backend now requires an implementation of `PluginEndpointDiscovery` from `@backstage/backend-common` to be passed in as `discovery`. See the changes to `@backstage/backend`. + +### @backstage/proxy-backend + +- The proxy backend now requires an implementation of `PluginEndpointDiscovery` from `@backstage/backend-common` to be passed in as `discovery`. See the changes to `@backstage/backend`. + +### @backstage/techdocs-backend + +- The TechDocs backend now requires an implementation of `PluginEndpointDiscovery` from `@backstage/backend-common` to be passed in as `discovery`. See the changes to `@backstage/backend`. ## v0.1.1-alpha.22 From 95fb7b5e4e59e05861c1deb4052132bc75ed9899 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20=C3=85lund?= Date: Fri, 25 Sep 2020 11:50:37 +0200 Subject: [PATCH 47/94] docs: update roadmap (#2602) --- docs/overview/roadmap.md | 44 ++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/docs/overview/roadmap.md b/docs/overview/roadmap.md index d6c1d2d641..c6b822ad62 100644 --- a/docs/overview/roadmap.md +++ b/docs/overview/roadmap.md @@ -27,10 +27,11 @@ We have divided the project into three high-level _phases_: With a single catalog, Backstage makes it easy for a team to manage ten services — and makes it possible for your company to manage thousands of them. -- 🐇 **Phase 3:** Ecosystem (later) - Everyone's infrastructure stack is - different. By fostering a vibrant community of contributors we hope to provide - an ecosystem of Open Source plugins/integrations that allows you to pick the - tools that match your stack. +- 🐇 **Phase 3:** Ecosystem (ongoing, see + [Plugin Marketplace](https://backstage.io/plugins)) - Everyone's + infrastructure stack is different. By fostering a vibrant community of + contributors we hope to provide an ecosystem of Open Source + plugins/integrations that allows you to pick the tools that match your stack. ## Detailed roadmap @@ -53,24 +54,24 @@ guidelines to get started. it much easier to see how a plugin can be built that integrates with the Backstage Service Catalog. +- **[Kubernetes support](https://github.com/spotify/backstage/milestone/20)** - + Native support for Kubernetes, making it easier for developers to see and + manage their services running in k8s. + +- **[Helm charts](https://github.com/spotify/backstage/issues/2540)** - Provide + Helm charts for easy deployments of Backstage and its subsystems on + Kubernetes. + +- **[Backstage platform is stable](https://github.com/spotify/backstage/milestone/19)** - + The platform APIs and features are stable and can be depended on for + production use. After this plugins will require little to no maintenance. + - **Backstage Design System** - By providing design guidelines for common plugin layouts together, rich set of reusable UI components ([Storybook](https://backstage.io/storybook)) and Figma design resources. The Design System will make it easy to design and build plugins that are consistent across the platform -- supporting both developers and designers. -- **[TechDocs v1](https://github.com/spotify/backstage/milestone/16)** - Our - docs-like-code feature TechDocs working end to end. - -- **[Initial GraphQL API](https://github.com/spotify/backstage/milestone/13)** - - A GraphQL API will open up the rich metadata provided by Backstage in a single - query. Plugins can easily query this API as well as extend the model where - needed. - -- **Production deployments** - Provide instructions and default configurations - (e.g. through Helm charts) for easy deployments of Backstage and its - subsystems on Kubernetes. - - **Cloud Cost Insights plugin (from Spotify)** - Spotify teams are fully responsible for their own software, including the cost of the cloud resources they use. By making our internal cost insights plugin available as open source @@ -96,10 +97,6 @@ Chances are that someone will jump in and help build it. ### Future work 🔮 -- **[Backstage platform is stable](https://github.com/spotify/backstage/milestone/19)** - - The platform APIs and features are stable and can be depended on for - production use. After this plugins will require little to no maintenance. - - **Deploy a product demo at `demo.backstage.io`** - Deploy a typical Backstage deployment available publicly so that people can click around and get a feel for the product without having to install anything. @@ -118,8 +115,15 @@ Chances are that someone will jump in and help build it. [AWS](https://github.com/spotify/backstage/issues/290), [Azure](https://github.com/spotify/backstage/issues/348) and others. +- **[Initial GraphQL API](https://github.com/spotify/backstage/milestone/13)** - + A GraphQL API will open up the rich metadata provided by Backstage in a single + query. Plugins can easily query this API as well as extend the model where + needed. + ### Completed milestones ✅ +- [Donate Backstage to the CNCF 🎉](https://backstage.io/blog/2020/09/23/backstage-cncf-sandbox) +- [TechDocs v1](https://backstage.io/blog/2020/09/08/announcing-tech-docs) - [Plugin marketplace](https://backstage.io/plugins) - [Improved and move documentation to backstage.io](https://backstage.io/docs/overview/what-is-backstage) - [Backstage Service Catalog (alpha)](https://backstage.io/blog/2020/06/22/backstage-service-catalog-alpha) From 1ae59f084b25dd95136e365e8dce2beaff8ff93a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 25 Sep 2020 11:11:33 +0200 Subject: [PATCH 48/94] create-app: update to use backend discovery --- .../default-app/packages/backend/src/index.ts | 6 ++++-- .../default-app/packages/backend/src/plugins/auth.ts | 3 ++- .../default-app/packages/backend/src/plugins/proxy.ts | 11 ++++++----- .../packages/backend/src/plugins/techdocs.ts | 2 ++ .../default-app/packages/backend/src/types.ts | 2 ++ 5 files changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/create-app/templates/default-app/packages/backend/src/index.ts b/packages/create-app/templates/default-app/packages/backend/src/index.ts index 457b95a7a9..fa058b6f71 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/index.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/index.ts @@ -14,6 +14,7 @@ import { getRootLogger, useHotMemoize, notFoundHandler, + SingleHostDiscovery, } from '@backstage/backend-common'; import { ConfigReader, AppConfig } from '@backstage/config'; import auth from './plugins/auth'; @@ -37,7 +38,8 @@ function makeCreateEnv(loadedConfigs: AppConfig[]) { }, }, ); - return { logger, database, config }; + const discovery = SingleHostDiscovery.fromConfig(config); + return { logger, database, config, discovery }; }; } @@ -59,7 +61,7 @@ async function main() { apiRouter.use('/auth', await auth(authEnv)) apiRouter.use('/identity', await identity(identityEnv)) apiRouter.use('/techdocs', await techdocs(techdocsEnv)) - apiRouter.use('/proxy', await proxy(proxyEnv, '/api/proxy')) + apiRouter.use('/proxy', await proxy(proxyEnv)) apiRouter.use(notFoundHandler()); const service = createServiceBuilder(module) diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/auth.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/auth.ts index 61af87cce0..fe19855d5d 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/auth.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/auth.ts @@ -5,6 +5,7 @@ export default async function createPlugin({ logger, database, config, + discovery, }: PluginEnvironment) { - return await createRouter({ logger, config, database }); + return await createRouter({ logger, config, database, discovery }); } diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/proxy.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/proxy.ts index b20265c2cd..388d3fc446 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/proxy.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/proxy.ts @@ -2,9 +2,10 @@ import { createRouter } from '@backstage/plugin-proxy-backend'; import { PluginEnvironment } from '../types'; -export default async function createPlugin( - { logger, config }: PluginEnvironment, - pathPrefix: string, -) { - return await createRouter({ logger, config, pathPrefix }); +export default async function createPlugin({ + logger, + config, + discovery, +}: PluginEnvironment) { + return await createRouter({ logger, config, discovery }); } diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts index 9c7de3512b..b522992a75 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts @@ -13,6 +13,7 @@ import Docker from 'dockerode'; export default async function createPlugin({ logger, config, + discovery, }: PluginEnvironment) { const generators = new Generators(); const techdocsGenerator = new TechdocsGenerator(logger, config); @@ -37,5 +38,6 @@ export default async function createPlugin({ dockerClient, logger, config, + discovery, }); } diff --git a/packages/create-app/templates/default-app/packages/backend/src/types.ts b/packages/create-app/templates/default-app/packages/backend/src/types.ts index 570a5fcdb1..d145255390 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/types.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/types.ts @@ -1,9 +1,11 @@ import Knex from 'knex'; import { Logger } from 'winston'; import { Config } from '@backstage/config'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; export type PluginEnvironment = { logger: Logger; database: Knex; config: Config; + discovery: PluginEndpointDiscovery; }; From ad90193d2ac45bf1e0bb16efd2f147ae11af8911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 25 Sep 2020 12:14:52 +0200 Subject: [PATCH 49/94] fix(backend): make csp configurable to unbreak app-backend served apps not being able to fetch --- app-config.development.yaml | 2 + app-config.yaml | 2 + .../src/service/lib/ServiceBuilderImpl.ts | 43 ++++++++++++++++- .../backend-common/src/service/lib/config.ts | 46 ++++++++++++++++--- .../default-app/app-config.development.yaml | 2 + .../templates/default-app/app-config.yaml.hbs | 2 + 6 files changed, 88 insertions(+), 9 deletions(-) diff --git a/app-config.development.yaml b/app-config.development.yaml index da274ba1a8..817847c6d6 100644 --- a/app-config.development.yaml +++ b/app-config.development.yaml @@ -9,3 +9,5 @@ backend: origin: http://localhost:3000 methods: [GET, POST, PUT, DELETE] credentials: true + csp: + connect-src: ["'self'", 'http:', 'https:'] diff --git a/app-config.yaml b/app-config.yaml index 01925c21dd..a29240fd9c 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -9,6 +9,8 @@ backend: database: client: sqlite3 connection: ':memory:' + csp: + connect-src: ["'self'", 'https:'] # See README.md in the proxy-backend plugin for information on the configuration format proxy: diff --git a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts index 10f9ca4d9b..35298dba6e 100644 --- a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts +++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts @@ -31,10 +31,12 @@ import { } from '../../middleware'; import { ServiceBuilder } from '../types'; import { + CspOptions, + HttpsSettings, readBaseOptions, readCorsOptions, + readCspOptions, readHttpsSettings, - HttpsSettings, } from './config'; import { createHttpServer, createHttpsServer } from './hostFactory'; import { metricsHandler } from './metrics'; @@ -42,12 +44,27 @@ import { metricsHandler } from './metrics'; const DEFAULT_PORT = 7000; // '' is express default, which listens to all interfaces const DEFAULT_HOST = ''; +// taken from the helmet source code - don't seem to be exported +const DEFAULT_CSP = { + 'default-src': ["'self'"], + 'base-uri': ["'self'"], + 'block-all-mixed-content': [], + 'font-src': ["'self'", 'https:', 'data:'], + 'frame-ancestors': ["'self'"], + 'img-src': ["'self'", 'data:'], + 'object-src': ["'none'"], + 'script-src': ["'self'"], + 'script-src-attr': ["'none'"], + 'style-src': ["'self'", 'https:', "'unsafe-inline'"], + 'upgrade-insecure-requests': [], +}; export class ServiceBuilderImpl implements ServiceBuilder { private port: number | undefined; private host: string | undefined; private logger: Logger | undefined; private corsOptions: cors.CorsOptions | undefined; + private cspOptions: CspOptions | undefined; private httpsSettings: HttpsSettings | undefined; private enableMetrics: boolean = true; private routers: [string, Router][]; @@ -79,6 +96,11 @@ export class ServiceBuilderImpl implements ServiceBuilder { this.corsOptions = corsOptions; } + const cspOptions = readCspOptions(backendConfig); + if (cspOptions) { + this.cspOptions = cspOptions; + } + const httpsSettings = readHttpsSettings(backendConfig); if (httpsSettings) { this.httpsSettings = httpsSettings; @@ -115,6 +137,11 @@ export class ServiceBuilderImpl implements ServiceBuilder { return this; } + setCsp(options: CspOptions): ServiceBuilder { + this.cspOptions = options; + return this; + } + addRouter(root: string, router: Router): ServiceBuilder { this.routers.push([root, router]); return this; @@ -127,10 +154,20 @@ export class ServiceBuilderImpl implements ServiceBuilder { host, logger, corsOptions, + cspOptions, httpsSettings, } = this.getOptions(); - app.use(helmet()); + app.use( + helmet({ + contentSecurityPolicy: { + directives: { + ...DEFAULT_CSP, + ...cspOptions, + }, + }, + }), + ); if (corsOptions) { app.use(cors(corsOptions)); } @@ -177,6 +214,7 @@ export class ServiceBuilderImpl implements ServiceBuilder { host: string; logger: Logger; corsOptions?: cors.CorsOptions; + cspOptions?: CspOptions; httpsSettings?: HttpsSettings; } { return { @@ -184,6 +222,7 @@ export class ServiceBuilderImpl implements ServiceBuilder { host: this.host ?? DEFAULT_HOST, logger: this.logger ?? getRootLogger(), corsOptions: this.corsOptions, + cspOptions: this.cspOptions, httpsSettings: this.httpsSettings, }; } diff --git a/packages/backend-common/src/service/lib/config.ts b/packages/backend-common/src/service/lib/config.ts index e257bd4111..bfd966b499 100644 --- a/packages/backend-common/src/service/lib/config.ts +++ b/packages/backend-common/src/service/lib/config.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ConfigReader } from '@backstage/config'; +import { Config } from '@backstage/config'; import { CorsOptions } from 'cors'; export type BaseOptions = { @@ -57,6 +57,13 @@ export type CertificateAttributes = { commonName?: string; }; +/** + * A map from CSP directive names to their values. + * + * Added here since helmet doesn't export this type publicly. + */ +export type CspOptions = Record; + /** * Reads some base options out of a config object. * @@ -71,7 +78,7 @@ export type CertificateAttributes = { * } * ``` */ -export function readBaseOptions(config: ConfigReader): BaseOptions { +export function readBaseOptions(config: Config): BaseOptions { if (typeof config.get('listen') === 'string') { // TODO(freben): Expand this to support more addresses and perhaps optional const { host, port } = parseListenAddress(config.getString('listen')); @@ -105,7 +112,7 @@ export function readBaseOptions(config: ConfigReader): BaseOptions { * } * ``` */ -export function readCorsOptions(config: ConfigReader): CorsOptions | undefined { +export function readCorsOptions(config: Config): CorsOptions | undefined { const cc = config.getOptionalConfig('cors'); if (!cc) { return undefined; @@ -123,6 +130,33 @@ export function readCorsOptions(config: ConfigReader): CorsOptions | undefined { }); } +/** + * Attempts to read a CSP options object from the root of a config object. + * + * @param config The root of a backend config object + * @returns A CSP options object, or undefined if not specified + * + * @example + * ```yaml + * backend: + * csp: + * connect-src: ["'self'", 'http:', 'https:'] + * ``` + */ +export function readCspOptions(config: Config): CspOptions | undefined { + const cc = config.getOptionalConfig('csp'); + if (!cc) { + return undefined; + } + + const result: CspOptions = {}; + for (const key of cc.keys()) { + result[key] = cc.getStringArray(key); + } + + return result; +} + /** * Attempts to read a https settings object from the root of a config object. * @@ -138,9 +172,7 @@ export function readCorsOptions(config: ConfigReader): CorsOptions | undefined { * } * ``` */ -export function readHttpsSettings( - config: ConfigReader, -): HttpsSettings | undefined { +export function readHttpsSettings(config: Config): HttpsSettings | undefined { const cc = config.getOptionalConfig('https'); if (!cc) { @@ -157,7 +189,7 @@ export function readHttpsSettings( } function getOptionalStringOrStrings( - config: ConfigReader, + config: Config, key: string, ): string | string[] | undefined { const value = config.getOptional(key); diff --git a/packages/create-app/templates/default-app/app-config.development.yaml b/packages/create-app/templates/default-app/app-config.development.yaml index da274ba1a8..817847c6d6 100644 --- a/packages/create-app/templates/default-app/app-config.development.yaml +++ b/packages/create-app/templates/default-app/app-config.development.yaml @@ -9,3 +9,5 @@ backend: origin: http://localhost:3000 methods: [GET, POST, PUT, DELETE] credentials: true + csp: + connect-src: ["'self'", 'http:', 'https:'] diff --git a/packages/create-app/templates/default-app/app-config.yaml.hbs b/packages/create-app/templates/default-app/app-config.yaml.hbs index 92c517bf93..17e9fa0728 100644 --- a/packages/create-app/templates/default-app/app-config.yaml.hbs +++ b/packages/create-app/templates/default-app/app-config.yaml.hbs @@ -9,6 +9,8 @@ backend: baseUrl: http://localhost:7000 listen: port: 7000 + csp: + connect-src: ["'self'", 'https:'] {{#if dbTypeSqlite}} database: client: sqlite3 From 29c1ed2d991c3a49cb0b01f1ce56618f88f3b3cc Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 25 Sep 2020 13:45:13 +0200 Subject: [PATCH 50/94] auth-backend: move auth provider router creation to router --- .../auth-backend/src/providers/factories.ts | 33 ++++--------------- plugins/auth-backend/src/providers/index.ts | 2 +- plugins/auth-backend/src/service/router.ts | 27 ++++++++++----- 3 files changed, 26 insertions(+), 36 deletions(-) diff --git a/plugins/auth-backend/src/providers/factories.ts b/plugins/auth-backend/src/providers/factories.ts index 6c83dffda3..37761c2305 100644 --- a/plugins/auth-backend/src/providers/factories.ts +++ b/plugins/auth-backend/src/providers/factories.ts @@ -14,9 +14,6 @@ * limitations under the License. */ -import Router from 'express-promise-router'; -import { Logger } from 'winston'; -import { TokenIssuer } from '../identity'; import { createGithubProvider } from './github'; import { createGitlabProvider } from './gitlab'; import { createGoogleProvider } from './google'; @@ -25,8 +22,7 @@ import { createOktaProvider } from './okta'; import { createSamlProvider } from './saml'; import { createAuth0Provider } from './auth0'; import { createMicrosoftProvider } from './microsoft'; -import { AuthProviderConfig, AuthProviderFactory } from './types'; -import { Config } from '@backstage/config'; +import { AuthProviderFactory, AuthProviderFactoryOptions } from './types'; const factories: { [providerId: string]: AuthProviderFactory } = { google: createGoogleProvider, @@ -39,31 +35,14 @@ const factories: { [providerId: string]: AuthProviderFactory } = { oauth2: createOAuth2Provider, }; -export const createAuthProviderRouter = ( +export function createAuthProvider( providerId: string, - globalConfig: AuthProviderConfig, - config: Config, - logger: Logger, - tokenIssuer: TokenIssuer, -) => { + options: AuthProviderFactoryOptions, +) { const factory = factories[providerId]; if (!factory) { throw Error(`No auth provider available for '${providerId}'`); } - const router = Router(); - - const handler = factory({ globalConfig, config, logger, tokenIssuer }); - - router.get('/start', handler.start.bind(handler)); - router.get('/handler/frame', handler.frameHandler.bind(handler)); - router.post('/handler/frame', handler.frameHandler.bind(handler)); - if (handler.logout) { - router.post('/logout', handler.logout.bind(handler)); - } - if (handler.refresh) { - router.get('/refresh', handler.refresh.bind(handler)); - } - - return router; -}; + return factory(options); +} diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts index d210bfd1bb..99348438be 100644 --- a/plugins/auth-backend/src/providers/index.ts +++ b/plugins/auth-backend/src/providers/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { createAuthProviderRouter } from './factories'; +export { createAuthProvider } from './factories'; diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 56ac587126..99b5ee800b 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -19,7 +19,7 @@ import Router from 'express-promise-router'; import cookieParser from 'cookie-parser'; import Knex from 'knex'; import { Logger } from 'winston'; -import { createAuthProviderRouter } from '../providers'; +import { createAuthProvider } from '../providers'; import { Config } from '@backstage/config'; import { DatabaseKeyStore, TokenFactory, createOidcRouter } from '../identity'; import { @@ -65,15 +65,26 @@ export async function createRouter( for (const providerId of providers) { logger.info(`Configuring provider, ${providerId}`); try { - const providerConfig = providersConfig.getConfig(providerId); - const providerRouter = createAuthProviderRouter( - providerId, - { baseUrl: authUrl, appUrl }, - providerConfig, + const provider = createAuthProvider(providerId, { + globalConfig: { baseUrl: authUrl, appUrl }, + config: providersConfig.getConfig(providerId), logger, tokenIssuer, - ); - router.use(`/${providerId}`, providerRouter); + }); + + const r = Router(); + + r.get('/start', provider.start.bind(provider)); + r.get('/handler/frame', provider.frameHandler.bind(provider)); + r.post('/handler/frame', provider.frameHandler.bind(provider)); + if (provider.logout) { + r.post('/logout', provider.logout.bind(provider)); + } + if (provider.refresh) { + r.get('/refresh', provider.refresh.bind(provider)); + } + + router.use(`/${providerId}`, r); } catch (e) { if (process.env.NODE_ENV !== 'development') { throw new Error( From 7965c098b8614153afd177490ba82c178a1ad1fc Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 25 Sep 2020 13:50:50 +0200 Subject: [PATCH 51/94] docs: tweak proxying docs --- docs/plugins/proxying.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugins/proxying.md b/docs/plugins/proxying.md index 2f809ffd10..f799d5fd33 100644 --- a/docs/plugins/proxying.md +++ b/docs/plugins/proxying.md @@ -23,7 +23,7 @@ const proxyEnv = useHotMemoize(module, () => createEnv('proxy')); const service = createServiceBuilder(module) .loadConfig(configReader) /** ... other routers ... */ - .addRouter('/proxy', await proxy(proxyEnv, '/proxy')); + .addRouter('/proxy', await proxy(proxyEnv)); ``` ## Configuration From 9b8e85d725a58f9b4b352b12fe321adc5fdc4473 Mon Sep 17 00:00:00 2001 From: Ivan Shmidt Date: Fri, 25 Sep 2020 14:33:36 +0200 Subject: [PATCH 52/94] fix(create-app): sync scaffolded backend with example --- .../backend/src/plugins/scaffolder.ts | 75 +++++++++++++------ 1 file changed, 52 insertions(+), 23 deletions(-) diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts index cfdfbb0a78..ed16d766f1 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts @@ -18,8 +18,8 @@ import type { PluginEnvironment } from '../types'; import Docker from 'dockerode'; export default async function createPlugin({ - logger, - config, + logger, + config, }: PluginEnvironment) { const cookiecutterTemplater = new CookieCutter(); const craTemplater = new CreateReactAppTemplater(); @@ -39,31 +39,60 @@ export default async function createPlugin({ const publishers = new Publishers(); - const githubToken = config.getString('scaffolder.github.token'); - const repoVisibility = config.getString( - 'scaffolder.github.visibility', - ) as RepoVisibilityOptions; + const githubConfig = config.getOptionalConfig('scaffolder.github'); - const githubClient = new Octokit({ auth: githubToken }); - const githubPublisher = new GithubPublisher({ - client: githubClient, - token: githubToken, - repoVisibility, - }); - publishers.register('file', githubPublisher); - publishers.register('github', githubPublisher); + if (githubConfig) { + try { + const repoVisibility = githubConfig.getString( + 'visibility', + ) as RepoVisibilityOptions; + + const githubToken = githubConfig.getString('token'); + const githubClient = new Octokit({ auth: githubToken }); + const githubPublisher = new GithubPublisher({ + client: githubClient, + token: githubToken, + repoVisibility, + }); + publishers.register('file', githubPublisher); + publishers.register('github', githubPublisher); + } catch (e) { + const providerName = 'github'; + if (process.env.NODE_ENV !== 'development') { + throw new Error( + `Failed to initialize ${providerName} scaffolding provider, ${e.message}`, + ); + } + + logger.warn( + `Skipping ${providerName} scaffolding provider, ${e.message}`, + ); + } + } const gitLabConfig = config.getOptionalConfig('scaffolder.gitlab.api'); - if (gitLabConfig) { - const gitLabToken = gitLabConfig.getString('token'); - const gitLabClient = new Gitlab({ - host: gitLabConfig.getOptionalString('baseUrl'), - token: gitLabToken, - }); - const gitLabPublisher = new GitlabPublisher(gitLabClient, gitLabToken); - publishers.register('gitlab', gitLabPublisher); - publishers.register('gitlab/api', gitLabPublisher); + try { + const gitLabToken = gitLabConfig.getString('token'); + const gitLabClient = new Gitlab({ + host: gitLabConfig.getOptionalString('baseUrl'), + token: gitLabToken, + }); + const gitLabPublisher = new GitlabPublisher(gitLabClient, gitLabToken); + publishers.register('gitlab', gitLabPublisher); + publishers.register('gitlab/api', gitLabPublisher); + } catch (e) { + const providerName = 'gitlab'; + if (process.env.NODE_ENV !== 'development') { + throw new Error( + `Failed to initialize ${providerName} scaffolding provider, ${e.message}`, + ); + } + + logger.warn( + `Skipping ${providerName} scaffolding provider, ${e.message}`, + ); + } } const dockerClient = new Docker(); From 0491230159743685a560f8e9b45447691beb02f6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 25 Sep 2020 14:49:51 +0200 Subject: [PATCH 53/94] create-app: remove discovery api override --- .../default-app/packages/app/src/apis.ts | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/packages/create-app/templates/default-app/packages/app/src/apis.ts b/packages/create-app/templates/default-app/packages/app/src/apis.ts index e0503e504f..24b22a83bc 100644 --- a/packages/create-app/templates/default-app/packages/app/src/apis.ts +++ b/packages/create-app/templates/default-app/packages/app/src/apis.ts @@ -1,17 +1 @@ -import { - discoveryApiRef, - UrlPatternDiscovery, - createApiFactory, - configApiRef, -} from '@backstage/core'; - -export const apis = [ - createApiFactory({ - api: discoveryApiRef, - deps: { configApi: configApiRef }, - factory: ({ configApi }) => - UrlPatternDiscovery.compile( - `${configApi.getString('backend.baseUrl')}/{{ pluginId }}`, - ), - }), -]; +export const apis = []; From 715257ff070b0bfab9a56c5330a1e093427850a6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 25 Sep 2020 15:07:07 +0200 Subject: [PATCH 54/94] plugins/jenkins: maybe refactor to use DiscoveryApi --- plugins/jenkins/src/api/index.ts | 53 +++++++++++++++++++++----------- plugins/jenkins/src/plugin.ts | 9 ++---- 2 files changed, 38 insertions(+), 24 deletions(-) diff --git a/plugins/jenkins/src/api/index.ts b/plugins/jenkins/src/api/index.ts index 2b95fe97d9..0fa48a1d17 100644 --- a/plugins/jenkins/src/api/index.ts +++ b/plugins/jenkins/src/api/index.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createApiRef } from '@backstage/core'; +import { createApiRef, DiscoveryApi } from '@backstage/core'; import { CITableBuildInfo } from '../components/BuildsPage/lib/CITable'; const jenkins = require('jenkins'); @@ -24,28 +24,42 @@ export const jenkinsApiRef = createApiRef({ description: 'Used by the Jenkins plugin to make requests', }); -export class JenkinsApi { - apiUrl: string; - jenkins: any; +const DEFAULT_PROXY_PATH = '/jenkins/api'; - constructor(apiUrl: string) { - this.apiUrl = apiUrl; - this.jenkins = jenkins({ baseUrl: apiUrl, promisify: true }); +type Options = { + discoveryApi: DiscoveryApi; + /** + * Path to use for requests via the proxy, defaults to /jenkins/api + */ + proxyPath?: string; +}; + +export class JenkinsApi { + private readonly discoveryApi: DiscoveryApi; + private readonly proxyPath: string; + + constructor(options: Options) { + this.discoveryApi = options.discoveryApi; + this.proxyPath = options.proxyPath ?? DEFAULT_PROXY_PATH; + } + + private async getClient() { + const proxyUrl = await this.discoveryApi.getBaseUrl('proxy'); + return jenkins({ baseUrl: proxyUrl + this.proxyPath, promisify: true }); } async retry(buildName: string) { + const client = await this.getClient(); // looks like the current SDK only supports triggering a new build // can't see any support for replay (re-running the specific build with the same SCM info) - return await this.jenkins.job.build(buildName); + return await client.job.build(buildName); } async getLastBuild(jobName: string) { - const job = await this.jenkins.job.get(jobName); + const client = await this.getClient(); + const job = await client.job.get(jobName); - const lastBuild = await this.jenkins.build.get( - jobName, - job.lastBuild.number, - ); + const lastBuild = await client.build.get(jobName, job.lastBuild.number); return lastBuild; } @@ -84,17 +98,19 @@ export class JenkinsApi { } async getJob(jobName: string) { - return this.jenkins.job.get({ + const client = await this.getClient(); + return client.job.get({ name: jobName, depth: 1, }); } async getFolder(folderName: string) { - const folder = await this.jenkins.job.get(folderName); + const client = await this.getClient(); + const folder = await client.job.get(folderName); const results = []; for (const jobSummary of folder.jobs) { - const jobDetails = await this.jenkins.job.get({ + const jobDetails = await client.job.get({ name: `${folderName}/${jobSummary.name}`, depth: 1, }); @@ -104,7 +120,7 @@ export class JenkinsApi { // skipping folders inside folders for now } else { for (const buildDetails of jobDetails.builds) { - const build = await this.jenkins.build.get({ + const build = await client.build.get({ name: `${folderName}/${jobSummary.name}`, number: buildDetails.number, depth: 1, @@ -191,10 +207,11 @@ export class JenkinsApi { } async getBuild(buildName: string) { + const client = await this.getClient(); const { jobName, buildNumber } = this.extractJobDetailsFromBuildName( buildName, ); - const buildResult = await this.jenkins.build.get(jobName, buildNumber); + const buildResult = await client.build.get(jobName, buildNumber); return buildResult; } diff --git a/plugins/jenkins/src/plugin.ts b/plugins/jenkins/src/plugin.ts index 8282f6b7f9..23c54743a3 100644 --- a/plugins/jenkins/src/plugin.ts +++ b/plugins/jenkins/src/plugin.ts @@ -18,7 +18,7 @@ import { createPlugin, createRouteRef, createApiFactory, - configApiRef, + discoveryApiRef, } from '@backstage/core'; import { jenkinsApiRef, JenkinsApi } from './api'; @@ -37,11 +37,8 @@ export const plugin = createPlugin({ apis: [ createApiFactory({ api: jenkinsApiRef, - deps: { configApi: configApiRef }, - factory: ({ configApi }) => - new JenkinsApi( - `${configApi.getString('backend.baseUrl')}/proxy/jenkins/api`, - ), + deps: { discoveryApi: discoveryApiRef }, + factory: ({ discoveryApi }) => new JenkinsApi({ discoveryApi }), }), ], }); From c6a879838d18497bfa7b4f53efd2cfd8a34dd517 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 25 Sep 2020 15:17:59 +0200 Subject: [PATCH 55/94] auth-backend: added CatalogIdentityClient --- plugins/auth-backend/package.json | 2 + .../src/lib/catalog/CatalogIdentityClient.ts | 55 +++++++++++++++++++ plugins/auth-backend/src/lib/catalog/index.ts | 17 ++++++ 3 files changed, 74 insertions(+) create mode 100644 plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts create mode 100644 plugins/auth-backend/src/lib/catalog/index.ts diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 8a9bfadca1..65b15abb8a 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -36,6 +36,7 @@ "knex": "^0.21.1", "moment": "^2.26.0", "morgan": "^1.10.0", + "node-fetch": "^2.6.1", "passport": "^0.4.1", "passport-github2": "^0.1.12", "passport-gitlab2": "^5.0.0", @@ -53,6 +54,7 @@ "@types/body-parser": "^1.19.0", "@types/cookie-parser": "^1.4.2", "@types/jwt-decode": "2.2.1", + "@types/node-fetch": "^2.5.7", "@types/passport": "^1.0.3", "@types/passport-github2": "^1.2.4", "@types/passport-google-oauth20": "^2.0.3", diff --git a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts new file mode 100644 index 0000000000..b09dfaa268 --- /dev/null +++ b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fetch from 'node-fetch'; +import { UserEntity } from '@backstage/catalog-model'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; + +type UserQuery = { + annotations: Record; +}; + +/** + * A catalog client tailored for reading out identity data from the catalog. + */ +export class CatalogIdentityClient { + private readonly discovery: PluginEndpointDiscovery; + + constructor(options: { discovery: PluginEndpointDiscovery }) { + this.discovery = options.discovery; + } + + async findUser(query: UserQuery): Promise { + const params = new URLSearchParams(); + params.append('kind', 'User'); + + for (const [key, value] of Object.entries(query.annotations)) { + params.append(`metadata.annotations.${key}`, value); + } + + const baseUrl = await this.discovery.getBaseUrl('catalog'); + const response = await fetch(`${baseUrl}/entities?${params}`); + + if (!response.ok) { + const text = await response.text(); + throw new Error( + `Request failed with ${response.status} ${response.statusText}, ${text}`, + ); + } + + return response.json(); + } +} diff --git a/plugins/auth-backend/src/lib/catalog/index.ts b/plugins/auth-backend/src/lib/catalog/index.ts new file mode 100644 index 0000000000..f15469668f --- /dev/null +++ b/plugins/auth-backend/src/lib/catalog/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { CatalogIdentityClient } from './CatalogIdentityClient'; From 3f72bda8e6fb8f0afac56f683651abe5dc75449d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 25 Sep 2020 15:36:41 +0200 Subject: [PATCH 56/94] auth-backend: forward discovery instance to providers --- plugins/auth-backend/package.json | 1 + plugins/auth-backend/src/providers/types.ts | 2 ++ plugins/auth-backend/src/service/router.ts | 21 +++++++++++---------- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 65b15abb8a..d8fe00ff31 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@backstage/backend-common": "^0.1.1-alpha.23", + "@backstage/catalog-model": "^0.1.1-alpha.23", "@backstage/config": "^0.1.1-alpha.23", "@types/express": "^4.17.6", "compression": "^1.7.4", diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index 5c05b02739..6776095f99 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -18,6 +18,7 @@ import express from 'express'; import { Logger } from 'winston'; import { TokenIssuer } from '../identity'; import { Config } from '@backstage/config'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; export type AuthProviderConfig = { /** @@ -115,6 +116,7 @@ export type AuthProviderFactoryOptions = { config: Config; logger: Logger; tokenIssuer: TokenIssuer; + discovery: PluginEndpointDiscovery; }; export type AuthProviderFactory = ( diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 99b5ee800b..bcd1fc1794 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -34,20 +34,20 @@ export interface RouterOptions { discovery: PluginEndpointDiscovery; } -export async function createRouter( - options: RouterOptions, -): Promise { +export async function createRouter({ + logger, + config, + discovery, + database, +}: RouterOptions): Promise { const router = Router(); - const logger = options.logger.child({ plugin: 'auth' }); - const appUrl = options.config.getString('app.baseUrl'); - const authUrl = await options.discovery.getExternalBaseUrl('auth'); + const appUrl = config.getString('app.baseUrl'); + const authUrl = await discovery.getExternalBaseUrl('auth'); const keyDurationSeconds = 3600; - const keyStore = await DatabaseKeyStore.create({ - database: options.database, - }); + const keyStore = await DatabaseKeyStore.create({ database }); const tokenIssuer = new TokenFactory({ issuer: authUrl, keyStore, @@ -59,7 +59,7 @@ export async function createRouter( router.use(express.urlencoded({ extended: false })); router.use(express.json()); - const providersConfig = options.config.getConfig('auth.providers'); + const providersConfig = config.getConfig('auth.providers'); const providers = providersConfig.keys(); for (const providerId of providers) { @@ -70,6 +70,7 @@ export async function createRouter( config: providersConfig.getConfig(providerId), logger, tokenIssuer, + discovery, }); const r = Router(); From 31d11082334e5a548b696707c294c2fcfa77d41c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 25 Sep 2020 15:44:48 +0200 Subject: [PATCH 57/94] auth-backend: look up google users in the catalog via email annotation --- .../src/providers/google/provider.ts | 48 +++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts index 3cc585605c..a93c964dbc 100644 --- a/plugins/auth-backend/src/providers/google/provider.ts +++ b/plugins/auth-backend/src/providers/google/provider.ts @@ -15,6 +15,7 @@ */ import express from 'express'; +import { Logger } from 'winston'; import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; import { executeFrameHandlerStrategy, @@ -36,15 +37,25 @@ import { OAuthRefreshRequest, } from '../../lib/oauth'; import passport from 'passport'; +import { CatalogIdentityClient } from '../../lib/catalog'; type PrivateInfo = { refreshToken: string; }; +export type GoogleAuthProviderOptions = OAuthProviderOptions & { + logger: Logger; + identityClient: CatalogIdentityClient; +}; + export class GoogleAuthProvider implements OAuthHandlers { private readonly _strategy: GoogleStrategy; + private readonly logger: Logger; + private readonly identityClient: CatalogIdentityClient; - constructor(options: OAuthProviderOptions) { + constructor(options: GoogleAuthProviderOptions) { + this.logger = options.logger; + this.identityClient = options.identityClient; // TODO: throw error if env variables not set? this._strategy = new GoogleStrategy( { @@ -138,17 +149,44 @@ export class GoogleAuthProvider implements OAuthHandlers { throw new Error('Google profile contained no email'); } - // TODO(Rugvip): Hardcoded to the local part of the email for now - const id = profile.email.split('@')[0]; + const users = await this.identityClient.findUser({ + annotations: { + 'google.com/email': profile.email, + }, + }); + if (users.length !== 1) { + if (users.length > 1) { + this.logger.info( + `Multiple identities found for Google user ${profile.email}`, + ); + } else { + this.logger.info(`No identity found for Google user ${profile.email}`); + } - return { ...response, backstageIdentity: { id } }; + this.logger.warn( + `Falling back to allowing login based on email pattern, this will probably break in the future`, + ); + return { + ...response, + backstageIdentity: { id: profile.email.split('@')[0] }, + }; + } + + return { + ...response, + backstageIdentity: { + id: users[0].metadata.name, + }, + }; } } export const createGoogleProvider: AuthProviderFactory = ({ globalConfig, config, + logger, tokenIssuer, + discovery, }) => OAuthEnvironmentHandler.mapConfig(config, envConfig => { const providerId = 'google'; @@ -160,6 +198,8 @@ export const createGoogleProvider: AuthProviderFactory = ({ clientId, clientSecret, callbackUrl, + logger, + identityClient: new CatalogIdentityClient({ discovery }), }); return OAuthAdapter.fromConfig(globalConfig, provider, { From a20bc00b41fd999f40e2c30bc11533ff51c49164 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 8 Sep 2020 19:42:36 +0200 Subject: [PATCH 58/94] plugins/circleci: refactor to use DiscoverApi --- docs/api/utility-apis.md | 4 ++-- plugins/circleci/dev/index.tsx | 10 +-------- plugins/circleci/src/api/index.ts | 34 +++++++++++++++++++++++-------- plugins/circleci/src/plugin.ts | 13 ++++++------ 4 files changed, 36 insertions(+), 25 deletions(-) diff --git a/docs/api/utility-apis.md b/docs/api/utility-apis.md index d192cbd6ee..599bbbb66c 100644 --- a/docs/api/utility-apis.md +++ b/docs/api/utility-apis.md @@ -97,7 +97,7 @@ app, and the app itself. ### Core APIs -Starting with the Backstage core library, it provides implementation for all of +Starting with the Backstage core library, it provides implementations for all of the core APIs. The core APIs are the ones exported by `@backstage/core`, such as the `errorApiRef` and `configApiRef`. You can find a full list of them [here](../reference/utility-apis/README.md). @@ -113,7 +113,7 @@ While doing so they should usually also provide default implementations of their own APIs, for example, the `catalog` plugin exports `catalogApiRef`, and also supplies a default `ApiFactory` of that API using the `CatalogClient`. There is one restriction to plugin-provided API Factories: plugins may not supply -factories for core APIs, trying to do so will cause the app to crash. +factories for core APIs, trying to do so will cause the app to refuse to start. Plugins supply their APIs through the `apis` option of `createPlugin`, for example: diff --git a/plugins/circleci/dev/index.tsx b/plugins/circleci/dev/index.tsx index 4bf67d5cb2..812a5585d4 100644 --- a/plugins/circleci/dev/index.tsx +++ b/plugins/circleci/dev/index.tsx @@ -16,13 +16,5 @@ import { createDevApp } from '@backstage/dev-utils'; import { plugin } from '../src/plugin'; -import { circleCIApiRef, CircleCIApi } from '../src/api'; -createDevApp() - .registerPlugin(plugin) - .registerApi({ - api: circleCIApiRef, - deps: {}, - factory: () => new CircleCIApi(), - }) - .render(); +createDevApp().registerPlugin(plugin).render(); diff --git a/plugins/circleci/src/api/index.ts b/plugins/circleci/src/api/index.ts index da4c811812..c71be6b781 100644 --- a/plugins/circleci/src/api/index.ts +++ b/plugins/circleci/src/api/index.ts @@ -26,7 +26,7 @@ import { BuildSummary, GitType, } from 'circleci-api'; -import { createApiRef } from '@backstage/core'; +import { createApiRef, DiscoveryApi } from '@backstage/core'; export { GitType }; export type { BuildWithSteps, BuildStepAction, BuildSummary }; @@ -36,15 +36,28 @@ export const circleCIApiRef = createApiRef({ description: 'Used by the CircleCI plugin to make requests', }); +const DEFAULT_PROXY_PATH = '/circleci/api'; + +type Options = { + discoveryApi: DiscoveryApi; + /** + * Path to use for requests via the proxy, defaults to /circleci/api + */ + proxyPath?: string; +}; + export class CircleCIApi { - apiUrl: string; - constructor(apiUrl: string = '/circleci/api') { - this.apiUrl = apiUrl; + private readonly discoveryApi: DiscoveryApi; + private readonly proxyPath: string; + + constructor(options: Options) { + this.discoveryApi = options.discoveryApi; + this.proxyPath = options.proxyPath ?? DEFAULT_PROXY_PATH; } async retry(buildNumber: number, options: Partial) { return postBuildActions('', buildNumber, BuildAction.RETRY, { - circleHost: this.apiUrl, + circleHost: await this.getApiUrl(), ...options.vcs, }); } @@ -59,19 +72,24 @@ export class CircleCIApi { offset, }, vcs: {}, - circleHost: this.apiUrl, + circleHost: await this.getApiUrl(), ...options, }); } async getUser(options: Partial) { - return getMe('', { circleHost: this.apiUrl, ...options }); + return getMe('', { circleHost: await this.getApiUrl(), ...options }); } async getBuild(buildNumber: number, options: Partial) { return getFullBuild('', buildNumber, { - circleHost: this.apiUrl, + circleHost: await this.getApiUrl(), ...options.vcs, }); } + + private async getApiUrl() { + const proxyUrl = await this.discoveryApi.getBaseUrl('proxy'); + return proxyUrl + this.proxyPath; + } } diff --git a/plugins/circleci/src/plugin.ts b/plugins/circleci/src/plugin.ts index f966caa383..c5bb122ed3 100644 --- a/plugins/circleci/src/plugin.ts +++ b/plugins/circleci/src/plugin.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { createPlugin, createApiFactory, configApiRef } from '@backstage/core'; +import { + createPlugin, + createApiFactory, + discoveryApiRef, +} from '@backstage/core'; import { circleCIApiRef, CircleCIApi } from './api'; export const plugin = createPlugin({ @@ -22,11 +26,8 @@ export const plugin = createPlugin({ apis: [ createApiFactory({ api: circleCIApiRef, - deps: { configApi: configApiRef }, - factory: ({ configApi }) => - new CircleCIApi( - `${configApi.getString('backend.baseUrl')}/proxy/circleci/api`, - ), + deps: { discoveryApi: discoveryApiRef }, + factory: ({ discoveryApi }) => new CircleCIApi({ discoveryApi }), }), ], }); From 2872517f256d7e6dcee61618a678c9401209cfc3 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 25 Sep 2020 16:04:45 +0200 Subject: [PATCH 59/94] create-app: default to using internal scope for new plugins --- packages/create-app/templates/default-app/package.json.hbs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/create-app/templates/default-app/package.json.hbs b/packages/create-app/templates/default-app/package.json.hbs index 19e3891551..a764a78b18 100644 --- a/packages/create-app/templates/default-app/package.json.hbs +++ b/packages/create-app/templates/default-app/package.json.hbs @@ -16,7 +16,7 @@ "test:all": "lerna run test -- --coverage", "lint": "lerna run lint --since origin/master --", "lint:all": "lerna run lint --", - "create-plugin": "backstage-cli create-plugin --scope backstage --no-private", + "create-plugin": "backstage-cli create-plugin --scope internal --no-private", "remove-plugin": "backstage-cli remove-plugin" }, "workspaces": { From a211b681ad75ed05c33198c3ea806652ccf9d78f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 25 Sep 2020 16:16:28 +0200 Subject: [PATCH 60/94] backend-common: use localhost to fall back to IPv4 if IPv6 isn't available --- packages/backend-common/src/discovery/SingleHostDiscovery.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/backend-common/src/discovery/SingleHostDiscovery.ts b/packages/backend-common/src/discovery/SingleHostDiscovery.ts index 1970583e5f..184746b924 100644 --- a/packages/backend-common/src/discovery/SingleHostDiscovery.ts +++ b/packages/backend-common/src/discovery/SingleHostDiscovery.ts @@ -49,7 +49,10 @@ export class SingleHostDiscovery implements PluginEndpointDiscovery { // Translate bind-all to localhost, and support IPv6 let host = listenHost; if (host === '::') { - host = '::1'; + // We use localhost instead of ::1, since IPv6-compatible systems should default + // to using IPv6 when they see localhost, but if the system doesn't support IPv6 + // things will still work. + host = 'localhost'; } else if (host === '0.0.0.0') { host = '127.0.0.1'; } From 7ae37ccc5d88779821f6380e2dc5ae3025bbecb3 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 25 Sep 2020 17:05:54 +0200 Subject: [PATCH 61/94] CHANGELOG: bump to alpha.23 --- CHANGELOG.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d6ac3a063..55250e9b7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,10 +13,6 @@ If you encounter issues while upgrading to a newer version, don't hesitate to re - The default mount point for backend plugins have been changed to `/api`. These changes are done in the backend package itself, so it is recommended that you sync up existing backend packages with this new pattern. [#2562](https://github.com/spotify/backstage/pull/2562) - A service discovery mechanism for backend plugins has been added, and is now a requirement for several backend plugins. See [packages/backend/src/index.ts](./packages/backend/src/index.ts) for how to set it up using `SingleHostDiscovery` from `@backstage/backend-common`. Note that the default base path for plugins is set to `/api` to that change, but it can be set to use the old behavior via the `basePath` option. [#2600](https://github.com/spotify/backstage/pull/2600) -### @backstage/core - -- Renamed `SessionStateApi` to `SessionApi` and `logout` to `signOut`. Custom implementations of the `SingInPage` app-component will need to rename their `logout` function. The different auth provider items for the `UserSettingsMenu` have been consolidated into a single `ProviderSettingsItem`, meaning you need to replace existing usages of `OAuthProviderSettings` and `OIDCProviderSettings`. [#2555](https://github.com/spotify/backstage/pull/2555). - ### @backstage/auth-backend - The default mount path of backend plugins was changed to `/api/:pluginId`, and as part of that it was needed to enable configuration of the base path of the auth backend, so that it can construct redirect URLs correctly. Note that you will also need to reconfigure any allowed redirect URLs to include `/api` if you switch to the new recommended pattern. [#2562](https://github.com/spotify/backstage/pull/2562) @@ -30,6 +26,12 @@ If you encounter issues while upgrading to a newer version, don't hesitate to re - The TechDocs backend now requires an implementation of `PluginEndpointDiscovery` from `@backstage/backend-common` to be passed in as `discovery`. See the changes to `@backstage/backend`. +## v0.1.1-alpha.23 + +### @backstage/core + +- Renamed `SessionStateApi` to `SessionApi` and `logout` to `signOut`. Custom implementations of the `SingInPage` app-component will need to rename their `logout` function. The different auth provider items for the `UserSettingsMenu` have been consolidated into a single `ProviderSettingsItem`, meaning you need to replace existing usages of `OAuthProviderSettings` and `OIDCProviderSettings`. [#2555](https://github.com/spotify/backstage/pull/2555). + ## v0.1.1-alpha.22 ### @backstage/core From eb4c704674a3f8b5d63b453b359a54fdf9ec2eda Mon Sep 17 00:00:00 2001 From: Matthew Clarke Date: Fri, 25 Sep 2020 16:25:25 +0100 Subject: [PATCH 62/94] kubernetes: deployment pod table (#2618) * deployment pod table * prettier manifest file * pr feedback --- .../dice-roller/dice-roller-manifests.yaml | 34 +- plugins/kubernetes/package.json | 2 + .../DeploymentTables.test.tsx | 41 + .../DeploymentTables/DeploymentTables.tsx | 220 + .../__fixtures__/2-deployments.json | 4435 +++++++++++++++++ .../src/components/DeploymentTables/index.ts | 16 + .../KubernetesContent/KubernetesContent.tsx | 80 +- plugins/kubernetes/src/types/types.ts | 23 + 8 files changed, 4825 insertions(+), 26 deletions(-) create mode 100644 plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.test.tsx create mode 100644 plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.tsx create mode 100644 plugins/kubernetes/src/components/DeploymentTables/__fixtures__/2-deployments.json create mode 100644 plugins/kubernetes/src/components/DeploymentTables/index.ts create mode 100644 plugins/kubernetes/src/types/types.ts diff --git a/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml b/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml index ef448c1688..f3a478f4d3 100644 --- a/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml +++ b/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml @@ -8,7 +8,7 @@ spec: selector: matchLabels: app: dice-roller - replicas: 2 + replicas: 10 template: metadata: labels: @@ -21,6 +21,38 @@ spec: ports: - containerPort: 80 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: dice-roller-canary + labels: + 'backstage.io/kubernetes-id': dice-roller +spec: + selector: + matchLabels: + app: dice-roller-canary + replicas: 2 + template: + metadata: + labels: + app: dice-roller-canary + 'backstage.io/kubernetes-id': dice-roller + spec: + containers: + - name: nginx + image: nginx:1.14.2 + ports: + - containerPort: 80 + - name: side-car + image: nginx:1.14.2 + ports: + - containerPort: 81 + - name: other-side-car + image: nginx:1.14.2 + ports: + - containerPort: 82 + --- apiVersion: v1 kind: ConfigMap diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index d1f3d5e7ba..6d177c8326 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -24,6 +24,7 @@ "@backstage/core": "^0.1.1-alpha.23", "@backstage/plugin-kubernetes-backend": "^0.1.1-alpha.23", "@backstage/theme": "^0.1.1-alpha.23", + "@kubernetes/client-node": "^0.12.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -35,6 +36,7 @@ "devDependencies": { "@backstage/cli": "^0.1.1-alpha.23", "@backstage/dev-utils": "^0.1.1-alpha.23", + "@backstage/test-utils": "^0.1.1-alpha.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.test.tsx b/plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.test.tsx new file mode 100644 index 0000000000..3dacac7055 --- /dev/null +++ b/plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.test.tsx @@ -0,0 +1,41 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { DeploymentTables } from './DeploymentTables'; +import * as twoDeployFixture from './__fixtures__/2-deployments.json'; +import { wrapInTestApp } from '@backstage/test-utils'; + +describe('DeploymentTables', () => { + it('should render 2 deployments', async () => { + const { getByText } = render( + wrapInTestApp( + , + ), + ); + + // title + expect(getByText('dice-roller')).toBeInTheDocument(); + expect(getByText('dice-roller-canary')).toBeInTheDocument(); + + // pod names + expect(getByText('dice-roller-6c8646bfd-2m5hv')).toBeInTheDocument(); + expect( + getByText('dice-roller-canary-7d64cd756c-55rfq'), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.tsx b/plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.tsx new file mode 100644 index 0000000000..a009c20d7e --- /dev/null +++ b/plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.tsx @@ -0,0 +1,220 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { Fragment } from 'react'; +import { Chip, Grid } from '@material-ui/core'; +import { + StatusAborted, + StatusError, + StatusOK, + SubvalueCell, + Table, + TableColumn, +} from '@backstage/core'; +import { + V1ComponentCondition, + V1Deployment, + V1Pod, + V1ReplicaSet, +} from '@kubernetes/client-node'; +import { V1OwnerReference } from '@kubernetes/client-node/dist/gen/model/v1OwnerReference'; +import { DeploymentTriple } from '../../types/types'; + +const renderCondition = (condition: V1ComponentCondition | undefined) => { + if (!condition) { + return ; + } + + const status = condition.status; + + if (status === 'True') { + return ; + } else if (status === 'False') { + return ; + } + return ; +}; + +const columns: TableColumn[] = [ + { + title: 'name', + highlight: true, + width: '20%', + render: (pod: V1Pod) => pod.metadata?.name ?? 'un-named pod', + }, + { + title: 'images', + width: '20%', + render: (pod: V1Pod) => { + const containerStatuses = pod.status?.containerStatuses ?? []; + return containerStatuses.map((cs, i) => { + return ; + }); + }, + }, + { + title: 'phase', + render: (pod: V1Pod) => pod.status?.phase ?? 'unknown', + }, + { + title: 'containers ready', + align: 'center', + render: (pod: V1Pod) => { + const containerStatuses = pod.status?.containerStatuses ?? []; + const containersReady = containerStatuses.filter(cs => cs.ready).length; + + return `${containersReady}/${containerStatuses.length}`; + }, + }, + { + title: 'total restarts', + render: (pod: V1Pod) => { + const containerStatuses = pod.status?.containerStatuses ?? []; + return containerStatuses?.reduce((a, b) => a + b.restartCount, 0); + }, + type: 'numeric', + }, + { + title: 'status', + width: '20%', + render: (pod: V1Pod) => { + const containerStatuses = pod.status?.containerStatuses ?? []; + const errors = containerStatuses.reduce((accum, next) => { + if (next.state === undefined) { + return accum; + } + + const waiting = next.state.waiting; + const terminated = next.state.terminated; + + const renderCell = (reason: string | undefined) => ( + + Container: {next.name}} + subvalue={reason} + /> +
+ + ); + + if (waiting) { + accum.push(renderCell(waiting.reason)); + } + + if (terminated) { + accum.push(renderCell(terminated.reason)); + } + + return accum; + }, [] as React.ReactNode[]); + + if (errors.length === 0) { + return OK; + } + + return errors; + }, + }, + { + title: 'Pod Initialized', + align: 'center', + render: (pod: V1Pod) => { + const conditions = pod.status?.conditions ?? []; + return renderCondition(conditions.find(c => c.type === 'Initialized')); + }, + }, + { + title: 'Pod Ready', + align: 'center', + render: (pod: V1Pod) => { + const conditions = pod.status?.conditions ?? []; + return renderCondition(conditions.find(c => c.type === 'Ready')); + }, + }, + { + title: 'Containers Ready', + align: 'center', + render: (pod: V1Pod) => { + const conditions = pod.status?.conditions ?? []; + return renderCondition( + conditions.find(c => c.type === 'ContainersReady'), + ); + }, + }, + { + title: 'Pod Scheduled', + align: 'center', + render: (pod: V1Pod) => { + const conditions = pod.status?.conditions ?? []; + return renderCondition(conditions.find(c => c.type === 'PodScheduled')); + }, + }, +]; + +type DeploymentTablesProps = { + deploymentTriple: DeploymentTriple; + children?: React.ReactNode; +}; + +export const DeploymentTables = ({ + deploymentTriple, +}: DeploymentTablesProps) => { + const isOwnedBy = ( + ownerReferences: V1OwnerReference[], + obj: V1Pod | V1ReplicaSet | V1Deployment, + ): boolean => { + return ownerReferences?.some(or => or.name === obj.metadata?.name); + }; + + return ( + + {deploymentTriple.deployments.map((deployment, i) => ( + + {deploymentTriple.replicaSets + // Filter out replica sets with no replicas + .filter(rs => rs.status && rs.status.replicas > 0) + // Find the replica sets this deployment owns + .filter(rs => + isOwnedBy(rs.metadata?.ownerReferences ?? [], deployment), + ) + .map((rs, j) => { + // Find the pods this replica set owns and render them in the table + const ownedPods = deploymentTriple.pods.filter(pod => + isOwnedBy(pod.metadata?.ownerReferences ?? [], rs), + ); + + return ( + +
+ + ); + })} + + ))} + + ); +}; diff --git a/plugins/kubernetes/src/components/DeploymentTables/__fixtures__/2-deployments.json b/plugins/kubernetes/src/components/DeploymentTables/__fixtures__/2-deployments.json new file mode 100644 index 0000000000..e51d0d0b5f --- /dev/null +++ b/plugins/kubernetes/src/components/DeploymentTables/__fixtures__/2-deployments.json @@ -0,0 +1,4435 @@ +{ + "pods": [ + { + "metadata": { + "creationTimestamp": "2020-09-25T09:58:50.000Z", + "generateName": "dice-roller-6c8646bfd-", + "labels": { + "app": "dice-roller", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "6c8646bfd" + }, + "managedFields": [ + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:generateName": {}, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"5126c354-4310-4e23-a9e4-c9b87cb69792\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:enableServiceLinks": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T09:58:50.000Z" + }, + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:status": { + "f:conditions": { + "k:{\"type\":\"ContainersReady\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Initialized\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Ready\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + } + }, + "f:containerStatuses": {}, + "f:hostIP": {}, + "f:phase": {}, + "f:podIP": {}, + "f:podIPs": { + ".": {}, + "k:{\"ip\":\"172.17.0.11\"}": { + ".": {}, + "f:ip": {} + } + }, + "f:startTime": {} + } + }, + "manager": "kubelet", + "operation": "Update", + "time": "2020-09-25T09:58:55.000Z" + } + ], + "name": "dice-roller-6c8646bfd-2m5hv", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ReplicaSet", + "name": "dice-roller-6c8646bfd", + "uid": "5126c354-4310-4e23-a9e4-c9b87cb69792" + } + ], + "resourceVersion": "593216", + "selfLink": "/api/v1/namespaces/default/pods/dice-roller-6c8646bfd-2m5hv", + "uid": "aadb71c0-36fa-43e3-b38a-162f134d4359" + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "nodeName": "minikube", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 30, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "name": "default-token-5gctn", + "secret": { + "defaultMode": 420, + "secretName": "default-token-5gctn" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:55.000Z", + "status": "True", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:55.000Z", + "status": "True", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "docker://aa4489297c34c48bb33c18474a8d2b33854a82ed42155680b259f635f556ce70", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": {}, + "name": "nginx", + "ready": true, + "restartCount": 0, + "started": true, + "state": { + "running": { + "startedAt": "2020-09-25T09:58:53.000Z" + } + } + } + ], + "hostIP": "192.168.64.2", + "phase": "Running", + "podIP": "172.17.0.11", + "podIPs": [ + { + "ip": "172.17.0.11" + } + ], + "qosClass": "BestEffort", + "startTime": "2020-09-25T09:58:50.000Z" + } + }, + { + "metadata": { + "creationTimestamp": "2020-09-25T09:58:50.000Z", + "generateName": "dice-roller-6c8646bfd-", + "labels": { + "app": "dice-roller", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "6c8646bfd" + }, + "managedFields": [ + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:generateName": {}, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"5126c354-4310-4e23-a9e4-c9b87cb69792\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:enableServiceLinks": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T09:58:50.000Z" + }, + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:status": { + "f:conditions": { + "k:{\"type\":\"ContainersReady\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Initialized\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Ready\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + } + }, + "f:containerStatuses": {}, + "f:hostIP": {}, + "f:phase": {}, + "f:podIP": {}, + "f:podIPs": { + ".": {}, + "k:{\"ip\":\"172.17.0.7\"}": { + ".": {}, + "f:ip": {} + } + }, + "f:startTime": {} + } + }, + "manager": "kubelet", + "operation": "Update", + "time": "2020-09-25T09:58:55.000Z" + } + ], + "name": "dice-roller-6c8646bfd-4v6s8", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ReplicaSet", + "name": "dice-roller-6c8646bfd", + "uid": "5126c354-4310-4e23-a9e4-c9b87cb69792" + } + ], + "resourceVersion": "593221", + "selfLink": "/api/v1/namespaces/default/pods/dice-roller-6c8646bfd-4v6s8", + "uid": "32e56490-6f20-4757-991f-a0c7fb407358" + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "nodeName": "minikube", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 30, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "name": "default-token-5gctn", + "secret": { + "defaultMode": 420, + "secretName": "default-token-5gctn" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:55.000Z", + "status": "True", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:55.000Z", + "status": "True", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "docker://d91cc76c41249b8d3dfcf538d180b471b2a7966aae0d17a818307c8a9b4af897", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": {}, + "name": "nginx", + "ready": true, + "restartCount": 0, + "started": true, + "state": { + "running": { + "startedAt": "2020-09-25T09:58:53.000Z" + } + } + } + ], + "hostIP": "192.168.64.2", + "phase": "Running", + "podIP": "172.17.0.7", + "podIPs": [ + { + "ip": "172.17.0.7" + } + ], + "qosClass": "BestEffort", + "startTime": "2020-09-25T09:58:50.000Z" + } + }, + { + "metadata": { + "creationTimestamp": "2020-09-24T11:39:26.000Z", + "generateName": "dice-roller-6c8646bfd-", + "labels": { + "app": "dice-roller", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "6c8646bfd" + }, + "managedFields": [ + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:generateName": {}, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"5126c354-4310-4e23-a9e4-c9b87cb69792\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:enableServiceLinks": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-24T11:39:26.000Z" + }, + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:status": { + "f:conditions": { + "k:{\"type\":\"ContainersReady\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Initialized\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Ready\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + } + }, + "f:containerStatuses": {}, + "f:hostIP": {}, + "f:phase": {}, + "f:podIP": {}, + "f:podIPs": { + ".": {}, + "k:{\"ip\":\"172.17.0.6\"}": { + ".": {}, + "f:ip": {} + } + }, + "f:startTime": {} + } + }, + "manager": "kubelet", + "operation": "Update", + "time": "2020-09-24T11:39:27.000Z" + } + ], + "name": "dice-roller-6c8646bfd-b9zt5", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ReplicaSet", + "name": "dice-roller-6c8646bfd", + "uid": "5126c354-4310-4e23-a9e4-c9b87cb69792" + } + ], + "resourceVersion": "503886", + "selfLink": "/api/v1/namespaces/default/pods/dice-roller-6c8646bfd-b9zt5", + "uid": "8b6601b6-469e-4e89-8fd0-abb2f1a567e4" + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "nodeName": "minikube", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 30, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "name": "default-token-5gctn", + "secret": { + "defaultMode": 420, + "secretName": "default-token-5gctn" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-24T11:39:26.000Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-24T11:39:27.000Z", + "status": "True", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-24T11:39:27.000Z", + "status": "True", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-24T11:39:26.000Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "docker://c50e0d0fa96f09a2ed5df7dd5a7f7de88e6b7c7addb8f002f299d6afc52d82ce", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": {}, + "name": "nginx", + "ready": true, + "restartCount": 0, + "started": true, + "state": { + "running": { + "startedAt": "2020-09-24T11:39:27.000Z" + } + } + } + ], + "hostIP": "192.168.64.2", + "phase": "Running", + "podIP": "172.17.0.6", + "podIPs": [ + { + "ip": "172.17.0.6" + } + ], + "qosClass": "BestEffort", + "startTime": "2020-09-24T11:39:26.000Z" + } + }, + { + "metadata": { + "creationTimestamp": "2020-09-25T09:58:50.000Z", + "generateName": "dice-roller-6c8646bfd-", + "labels": { + "app": "dice-roller", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "6c8646bfd" + }, + "managedFields": [ + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:generateName": {}, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"5126c354-4310-4e23-a9e4-c9b87cb69792\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:enableServiceLinks": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T09:58:50.000Z" + }, + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:status": { + "f:conditions": { + "k:{\"type\":\"ContainersReady\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Initialized\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Ready\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + } + }, + "f:containerStatuses": {}, + "f:hostIP": {}, + "f:phase": {}, + "f:podIP": {}, + "f:podIPs": { + ".": {}, + "k:{\"ip\":\"172.17.0.13\"}": { + ".": {}, + "f:ip": {} + } + }, + "f:startTime": {} + } + }, + "manager": "kubelet", + "operation": "Update", + "time": "2020-09-25T09:58:55.000Z" + } + ], + "name": "dice-roller-6c8646bfd-cfxqc", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ReplicaSet", + "name": "dice-roller-6c8646bfd", + "uid": "5126c354-4310-4e23-a9e4-c9b87cb69792" + } + ], + "resourceVersion": "593211", + "selfLink": "/api/v1/namespaces/default/pods/dice-roller-6c8646bfd-cfxqc", + "uid": "e87e1776-0ca7-41fb-aeea-e1f648387545" + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "nodeName": "minikube", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 30, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "name": "default-token-5gctn", + "secret": { + "defaultMode": 420, + "secretName": "default-token-5gctn" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:51.000Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:55.000Z", + "status": "True", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:55.000Z", + "status": "True", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "docker://c1641d986aae424429b7c2c1117baeb17d3794f73206bd57292cdf8264f929b2", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": {}, + "name": "nginx", + "ready": true, + "restartCount": 0, + "started": true, + "state": { + "running": { + "startedAt": "2020-09-25T09:58:53.000Z" + } + } + } + ], + "hostIP": "192.168.64.2", + "phase": "Running", + "podIP": "172.17.0.13", + "podIPs": [ + { + "ip": "172.17.0.13" + } + ], + "qosClass": "BestEffort", + "startTime": "2020-09-25T09:58:51.000Z" + } + }, + { + "metadata": { + "creationTimestamp": "2020-09-25T09:58:50.000Z", + "generateName": "dice-roller-6c8646bfd-", + "labels": { + "app": "dice-roller", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "6c8646bfd" + }, + "managedFields": [ + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:generateName": {}, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"5126c354-4310-4e23-a9e4-c9b87cb69792\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:enableServiceLinks": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T09:58:50.000Z" + }, + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:status": { + "f:conditions": { + "k:{\"type\":\"ContainersReady\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Initialized\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Ready\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + } + }, + "f:containerStatuses": {}, + "f:hostIP": {}, + "f:phase": {}, + "f:podIP": {}, + "f:podIPs": { + ".": {}, + "k:{\"ip\":\"172.17.0.15\"}": { + ".": {}, + "f:ip": {} + } + }, + "f:startTime": {} + } + }, + "manager": "kubelet", + "operation": "Update", + "time": "2020-09-25T09:58:54.000Z" + } + ], + "name": "dice-roller-6c8646bfd-dtv5z", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ReplicaSet", + "name": "dice-roller-6c8646bfd", + "uid": "5126c354-4310-4e23-a9e4-c9b87cb69792" + } + ], + "resourceVersion": "593190", + "selfLink": "/api/v1/namespaces/default/pods/dice-roller-6c8646bfd-dtv5z", + "uid": "1638a41b-e47e-4c17-a1f7-7b447df248b6" + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "nodeName": "minikube", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 30, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "name": "default-token-5gctn", + "secret": { + "defaultMode": 420, + "secretName": "default-token-5gctn" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:51.000Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:53.000Z", + "status": "True", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:53.000Z", + "status": "True", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "docker://e62c32db58dbfe0a74b2d2cba0e0a97b7f222693c68e930037ac8738c4758ca3", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": {}, + "name": "nginx", + "ready": true, + "restartCount": 0, + "started": true, + "state": { + "running": { + "startedAt": "2020-09-25T09:58:53.000Z" + } + } + } + ], + "hostIP": "192.168.64.2", + "phase": "Running", + "podIP": "172.17.0.15", + "podIPs": [ + { + "ip": "172.17.0.15" + } + ], + "qosClass": "BestEffort", + "startTime": "2020-09-25T09:58:51.000Z" + } + }, + { + "metadata": { + "creationTimestamp": "2020-09-25T09:58:50.000Z", + "generateName": "dice-roller-6c8646bfd-", + "labels": { + "app": "dice-roller", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "6c8646bfd" + }, + "managedFields": [ + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:generateName": {}, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"5126c354-4310-4e23-a9e4-c9b87cb69792\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:enableServiceLinks": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T09:58:50.000Z" + }, + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:status": { + "f:conditions": { + "k:{\"type\":\"ContainersReady\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Initialized\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Ready\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + } + }, + "f:containerStatuses": {}, + "f:hostIP": {}, + "f:phase": {}, + "f:podIP": {}, + "f:podIPs": { + ".": {}, + "k:{\"ip\":\"172.17.0.12\"}": { + ".": {}, + "f:ip": {} + } + }, + "f:startTime": {} + } + }, + "manager": "kubelet", + "operation": "Update", + "time": "2020-09-25T09:58:53.000Z" + } + ], + "name": "dice-roller-6c8646bfd-hhts2", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ReplicaSet", + "name": "dice-roller-6c8646bfd", + "uid": "5126c354-4310-4e23-a9e4-c9b87cb69792" + } + ], + "resourceVersion": "593186", + "selfLink": "/api/v1/namespaces/default/pods/dice-roller-6c8646bfd-hhts2", + "uid": "ea0b147a-c56a-46e6-9445-7a0b1b0ed3c0" + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "nodeName": "minikube", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 30, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "name": "default-token-5gctn", + "secret": { + "defaultMode": 420, + "secretName": "default-token-5gctn" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:53.000Z", + "status": "True", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:53.000Z", + "status": "True", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "docker://bcdbc153630f90556d57ff0d62f04d847ca63a5f0a7e5d9230683623d33d4b8d", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": {}, + "name": "nginx", + "ready": true, + "restartCount": 0, + "started": true, + "state": { + "running": { + "startedAt": "2020-09-25T09:58:53.000Z" + } + } + } + ], + "hostIP": "192.168.64.2", + "phase": "Running", + "podIP": "172.17.0.12", + "podIPs": [ + { + "ip": "172.17.0.12" + } + ], + "qosClass": "BestEffort", + "startTime": "2020-09-25T09:58:50.000Z" + } + }, + { + "metadata": { + "creationTimestamp": "2020-09-25T09:58:50.000Z", + "generateName": "dice-roller-6c8646bfd-", + "labels": { + "app": "dice-roller", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "6c8646bfd" + }, + "managedFields": [ + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:generateName": {}, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"5126c354-4310-4e23-a9e4-c9b87cb69792\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:enableServiceLinks": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T09:58:50.000Z" + }, + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:status": { + "f:conditions": { + "k:{\"type\":\"ContainersReady\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Initialized\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Ready\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + } + }, + "f:containerStatuses": {}, + "f:hostIP": {}, + "f:phase": {}, + "f:podIP": {}, + "f:podIPs": { + ".": {}, + "k:{\"ip\":\"172.17.0.14\"}": { + ".": {}, + "f:ip": {} + } + }, + "f:startTime": {} + } + }, + "manager": "kubelet", + "operation": "Update", + "time": "2020-09-25T09:58:55.000Z" + } + ], + "name": "dice-roller-6c8646bfd-j68lm", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ReplicaSet", + "name": "dice-roller-6c8646bfd", + "uid": "5126c354-4310-4e23-a9e4-c9b87cb69792" + } + ], + "resourceVersion": "593226", + "selfLink": "/api/v1/namespaces/default/pods/dice-roller-6c8646bfd-j68lm", + "uid": "b230ff1d-7a13-479b-9c7b-77c26bd79f62" + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "nodeName": "minikube", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 30, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "name": "default-token-5gctn", + "secret": { + "defaultMode": 420, + "secretName": "default-token-5gctn" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:55.000Z", + "status": "True", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:55.000Z", + "status": "True", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "docker://30fa8a3429f86ab8e9a4cec472b79d74266609082703f48950f4aae021e9d6a2", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": {}, + "name": "nginx", + "ready": true, + "restartCount": 0, + "started": true, + "state": { + "running": { + "startedAt": "2020-09-25T09:58:53.000Z" + } + } + } + ], + "hostIP": "192.168.64.2", + "phase": "Running", + "podIP": "172.17.0.14", + "podIPs": [ + { + "ip": "172.17.0.14" + } + ], + "qosClass": "BestEffort", + "startTime": "2020-09-25T09:58:50.000Z" + } + }, + { + "metadata": { + "creationTimestamp": "2020-09-25T09:58:50.000Z", + "generateName": "dice-roller-6c8646bfd-", + "labels": { + "app": "dice-roller", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "6c8646bfd" + }, + "managedFields": [ + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:generateName": {}, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"5126c354-4310-4e23-a9e4-c9b87cb69792\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:enableServiceLinks": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T09:58:50.000Z" + }, + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:status": { + "f:conditions": { + "k:{\"type\":\"ContainersReady\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Initialized\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Ready\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + } + }, + "f:containerStatuses": {}, + "f:hostIP": {}, + "f:phase": {}, + "f:podIP": {}, + "f:podIPs": { + ".": {}, + "k:{\"ip\":\"172.17.0.9\"}": { + ".": {}, + "f:ip": {} + } + }, + "f:startTime": {} + } + }, + "manager": "kubelet", + "operation": "Update", + "time": "2020-09-25T09:58:53.000Z" + } + ], + "name": "dice-roller-6c8646bfd-m6f9w", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ReplicaSet", + "name": "dice-roller-6c8646bfd", + "uid": "5126c354-4310-4e23-a9e4-c9b87cb69792" + } + ], + "resourceVersion": "593181", + "selfLink": "/api/v1/namespaces/default/pods/dice-roller-6c8646bfd-m6f9w", + "uid": "9b0079f6-ff29-4619-bf6e-71cc10199ac5" + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "nodeName": "minikube", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 30, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "name": "default-token-5gctn", + "secret": { + "defaultMode": 420, + "secretName": "default-token-5gctn" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:53.000Z", + "status": "True", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:53.000Z", + "status": "True", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "docker://f78767ee90eedffe46ff64bfe76d7f69426800dd89f3df012daf0baf3a9c5bdd", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": {}, + "name": "nginx", + "ready": true, + "restartCount": 0, + "started": true, + "state": { + "running": { + "startedAt": "2020-09-25T09:58:53.000Z" + } + } + } + ], + "hostIP": "192.168.64.2", + "phase": "Running", + "podIP": "172.17.0.9", + "podIPs": [ + { + "ip": "172.17.0.9" + } + ], + "qosClass": "BestEffort", + "startTime": "2020-09-25T09:58:50.000Z" + } + }, + { + "metadata": { + "creationTimestamp": "2020-09-24T11:39:27.000Z", + "generateName": "dice-roller-6c8646bfd-", + "labels": { + "app": "dice-roller", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "6c8646bfd" + }, + "managedFields": [ + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:generateName": {}, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"5126c354-4310-4e23-a9e4-c9b87cb69792\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:enableServiceLinks": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-24T11:39:27.000Z" + }, + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:status": { + "f:conditions": { + "k:{\"type\":\"ContainersReady\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Initialized\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Ready\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + } + }, + "f:containerStatuses": {}, + "f:hostIP": {}, + "f:phase": {}, + "f:podIP": {}, + "f:podIPs": { + ".": {}, + "k:{\"ip\":\"172.17.0.4\"}": { + ".": {}, + "f:ip": {} + } + }, + "f:startTime": {} + } + }, + "manager": "kubelet", + "operation": "Update", + "time": "2020-09-24T11:39:28.000Z" + } + ], + "name": "dice-roller-6c8646bfd-nv9pk", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ReplicaSet", + "name": "dice-roller-6c8646bfd", + "uid": "5126c354-4310-4e23-a9e4-c9b87cb69792" + } + ], + "resourceVersion": "503918", + "selfLink": "/api/v1/namespaces/default/pods/dice-roller-6c8646bfd-nv9pk", + "uid": "acf7f77f-78c6-4d4b-bc73-637211770ffc" + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "nodeName": "minikube", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 30, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "name": "default-token-5gctn", + "secret": { + "defaultMode": 420, + "secretName": "default-token-5gctn" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-24T11:39:27.000Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-24T11:39:28.000Z", + "status": "True", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-24T11:39:28.000Z", + "status": "True", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-24T11:39:27.000Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "docker://63dec9b32942c4b09ae43d09d6978261f674a3ee623823b8f1d20da8044c12ac", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": {}, + "name": "nginx", + "ready": true, + "restartCount": 0, + "started": true, + "state": { + "running": { + "startedAt": "2020-09-24T11:39:28.000Z" + } + } + } + ], + "hostIP": "192.168.64.2", + "phase": "Running", + "podIP": "172.17.0.4", + "podIPs": [ + { + "ip": "172.17.0.4" + } + ], + "qosClass": "BestEffort", + "startTime": "2020-09-24T11:39:27.000Z" + } + }, + { + "metadata": { + "creationTimestamp": "2020-09-25T09:58:50.000Z", + "generateName": "dice-roller-6c8646bfd-", + "labels": { + "app": "dice-roller", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "6c8646bfd" + }, + "managedFields": [ + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:generateName": {}, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"5126c354-4310-4e23-a9e4-c9b87cb69792\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:enableServiceLinks": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T09:58:50.000Z" + }, + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:status": { + "f:conditions": { + "k:{\"type\":\"ContainersReady\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Initialized\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Ready\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + } + }, + "f:containerStatuses": {}, + "f:hostIP": {}, + "f:phase": {}, + "f:podIP": {}, + "f:podIPs": { + ".": {}, + "k:{\"ip\":\"172.17.0.10\"}": { + ".": {}, + "f:ip": {} + } + }, + "f:startTime": {} + } + }, + "manager": "kubelet", + "operation": "Update", + "time": "2020-09-25T09:58:55.000Z" + } + ], + "name": "dice-roller-6c8646bfd-pjhfj", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ReplicaSet", + "name": "dice-roller-6c8646bfd", + "uid": "5126c354-4310-4e23-a9e4-c9b87cb69792" + } + ], + "resourceVersion": "593205", + "selfLink": "/api/v1/namespaces/default/pods/dice-roller-6c8646bfd-pjhfj", + "uid": "78775c3a-7af2-4ebe-a676-bc2e5dfa2bcc" + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "nodeName": "minikube", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 30, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "name": "default-token-5gctn", + "secret": { + "defaultMode": 420, + "secretName": "default-token-5gctn" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:55.000Z", + "status": "True", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:55.000Z", + "status": "True", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "docker://cbb83dda250db74285dcbe0b81bd0896acf402bac9710af090311f7360f824aa", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": {}, + "name": "nginx", + "ready": true, + "restartCount": 0, + "started": true, + "state": { + "running": { + "startedAt": "2020-09-25T09:58:53.000Z" + } + } + } + ], + "hostIP": "192.168.64.2", + "phase": "Running", + "podIP": "172.17.0.10", + "podIPs": [ + { + "ip": "172.17.0.10" + } + ], + "qosClass": "BestEffort", + "startTime": "2020-09-25T09:58:50.000Z" + } + }, + { + "metadata": { + "creationTimestamp": "2020-09-25T10:34:01.000Z", + "generateName": "dice-roller-canary-7d64cd756c-", + "labels": { + "app": "dice-roller-canary", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "7d64cd756c" + }, + "managedFields": [ + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:generateName": {}, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"9208395b-a9a7-4e46-b881-6a189f7fbdb0\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + }, + "k:{\"name\":\"other-side-car\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":82,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + }, + "k:{\"name\":\"side-car\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":81,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:enableServiceLinks": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T10:34:01.000Z" + }, + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:status": { + "f:conditions": { + "k:{\"type\":\"ContainersReady\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:message": {}, + "f:reason": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Initialized\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Ready\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:message": {}, + "f:reason": {}, + "f:status": {}, + "f:type": {} + } + }, + "f:containerStatuses": {}, + "f:hostIP": {}, + "f:phase": {}, + "f:podIP": {}, + "f:podIPs": { + ".": {}, + "k:{\"ip\":\"172.17.0.16\"}": { + ".": {}, + "f:ip": {} + } + }, + "f:startTime": {} + } + }, + "manager": "kubelet", + "operation": "Update", + "time": "2020-09-25T14:18:54.000Z" + } + ], + "name": "dice-roller-canary-7d64cd756c-55rfq", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ReplicaSet", + "name": "dice-roller-canary-7d64cd756c", + "uid": "9208395b-a9a7-4e46-b881-6a189f7fbdb0" + } + ], + "resourceVersion": "620452", + "selfLink": "/api/v1/namespaces/default/pods/dice-roller-canary-7d64cd756c-55rfq", + "uid": "65ad28e3-5d51-4b4b-9bf8-4cb069803034" + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + }, + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "side-car", + "ports": [ + { + "containerPort": 81, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + }, + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "other-side-car", + "ports": [ + { + "containerPort": 82, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "nodeName": "minikube", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 30, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "name": "default-token-5gctn", + "secret": { + "defaultMode": 420, + "secretName": "default-token-5gctn" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T10:34:01.000Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T14:18:53.000Z", + "message": "containers with unready status: [side-car other-side-car]", + "reason": "ContainersNotReady", + "status": "False", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T14:18:53.000Z", + "message": "containers with unready status: [side-car other-side-car]", + "reason": "ContainersNotReady", + "status": "False", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T10:34:01.000Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "docker://6ce15178d114a85f3d2e832de45c3355ab5b71ed5f4d4d225ee1c83bf07f69d9", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": {}, + "name": "nginx", + "ready": true, + "restartCount": 0, + "started": true, + "state": { + "running": { + "startedAt": "2020-09-25T10:34:01.000Z" + } + } + }, + { + "containerID": "docker://b3ce93d7f90bfe22558c61d2505b8473580574accdebb5fa4e51c0729c3511f4", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": { + "terminated": { + "containerID": "docker://b3ce93d7f90bfe22558c61d2505b8473580574accdebb5fa4e51c0729c3511f4", + "exitCode": 1, + "finishedAt": "2020-09-25T14:18:52.000Z", + "reason": "Error", + "startedAt": "2020-09-25T14:18:50.000Z" + } + }, + "name": "other-side-car", + "ready": false, + "restartCount": 38, + "started": false, + "state": { + "waiting": { + "message": "back-off 5m0s restarting failed container=other-side-car pod=dice-roller-canary-7d64cd756c-55rfq_default(65ad28e3-5d51-4b4b-9bf8-4cb069803034)", + "reason": "CrashLoopBackOff" + } + } + }, + { + "containerID": "docker://b7f0e65a2b8ab48c5f234616cfe8286aa96b55c3ef09c5cfbc4cdbe67a96f8cb", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": { + "terminated": { + "containerID": "docker://b7f0e65a2b8ab48c5f234616cfe8286aa96b55c3ef09c5cfbc4cdbe67a96f8cb", + "exitCode": 1, + "finishedAt": "2020-09-25T14:18:52.000Z", + "reason": "Error", + "startedAt": "2020-09-25T14:18:50.000Z" + } + }, + "name": "side-car", + "ready": false, + "restartCount": 38, + "started": false, + "state": { + "waiting": { + "message": "back-off 5m0s restarting failed container=side-car pod=dice-roller-canary-7d64cd756c-55rfq_default(65ad28e3-5d51-4b4b-9bf8-4cb069803034)", + "reason": "CrashLoopBackOff" + } + } + } + ], + "hostIP": "192.168.64.2", + "phase": "Running", + "podIP": "172.17.0.16", + "podIPs": [ + { + "ip": "172.17.0.16" + } + ], + "qosClass": "BestEffort", + "startTime": "2020-09-25T10:34:01.000Z" + } + }, + { + "metadata": { + "creationTimestamp": "2020-09-25T10:34:02.000Z", + "generateName": "dice-roller-canary-7d64cd756c-", + "labels": { + "app": "dice-roller-canary", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "7d64cd756c" + }, + "managedFields": [ + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:generateName": {}, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"9208395b-a9a7-4e46-b881-6a189f7fbdb0\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + }, + "k:{\"name\":\"other-side-car\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":82,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + }, + "k:{\"name\":\"side-car\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":81,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:enableServiceLinks": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T10:34:02.000Z" + }, + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:status": { + "f:conditions": { + "k:{\"type\":\"ContainersReady\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:message": {}, + "f:reason": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Initialized\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Ready\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:message": {}, + "f:reason": {}, + "f:status": {}, + "f:type": {} + } + }, + "f:containerStatuses": {}, + "f:hostIP": {}, + "f:phase": {}, + "f:podIP": {}, + "f:podIPs": { + ".": {}, + "k:{\"ip\":\"172.17.0.5\"}": { + ".": {}, + "f:ip": {} + } + }, + "f:startTime": {} + } + }, + "manager": "kubelet", + "operation": "Update", + "time": "2020-09-25T14:19:05.000Z" + } + ], + "name": "dice-roller-canary-7d64cd756c-vtbdx", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ReplicaSet", + "name": "dice-roller-canary-7d64cd756c", + "uid": "9208395b-a9a7-4e46-b881-6a189f7fbdb0" + } + ], + "resourceVersion": "620481", + "selfLink": "/api/v1/namespaces/default/pods/dice-roller-canary-7d64cd756c-vtbdx", + "uid": "0b8d9d79-b43d-4339-be57-ad5c63add77e" + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + }, + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "side-car", + "ports": [ + { + "containerPort": 81, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + }, + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "other-side-car", + "ports": [ + { + "containerPort": 82, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "nodeName": "minikube", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 30, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "name": "default-token-5gctn", + "secret": { + "defaultMode": 420, + "secretName": "default-token-5gctn" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T10:34:02.000Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T14:19:04.000Z", + "message": "containers with unready status: [side-car other-side-car]", + "reason": "ContainersNotReady", + "status": "False", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T14:19:04.000Z", + "message": "containers with unready status: [side-car other-side-car]", + "reason": "ContainersNotReady", + "status": "False", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T10:34:02.000Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "docker://3f9cadc6f135247eb2df9aaca8f25ea05dcf42be86d58fb833c8acf192da0d66", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": {}, + "name": "nginx", + "ready": true, + "restartCount": 0, + "started": true, + "state": { + "running": { + "startedAt": "2020-09-25T10:34:03.000Z" + } + } + }, + { + "containerID": "docker://5e3a9f9129e5ce74fea249c013afcc056ee95a940fed24495ef9b58df67771f3", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": { + "terminated": { + "containerID": "docker://5e3a9f9129e5ce74fea249c013afcc056ee95a940fed24495ef9b58df67771f3", + "exitCode": 1, + "finishedAt": "2020-09-25T14:19:03.000Z", + "reason": "Error", + "startedAt": "2020-09-25T14:19:01.000Z" + } + }, + "name": "other-side-car", + "ready": false, + "restartCount": 38, + "started": false, + "state": { + "waiting": { + "message": "back-off 5m0s restarting failed container=other-side-car pod=dice-roller-canary-7d64cd756c-vtbdx_default(0b8d9d79-b43d-4339-be57-ad5c63add77e)", + "reason": "CrashLoopBackOff" + } + } + }, + { + "containerID": "docker://21b42cae298c0ed7f90373974d8c88b0941d17733f35398144a17ece32e03210", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": { + "terminated": { + "containerID": "docker://21b42cae298c0ed7f90373974d8c88b0941d17733f35398144a17ece32e03210", + "exitCode": 1, + "finishedAt": "2020-09-25T14:19:03.000Z", + "reason": "Error", + "startedAt": "2020-09-25T14:19:01.000Z" + } + }, + "name": "side-car", + "ready": false, + "restartCount": 38, + "started": false, + "state": { + "waiting": { + "message": "back-off 5m0s restarting failed container=side-car pod=dice-roller-canary-7d64cd756c-vtbdx_default(0b8d9d79-b43d-4339-be57-ad5c63add77e)", + "reason": "CrashLoopBackOff" + } + } + } + ], + "hostIP": "192.168.64.2", + "phase": "Running", + "podIP": "172.17.0.5", + "podIPs": [ + { + "ip": "172.17.0.5" + } + ], + "qosClass": "BestEffort", + "startTime": "2020-09-25T10:34:02.000Z" + } + } + ], + "replicaSets": [ + { + "metadata": { + "annotations": { + "deployment.kubernetes.io/desired-replicas": "10", + "deployment.kubernetes.io/max-replicas": "13", + "deployment.kubernetes.io/revision": "2" + }, + "creationTimestamp": "2020-09-24T11:39:26.000Z", + "generation": 3, + "labels": { + "app": "dice-roller", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "6c8646bfd" + }, + "managedFields": [ + { + "apiVersion": "apps/v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:annotations": { + ".": {}, + "f:deployment.kubernetes.io/desired-replicas": {}, + "f:deployment.kubernetes.io/max-replicas": {}, + "f:deployment.kubernetes.io/revision": {} + }, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"7551e949-42d1-4061-83c5-9da107186e47\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:replicas": {}, + "f:selector": { + "f:matchLabels": { + ".": {}, + "f:app": {}, + "f:pod-template-hash": {} + } + }, + "f:template": { + "f:metadata": { + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + } + }, + "f:status": { + "f:availableReplicas": {}, + "f:fullyLabeledReplicas": {}, + "f:observedGeneration": {}, + "f:readyReplicas": {}, + "f:replicas": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T09:58:55.000Z" + } + ], + "name": "dice-roller-6c8646bfd", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Deployment", + "name": "dice-roller", + "uid": "7551e949-42d1-4061-83c5-9da107186e47" + } + ], + "resourceVersion": "593228", + "selfLink": "/apis/apps/v1/namespaces/default/replicasets/dice-roller-6c8646bfd", + "uid": "5126c354-4310-4e23-a9e4-c9b87cb69792" + }, + "spec": { + "replicas": 10, + "selector": { + "matchLabels": { + "app": "dice-roller", + "pod-template-hash": "6c8646bfd" + } + }, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "app": "dice-roller", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "6c8646bfd" + } + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "dnsPolicy": "ClusterFirst", + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "terminationGracePeriodSeconds": 30 + } + } + }, + "status": { + "availableReplicas": 10, + "fullyLabeledReplicas": 10, + "observedGeneration": 3, + "readyReplicas": 10, + "replicas": 10 + } + }, + { + "metadata": { + "annotations": { + "deployment.kubernetes.io/desired-replicas": "2", + "deployment.kubernetes.io/max-replicas": "3", + "deployment.kubernetes.io/revision": "3" + }, + "creationTimestamp": "2020-09-25T10:34:01.000Z", + "generation": 2, + "labels": { + "app": "dice-roller-canary", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "7d64cd756c" + }, + "managedFields": [ + { + "apiVersion": "apps/v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:annotations": { + ".": {}, + "f:deployment.kubernetes.io/desired-replicas": {}, + "f:deployment.kubernetes.io/max-replicas": {}, + "f:deployment.kubernetes.io/revision": {} + }, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"0b6ae80f-999b-40e9-b116-ea925f0ed07b\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:replicas": {}, + "f:selector": { + "f:matchLabels": { + ".": {}, + "f:app": {}, + "f:pod-template-hash": {} + } + }, + "f:template": { + "f:metadata": { + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + }, + "k:{\"name\":\"other-side-car\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":82,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + }, + "k:{\"name\":\"side-car\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":81,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + } + }, + "f:status": { + "f:fullyLabeledReplicas": {}, + "f:observedGeneration": {}, + "f:replicas": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T14:19:01.000Z" + } + ], + "name": "dice-roller-canary-7d64cd756c", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Deployment", + "name": "dice-roller-canary", + "uid": "0b6ae80f-999b-40e9-b116-ea925f0ed07b" + } + ], + "resourceVersion": "620479", + "selfLink": "/apis/apps/v1/namespaces/default/replicasets/dice-roller-canary-7d64cd756c", + "uid": "9208395b-a9a7-4e46-b881-6a189f7fbdb0" + }, + "spec": { + "replicas": 2, + "selector": { + "matchLabels": { + "app": "dice-roller-canary", + "pod-template-hash": "7d64cd756c" + } + }, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "app": "dice-roller-canary", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "7d64cd756c" + } + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + }, + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "side-car", + "ports": [ + { + "containerPort": 81, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + }, + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "other-side-car", + "ports": [ + { + "containerPort": 82, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "dnsPolicy": "ClusterFirst", + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "terminationGracePeriodSeconds": 30 + } + } + }, + "status": { + "fullyLabeledReplicas": 2, + "observedGeneration": 2, + "replicas": 2 + } + }, + { + "metadata": { + "annotations": { + "deployment.kubernetes.io/desired-replicas": "2", + "deployment.kubernetes.io/max-replicas": "3", + "deployment.kubernetes.io/revision": "2" + }, + "creationTimestamp": "2020-09-25T09:25:16.000Z", + "generation": 4, + "labels": { + "app": "dice-roller-canary", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "bcb8d54dd" + }, + "managedFields": [ + { + "apiVersion": "apps/v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:annotations": { + ".": {}, + "f:deployment.kubernetes.io/desired-replicas": {}, + "f:deployment.kubernetes.io/max-replicas": {}, + "f:deployment.kubernetes.io/revision": {} + }, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"0b6ae80f-999b-40e9-b116-ea925f0ed07b\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:replicas": {}, + "f:selector": { + "f:matchLabels": { + ".": {}, + "f:app": {}, + "f:pod-template-hash": {} + } + }, + "f:template": { + "f:metadata": { + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + }, + "k:{\"name\":\"side-car\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":81,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + } + }, + "f:status": { + "f:observedGeneration": {}, + "f:replicas": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T10:34:04.000Z" + } + ], + "name": "dice-roller-canary-bcb8d54dd", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Deployment", + "name": "dice-roller-canary", + "uid": "0b6ae80f-999b-40e9-b116-ea925f0ed07b" + } + ], + "resourceVersion": "598025", + "selfLink": "/apis/apps/v1/namespaces/default/replicasets/dice-roller-canary-bcb8d54dd", + "uid": "51942585-d599-42aa-bf23-9cf1acad4006" + }, + "spec": { + "replicas": 0, + "selector": { + "matchLabels": { + "app": "dice-roller-canary", + "pod-template-hash": "bcb8d54dd" + } + }, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "app": "dice-roller-canary", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "bcb8d54dd" + } + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + }, + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "side-car", + "ports": [ + { + "containerPort": 81, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "dnsPolicy": "ClusterFirst", + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "terminationGracePeriodSeconds": 30 + } + } + }, + "status": { + "observedGeneration": 4, + "replicas": 0 + } + }, + { + "metadata": { + "annotations": { + "deployment.kubernetes.io/desired-replicas": "2", + "deployment.kubernetes.io/max-replicas": "3", + "deployment.kubernetes.io/revision": "1" + }, + "creationTimestamp": "2020-09-25T09:02:53.000Z", + "generation": 3, + "labels": { + "app": "dice-roller-canary", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "c866fbf67" + }, + "managedFields": [ + { + "apiVersion": "apps/v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:annotations": { + ".": {}, + "f:deployment.kubernetes.io/desired-replicas": {}, + "f:deployment.kubernetes.io/max-replicas": {}, + "f:deployment.kubernetes.io/revision": {} + }, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"0b6ae80f-999b-40e9-b116-ea925f0ed07b\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:replicas": {}, + "f:selector": { + "f:matchLabels": { + ".": {}, + "f:app": {}, + "f:pod-template-hash": {} + } + }, + "f:template": { + "f:metadata": { + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + } + }, + "f:status": { + "f:observedGeneration": {}, + "f:replicas": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T09:25:20.000Z" + } + ], + "name": "dice-roller-canary-c866fbf67", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Deployment", + "name": "dice-roller-canary", + "uid": "0b6ae80f-999b-40e9-b116-ea925f0ed07b" + } + ], + "resourceVersion": "588501", + "selfLink": "/apis/apps/v1/namespaces/default/replicasets/dice-roller-canary-c866fbf67", + "uid": "4d2dfe13-978f-4504-9036-ca585acdea6c" + }, + "spec": { + "replicas": 0, + "selector": { + "matchLabels": { + "app": "dice-roller-canary", + "pod-template-hash": "c866fbf67" + } + }, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "app": "dice-roller-canary", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "c866fbf67" + } + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "dnsPolicy": "ClusterFirst", + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "terminationGracePeriodSeconds": 30 + } + } + }, + "status": { + "observedGeneration": 3, + "replicas": 0 + } + } + ], + "deployments": [ + { + "metadata": { + "annotations": { + "deployment.kubernetes.io/revision": "2", + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"apps/v1\",\"kind\":\"Deployment\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"},\"spec\":{\"replicas\":10,\"selector\":{\"matchLabels\":{\"app\":\"dice-roller\"}},\"template\":{\"metadata\":{\"labels\":{\"app\":\"dice-roller\",\"backstage.io/kubernetes-id\":\"dice-roller\"}},\"spec\":{\"containers\":[{\"image\":\"nginx:1.14.2\",\"name\":\"nginx\",\"ports\":[{\"containerPort\":80}]}]}}}}\n" + }, + "creationTimestamp": "2020-09-23T12:00:55.000Z", + "generation": 3, + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "managedFields": [ + { + "apiVersion": "apps/v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:annotations": { + ".": {}, + "f:kubectl.kubernetes.io/last-applied-configuration": {} + }, + "f:labels": { + ".": {}, + "f:backstage.io/kubernetes-id": {} + } + }, + "f:spec": { + "f:progressDeadlineSeconds": {}, + "f:replicas": {}, + "f:revisionHistoryLimit": {}, + "f:selector": { + "f:matchLabels": { + ".": {}, + "f:app": {} + } + }, + "f:strategy": { + "f:rollingUpdate": { + ".": {}, + "f:maxSurge": {}, + "f:maxUnavailable": {} + }, + "f:type": {} + }, + "f:template": { + "f:metadata": { + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {} + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + } + } + }, + "manager": "kubectl", + "operation": "Update", + "time": "2020-09-25T09:58:50.000Z" + }, + { + "apiVersion": "apps/v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:annotations": { + "f:deployment.kubernetes.io/revision": {} + } + }, + "f:status": { + "f:availableReplicas": {}, + "f:conditions": { + ".": {}, + "k:{\"type\":\"Available\"}": { + ".": {}, + "f:lastTransitionTime": {}, + "f:lastUpdateTime": {}, + "f:message": {}, + "f:reason": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Progressing\"}": { + ".": {}, + "f:lastTransitionTime": {}, + "f:lastUpdateTime": {}, + "f:message": {}, + "f:reason": {}, + "f:status": {}, + "f:type": {} + } + }, + "f:observedGeneration": {}, + "f:readyReplicas": {}, + "f:replicas": {}, + "f:updatedReplicas": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T09:58:55.000Z" + } + ], + "name": "dice-roller", + "namespace": "default", + "resourceVersion": "593230", + "selfLink": "/apis/apps/v1/namespaces/default/deployments/dice-roller", + "uid": "7551e949-42d1-4061-83c5-9da107186e47" + }, + "spec": { + "progressDeadlineSeconds": 600, + "replicas": 10, + "revisionHistoryLimit": 10, + "selector": { + "matchLabels": { + "app": "dice-roller" + } + }, + "strategy": { + "rollingUpdate": { + "maxSurge": "25%", + "maxUnavailable": "25%" + }, + "type": "RollingUpdate" + }, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "app": "dice-roller", + "backstage.io/kubernetes-id": "dice-roller" + } + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "dnsPolicy": "ClusterFirst", + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "terminationGracePeriodSeconds": 30 + } + } + }, + "status": { + "availableReplicas": 10, + "conditions": [ + { + "lastTransitionTime": "2020-09-23T12:00:55.000Z", + "lastUpdateTime": "2020-09-24T11:39:28.000Z", + "message": "ReplicaSet \"dice-roller-6c8646bfd\" has successfully progressed.", + "reason": "NewReplicaSetAvailable", + "status": "True", + "type": "Progressing" + }, + { + "lastTransitionTime": "2020-09-25T09:58:55.000Z", + "lastUpdateTime": "2020-09-25T09:58:55.000Z", + "message": "Deployment has minimum availability.", + "reason": "MinimumReplicasAvailable", + "status": "True", + "type": "Available" + } + ], + "observedGeneration": 3, + "readyReplicas": 10, + "replicas": 10, + "updatedReplicas": 10 + } + }, + { + "metadata": { + "annotations": { + "deployment.kubernetes.io/revision": "3", + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"apps/v1\",\"kind\":\"Deployment\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller-canary\",\"namespace\":\"default\"},\"spec\":{\"replicas\":2,\"selector\":{\"matchLabels\":{\"app\":\"dice-roller-canary\"}},\"template\":{\"metadata\":{\"labels\":{\"app\":\"dice-roller-canary\",\"backstage.io/kubernetes-id\":\"dice-roller\"}},\"spec\":{\"containers\":[{\"image\":\"nginx:1.14.2\",\"name\":\"nginx\",\"ports\":[{\"containerPort\":80}]},{\"image\":\"nginx:1.14.2\",\"name\":\"side-car\",\"ports\":[{\"containerPort\":81}]},{\"image\":\"nginx:1.14.2\",\"name\":\"other-side-car\",\"ports\":[{\"containerPort\":82}]}]}}}}\n" + }, + "creationTimestamp": "2020-09-25T09:02:53.000Z", + "generation": 3, + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "managedFields": [ + { + "apiVersion": "apps/v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:annotations": { + ".": {}, + "f:kubectl.kubernetes.io/last-applied-configuration": {} + }, + "f:labels": { + ".": {}, + "f:backstage.io/kubernetes-id": {} + } + }, + "f:spec": { + "f:progressDeadlineSeconds": {}, + "f:replicas": {}, + "f:revisionHistoryLimit": {}, + "f:selector": { + "f:matchLabels": { + ".": {}, + "f:app": {} + } + }, + "f:strategy": { + "f:rollingUpdate": { + ".": {}, + "f:maxSurge": {}, + "f:maxUnavailable": {} + }, + "f:type": {} + }, + "f:template": { + "f:metadata": { + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {} + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + }, + "k:{\"name\":\"other-side-car\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":82,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + }, + "k:{\"name\":\"side-car\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":81,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + } + } + }, + "manager": "kubectl", + "operation": "Update", + "time": "2020-09-25T10:34:01.000Z" + }, + { + "apiVersion": "apps/v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:annotations": { + "f:deployment.kubernetes.io/revision": {} + } + }, + "f:status": { + "f:conditions": { + ".": {}, + "k:{\"type\":\"Available\"}": { + ".": {}, + "f:lastTransitionTime": {}, + "f:lastUpdateTime": {}, + "f:message": {}, + "f:reason": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Progressing\"}": { + ".": {}, + "f:lastTransitionTime": {}, + "f:lastUpdateTime": {}, + "f:message": {}, + "f:reason": {}, + "f:status": {}, + "f:type": {} + } + }, + "f:observedGeneration": {}, + "f:replicas": {}, + "f:unavailableReplicas": {}, + "f:updatedReplicas": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T14:19:04.000Z" + } + ], + "name": "dice-roller-canary", + "namespace": "default", + "resourceVersion": "620480", + "selfLink": "/apis/apps/v1/namespaces/default/deployments/dice-roller-canary", + "uid": "0b6ae80f-999b-40e9-b116-ea925f0ed07b" + }, + "spec": { + "progressDeadlineSeconds": 600, + "replicas": 2, + "revisionHistoryLimit": 10, + "selector": { + "matchLabels": { + "app": "dice-roller-canary" + } + }, + "strategy": { + "rollingUpdate": { + "maxSurge": "25%", + "maxUnavailable": "25%" + }, + "type": "RollingUpdate" + }, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "app": "dice-roller-canary", + "backstage.io/kubernetes-id": "dice-roller" + } + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + }, + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "side-car", + "ports": [ + { + "containerPort": 81, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + }, + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "other-side-car", + "ports": [ + { + "containerPort": 82, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "dnsPolicy": "ClusterFirst", + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "terminationGracePeriodSeconds": 30 + } + } + }, + "status": { + "conditions": [ + { + "lastTransitionTime": "2020-09-25T09:02:53.000Z", + "lastUpdateTime": "2020-09-25T10:34:04.000Z", + "message": "ReplicaSet \"dice-roller-canary-7d64cd756c\" has successfully progressed.", + "reason": "NewReplicaSetAvailable", + "status": "True", + "type": "Progressing" + }, + { + "lastTransitionTime": "2020-09-25T13:48:06.000Z", + "lastUpdateTime": "2020-09-25T13:48:06.000Z", + "message": "Deployment does not have minimum availability.", + "reason": "MinimumReplicasUnavailable", + "status": "False", + "type": "Available" + } + ], + "observedGeneration": 3, + "replicas": 2, + "unavailableReplicas": 2, + "updatedReplicas": 2 + } + } + ] +} diff --git a/plugins/kubernetes/src/components/DeploymentTables/index.ts b/plugins/kubernetes/src/components/DeploymentTables/index.ts new file mode 100644 index 0000000000..dac38c5c81 --- /dev/null +++ b/plugins/kubernetes/src/components/DeploymentTables/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { DeploymentTables } from './DeploymentTables'; diff --git a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx index bbcef6a0d0..d14a2dc4f7 100644 --- a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx +++ b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx @@ -14,17 +14,57 @@ * limitations under the License. */ -import React, { FC, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Grid } from '@material-ui/core'; -import { InfoCard, Page, pageTheme, Content, useApi } from '@backstage/core'; +import { + Content, + InfoCard, + Page, + pageTheme, + Progress, + useApi, +} from '@backstage/core'; import { Entity } from '@backstage/catalog-model'; import { kubernetesApiRef } from '../../api/types'; -import { ObjectsByServiceIdResponse } from '@backstage/plugin-kubernetes-backend'; +import { + FetchResponse, + ObjectsByServiceIdResponse, +} from '@backstage/plugin-kubernetes-backend'; +import { DeploymentTables } from '../DeploymentTables'; +import { DeploymentTriple } from '../../types/types'; -// TODO this is a temporary component used to construct the Kubernetes plugin boilerplate +const findDeployments = (fetchResponse: FetchResponse[]): DeploymentTriple => { + return fetchResponse.reduce( + (prev, next) => { + switch (next.type) { + case 'deployments': + prev.deployments.push(...next.resources); + break; + case 'pods': + prev.pods.push(...next.resources); + break; + case 'replicasets': + prev.replicaSets.push(...next.resources); + break; + default: + } + return prev; + }, + { + pods: [], + replicaSets: [], + deployments: [], + } as DeploymentTriple, + ); +}; -export const KubernetesContent: FC<{ entity: Entity }> = ({ entity }) => { +// TODO proper error handling + +type KubernetesContentProps = { entity: Entity; children?: React.ReactNode }; + +export const KubernetesContent = ({ entity }: KubernetesContentProps) => { const kubernetesApi = useApi(kubernetesApiRef); + const [kubernetesObjects, setKubernetesObjects] = useState< ObjectsByServiceIdResponse | undefined >(undefined); @@ -45,27 +85,17 @@ export const KubernetesContent: FC<{ entity: Entity }> = ({ entity }) => { - {kubernetesObjects === undefined &&
loading....
} + {kubernetesObjects === undefined && } {error !== undefined &&
{error}
} - {kubernetesObjects !== undefined && ( -
- {kubernetesObjects.items.map((item, i) => ( - - - {item.resources.map((fr, j) => ( -
-
- {fr.type}:{' '} - {(fr.resources as any) - .map((v: any) => v.metadata.name) - .join(' ')} -
- ))} -
-
- ))} -
- )} + {kubernetesObjects?.items.map((item, i) => ( + + + + + + ))}
diff --git a/plugins/kubernetes/src/types/types.ts b/plugins/kubernetes/src/types/types.ts new file mode 100644 index 0000000000..09bff511a7 --- /dev/null +++ b/plugins/kubernetes/src/types/types.ts @@ -0,0 +1,23 @@ +/* + * 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 { V1Deployment, V1Pod, V1ReplicaSet } from '@kubernetes/client-node'; + +export interface DeploymentTriple { + pods: V1Pod[]; + replicaSets: V1ReplicaSet[]; + deployments: V1Deployment[]; +} From 2caf66eb54e911ea65bcbf08a1a1104ada95db8d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 25 Sep 2020 17:39:53 +0200 Subject: [PATCH 63/94] Update publishing.md --- docs/plugins/publishing.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/plugins/publishing.md b/docs/plugins/publishing.md index 0ed85e8cdc..fbeab6340c 100644 --- a/docs/plugins/publishing.md +++ b/docs/plugins/publishing.md @@ -21,6 +21,9 @@ out a new branch that you will use for the release, e.g. $ git checkout -b new-release ``` +First bump the `CHANGELOG.md` in the root of the repo and commit. You bump it by +adding a header for the new version just below the `## Next Release` one. + Then, from the root of the repo, run ```sh From e9de56f2991fd43c2a409ae5b301462fc83248de Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Fri, 25 Sep 2020 15:38:23 -0400 Subject: [PATCH 64/94] Update CODEOWNERS --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 20e574382b..0aebb18a41 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -6,7 +6,7 @@ * @spotify/backstage-core /docs/features/techdocs @spotify/techdocs-core -/plugins/cost-engineering @cost-engineering/silver-lining +/plugins/cost-insights @spotify/silver-lining /plugins/techdocs @spotify/techdocs-core /plugins/techdocs-backend @spotify/techdocs-core /packages/techdocs-cli @spotify/techdocs-core From dbeb43fac34f7c75abbc720a0afc762b174784b6 Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Fri, 25 Sep 2020 15:52:28 -0400 Subject: [PATCH 65/94] reset from upstream --- .github/CODEOWNERS | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0aebb18a41..de0655cdbc 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -6,7 +6,6 @@ * @spotify/backstage-core /docs/features/techdocs @spotify/techdocs-core -/plugins/cost-insights @spotify/silver-lining /plugins/techdocs @spotify/techdocs-core /plugins/techdocs-backend @spotify/techdocs-core /packages/techdocs-cli @spotify/techdocs-core From 9a5ea983b0e3fcb3ac5a2eb7e9dc69c322354a99 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 25 Sep 2020 16:26:43 +0200 Subject: [PATCH 66/94] plugins: remove identity-backend --- .changeset/config.json | 1 - packages/backend/package.json | 1 - packages/backend/src/index.ts | 3 - packages/backend/src/plugins/identity.ts | 22 ----- .../packages/backend/package.json.hbs | 1 - .../default-app/packages/backend/src/index.ts | 3 - .../packages/backend/src/plugins/identity.ts | 6 -- plugins/identity-backend/.eslintrc.js | 3 - plugins/identity-backend/README.md | 11 --- plugins/identity-backend/package.json | 43 ---------- .../src/adapters/StaticJsonAdapter.ts | 82 ------------------- .../identity-backend/src/adapters/index.ts | 17 ---- .../identity-backend/src/adapters/types.ts | 42 ---------- .../src/adapters/userGroups.ts | 35 -------- plugins/identity-backend/src/index.test.ts | 23 ------ plugins/identity-backend/src/index.ts | 17 ---- plugins/identity-backend/src/run.ts | 33 -------- .../identity-backend/src/service/router.ts | 50 ----------- .../src/service/standaloneApplication.ts | 52 ------------ .../src/service/standaloneServer.ts | 50 ----------- plugins/identity-backend/src/setupTests.ts | 19 ----- 21 files changed, 514 deletions(-) delete mode 100644 packages/backend/src/plugins/identity.ts delete mode 100644 packages/create-app/templates/default-app/packages/backend/src/plugins/identity.ts delete mode 100644 plugins/identity-backend/.eslintrc.js delete mode 100644 plugins/identity-backend/README.md delete mode 100644 plugins/identity-backend/package.json delete mode 100644 plugins/identity-backend/src/adapters/StaticJsonAdapter.ts delete mode 100644 plugins/identity-backend/src/adapters/index.ts delete mode 100644 plugins/identity-backend/src/adapters/types.ts delete mode 100644 plugins/identity-backend/src/adapters/userGroups.ts delete mode 100644 plugins/identity-backend/src/index.test.ts delete mode 100644 plugins/identity-backend/src/index.ts delete mode 100644 plugins/identity-backend/src/run.ts delete mode 100644 plugins/identity-backend/src/service/router.ts delete mode 100644 plugins/identity-backend/src/service/standaloneApplication.ts delete mode 100644 plugins/identity-backend/src/service/standaloneServer.ts delete mode 100644 plugins/identity-backend/src/setupTests.ts diff --git a/.changeset/config.json b/.changeset/config.json index 02236339b9..c7c4f11e57 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -34,7 +34,6 @@ "@backstage/plugin-gitops-profiles", "@backstage/plugin-graphiql", "@backstage/plugin-graphql-backend", - "@backstage/plugin-identity-backend", "@backstage/plugin-jenkins", "@backstage/plugin-lighthouse", "@backstage/plugin-newrelic", diff --git a/packages/backend/package.json b/packages/backend/package.json index 5bfaf2ee46..3c40d03def 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -25,7 +25,6 @@ "@backstage/plugin-auth-backend": "^0.1.1-alpha.23", "@backstage/plugin-catalog-backend": "^0.1.1-alpha.23", "@backstage/plugin-graphql-backend": "^0.1.1-alpha.23", - "@backstage/plugin-identity-backend": "^0.1.1-alpha.23", "@backstage/plugin-kubernetes-backend": "^0.1.1-alpha.23", "@backstage/plugin-proxy-backend": "^0.1.1-alpha.23", "@backstage/plugin-rollbar-backend": "^0.1.1-alpha.23", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 16cb35cade..dbd7842f8b 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -36,7 +36,6 @@ import { ConfigReader, AppConfig } from '@backstage/config'; import healthcheck from './plugins/healthcheck'; import auth from './plugins/auth'; import catalog from './plugins/catalog'; -import identity from './plugins/identity'; import kubernetes from './plugins/kubernetes'; import rollbar from './plugins/rollbar'; import scaffolder from './plugins/scaffolder'; @@ -73,7 +72,6 @@ async function main() { const catalogEnv = useHotMemoize(module, () => createEnv('catalog')); const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder')); const authEnv = useHotMemoize(module, () => createEnv('auth')); - const identityEnv = useHotMemoize(module, () => createEnv('identity')); const proxyEnv = useHotMemoize(module, () => createEnv('proxy')); const rollbarEnv = useHotMemoize(module, () => createEnv('rollbar')); const sentryEnv = useHotMemoize(module, () => createEnv('sentry')); @@ -88,7 +86,6 @@ async function main() { apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv)); apiRouter.use('/sentry', await sentry(sentryEnv)); apiRouter.use('/auth', await auth(authEnv)); - apiRouter.use('/identity', await identity(identityEnv)); apiRouter.use('/techdocs', await techdocs(techdocsEnv)); apiRouter.use('/kubernetes', await kubernetes(kubernetesEnv)); apiRouter.use('/proxy', await proxy(proxyEnv)); diff --git a/packages/backend/src/plugins/identity.ts b/packages/backend/src/plugins/identity.ts deleted file mode 100644 index 63a326965c..0000000000 --- a/packages/backend/src/plugins/identity.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { createRouter } from '@backstage/plugin-identity-backend'; -import { PluginEnvironment } from '../types'; - -export default async function createPlugin({ logger }: PluginEnvironment) { - return await createRouter({ logger }); -} diff --git a/packages/create-app/templates/default-app/packages/backend/package.json.hbs b/packages/create-app/templates/default-app/packages/backend/package.json.hbs index b70f36650e..78c80b7ee1 100644 --- a/packages/create-app/templates/default-app/packages/backend/package.json.hbs +++ b/packages/create-app/templates/default-app/packages/backend/package.json.hbs @@ -22,7 +22,6 @@ "@backstage/config": "^{{version}}", "@backstage/plugin-auth-backend": "^{{version}}", "@backstage/plugin-catalog-backend": "^{{version}}", - "@backstage/plugin-identity-backend": "^{{version}}", "@backstage/plugin-proxy-backend": "^{{version}}", "@backstage/plugin-rollbar-backend": "^{{version}}", "@backstage/plugin-scaffolder-backend": "^{{version}}", diff --git a/packages/create-app/templates/default-app/packages/backend/src/index.ts b/packages/create-app/templates/default-app/packages/backend/src/index.ts index fa058b6f71..07f1b1c99a 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/index.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/index.ts @@ -19,7 +19,6 @@ import { import { ConfigReader, AppConfig } from '@backstage/config'; import auth from './plugins/auth'; import catalog from './plugins/catalog'; -import identity from './plugins/identity'; import scaffolder from './plugins/scaffolder'; import proxy from './plugins/proxy'; import techdocs from './plugins/techdocs'; @@ -51,7 +50,6 @@ async function main() { const catalogEnv = useHotMemoize(module, () => createEnv('catalog')); const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder')); const authEnv = useHotMemoize(module, () => createEnv('auth')); - const identityEnv = useHotMemoize(module, () => createEnv('identity')); const proxyEnv = useHotMemoize(module, () => createEnv('proxy')); const techdocsEnv = useHotMemoize(module, () => createEnv('techdocs')); @@ -59,7 +57,6 @@ async function main() { apiRouter.use('/catalog', await catalog(catalogEnv)) apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv)) apiRouter.use('/auth', await auth(authEnv)) - apiRouter.use('/identity', await identity(identityEnv)) apiRouter.use('/techdocs', await techdocs(techdocsEnv)) apiRouter.use('/proxy', await proxy(proxyEnv)) apiRouter.use(notFoundHandler()); diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/identity.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/identity.ts deleted file mode 100644 index f82090eec9..0000000000 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/identity.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createRouter } from '@backstage/plugin-identity-backend'; -import { PluginEnvironment } from '../types'; - -export default async function createPlugin({ logger }: PluginEnvironment) { - return await createRouter({ logger }); -} diff --git a/plugins/identity-backend/.eslintrc.js b/plugins/identity-backend/.eslintrc.js deleted file mode 100644 index 16a033dbc6..0000000000 --- a/plugins/identity-backend/.eslintrc.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - extends: [require.resolve('@backstage/cli/config/eslint.backend')], -}; diff --git a/plugins/identity-backend/README.md b/plugins/identity-backend/README.md deleted file mode 100644 index c95fed0698..0000000000 --- a/plugins/identity-backend/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Identity Backend - -WORK IN PROGRESS - -This is the backend part of the identity plugin. - -It responds to identity requests from the frontend. - -## Links - -- [The Backstage homepage](https://backstage.io) diff --git a/plugins/identity-backend/package.json b/plugins/identity-backend/package.json deleted file mode 100644 index 63ecb65d1e..0000000000 --- a/plugins/identity-backend/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "@backstage/plugin-identity-backend", - "version": "0.1.1-alpha.23", - "main": "src/index.ts", - "types": "src/index.ts", - "license": "Apache-2.0", - "private": false, - "publishConfig": { - "access": "public", - "main": "dist/index.cjs.js", - "types": "dist/index.d.ts" - }, - "scripts": { - "start": "backstage-cli backend:dev", - "build": "backstage-cli backend:build", - "lint": "backstage-cli lint", - "test": "backstage-cli test", - "prepack": "backstage-cli prepack", - "postpack": "backstage-cli postpack", - "clean": "backstage-cli clean" - }, - "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.23", - "@types/express": "^4.17.6", - "compression": "^1.7.4", - "cors": "^2.8.5", - "express": "^4.17.1", - "express-promise-router": "^3.0.3", - "fs-extra": "^9.0.0", - "helmet": "^4.0.0", - "morgan": "^1.10.0", - "winston": "^3.2.1", - "yn": "^4.0.0" - }, - "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.23", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5" - }, - "files": [ - "dist" - ] -} diff --git a/plugins/identity-backend/src/adapters/StaticJsonAdapter.ts b/plugins/identity-backend/src/adapters/StaticJsonAdapter.ts deleted file mode 100644 index 9a58da2e85..0000000000 --- a/plugins/identity-backend/src/adapters/StaticJsonAdapter.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - Group, - GroupsResponse, - IdentityApi, - GroupsRequest, - GroupsJson, -} from './types'; - -export class StaticJsonAdapter implements IdentityApi { - private readonly groups: Group[]; - - constructor(userGroups: GroupsJson) { - this.groups = userGroups.groups; - } - - getUserGroups(req: GroupsRequest): Promise { - return new Promise(resolve => { - const { user, type } = req; - const userGroups = this._getUserGroups(this.groups, user); - const groups = this.filterGroupsByType(userGroups, type); - resolve({ groups }); - }); - } - - _getUserGroups(groups: Group[], user: string) { - const userGroups: Set = new Set(); - groups.forEach(group => { - if (this.isUserInGroup(group, user)) { - userGroups.add(group); - } - - if (group.children) { - const userSubGroups = this._getUserGroups(group.children, user) ?? []; - const isUserInSubGroup = Boolean(userSubGroups.length); - if (isUserInSubGroup) { - userGroups.add(group); - } - userSubGroups.forEach(subGroup => userGroups.add(subGroup)); - } - }); - return Array.from(userGroups); - } - - private filterGroupsByType = (userGroups: Group[], type: string) => { - const groups = type - ? userGroups - .filter((group: Group) => group.type === type) - .map(group => ({ name: group.name, type: group.type })) - : userGroups.map(group => ({ - name: group.name, - type: group.type, - })); - return groups; - }; - - private isUserInGroup = (group: Group, user: string): boolean => { - if (group.members) { - const groupMembers = group.members; - const groupsWithUser = groupMembers.filter( - member => member.name === user, - ); - return Boolean(groupsWithUser.length); - } - return false; - }; -} diff --git a/plugins/identity-backend/src/adapters/index.ts b/plugins/identity-backend/src/adapters/index.ts deleted file mode 100644 index 357391a25b..0000000000 --- a/plugins/identity-backend/src/adapters/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export * from './StaticJsonAdapter'; diff --git a/plugins/identity-backend/src/adapters/types.ts b/plugins/identity-backend/src/adapters/types.ts deleted file mode 100644 index 59fd1324d1..0000000000 --- a/plugins/identity-backend/src/adapters/types.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export type User = { - name: string; -}; - -export type Group = { - name: string; - type: string; - members?: User[]; - children?: Group[]; -}; - -export type GroupsJson = { - groups: Group[]; -}; - -export type GroupsResponse = { - groups: Group[]; -}; - -export type GroupsRequest = { - user: string; - type: string; -}; -export interface IdentityApi { - getUserGroups(req: GroupsRequest): Promise; -} diff --git a/plugins/identity-backend/src/adapters/userGroups.ts b/plugins/identity-backend/src/adapters/userGroups.ts deleted file mode 100644 index 806ca1f43f..0000000000 --- a/plugins/identity-backend/src/adapters/userGroups.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export const userGroups = { - groups: [ - { - name: 'engineering', - type: 'org', - children: [ - { - name: 'authentication', - type: 'team', - members: [{ name: 'kent' }, { name: 'dobbs' }], - }, - { - name: 'checkout', - type: 'team', - members: [{ name: 'don' }, { name: 'abramev' }], - }, - ], - }, - ], -}; diff --git a/plugins/identity-backend/src/index.test.ts b/plugins/identity-backend/src/index.test.ts deleted file mode 100644 index 4fca4ca746..0000000000 --- a/plugins/identity-backend/src/index.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import '@backstage/backend-common'; - -describe('test', () => { - it('unbreaks the test runner', () => { - expect(true).toBeTruthy(); - }); -}); diff --git a/plugins/identity-backend/src/index.ts b/plugins/identity-backend/src/index.ts deleted file mode 100644 index 7612c392a2..0000000000 --- a/plugins/identity-backend/src/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export * from './service/router'; diff --git a/plugins/identity-backend/src/run.ts b/plugins/identity-backend/src/run.ts deleted file mode 100644 index a2c2601258..0000000000 --- a/plugins/identity-backend/src/run.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import yn from 'yn'; -import { getRootLogger } from '@backstage/backend-common'; -import { startStandaloneServer } from './service/standaloneServer'; - -const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 3003; -const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); -const logger = getRootLogger(); - -startStandaloneServer({ port, enableCors, logger }).catch(err => { - logger.error(err); - process.exit(1); -}); - -process.on('SIGINT', () => { - logger.info('CTRL+C pressed; exiting.'); - process.exit(0); -}); diff --git a/plugins/identity-backend/src/service/router.ts b/plugins/identity-backend/src/service/router.ts deleted file mode 100644 index 9d16e8f332..0000000000 --- a/plugins/identity-backend/src/service/router.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import express from 'express'; -import Router from 'express-promise-router'; -import { Logger } from 'winston'; -import { StaticJsonAdapter } from '../adapters'; -import { IdentityApi } from '../adapters/types'; -import { userGroups } from '../adapters/userGroups'; - -export interface RouterOptions { - logger: Logger; -} - -const makeRouter = (adapter: IdentityApi): express.Router => { - const router = Router(); - router.use(express.json()); - - router.get('/users/:user/groups', async (req, res) => { - const user = req.params.user; - const type = req.query.type?.toString() ?? ''; - const response = await adapter.getUserGroups({ user, type }); - res.send(response); - }); - - return router; -}; - -export async function createRouter( - options: RouterOptions, -): Promise { - const logger = options.logger; - - logger.info('Initializing identity API backend'); - const adapter = new StaticJsonAdapter(userGroups); - return makeRouter(adapter); -} diff --git a/plugins/identity-backend/src/service/standaloneApplication.ts b/plugins/identity-backend/src/service/standaloneApplication.ts deleted file mode 100644 index 5eea4fb8f5..0000000000 --- a/plugins/identity-backend/src/service/standaloneApplication.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - errorHandler, - notFoundHandler, - requestLoggingHandler, -} from '@backstage/backend-common'; -import compression from 'compression'; -import cors from 'cors'; -import express from 'express'; -import helmet from 'helmet'; -import { Logger } from 'winston'; -import { createRouter } from './router'; - -export interface ApplicationOptions { - enableCors: boolean; - logger: Logger; -} - -export async function createStandaloneApplication( - options: ApplicationOptions, -): Promise { - const { enableCors, logger } = options; - const app = express(); - - app.use(helmet()); - if (enableCors) { - app.use(cors()); - } - app.use(compression()); - app.use(express.json()); - app.use(requestLoggingHandler()); - app.use('/', await createRouter({ logger })); - app.use(notFoundHandler()); - app.use(errorHandler()); - - return app; -} diff --git a/plugins/identity-backend/src/service/standaloneServer.ts b/plugins/identity-backend/src/service/standaloneServer.ts deleted file mode 100644 index 42231b45a9..0000000000 --- a/plugins/identity-backend/src/service/standaloneServer.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Server } from 'http'; -import { Logger } from 'winston'; -import { createStandaloneApplication } from './standaloneApplication'; - -export interface ServerOptions { - port: number; - enableCors: boolean; - logger: Logger; -} - -export async function startStandaloneServer( - options: ServerOptions, -): Promise { - const logger = options.logger.child({ service: 'identity-backend' }); - - logger.debug('Creating application...'); - const app = await createStandaloneApplication({ - enableCors: options.enableCors, - logger, - }); - - logger.debug('Starting application server...'); - return await new Promise((resolve, reject) => { - const server = app.listen(options.port, (err?: Error) => { - if (err) { - reject(err); - return; - } - - logger.info(`Listening on port ${options.port}`); - resolve(server); - }); - }); -} diff --git a/plugins/identity-backend/src/setupTests.ts b/plugins/identity-backend/src/setupTests.ts deleted file mode 100644 index f7b6ca962d..0000000000 --- a/plugins/identity-backend/src/setupTests.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -require('jest-fetch-mock').enableMocks(); - -export {}; From d49adc490ef1bb5e38aa2018785eb9ab99c463d5 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 25 Sep 2020 16:59:49 +0200 Subject: [PATCH 67/94] CHANGELOG: add entry for identity-backend removal --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55250e9b7c..bf2638cfe9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,10 @@ If you encounter issues while upgrading to a newer version, don't hesitate to re - The TechDocs backend now requires an implementation of `PluginEndpointDiscovery` from `@backstage/backend-common` to be passed in as `discovery`. See the changes to `@backstage/backend`. +### @backstage/plugin-identity-backend + +- This plugin was removed, remove it from your backend if it's there. [#2616](https://github.com/spotify/backstage/pull/2616) + ## v0.1.1-alpha.23 ### @backstage/core From 572abfe121ed8b0d2610addf1f246f93138fde93 Mon Sep 17 00:00:00 2001 From: Andrew Thauer <6507159+andrewthauer@users.noreply.github.com> Date: Sat, 26 Sep 2020 06:06:47 -0400 Subject: [PATCH 68/94] fix(circleci): add api route prefix to proxy rewrite path --- app-config.yaml | 3 --- docs/plugins/proxying.md | 6 +++--- plugins/circleci/README.md | 3 --- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/app-config.yaml b/app-config.yaml index a29240fd9c..71cbecd12b 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -16,9 +16,6 @@ backend: proxy: '/circleci/api': target: https://circleci.com/api/v1.1 - changeOrigin: true - pathRewrite: - '^/proxy/circleci/api/': '/' headers: Circle-Token: $secret: diff --git a/docs/plugins/proxying.md b/docs/plugins/proxying.md index f799d5fd33..a691c7f0bc 100644 --- a/docs/plugins/proxying.md +++ b/docs/plugins/proxying.md @@ -49,7 +49,7 @@ Each key under the proxy configuration entry is a route to match, below the prefix that the proxy plugin is mounted on. It must start with a slash. For example, if the backend mounts the proxy plugin as `/proxy`, the above configuration will lead to the proxy acting on backend requests to -`/proxy/simple-example/...` and `/proxy/larger-example/v1/...`. +`/api/proxy/simple-example/...` and `/api/proxy/larger-example/v1/...`. The value inside each route is either a simple URL string, or an object on the format accepted by @@ -74,6 +74,6 @@ except with the following caveats for convenience: commonly useful value. - If `pathRewrite` is not specified, it is set to a single rewrite that removes the entire prefix and route. In the above example, a rewrite of - `'^/proxy/larger-example/v1/': '/'` is added. That means that a request to - `/proxy/larger-example/v1/some/path` will be translated to a request to + `'^/api/proxy/larger-example/v1/': '/'` is added. That means that a request to + `/api/proxy/larger-example/v1/some/path` will be translated to a request to `http://larger.example.com:8080/svc.v1/some/path`. diff --git a/plugins/circleci/README.md b/plugins/circleci/README.md index 48e1bee6cc..19c2c35899 100644 --- a/plugins/circleci/README.md +++ b/plugins/circleci/README.md @@ -55,9 +55,6 @@ import { Router as CircleCIRouter } from '@backstage/plugin-circleci'; proxy: '/circleci/api': target: https://circleci.com/api/v1.1 - changeOrigin: true - pathRewrite: - '^/proxy/circleci/api/': '/' headers: Circle-Token: $secret: From 21a38faf9fd008458da386f7a6785205efc409a1 Mon Sep 17 00:00:00 2001 From: Navaneeth Suresh Date: Sat, 26 Sep 2020 16:33:04 +0530 Subject: [PATCH 69/94] Make title meaningful after component creation --- .../components/JobStatusModal/JobStatusModal.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx b/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx index 87b70a9553..c531e0ec2c 100644 --- a/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx +++ b/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useEffect } from 'react'; +import React, { useState, useEffect } from 'react'; import { Dialog, LinearProgress, @@ -43,16 +43,19 @@ export const JobStatusModal = ({ entity, }: Props) => { const job = useJobPolling(jobId); + const [dialogTitle, setDialogTitle] = useState('Creating component...'); useEffect(() => { - if (job?.status === 'COMPLETED') onComplete(job); - }, [job, onComplete]); + if (job?.status === 'COMPLETED') { + setDialogTitle('Successfully created component'); + onComplete(job); + } else if (job?.status === 'FAILED') + setDialogTitle('Failed to create component'); + }, [job, onComplete, setDialogTitle]); return ( - - Creating Component... - + {dialogTitle} {!job ? ( From e85030902fbb454f26c7d58214e50e2c78eb38c8 Mon Sep 17 00:00:00 2001 From: Navaneeth Suresh Date: Fri, 25 Sep 2020 00:24:02 +0530 Subject: [PATCH 70/94] Add a message if techdocs takes long time to load --- .../techdocs/src/reader/components/Reader.tsx | 5 +- .../components/TechDocsProgressBar.test.tsx | 36 +++++++++++++ .../reader/components/TechDocsProgressBar.tsx | 50 +++++++++++++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 plugins/techdocs/src/reader/components/TechDocsProgressBar.test.tsx create mode 100644 plugins/techdocs/src/reader/components/TechDocsProgressBar.tsx diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index 2aa87ea1ee..f2c6d1e2da 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -15,7 +15,7 @@ */ import React from 'react'; -import { useApi, Progress } from '@backstage/core'; +import { useApi } from '@backstage/core'; import { useShadowDom } from '..'; import { useAsync } from 'react-use'; import { techdocsStorageApiRef } from '../../api'; @@ -23,6 +23,7 @@ import { useParams, useNavigate } from 'react-router-dom'; import { ParsedEntityId } from '../../types'; import { useTheme } from '@material-ui/core'; import { BackstageTheme } from '@backstage/theme'; +import TechDocsProgressBar from './TechDocsProgressBar'; import transformer, { addBaseUrl, @@ -150,7 +151,7 @@ export const Reader = ({ entityId }: Props) => { return ( <> - {loading ? : null} + {loading ? : null}
); diff --git a/plugins/techdocs/src/reader/components/TechDocsProgressBar.test.tsx b/plugins/techdocs/src/reader/components/TechDocsProgressBar.test.tsx new file mode 100644 index 0000000000..be464bd485 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsProgressBar.test.tsx @@ -0,0 +1,36 @@ +/* + * 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 TechDocsProgressBar from './TechDocsProgressBar'; +import React from 'react'; +import { render } from '@testing-library/react'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { act } from 'react-dom/test-utils'; + +jest.useFakeTimers(); + +describe('', () => { + it('should render a message if techdocs page takes more time to load', () => { + const rendered = render(wrapInTestApp()); + + expect(rendered.getByTestId('progress')).toBeDefined(); + expect(rendered.queryByTestId('delay-reason')).toBeNull(); + act(() => { + jest.advanceTimersByTime(5000); + }); + expect(rendered.getByTestId('delay-reason')).toBeDefined(); + }); +}); diff --git a/plugins/techdocs/src/reader/components/TechDocsProgressBar.tsx b/plugins/techdocs/src/reader/components/TechDocsProgressBar.tsx new file mode 100644 index 0000000000..c7caa125bc --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsProgressBar.tsx @@ -0,0 +1,50 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState, useEffect } from 'react'; +import { useMountedState } from 'react-use'; +import { Progress } from '@backstage/core'; +import { Typography } from '@material-ui/core'; + +const TechDocsProgressBar = () => { + const isMounted = useMountedState(); + const [hasBeenDelayed, setHasBeenDelayed] = useState(false); + + const delayReason = `Docs are still loading...Backstage takes some extra time to load docs + for the first time. The subsequent loads are much faster.`; + + // Allowed time that docs can take to load (in milliseconds) + const allowedDelayTime = 5000; + + useEffect(() => { + setTimeout(() => { + if (isMounted()) { + setHasBeenDelayed(true); + } + }, allowedDelayTime); + }); + + return ( + <> + {hasBeenDelayed ? ( + {delayReason} + ) : null} + + + ); +}; + +export default TechDocsProgressBar; From b9cfa5e4662bcc25f88c5f3427ad2b35956ec0e7 Mon Sep 17 00:00:00 2001 From: Patrick Balestra Date: Sun, 27 Sep 2020 16:25:06 +0200 Subject: [PATCH 71/94] Fix small docs typo --- docs/tutorials/quickstart-app-plugin.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorials/quickstart-app-plugin.md b/docs/tutorials/quickstart-app-plugin.md index 51a7f8ded6..87bc1bb38a 100644 --- a/docs/tutorials/quickstart-app-plugin.md +++ b/docs/tutorials/quickstart-app-plugin.md @@ -210,7 +210,7 @@ type Viewer = { }; ``` -# The Tabel Model +# The Table Model Using Backstage's own component library, let's define a custom table. This component will get used if we have data to display. From 17c30eaf3efd026032110c8d80217e63f4385b9f Mon Sep 17 00:00:00 2001 From: Mayurs Date: Sun, 27 Sep 2020 22:27:20 +0530 Subject: [PATCH 72/94] fix(docs): Fix typos in several .md files (#2627) --- CONTRIBUTING.md | 2 +- docs/architecture-decisions/adr004-module-export-structure.md | 2 +- docs/plugins/index.md | 2 +- plugins/README.md | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d3c2780967..2b6b49e1f8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,7 +28,7 @@ What kind of plugins should/could be created? Some inspiration from the 120+ plu ## Suggesting a plugin -If you start developing a plugin that you aim to release as open source, we suggest that you create a new [new Issue](https://github.com/spotify/backstage/issues/new?labels=plugin&template=plugin_template.md&title=%5BPlugin%5D+THE+PLUGIN+NAME). This helps the community know what plugins are in development. +If you start developing a plugin that you aim to release as open source, we suggest that you create a [new Issue](https://github.com/spotify/backstage/issues/new?labels=plugin&template=plugin_template.md&title=%5BPlugin%5D+THE+PLUGIN+NAME). This helps the community know what plugins are in development. You can also use this process if you have an idea for a good plugin but you hope that someone else will pick up the work. diff --git a/docs/architecture-decisions/adr004-module-export-structure.md b/docs/architecture-decisions/adr004-module-export-structure.md index 12408abac1..85f06c6894 100644 --- a/docs/architecture-decisions/adr004-module-export-structure.md +++ b/docs/architecture-decisions/adr004-module-export-structure.md @@ -23,7 +23,7 @@ We currently do not use any pattern for how to structure exports. There is a mix of package-level re-exports deep into the directory tree, shallow re-exports for each directory, exports using `*` and explicit lists of each symbol, etc. The mix and lack of predictability makes it difficult to reason about the boundaries -of a module, and for example knowing whether is is safe to export a symbol in a +of a module, and for example knowing whether it is safe to export a symbol in a given file. ## Decision diff --git a/docs/plugins/index.md b/docs/plugins/index.md index 8a61aeee80..32cbfebc4b 100644 --- a/docs/plugins/index.md +++ b/docs/plugins/index.md @@ -9,7 +9,7 @@ Backstage is a single-page application composed of a set of plugins. Our goal for the plugin ecosystem is that the definition of a plugin is flexible enough to allow you to expose pretty much any kind of infrastructure or software development tool as a plugin in Backstage. By following strong -[design guidelines](../dls/design.md) we ensure the the overall user experience +[design guidelines](../dls/design.md) we ensure the overall user experience stays consistent between plugins. ![plugin](../assets/my-plugin_screenshot.png) diff --git a/plugins/README.md b/plugins/README.md index 91ad94b883..d56758b8f0 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -2,7 +2,7 @@ Backstage is a single-page application composed of a set of plugins. -Our goal for the plugin ecosystem is that the definition of a plugin is flexible enough to allow you to expose pretty much any kind of infrastructure or software development tool as a plugin in Backstage. By following strong [design guidelines](https://github.com/spotify/backstage/blob/master/docs/dls/design.md) we ensure the the overall user experience stays consistent between plugins. +Our goal for the plugin ecosystem is that the definition of a plugin is flexible enough to allow you to expose pretty much any kind of infrastructure or software development tool as a plugin in Backstage. By following strong [design guidelines](https://github.com/spotify/backstage/blob/master/docs/dls/design.md) we ensure the overall user experience stays consistent between plugins. ![plugin](../docs/assets/my-plugin_screenshot.png) @@ -12,6 +12,6 @@ To create a plugin, follow the steps outlined [here](https://github.com/spotify/ ## Suggesting a plugin -If you start developing a plugin that you aim to release as open source, we suggest that you create a new [new Issue](https://github.com/spotify/backstage/issues/new?template=plugin_template.md). This helps the community know what plugins are in development. +If you start developing a plugin that you aim to release as open source, we suggest that you create a [new Issue](https://github.com/spotify/backstage/issues/new?template=plugin_template.md). This helps the community know what plugins are in development. You can also use this process if you have an idea for a good plugin but you hope that someone else will pick up the work. From 37c8d0886573a50e91e1f55f6958586c2b520088 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2020 04:33:53 +0000 Subject: [PATCH 73/94] chore(deps-dev): bump storybook-dark-mode from 1.0.2 to 1.0.3 Bumps [storybook-dark-mode](https://github.com/hipstersmoothie/storybook-dark-mode) from 1.0.2 to 1.0.3. - [Release notes](https://github.com/hipstersmoothie/storybook-dark-mode/releases) - [Changelog](https://github.com/hipstersmoothie/storybook-dark-mode/blob/master/CHANGELOG.md) - [Commits](https://github.com/hipstersmoothie/storybook-dark-mode/compare/v1.0.2...v1.0.3) Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 804eb9dc10..c02e800ff0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -21003,9 +21003,9 @@ store2@^2.7.1: integrity sha512-tWEpK0snS2RPUq1i3R6OahfJNjWCQYNxq0+by1amCSuw0mXtymJpzmZIeYpA1UAa+7B0grCpNYIbDcd7AgTbFg== storybook-dark-mode@^1.0.2: - version "1.0.2" - resolved "https://registry.npmjs.org/storybook-dark-mode/-/storybook-dark-mode-1.0.2.tgz#963007e72b628e0efe29bdc5ee8449513bc056b8" - integrity sha512-HBeXTUzYaRgdGJ6YbmcJ3RXw47xp2b5kegZwtcqnSvRblNUZlAKSKsD6UQTWA30hPJ+u3O/5OE5bXk8+H4Zdqg== + version "1.0.3" + resolved "https://registry.npmjs.org/storybook-dark-mode/-/storybook-dark-mode-1.0.3.tgz#8d58a874b6107ff1a7f29ebb0e6726b8036eee08" + integrity sha512-mjHLrv/dwtqKmbOoQ2CMtGKDttWSnUybutujsIPxLcEC77EujjWiRBFv46LtXAZEyZLm8sGFUz0s6HJJfJ3tSw== dependencies: fast-deep-equal "^3.0.0" memoizerific "^1.11.3" From dad7b961942915b9d0548a2b10b172be9435dd3c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 28 Sep 2020 09:59:05 +0200 Subject: [PATCH 74/94] package: allow node v14 and add to master build matrix --- .github/workflows/ci.yml | 2 +- .github/workflows/e2e-win.yml | 2 +- .github/workflows/e2e.yml | 2 +- .github/workflows/master-win.yml | 2 +- .github/workflows/master.yml | 4 +++- .github/workflows/microsite-build-check.yml | 2 +- package.json | 2 +- packages/backend/package.json | 2 +- packages/create-app/templates/default-app/package.json.hbs | 2 +- .../templates/default-app/packages/backend/package.json.hbs | 2 +- 10 files changed, 12 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8c92c57ff..845cec0b0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: strategy: matrix: - node-version: [12.x] + node-version: [12.x, 14.x] env: CI: true diff --git a/.github/workflows/e2e-win.yml b/.github/workflows/e2e-win.yml index 5bab1b1299..e8942e4451 100644 --- a/.github/workflows/e2e-win.yml +++ b/.github/workflows/e2e-win.yml @@ -17,7 +17,7 @@ jobs: strategy: matrix: os: [windows-latest] - node-version: [12.x] + node-version: [12.x, 14.x] env: CI: true diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 69bd8393c8..33fd506f83 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -26,7 +26,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - node-version: [12.x] + node-version: [12.x, 14.x] env: CI: true diff --git a/.github/workflows/master-win.yml b/.github/workflows/master-win.yml index 3f65cf9aec..c103583052 100644 --- a/.github/workflows/master-win.yml +++ b/.github/workflows/master-win.yml @@ -10,7 +10,7 @@ jobs: strategy: matrix: - node-version: [12.x] + node-version: [12.x, 14.x] env: CI: true diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index bebca7c6f9..3aa44cbf59 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -10,7 +10,7 @@ jobs: strategy: matrix: - node-version: [12.x] + node-version: [12.x, 14.x] env: CI: true @@ -69,12 +69,14 @@ jobs: # Publishes current version of packages that are not already present in the registry - name: publish + if: matrix.node-version == '12.x' run: yarn lerna -- publish from-package --yes env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} # Tags the commit with the version in the core package if the tag doesn't exist - uses: Klemensas/action-autotag@1.2.3 + if: matrix.node-version == '12.x' with: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' package_root: 'packages/core' diff --git a/.github/workflows/microsite-build-check.yml b/.github/workflows/microsite-build-check.yml index ff290b71e4..0b614509f2 100644 --- a/.github/workflows/microsite-build-check.yml +++ b/.github/workflows/microsite-build-check.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: - node-version: [12.x] + node-version: [12.x, 14.x] env: CI: true diff --git a/package.json b/package.json index 3a77fe6880..45e13d30f4 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "root", "private": true, "engines": { - "node": "12" + "node": "12 || 14" }, "scripts": { "dev": "concurrently \"yarn start\" \"yarn start-backend\"", diff --git a/packages/backend/package.json b/packages/backend/package.json index 3c40d03def..f47879e996 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -6,7 +6,7 @@ "private": true, "license": "Apache-2.0", "engines": { - "node": "12" + "node": "12 || 14" }, "scripts": { "build": "backstage-cli backend:build", diff --git a/packages/create-app/templates/default-app/package.json.hbs b/packages/create-app/templates/default-app/package.json.hbs index a764a78b18..27606640f3 100644 --- a/packages/create-app/templates/default-app/package.json.hbs +++ b/packages/create-app/templates/default-app/package.json.hbs @@ -3,7 +3,7 @@ "version": "1.0.0", "private": true, "engines": { - "node": "12" + "node": "12 || 14" }, "scripts": { "start": "yarn workspace app start", diff --git a/packages/create-app/templates/default-app/packages/backend/package.json.hbs b/packages/create-app/templates/default-app/packages/backend/package.json.hbs index 78c80b7ee1..7f811e3739 100644 --- a/packages/create-app/templates/default-app/packages/backend/package.json.hbs +++ b/packages/create-app/templates/default-app/packages/backend/package.json.hbs @@ -5,7 +5,7 @@ "types": "src/index.ts", "private": true, "engines": { - "node": "12" + "node": "12 || 14" }, "scripts": { "build": "backstage-cli backend:build", From 940ea11719334d2321c1127a186560ed97309645 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 28 Sep 2020 12:34:57 +0200 Subject: [PATCH 75/94] backend: bump sqlite3 to v5 --- packages/backend/package.json | 2 +- plugins/catalog-backend/package.json | 2 +- yarn.lock | 65 +++++++++++++++++++++++++--- 3 files changed, 60 insertions(+), 9 deletions(-) diff --git a/packages/backend/package.json b/packages/backend/package.json index f47879e996..74e833cf4e 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -40,7 +40,7 @@ "knex": "^0.21.1", "pg": "^8.3.0", "pg-connection-string": "^2.3.0", - "sqlite3": "^4.2.0", + "sqlite3": "^5.0.0", "winston": "^3.2.1" }, "devDependencies": { diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index 7e3134a012..6e2aa096d3 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -32,7 +32,7 @@ "lodash": "^4.17.15", "morgan": "^1.10.0", "node-fetch": "^2.6.0", - "sqlite3": "^4.2.0", + "sqlite3": "^5.0.0", "uuid": "^8.0.0", "winston": "^3.2.1", "yaml": "^1.9.2", diff --git a/yarn.lock b/yarn.lock index 804eb9dc10..736bfc6b42 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7343,6 +7343,13 @@ bl@^4.0.1: inherits "^2.0.4" readable-stream "^3.4.0" +block-stream@*: + version "0.0.9" + resolved "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz#13ebfe778a03205cfe03751481ebb4b3300c126a" + integrity sha1-E+v+d4oDIFz+A3UUgeu0szAMEmo= + dependencies: + inherits "~2.0.0" + bluebird@3.7.2, bluebird@^3.3.5, bluebird@^3.5.1, bluebird@^3.5.3, bluebird@^3.5.5, bluebird@^3.7.2: version "3.7.2" resolved "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" @@ -11567,6 +11574,16 @@ fsevents@^2.1.2, fsevents@~2.1.2: resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.1.2.tgz#4c0a1fb34bc68e543b4b82a9ec392bfbda840805" integrity sha512-R4wDiBwZ0KzpgOWetKDug1FZcYhqYnUYKtfZYt4mD5SBz76q0KR4Q9o7GIPamsVPGmW3EYPPJ0dOOjvx32ldZA== +fstream@^1.0.0, fstream@^1.0.12: + version "1.0.12" + resolved "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz#4e8ba8ee2d48be4f7d0de505455548eae5932045" + integrity sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg== + dependencies: + graceful-fs "^4.1.2" + inherits "~2.0.0" + mkdirp ">=0.5 0" + rimraf "2" + function-bind@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" @@ -12975,7 +12992,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.0, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.3, inherits@~2.0.4: +inherits@2, inherits@2.0.4, inherits@^2.0.0, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.0, inherits@~2.0.1, inherits@~2.0.3, inherits@~2.0.4: version "2.0.4" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -15966,7 +15983,7 @@ mkdirp@*, mkdirp@1.x, mkdirp@^1.0.3, mkdirp@^1.0.4: resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== -mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@^0.5.4, mkdirp@^0.5.5, mkdirp@~0.5.1: +"mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@^0.5.4, mkdirp@^0.5.5, mkdirp@~0.5.1: version "0.5.5" resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== @@ -16187,6 +16204,11 @@ no-case@^3.0.3: lower-case "^2.0.1" tslib "^1.10.0" +node-addon-api@2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.0.tgz#f9afb8d777a91525244b01775ea0ddbe1125483b" + integrity sha512-ASCL5U13as7HhOExbT6OlWJJUV/lLzL2voOSP1UVehpRD8FbSrSDjfScK/KwAvVTI5AS6r4VwbOMlIqtvRidnA== + node-dir@^0.1.10: version "0.1.17" resolved "https://registry.npmjs.org/node-dir/-/node-dir-0.1.17.tgz#5f5665d93351335caabef8f1c554516cf5f1e4e5" @@ -16231,6 +16253,24 @@ node-forge@^0.7.0: resolved "https://registry.npmjs.org/node-forge/-/node-forge-0.7.6.tgz#fdf3b418aee1f94f0ef642cd63486c77ca9724ac" integrity sha512-sol30LUpz1jQFBjOKwbjxijiE3b6pjd74YwfD0fJOKPjF+fONKb2Yg8rYgS6+bK6VDl+/wfr4IYpC7jDzLUIfw== +node-gyp@3.x: + version "3.8.0" + resolved "https://registry.npmjs.org/node-gyp/-/node-gyp-3.8.0.tgz#540304261c330e80d0d5edce253a68cb3964218c" + integrity sha512-3g8lYefrRRzvGeSowdJKAKyks8oUpLEd/DyPV4eMhVlhJ0aNaZqIrNUIPuEWWTAoPqyFkfGrM67MC69baqn6vA== + dependencies: + fstream "^1.0.0" + glob "^7.0.3" + graceful-fs "^4.1.2" + mkdirp "^0.5.0" + nopt "2 || 3" + npmlog "0 || 1 || 2 || 3 || 4" + osenv "0" + request "^2.87.0" + rimraf "2" + semver "~5.3.0" + tar "^2.0.0" + which "1" + node-gyp@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/node-gyp/-/node-gyp-4.0.0.tgz#972654af4e5dd0cd2a19081b4b46fe0442ba6f45" @@ -20832,13 +20872,15 @@ sprintf-js@~1.0.2: resolved "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= -sqlite3@^4.2.0: - version "4.2.0" - resolved "https://registry.npmjs.org/sqlite3/-/sqlite3-4.2.0.tgz#49026d665e9fc4f922e56fb9711ba5b4c85c4901" - integrity sha512-roEOz41hxui2Q7uYnWsjMOTry6TcNUNmp8audCx18gF10P2NknwdpF+E+HKvz/F2NvPKGGBF4NGc+ZPQ+AABwg== +sqlite3@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/sqlite3/-/sqlite3-5.0.0.tgz#1bfef2151c6bc48a3ab1a6c126088bb8dd233566" + integrity sha512-rjvqHFUaSGnzxDy2AHCwhHy6Zp6MNJzCPGYju4kD8yi6bze4d1/zMTg6C7JI49b7/EM7jKMTvyfN/4ylBKdwfw== dependencies: - nan "^2.12.1" + node-addon-api "2.0.0" node-pre-gyp "^0.11.0" + optionalDependencies: + node-gyp "3.x" srcset@^2.0.1: version "2.0.1" @@ -21610,6 +21652,15 @@ tar-stream@^2.0.0: inherits "^2.0.3" readable-stream "^3.1.1" +tar@^2.0.0: + version "2.2.2" + resolved "https://registry.npmjs.org/tar/-/tar-2.2.2.tgz#0ca8848562c7299b8b446ff6a4d60cdbb23edc40" + integrity sha512-FCEhQ/4rE1zYv9rYXJw/msRqsnmlje5jHP6huWeBZ704jUTy02c5AZyWujpMR1ax6mVw9NyJMfuK2CMDWVIfgA== + dependencies: + block-stream "*" + fstream "^1.0.12" + inherits "2" + tar@^4, tar@^4.4.10, tar@^4.4.12, tar@^4.4.8: version "4.4.13" resolved "https://registry.npmjs.org/tar/-/tar-4.4.13.tgz#43b364bc52888d555298637b10d60790254ab525" From e7686833fd22db87d1a3f5ac71b9c3109786d285 Mon Sep 17 00:00:00 2001 From: Navaneeth Suresh Date: Mon, 28 Sep 2020 16:48:40 +0530 Subject: [PATCH 76/94] docs: fix typos in the documentation (#2633) --- docs/FAQ.md | 4 ++-- docs/api/utility-apis.md | 4 ++-- .../adr002-default-catalog-file-format.md | 4 ++-- .../adr003-avoid-default-exports.md | 2 +- .../adr004-module-export-structure.md | 2 +- docs/architecture-decisions/adr006-avoid-react-fc.md | 2 +- .../adr007-use-msw-to-mock-service-requests.md | 2 +- docs/auth/index.md | 2 +- docs/auth/oauth.md | 4 ++-- docs/features/software-catalog/extending-the-model.md | 4 ++-- docs/features/software-catalog/index.md | 2 +- docs/features/software-catalog/references.md | 4 ++-- docs/features/software-catalog/well-known-annotations.md | 2 +- docs/features/software-templates/adding-templates.md | 6 +++--- .../extending/create-your-own-preparer.md | 4 ++-- .../extending/create-your-own-publisher.md | 8 ++++---- .../extending/create-your-own-templater.md | 2 +- docs/features/software-templates/extending/index.md | 8 ++++---- docs/features/software-templates/installation.md | 2 +- docs/features/techdocs/getting-started.md | 2 +- docs/plugins/index.md | 2 +- docs/plugins/structure-of-a-plugin.md | 2 +- docs/plugins/testing.md | 6 +++--- docs/tutorials/journey.md | 8 ++++---- docs/tutorials/quickstart-app-plugin.md | 4 ++-- 25 files changed, 46 insertions(+), 46 deletions(-) diff --git a/docs/FAQ.md b/docs/FAQ.md index b4c4970ff8..d4d129a0d6 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -86,7 +86,7 @@ well-known tech and a large flora of components. ### What technology does Backstage use? -The code base is a large-scale React application that uses TypeScript. For +The codebase is a large-scale React application that uses TypeScript. For [Phase 2](https://github.com/spotify/backstage#project-roadmap), we plan to use Node.js and GraphQL. @@ -205,7 +205,7 @@ Please report sensitive security issues via Spotify's ### Does Backstage collect any information that is shared with Spotify? No. Backstage does not collect any telemetry from any third party using the -platform. Spotify, and the open source community, does have access to +platform. Spotify, and the open source community, do have access to [GitHub Insights](https://github.com/features/insights), which contains information such as contributors, commits, traffic, and dependencies. Backstage is an open platform, but you are in control of your own data. You control who diff --git a/docs/api/utility-apis.md b/docs/api/utility-apis.md index 599bbbb66c..1ebf2ae88b 100644 --- a/docs/api/utility-apis.md +++ b/docs/api/utility-apis.md @@ -248,8 +248,8 @@ directly tied to React. The indirection provided by Utility APIs also makes it straightforward to test components that depend on APIs, and to provide a standard common development environment for plugins. A proper test wrapper with mocked API implementations -is not yet ready, but it will provided as a part of `@backstage/test-utils`. It -will provide mocked variants of APIs, with additional methods for asserting a +is not yet ready, but it will be provided as a part of `@backstage/test-utils`. +It will provide mocked variants of APIs, with additional methods for asserting a component's interaction with the API. The common development environment for plugins is included in diff --git a/docs/architecture-decisions/adr002-default-catalog-file-format.md b/docs/architecture-decisions/adr002-default-catalog-file-format.md index f4fd3a1158..d831581d5b 100644 --- a/docs/architecture-decisions/adr002-default-catalog-file-format.md +++ b/docs/architecture-decisions/adr002-default-catalog-file-format.md @@ -22,7 +22,7 @@ This ADR describes the default format of these descriptor files. ### Inspiration -Internally at Spotify, a home grown software catalog system is used heavily and +Internally at Spotify, a homegrown software catalog system is used heavily and forms a core part of Backstage and other important pieces of the infrastructure. The user experience, learnings and certain pieces of metadata from that catalog are being carried over to the open source effort. @@ -40,7 +40,7 @@ triggers from the version control system, etc. Each file describes one or more entities in accordance with the [Backstage System Model](https://github.com/spotify/backstage/issues/390). All -of these entities have a common stucture and nomenclature, and they are stored +of these entities have a common structure and nomenclature, and they are stored in the software catalog from which they then can be queried. Entities have distinct names, and they may reference each other by those names. diff --git a/docs/architecture-decisions/adr003-avoid-default-exports.md b/docs/architecture-decisions/adr003-avoid-default-exports.md index 8634a19632..d08bc6a882 100644 --- a/docs/architecture-decisions/adr003-avoid-default-exports.md +++ b/docs/architecture-decisions/adr003-avoid-default-exports.md @@ -44,7 +44,7 @@ A summary: name each. Using named exports helps prevent needing to rename symbols, which has myriad -benefts. A few are: +benefits. A few are: - IDE tools like "Find All References" and "Go To Definition" function - Manual codebase searching ("grep", etc) is easier with a unique symbol diff --git a/docs/architecture-decisions/adr004-module-export-structure.md b/docs/architecture-decisions/adr004-module-export-structure.md index 85f06c6894..14c94c4ed1 100644 --- a/docs/architecture-decisions/adr004-module-export-structure.md +++ b/docs/architecture-decisions/adr004-module-export-structure.md @@ -22,7 +22,7 @@ or We currently do not use any pattern for how to structure exports. There is a mix of package-level re-exports deep into the directory tree, shallow re-exports for each directory, exports using `*` and explicit lists of each symbol, etc. The -mix and lack of predictability makes it difficult to reason about the boundaries +mix and lack of predictability make it difficult to reason about the boundaries of a module, and for example knowing whether it is safe to export a symbol in a given file. diff --git a/docs/architecture-decisions/adr006-avoid-react-fc.md b/docs/architecture-decisions/adr006-avoid-react-fc.md index bea36712d7..4aafa307cf 100644 --- a/docs/architecture-decisions/adr006-avoid-react-fc.md +++ b/docs/architecture-decisions/adr006-avoid-react-fc.md @@ -13,7 +13,7 @@ with next to no benefits in combination with a few downsides. The main reasons were: - **children props** were implicitly added -- **Generic Type** were not supported on children +- **Generic Type** was not supported on children Read more about the removal in [this PR](https://github.com/facebook/create-react-app/pull/8177). diff --git a/docs/architecture-decisions/adr007-use-msw-to-mock-service-requests.md b/docs/architecture-decisions/adr007-use-msw-to-mock-service-requests.md index 6b18f67d40..045588f505 100644 --- a/docs/architecture-decisions/adr007-use-msw-to-mock-service-requests.md +++ b/docs/architecture-decisions/adr007-use-msw-to-mock-service-requests.md @@ -11,7 +11,7 @@ tests, unit tests to e2e tests always have their own implementation of mocking these requests. There's been traction in the outer community towards using this library to mock network requests by using an express style declaration for routes. react-testing-library suggests using this library instead of mocking -fetch directly wether this be in a browser or in node. +fetch directly whether this be in a browser or in node. https://github.com/mswjs/msw diff --git a/docs/auth/index.md b/docs/auth/index.md index aed3a580b0..d7fb5d8c59 100644 --- a/docs/auth/index.md +++ b/docs/auth/index.md @@ -6,7 +6,7 @@ description: Documentation on User Authentication and Authorization in Backstage ## Summary -The purpose of the Auth APIs in Backstage are to identify the user, and to +The purpose of the Auth APIs in Backstage is to identify the user, and to provide a way for plugins to request access to 3rd party services on behalf of the user (OAuth). This documentation focuses on the implementation of that solution and how to extend it. For documentation on how to consume the Auth APIs diff --git a/docs/auth/oauth.md b/docs/auth/oauth.md index e90b7b665a..178b99b153 100644 --- a/docs/auth/oauth.md +++ b/docs/auth/oauth.md @@ -52,7 +52,7 @@ in a new popup window that is opened by the app. By using a popup-based flow it is possible to request authentication at any point in the app, without requiring a redirect. Because of this there is no need to ask for all scopes upfront, or interrupt the app with a redirect and forcing plugin authors to take care in -restoring state after a redirect has been make. All in all it makes it much +restoring state after a redirect has been made. All in all it makes it much easier to make authenticated requests inside a plugin. ## OAuth Flow @@ -67,7 +67,7 @@ Component and APIs can request Access or ID Tokens from any available Auth provider. If there already exists a cached fresh token that covers (at least) the requested scopes, it will be returned immediately. If the OAuth provider implements token refreshes, this check will also trigger a token refresh attempt -if no session is a available. +if no session is available. If new scopes are requested, or the user is not yet logged in with that provider, a dialog is shown informing the user that they need to log in with the diff --git a/docs/features/software-catalog/extending-the-model.md b/docs/features/software-catalog/extending-the-model.md index 4e8a357ea7..7e2a4c3867 100644 --- a/docs/features/software-catalog/extending-the-model.md +++ b/docs/features/software-catalog/extending-the-model.md @@ -18,7 +18,7 @@ Backstage natively supports tracking of the following component Since these types are likely not the only kind of software you will want to track in Backstage, it is possible to -It is possible to add your own software types that fits your organization's data +It is possible to add your own software types that fit your organization's data model. Inside Spotify our model has grown significantly over the years, and now includes ML models, Apps, data pipelines and many more. @@ -30,7 +30,7 @@ catalog. ## The Other type It might be tempting to put software that doesn't fit into any of the existing -types into Other. There are a few reasons why we advice against this; firstly, +types into Other. There are a few reasons why we advise against this; firstly, we have found that it is preferred to match the conceptual model that your engineers have when describing your software. Secondly, Backstage helps your engineers manage their software by integrating the infrastructure tooling diff --git a/docs/features/software-catalog/index.md b/docs/features/software-catalog/index.md index 49a490d78b..8be6a00579 100644 --- a/docs/features/software-catalog/index.md +++ b/docs/features/software-catalog/index.md @@ -84,7 +84,7 @@ registered in the catalog. ### Static catalog configuration In addition to manually registering components, it is also possible to register -components though [static configuration](../../conf/index.md). For example, the +components through [static configuration](../../conf/index.md). For example, the above example can be added using the following configuration: ```yaml diff --git a/docs/features/software-catalog/references.md b/docs/features/software-catalog/references.md index 5093ecc49e..e438ddc977 100644 --- a/docs/features/software-catalog/references.md +++ b/docs/features/software-catalog/references.md @@ -67,7 +67,7 @@ be left out in its own yaml file because that's the default value there too), and name `pet-managers`. The entries in `implementsApis` are also references. In this case, none of them -need to specify a kind since we know from the context that that's the only kind +needs to specify a kind since we know from the context that that's the only kind that's supported here. The second entry specifies a namespace but the other ones don't, and in this context, the default is to refer to the same namespace as the originating entity (`external-systems` here). So the three references @@ -79,7 +79,7 @@ there to exist three API kind entities in the catalog matching those references. This is a more verbose version of a reference, where each part of the kind-namespace-name triplet is expressed as a field in a structure. This format -can be used where necessary, such as if either of the three elements contain +can be used where necessary, such as if either of the three elements contains colons or forward slashes. Avoid using it where possible, since it is harder to read and write for humans. diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md index e5b66bb3b2..e0fc10ff1e 100644 --- a/docs/features/software-catalog/well-known-annotations.md +++ b/docs/features/software-catalog/well-known-annotations.md @@ -57,7 +57,7 @@ spec: This annotation allows to fetch an API definition from another location, instead of wrapping the API definition inside the definition field. This allows to -easitly consume existing API definition. The definition is fetched during +easily consume existing API definition. The definition is fetched during ingestion by a processor and included in the entity. It is updated on every refresh. The annotation contains a location reference string that contains the location processor type and the target. diff --git a/docs/features/software-templates/adding-templates.md b/docs/features/software-templates/adding-templates.md index 59019e754e..4283af7c72 100644 --- a/docs/features/software-templates/adding-templates.md +++ b/docs/features/software-templates/adding-templates.md @@ -5,7 +5,7 @@ description: Documentation on Adding your own Templates --- Templates are stored in the **Service Catalog** under a kind `Template`. The -minimum that the a template skeleton needs is a `template.yaml` but it would be +minimum that the template skeleton needs is a `template.yaml` but it would be good to also have some files in there that can be templated in. A simple `template.yaml` definition might look something like this: @@ -30,7 +30,7 @@ spec: templater: cookiecutter # what does this template create type: website - # if the template is not in the current directory where this definition is kept then specfiy + # if the template is not in the current directory where this definition is kept then specify path: './template' # the schema for the form which is displayed in the frontend. # should follow JSON schema for forms: https://jsonforms.io/ @@ -107,7 +107,7 @@ This should then have added the catalog, and also should now be listed under the create page at http://localhost:3000/create. The `type` field which is chosen in the request to add the `template.yaml` to -the Service Catalog here, will be come the `PreparerKey` which will be used to +the Service Catalog here, will become the `PreparerKey` which will be used to select the `Preparer` when creating a job. ### Adding form values in the Scaffolder Wizard diff --git a/docs/features/software-templates/extending/create-your-own-preparer.md b/docs/features/software-templates/extending/create-your-own-preparer.md index 14c75b0ffb..e354c0941e 100644 --- a/docs/features/software-templates/extending/create-your-own-preparer.md +++ b/docs/features/software-templates/extending/create-your-own-preparer.md @@ -17,7 +17,7 @@ location protocols: These two are added to the `PreparersBuilder` and then passed into the `createRouter` function of the `@spotify/plugin-scaffolder-backend` -An full example backend can be found +A full example backend can be found [here](https://github.com/spotify/backstage/blob/d91c10f654475a60829fa33a5c81018e517a319a/packages/backend/src/plugins/scaffolder.ts), but it looks something like the following @@ -98,4 +98,4 @@ const preparers = new Preparers(); preparers.register('gcs', new GoogleCloudStoragePreparer()); ``` -And then pass this in to the `createRouter` function. +And then pass this into the `createRouter` function. diff --git a/docs/features/software-templates/extending/create-your-own-publisher.md b/docs/features/software-templates/extending/create-your-own-publisher.md index 25d7bcf676..ecb097f9d9 100644 --- a/docs/features/software-templates/extending/create-your-own-publisher.md +++ b/docs/features/software-templates/extending/create-your-own-publisher.md @@ -8,10 +8,10 @@ Publishers are responsible for pushing and storing the templated skeleton after the values have been templated by the `Templater`. See [Create your own templater](./create-your-own-templater.md) for more info. -They receive a directory or location where the templater has sucessfully run and -is now ready to store somewhere. They also are given some other options which -are sent from the frontend, such as the `storePath` which is a string of where -the frontend thinks we should save this templated folder. +They receive a directory or location where the templater has successfully run +and is now ready to store somewhere. They also are given some other options +which are sent from the frontend, such as the `storePath` which is a string of +where the frontend thinks we should save this templated folder. Currently we provide the following `publishers`: diff --git a/docs/features/software-templates/extending/create-your-own-templater.md b/docs/features/software-templates/extending/create-your-own-templater.md index 36c362b9c3..d4a6eef5d2 100644 --- a/docs/features/software-templates/extending/create-your-own-templater.md +++ b/docs/features/software-templates/extending/create-your-own-templater.md @@ -147,4 +147,4 @@ const templaters = new Templaters(); templaters.register('handlebars', new HandlebarsTemplater()); ``` -And then pass this in to the `createRouter` function. +And then pass this into the `createRouter` function. diff --git a/docs/features/software-templates/extending/index.md b/docs/features/software-templates/extending/index.md index 02d27a5725..944bf0e3ca 100644 --- a/docs/features/software-templates/extending/index.md +++ b/docs/features/software-templates/extending/index.md @@ -11,13 +11,13 @@ You're at the right place. This guide is going to take you through how the Scaffolder in Backstage works. We'll dive into some jargon and run through what's going on in the backend to be able to create these templates. There's also more guides that you might find -useful at the bottom of this document. At it's core, theres 3 simple stages. +useful at the bottom of this document. At its core, there are 3 simple stages. 1. Pick a skeleton 2. Template some variables into the skeleton 3. Send the templated skeleton somewhere -These three steps are translated to the folllowing stages under the hood in the +These three steps are translated to the following stages under the hood in the scaffolder that you will need to know: 1. Prepare @@ -38,7 +38,7 @@ the router to pick the correct `Preparer` to run for the `Template` entity. **Templater** - The templater is responsible for actually running the chosen templater on top of the previously returned temporary directory from the -**Preprarer**. We advise making these Docker containers as it can keep all +**Preparer**. We advise making these Docker containers as it can keep all dependencies--for example Cookiecutter--self contained and not a dependency on the host machine. @@ -82,7 +82,7 @@ Once that has been posted, a job will be setup with different stages, and the job processor will complete each stage before moving onto the next stage, whilst collecting logs and mutating the running job. -Here's some futher reading that you might find useful: +Here's some further reading that you might find useful: - [Adding your own Template](../adding-templates.md) - [Creating your own Templater](./create-your-own-templater.md) diff --git a/docs/features/software-templates/installation.md b/docs/features/software-templates/installation.md index 90d25cab81..9bd36ff4dd 100644 --- a/docs/features/software-templates/installation.md +++ b/docs/features/software-templates/installation.md @@ -240,7 +240,7 @@ scaffolder: You can configure who can see the new repositories that the scaffolder creates by specifying `visibility` option. Valid options are `public`, `private` and -`internal`. `internal` options is for GitHub Enterprise clients, which means +`internal`. `internal` option is for GitHub Enterprise clients, which means public within the organization. ### Running the Backend diff --git a/docs/features/techdocs/getting-started.md b/docs/features/techdocs/getting-started.md index 03494564c3..2e5c8d000d 100644 --- a/docs/features/techdocs/getting-started.md +++ b/docs/features/techdocs/getting-started.md @@ -13,7 +13,7 @@ If you haven't setup Backstage already, start ## Installing TechDocs TechDocs is provided with the Backstage application by default. If you want to -set up TechDocs manually, keep follow the instructions below. +set up TechDocs manually, keep following the instructions below. ### Adding the package diff --git a/docs/plugins/index.md b/docs/plugins/index.md index 32cbfebc4b..8836c1cd42 100644 --- a/docs/plugins/index.md +++ b/docs/plugins/index.md @@ -21,7 +21,7 @@ To create a plugin, follow the steps outlined [here](create-a-plugin.md). ## Suggesting a plugin If you start developing a plugin that you aim to release as open source, we -suggest that you create a new +suggest that you create a [new Issue](https://github.com/spotify/backstage/issues/new?labels=plugin&template=plugin_template.md&title=%5BPlugin%5D+THE+PLUGIN+NAME). This helps the community know what plugins are in development. diff --git a/docs/plugins/structure-of-a-plugin.md b/docs/plugins/structure-of-a-plugin.md index f061d2f4a8..ee0a27cde2 100644 --- a/docs/plugins/structure-of-a-plugin.md +++ b/docs/plugins/structure-of-a-plugin.md @@ -84,7 +84,7 @@ structure our plugins. There are usually one or multiple page components and next to them you can split up the UI in as many components as you feel like. We have the `ExamplePage` to show an example Backstage page component. The -`ExampleFetchComponent` show cases the common task of making an async request to +`ExampleFetchComponent` showcases the common task of making an async request to a public API and plot the response data in a table using Material-UI components. You may tweak these components, rename them and/or replace them completely. diff --git a/docs/plugins/testing.md b/docs/plugins/testing.md index 696f8b1368..bb5a577c7d 100644 --- a/docs/plugins/testing.md +++ b/docs/plugins/testing.md @@ -32,7 +32,7 @@ working on. ## Naming Test Files -Tests should be name `[filename].test.js`. +Tests should be named `[filename].test.js`. For example, the tests for **`Link.js`** exist in the file **`Link.test.js`**. @@ -54,7 +54,7 @@ TODO. # Writing Unit Tests -The following principles are good guides to determining if you are writing high +The following principles are good guides for determining if you are writing high quality frontend unit tests. ## Bad Unit Test Principle @@ -170,7 +170,7 @@ input changes component is mounted, just that it is mounted in response to the input. ✓ **Fulfills Scalability Principle**: If we decide to refactor the entire way -the loading indicator is displayed the test still works without touching it. +the loading indicator has displayed the test still works without touching it. ✓ **Fulfills Broken Functionality Principle**: this test verifies the functionality (displaying an indicator) is working, rather than how it is diff --git a/docs/tutorials/journey.md b/docs/tutorials/journey.md index 24765e8531..908d7fc61b 100644 --- a/docs/tutorials/journey.md +++ b/docs/tutorials/journey.md @@ -134,7 +134,7 @@ work. # 5. The First User At this point the only things that anyone that wants to use Sam's plugin needs -to do is to add +to do are to add https://raw.githubusercontent.com/sam/backstage-spotify-theme/master/annotation.json#/sam.wise/spotify-track-id to their catalog schema, import and add the `PlayerWidget` on the desired entity template pages, and make sure they're providing Spotify auth. @@ -145,7 +145,7 @@ select a theme tune when creating a new component. Sam jumps on the idea and adds a new creation hook that is exported by the plugin. The hook can be installed either in a single, all, or component templates that match a label. It adds a field as a part of the component creation process with a nice search box -that allow users to search for a track that they want to use as the theme tune. +that allows users to search for a track that they want to use as the theme tune. # 6. Return to the Repo @@ -203,7 +203,7 @@ backend plugin also extends the common GraphQL schema with a mutation that updates the track ID in the database. On the frontend the Pull Request doesn't change much. It defines the save action -the was previously using the `RepoApi` in it's own API. +the was previously using the `RepoApi` in its own API. ```ts type ThemeTuneStorageApi = { @@ -247,7 +247,7 @@ once an internal concern of the plugin is now becoming a standard in the community. In order to standardize the annotation in Backstage, Sam submits a Pull Request -to the Backstage Core repo. The request suggest a new well-known metadata +to the Backstage Core repo. The request suggests a new well-known metadata annotation called `spotify.com/track-id`, with the same schema definition as Sam's label, and refers to Sam's own plugin and the `spotify-album-art` plugin as existing usages. The Backstage maintainers merge the Pull Request, after diff --git a/docs/tutorials/quickstart-app-plugin.md b/docs/tutorials/quickstart-app-plugin.md index 87bc1bb38a..020c43a294 100644 --- a/docs/tutorials/quickstart-app-plugin.md +++ b/docs/tutorials/quickstart-app-plugin.md @@ -312,6 +312,6 @@ return ( > Break apart ExampleFetchComponent into smaller logical parts contained in > their own files. Rename your components to something other than ExampleXxx. > -> You might be real proud of a plugin you develop. Follow this next tutorial for -> an in-depth look at publishing and including that for the entire Backstage +> You might be really proud of a plugin you develop. Follow this next tutorial +> for an in-depth look at publishing and including that for the entire Backstage > community. [TODO](#). From c109cbe4df074a3c3a1359c34efaa3e9273b86e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Frinnstr=C3=B6m?= Date: Fri, 18 Sep 2020 06:34:10 +0000 Subject: [PATCH 77/94] Add Azure prepare stage to scaffolder --- app-config.yaml | 5 + .../software-templates/installation.md | 22 ++- packages/backend/src/plugins/scaffolder.ts | 3 + .../scaffolder/stages/prepare/azure.test.ts | 138 ++++++++++++++++++ .../src/scaffolder/stages/prepare/azure.ts | 76 ++++++++++ .../src/scaffolder/stages/prepare/index.ts | 1 + .../src/scaffolder/stages/types.ts | 7 +- 7 files changed, 247 insertions(+), 5 deletions(-) create mode 100644 plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts create mode 100644 plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts diff --git a/app-config.yaml b/app-config.yaml index a29240fd9c..c7c5101b55 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -132,6 +132,11 @@ scaffolder: token: $secret: env: GITLAB_ACCESS_TOKEN + azure: + api: + token: + $secret: + env: AZURE_PRIVATE_TOKEN auth: providers: diff --git a/docs/features/software-templates/installation.md b/docs/features/software-templates/installation.md index 9bd36ff4dd..b804c84e52 100644 --- a/docs/features/software-templates/installation.md +++ b/docs/features/software-templates/installation.md @@ -217,6 +217,11 @@ The Github access token is retrieved from environment variables via the config. The config file needs to specify what environment variable the token is retrieved from. Your config should have the following objects. +You can configure who can see the new repositories that the scaffolder creates +by specifying `visibility` option. Valid options are `public`, `private` and +`internal`. `internal` options is for GitHub Enterprise clients, which means +public within the organization. + #### Gitlab For Gitlab, we currently support the configuration of the GitLab publisher and @@ -238,10 +243,19 @@ scaffolder: env: SCAFFOLDER_GITLAB_PRIVATE_TOKEN ``` -You can configure who can see the new repositories that the scaffolder creates -by specifying `visibility` option. Valid options are `public`, `private` and -`internal`. `internal` option is for GitHub Enterprise clients, which means -public within the organization. +#### Azure DevOps + +For Azure DevOps we currently support the preparer stage with the configuration +of a private access token (PAT) when needed: + +```yaml +scaffolder: + azure: + api: + token: + $secret: + env: AZURE_PRIVATE_TOKEN +``` ### Running the Backend diff --git a/packages/backend/src/plugins/scaffolder.ts b/packages/backend/src/plugins/scaffolder.ts index f902ee76f0..17cd7ba10e 100644 --- a/packages/backend/src/plugins/scaffolder.ts +++ b/packages/backend/src/plugins/scaffolder.ts @@ -20,6 +20,7 @@ import { FilePreparer, GithubPreparer, GitlabPreparer, + AzurePreparer, Preparers, Publishers, GithubPublisher, @@ -46,12 +47,14 @@ export default async function createPlugin({ const filePreparer = new FilePreparer(); const githubPreparer = new GithubPreparer(); const gitlabPreparer = new GitlabPreparer(config); + const azurePreparer = new AzurePreparer(config); const preparers = new Preparers(); preparers.register('file', filePreparer); preparers.register('github', githubPreparer); preparers.register('gitlab', gitlabPreparer); preparers.register('gitlab/api', gitlabPreparer); + preparers.register('azure/api', azurePreparer); const publishers = new Publishers(); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts new file mode 100644 index 0000000000..7a7973e981 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts @@ -0,0 +1,138 @@ +/* + * 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. + */ + +const mocks = { + Clone: { clone: jest.fn() }, + CheckoutOptions: jest.fn(() => {}), +}; +jest.doMock('nodegit', () => mocks); + +import { AzurePreparer } from './azure'; +import { + TemplateEntityV1alpha1, + LOCATION_ANNOTATION, +} from '@backstage/catalog-model'; +import { ConfigReader } from '@backstage/config'; + +describe('AzurePreparer', () => { + let mockEntity: TemplateEntityV1alpha1; + beforeEach(() => { + jest.clearAllMocks(); + mockEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Template', + metadata: { + annotations: { + [LOCATION_ANNOTATION]: + 'azure/api:https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=%2Ftemplate.yaml', + }, + name: 'graphql-starter', + title: 'GraphQL Service', + description: + 'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n', + uid: '9cf16bad-16e0-4213-b314-c4eec773c50b', + etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2', + generation: 1, + }, + spec: { + type: 'website', + templater: 'cookiecutter', + path: './template', + schema: { + $schema: 'http://json-schema.org/draft-07/schema#', + required: ['storePath', 'owner'], + properties: { + owner: { + type: 'string', + title: 'Owner', + description: 'Who is going to own this component', + }, + storePath: { + type: 'string', + title: 'Store path', + description: 'GitHub store path in org/repo format', + }, + }, + }, + }, + }; + }); + + it('calls the clone command with the correct arguments for a repository', async () => { + const preparer = new AzurePreparer(ConfigReader.fromConfigs([])); + await preparer.prepare(mockEntity); + expect(mocks.Clone.clone).toHaveBeenNthCalledWith( + 1, + 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', + expect.any(String), + {}, + ); + }); + + it('calls the clone command with the correct arguments if an access token is provided for a repository', async () => { + const preparer = new AzurePreparer( + ConfigReader.fromConfigs([ + { + context: '', + data: { + scaffolder: { + azure: { + api: { + token: 'fake-token', + }, + }, + }, + }, + }, + ]), + ); + await preparer.prepare(mockEntity); + expect(mocks.Clone.clone).toHaveBeenNthCalledWith( + 1, + 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', + expect.any(String), + { + fetchOpts: { + callbacks: { + credentials: expect.anything(), + }, + }, + }, + ); + }); + + it('calls the clone command with the correct arguments for a repository when no path is provided', async () => { + const preparer = new AzurePreparer(ConfigReader.fromConfigs([])); + delete mockEntity.spec.path; + await preparer.prepare(mockEntity); + expect(mocks.Clone.clone).toHaveBeenNthCalledWith( + 1, + 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', + expect.any(String), + {}, + ); + }); + + it('return the temp directory with the path to the folder if it is specified', async () => { + const preparer = new AzurePreparer(ConfigReader.fromConfigs([])); + mockEntity.spec.path = './template/test/1/2/3'; + const response = await preparer.prepare(mockEntity); + + expect(response.split('\\').join('/')).toMatch( + /\/template\/test\/1\/2\/3$/, + ); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts new file mode 100644 index 0000000000..c2cb97e505 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts @@ -0,0 +1,76 @@ +/* + * 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 fs from 'fs-extra'; +import path from 'path'; +import os from 'os'; +import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import { parseLocationAnnotation } from '../helpers'; +import { InputError } from '@backstage/backend-common'; +import { PreparerBase } from './types'; +import GitUriParser from 'git-url-parse'; +import { Clone, Cred } from 'nodegit'; +import { Config } from '@backstage/config'; + +export class AzurePreparer implements PreparerBase { + private readonly privateToken: string; + + constructor(config: Config) { + this.privateToken = + config.getOptionalString('scaffolder.azure.api.token') ?? ''; + } + + async prepare(template: TemplateEntityV1alpha1): Promise { + const { protocol, location } = parseLocationAnnotation(template); + + if (protocol !== 'azure/api') { + throw new InputError( + `Wrong location protocol: ${protocol}, should be 'azure/api'`, + ); + } + const templateId = template.metadata.name; + + const url = new URL(location); // Need to extract filepath from search parameter + const parsedGitLocation = GitUriParser(location); + const repositoryCheckoutUrl = parsedGitLocation.toString('https'); + + const tempDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), templateId), + ); + + const templateDirectory = path.join( + `${path + .dirname(url.searchParams.get('path') || '') + .replace(/^\/+/g, '')}`, // Strip leading slash + template.spec.path ?? '.', + ); + + const options = this.privateToken + ? { + fetchOpts: { + callbacks: { + credentials: () => + // Username can anything but the empty string according to: https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=preview-page#use-a-pat + Cred.userpassPlaintextNew('notempty', this.privateToken), + }, + }, + } + : {}; + + await Clone.clone(repositoryCheckoutUrl, tempDir, options); + + return path.resolve(tempDir, templateDirectory); + } +} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/index.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/index.ts index 80a7dc620d..f7bc535189 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/index.ts @@ -18,3 +18,4 @@ export * from './types'; export * from './file'; export * from './github'; export * from './gitlab'; +export * from './azure'; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/types.ts index 13817ba190..938d906857 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/types.ts @@ -13,4 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export type RemoteProtocol = 'file' | 'github' | 'gitlab' | 'gitlab/api'; +export type RemoteProtocol = + | 'file' + | 'github' + | 'gitlab' + | 'gitlab/api' + | 'azure/api'; From 56a9f8389b3a40fd5d1870969a10e18b55af7838 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Frinnstr=C3=B6m?= Date: Mon, 21 Sep 2020 13:50:31 +0000 Subject: [PATCH 78/94] Add Azure DevOps publisher to scaffolder --- app-config.yaml | 3 + .../software-templates/installation.md | 9 +- packages/backend/package.json | 1 + packages/backend/src/plugins/scaffolder.ts | 29 +++ plugins/scaffolder-backend/package.json | 1 + .../azure-devops-node-api/GitApi/index.ts | 25 +++ .../scaffolder/stages/publish/azure.test.ts | 179 ++++++++++++++++++ .../src/scaffolder/stages/publish/azure.ts | 82 ++++++++ .../src/scaffolder/stages/publish/index.ts | 1 + yarn.lock | 30 ++- 10 files changed, 357 insertions(+), 3 deletions(-) create mode 100644 plugins/scaffolder-backend/src/scaffolder/__mocks__/azure-devops-node-api/GitApi/index.ts create mode 100644 plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts create mode 100644 plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts diff --git a/app-config.yaml b/app-config.yaml index c7c5101b55..b9bdd94192 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -134,6 +134,9 @@ scaffolder: env: GITLAB_ACCESS_TOKEN azure: api: + organization: + $secret: + env: AZURE_ORGANIZATION token: $secret: env: AZURE_PRIVATE_TOKEN diff --git a/docs/features/software-templates/installation.md b/docs/features/software-templates/installation.md index b804c84e52..c374ab8d72 100644 --- a/docs/features/software-templates/installation.md +++ b/docs/features/software-templates/installation.md @@ -245,13 +245,18 @@ scaffolder: #### Azure DevOps -For Azure DevOps we currently support the preparer stage with the configuration -of a private access token (PAT) when needed: +For Azure DevOps we support both the preparer and publisher stage with the +configuration of a private access token (PAT) when needed. For the publisher +it's also required to define the organization that you want to create the +repository in. ```yaml scaffolder: azure: api: + organization: + $secret: + env: AZURE_ORGANIZATION token: $secret: env: AZURE_PRIVATE_TOKEN diff --git a/packages/backend/package.json b/packages/backend/package.json index 3c40d03def..8f70aa1fc3 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -33,6 +33,7 @@ "@backstage/plugin-techdocs-backend": "^0.1.1-alpha.23", "@gitbeaker/node": "^23.5.0", "@octokit/rest": "^18.0.0", + "azure-devops-node-api": "^10.1.1", "dockerode": "^3.2.0", "example-app": "^0.1.1-alpha.23", "express": "^4.17.1", diff --git a/packages/backend/src/plugins/scaffolder.ts b/packages/backend/src/plugins/scaffolder.ts index 17cd7ba10e..f3dde321f3 100644 --- a/packages/backend/src/plugins/scaffolder.ts +++ b/packages/backend/src/plugins/scaffolder.ts @@ -25,12 +25,14 @@ import { Publishers, GithubPublisher, GitlabPublisher, + AzurePublisher, CreateReactAppTemplater, Templaters, RepoVisibilityOptions, } from '@backstage/plugin-scaffolder-backend'; import { Octokit } from '@octokit/rest'; import { Gitlab } from '@gitbeaker/node'; +import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api'; import type { PluginEnvironment } from '../types'; import Docker from 'dockerode'; @@ -114,6 +116,33 @@ export default async function createPlugin({ } } + const azureConfig = config.getOptionalConfig('scaffolder.azure.api'); + if (azureConfig) { + try { + const organization = azureConfig.getString('organization'); + const azureToken = azureConfig.getString('token'); + + const serverUrl = `https://dev.azure.com/${organization}`; + const authHandler = getPersonalAccessTokenHandler(azureToken); + const webApi = new WebApi(serverUrl, authHandler); + const azureClient = await webApi.getGitApi(); + + const azurePublisher = new AzurePublisher(azureClient, azureToken); + publishers.register('azure/api', azurePublisher); + } catch (e) { + const providerName = 'azure'; + if (process.env.NODE_ENV !== 'development') { + throw new Error( + `Failed to initialize ${providerName} scaffolding provider, ${e.message}`, + ); + } + + logger.warn( + `Skipping ${providerName} scaffolding provider, ${e.message}`, + ); + } + } + const dockerClient = new Docker(); return await createRouter({ preparers, diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 90d1af4b5f..7a7938a483 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -28,6 +28,7 @@ "@octokit/rest": "^18.0.0", "@types/dockerode": "^2.5.32", "@types/express": "^4.17.6", + "azure-devops-node-api": "^10.1.1", "command-exists-promise": "^2.0.2", "compression": "^1.7.4", "cors": "^2.8.5", diff --git a/plugins/scaffolder-backend/src/scaffolder/__mocks__/azure-devops-node-api/GitApi/index.ts b/plugins/scaffolder-backend/src/scaffolder/__mocks__/azure-devops-node-api/GitApi/index.ts new file mode 100644 index 0000000000..116f092866 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/__mocks__/azure-devops-node-api/GitApi/index.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const mockGitApi = { + createRepository: jest.fn(), +}; + +export class GitApi { + constructor() { + return mockGitApi; + } +} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts new file mode 100644 index 0000000000..8b84eb0edf --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts @@ -0,0 +1,179 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +jest.mock('nodegit'); +jest.mock('azure-devops-node-api/GitApi'); +jest.mock('azure-devops-node-api/interfaces/GitInterfaces'); + +import { AzurePublisher } from './azure'; +import { GitApi } from 'azure-devops-node-api/GitApi'; +import * as NodeGit from 'nodegit'; + +const { mockGitApi } = require('azure-devops-node-api/GitApi') as { + mockGitApi: { + createRepository: jest.MockedFunction; + }; +}; + +const { + Repository, + mockRepo, + mockIndex, + Signature, + Remote, + mockRemote, + Cred, +} = require('nodegit') as { + Repository: jest.Mocked<{ init: any }>; + Signature: jest.Mocked<{ now: any }>; + Cred: jest.Mocked<{ userpassPlaintextNew: any }>; + Remote: jest.Mocked<{ create: any }>; + + mockIndex: jest.Mocked; + mockRepo: jest.Mocked; + mockRemote: jest.Mocked; +}; + +describe('Azure Publisher', () => { + const publisher = new AzurePublisher(new GitApi('', []), 'fake-token'); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('publish: createRemoteInAzure', () => { + it('should use azure-devops-node-api to create a repo in the given project', async () => { + mockGitApi.createRepository.mockResolvedValue({ + remoteUrl: 'mockclone', + } as { remoteUrl: string }); + + await publisher.publish({ + values: { + storePath: 'project/repo', + owner: 'bob', + }, + directory: '/tmp/test', + }); + + expect(mockGitApi.createRepository).toHaveBeenCalledWith( + { + name: 'repo', + }, + 'project', + ); + }); + }); + + describe('publish: createGitDirectory', () => { + const values = { + isOrg: true, + storePath: 'blam/test', + owner: 'lols', + }; + + const mockDir = '/tmp/test/dir'; + + mockGitApi.createRepository.mockResolvedValue({ + remoteUrl: 'mockclone', + } as { remoteUrl: string }); + + it('should call init on the repo with the directory', async () => { + await publisher.publish({ + values, + directory: mockDir, + }); + + expect(Repository.init).toHaveBeenCalledWith(mockDir, 0); + }); + + it('should call refresh index on the index and write the new files', async () => { + await publisher.publish({ + values, + directory: mockDir, + }); + + expect(mockRepo.refreshIndex).toHaveBeenCalled(); + }); + + it('should call add all files and write', async () => { + await publisher.publish({ + values, + directory: mockDir, + }); + + expect(mockIndex.addAll).toHaveBeenCalled(); + expect(mockIndex.write).toHaveBeenCalled(); + expect(mockIndex.writeTree).toHaveBeenCalled(); + }); + + it('should create a commit with on head with the right name and commiter', async () => { + const mockSignature = { mockSignature: 'bloblly' }; + Signature.now.mockReturnValue(mockSignature); + + await publisher.publish({ + values, + directory: mockDir, + }); + + expect(Signature.now).toHaveBeenCalledTimes(2); + expect(Signature.now).toHaveBeenCalledWith( + 'Scaffolder', + 'scaffolder@backstage.io', + ); + + expect(mockRepo.createCommit).toHaveBeenCalledWith( + 'HEAD', + mockSignature, + mockSignature, + 'initial commit', + 'mockoid', + [], + ); + }); + + it('creates a remote with the repo and remote', async () => { + await publisher.publish({ + values, + directory: mockDir, + }); + + expect(Remote.create).toHaveBeenCalledWith( + mockRepo, + 'origin', + 'mockclone', + ); + }); + + it('shoud push to the remote repo', async () => { + await publisher.publish({ + values, + directory: mockDir, + }); + + const [remotes, { callbacks }] = mockRemote.push.mock + .calls[0] as NodeGit.PushOptions[]; + + expect(remotes).toEqual(['refs/heads/master:refs/heads/master']); + + callbacks?.credentials?.(); + + expect(Cred.userpassPlaintextNew).toHaveBeenCalledWith( + 'notempty', + 'fake-token', + ); + }); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts new file mode 100644 index 0000000000..9dfc7b5b66 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PublisherBase } from './types'; +import { GitApi } from 'azure-devops-node-api/GitApi'; +import { GitRepositoryCreateOptions } from 'azure-devops-node-api/interfaces/GitInterfaces'; + +import { JsonValue } from '@backstage/config'; +import { RequiredTemplateValues } from '../templater'; +import { Repository, Remote, Signature, Cred } from 'nodegit'; + +export class AzurePublisher implements PublisherBase { + private readonly client: GitApi; + private readonly token: string; + + constructor(client: GitApi, token: string) { + this.client = client; + this.token = token; + } + + async publish({ + values, + directory, + }: { + values: RequiredTemplateValues & Record; + directory: string; + }): Promise<{ remoteUrl: string }> { + const remoteUrl = await this.createRemote(values); + await this.pushToRemote(directory, remoteUrl); + + return { remoteUrl }; + } + + private async createRemote( + values: RequiredTemplateValues & Record, + ) { + const [project, name] = values.storePath.split('/'); + + const createOptions: GitRepositoryCreateOptions = { name }; + const repo = await this.client.createRepository(createOptions, project); + + return repo.remoteUrl || ''; + } + + private async pushToRemote(directory: string, remote: string): Promise { + const repo = await Repository.init(directory, 0); + const index = await repo.refreshIndex(); + await index.addAll(); + await index.write(); + const oid = await index.writeTree(); + await repo.createCommit( + 'HEAD', + Signature.now('Scaffolder', 'scaffolder@backstage.io'), + Signature.now('Scaffolder', 'scaffolder@backstage.io'), + 'initial commit', + oid, + [], + ); + + const remoteRepo = await Remote.create(repo, 'origin', remote); + + await remoteRepo.push(['refs/heads/master:refs/heads/master'], { + callbacks: { + // Username can anything but the empty string according to: https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=preview-page#use-a-pat + credentials: () => Cred.userpassPlaintextNew('notempty', this.token), + }, + }); + } +} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/index.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/index.ts index 1d89cd37f0..b37baa3246 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/index.ts @@ -16,4 +16,5 @@ export * from './publishers'; export * from './github'; export * from './gitlab'; +export * from './azure'; export * from './types'; diff --git a/yarn.lock b/yarn.lock index 804eb9dc10..73863f110f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6822,6 +6822,15 @@ axobject-query@^2.0.2: resolved "https://registry.npmjs.org/axobject-query/-/axobject-query-2.1.2.tgz#2bdffc0371e643e5f03ba99065d5179b9ca79799" integrity sha512-ICt34ZmrVt8UQnvPl6TVyDTkmhXmAyAT4Jh5ugfGUX4MOrZ+U/ZY6/sdylRw3qGNr9Ub5AJsaHeDMzNLehRdOQ== +azure-devops-node-api@^10.1.1: + version "10.1.1" + resolved "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-10.1.1.tgz#9016d8935316fff260f5f8fafd81d0caff90a19e" + integrity sha512-P4Hyrh/+Nzc2KXQk73z72/GsenSWIH5o8uiyELqykJYs9TWTVCxVwghoR7lPeiY6QVoXkq2S2KtvAgi5fyjl9w== + dependencies: + tunnel "0.0.6" + typed-rest-client "^1.7.3" + underscore "1.8.3" + babel-code-frame@^6.22.0: version "6.26.0" resolved "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b" @@ -18545,7 +18554,7 @@ qs@6.7.0: resolved "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ== -qs@^6.5.1, qs@^6.6.0, qs@^6.9.4: +qs@^6.5.1, qs@^6.6.0, qs@^6.9.1, qs@^6.9.4: version "6.9.4" resolved "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz#9090b290d1f91728d3c22e54843ca44aea5ab687" integrity sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ== @@ -22226,6 +22235,11 @@ tunnel-agent@^0.6.0: dependencies: safe-buffer "^5.0.1" +tunnel@0.0.6: + version "0.0.6" + resolved "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c" + integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg== + tweetnacl@^0.14.3, tweetnacl@~0.14.0: version "0.14.5" resolved "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" @@ -22293,6 +22307,15 @@ type@^2.0.0: resolved "https://registry.npmjs.org/type/-/type-2.0.0.tgz#5f16ff6ef2eb44f260494dae271033b29c09a9c3" integrity sha512-KBt58xCHry4Cejnc2ISQAF7QY+ORngsWfxezO68+12hKV6lQY8P/psIkcbjeHWn7MqcgciWJyCCevFMJdIXpow== +typed-rest-client@^1.7.3: + version "1.7.3" + resolved "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.7.3.tgz#1beb263b86b14d34596f6127c6172dd5fd652e7b" + integrity sha512-CwTpx/TkRHGZoHkJhBcp4X8K3/WtlzSHVQR0OIFnt10j4tgy4ypgq/SrrgVpA1s6tAL49Q6J3R5C0Cgfh2ddqA== + dependencies: + qs "^6.9.1" + tunnel "0.0.6" + underscore "1.8.3" + typed-styles@^0.0.7: version "0.0.7" resolved "https://registry.npmjs.org/typed-styles/-/typed-styles-0.0.7.tgz#93392a008794c4595119ff62dde6809dbc40a3d9" @@ -22360,6 +22383,11 @@ undefsafe@^2.0.2: dependencies: debug "^2.2.0" +underscore@1.8.3: + version "1.8.3" + resolved "https://registry.npmjs.org/underscore/-/underscore-1.8.3.tgz#4f3fb53b106e6097fcf9cb4109f2a5e9bdfa5022" + integrity sha1-Tz+1OxBuYJf8+ctBCfKl6b36UCI= + underscore@^1.9.1: version "1.11.0" resolved "https://registry.npmjs.org/underscore/-/underscore-1.11.0.tgz#dd7c23a195db34267186044649870ff1bab5929e" From 0f732731ef0db241ded282e338d9c5bd8b2e0b35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Frinnstr=C3=B6m?= Date: Wed, 23 Sep 2020 08:13:40 +0000 Subject: [PATCH 79/94] Restructure config for Azure DevOps scaffolder --- app-config.yaml | 4 +--- docs/features/software-templates/installation.md | 11 +++++------ packages/backend/src/plugins/scaffolder.ts | 9 ++++----- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/app-config.yaml b/app-config.yaml index b9bdd94192..4eabe3ec2b 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -133,10 +133,8 @@ scaffolder: $secret: env: GITLAB_ACCESS_TOKEN azure: + baseUrl: https://dev.azure.com/{your-organization} api: - organization: - $secret: - env: AZURE_ORGANIZATION token: $secret: env: AZURE_PRIVATE_TOKEN diff --git a/docs/features/software-templates/installation.md b/docs/features/software-templates/installation.md index c374ab8d72..eff7e53ad3 100644 --- a/docs/features/software-templates/installation.md +++ b/docs/features/software-templates/installation.md @@ -246,17 +246,16 @@ scaffolder: #### Azure DevOps For Azure DevOps we support both the preparer and publisher stage with the -configuration of a private access token (PAT) when needed. For the publisher -it's also required to define the organization that you want to create the -repository in. +configuration of a private access token (PAT). For the publisher it's also +required to define the base URL for the client to connect to the service. This +will hopefully support on-prem installations as well but that has not been +verified. ```yaml scaffolder: azure: + baseUrl: https://dev.azure.com/{your-organization} api: - organization: - $secret: - env: AZURE_ORGANIZATION token: $secret: env: AZURE_PRIVATE_TOKEN diff --git a/packages/backend/src/plugins/scaffolder.ts b/packages/backend/src/plugins/scaffolder.ts index f3dde321f3..c5b36ab8da 100644 --- a/packages/backend/src/plugins/scaffolder.ts +++ b/packages/backend/src/plugins/scaffolder.ts @@ -116,15 +116,14 @@ export default async function createPlugin({ } } - const azureConfig = config.getOptionalConfig('scaffolder.azure.api'); + const azureConfig = config.getOptionalConfig('scaffolder.azure'); if (azureConfig) { try { - const organization = azureConfig.getString('organization'); - const azureToken = azureConfig.getString('token'); + const baseUrl = azureConfig.getString('baseUrl'); + const azureToken = azureConfig.getConfig('api').getString('token'); - const serverUrl = `https://dev.azure.com/${organization}`; const authHandler = getPersonalAccessTokenHandler(azureToken); - const webApi = new WebApi(serverUrl, authHandler); + const webApi = new WebApi(baseUrl, authHandler); const azureClient = await webApi.getGitApi(); const azurePublisher = new AzurePublisher(azureClient, azureToken); From a0214130758e4dfcb27646c605ffbd0dfa5eced1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 25 Sep 2020 17:06:32 +0200 Subject: [PATCH 80/94] feat(catalog-backend): placeholder substitution in entity yaml This feature works the same as $secret does in config - it allows programmatic substitution of values into a document. This is particularly useful e.g. for API type entities where you do not want to repeat your entire API spec document inside the catalog-info.yaml file. For those cases, you can instead do something like ```yaml apiVersion: backstage.io/v1alpha1 kind: API metadata: name: my-federated-service spec: type: graphql definition: $file: ./schema.graphql ``` The textual content of that file will be injected as the value of `definition`, during each refresh loop. Both relative and absolute paths are supported, as well as any HTTP/HTTPS URL pointing to a service that returns the relevant data. --- .../src/ingestion/LocationReaders.ts | 16 +- .../processors/PlaceholderProcessor.test.ts | 364 ++++++++++++++++++ .../processors/PlaceholderProcessor.ts | 214 ++++++++++ .../src/ingestion/processors/types.ts | 2 +- 4 files changed, 588 insertions(+), 8 deletions(-) create mode 100644 plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts create mode 100644 plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts diff --git a/plugins/catalog-backend/src/ingestion/LocationReaders.ts b/plugins/catalog-backend/src/ingestion/LocationReaders.ts index 11558feae0..14b7f727e4 100644 --- a/plugins/catalog-backend/src/ingestion/LocationReaders.ts +++ b/plugins/catalog-backend/src/ingestion/LocationReaders.ts @@ -15,26 +15,28 @@ */ import { getVoidLogger } from '@backstage/backend-common'; -import { Config, ConfigReader } from '@backstage/config'; import { Entity, EntityPolicies, EntityPolicy, LocationSpec, } from '@backstage/catalog-model'; +import { Config, ConfigReader } from '@backstage/config'; import { Logger } from 'winston'; +import { CatalogRulesEnforcer } from './CatalogRules'; import { AnnotateLocationEntityProcessor } from './processors/AnnotateLocationEntityProcessor'; +import { ApiDefinitionAtLocationProcessor } from './processors/ApiDefinitionAtLocationProcessor'; +import { AzureApiReaderProcessor } from './processors/AzureApiReaderProcessor'; +import { BitbucketApiReaderProcessor } from './processors/BitbucketApiReaderProcessor'; import { EntityPolicyProcessor } from './processors/EntityPolicyProcessor'; import { FileReaderProcessor } from './processors/FileReaderProcessor'; import { GithubReaderProcessor } from './processors/GithubReaderProcessor'; import { GitlabApiReaderProcessor } from './processors/GitlabApiReaderProcessor'; import { GitlabReaderProcessor } from './processors/GitlabReaderProcessor'; -import { BitbucketApiReaderProcessor } from './processors/BitbucketApiReaderProcessor'; -import { AzureApiReaderProcessor } from './processors/AzureApiReaderProcessor'; -import { UrlReaderProcessor } from './processors/UrlReaderProcessor'; import { LocationRefProcessor } from './processors/LocationEntityProcessor'; -import { StaticLocationProcessor } from './processors/StaticLocationProcessor'; +import { PlaceholderProcessor } from './processors/PlaceholderProcessor'; import * as result from './processors/results'; +import { StaticLocationProcessor } from './processors/StaticLocationProcessor'; import { LocationProcessor, LocationProcessorDataResult, @@ -44,10 +46,9 @@ import { LocationProcessorLocationResult, LocationProcessorResult, } from './processors/types'; +import { UrlReaderProcessor } from './processors/UrlReaderProcessor'; import { YamlProcessor } from './processors/YamlProcessor'; import { LocationReader, ReadLocationResult } from './types'; -import { CatalogRulesEnforcer } from './CatalogRules'; -import { ApiDefinitionAtLocationProcessor } from './processors/ApiDefinitionAtLocationProcessor'; // The max amount of nesting depth of generated work items const MAX_DEPTH = 10; @@ -86,6 +87,7 @@ export class LocationReaders implements LocationReader { new AzureApiReaderProcessor(config), new UrlReaderProcessor(), new YamlProcessor(), + PlaceholderProcessor.default(), new ApiDefinitionAtLocationProcessor(), new EntityPolicyProcessor(entityPolicy), new LocationRefProcessor(), diff --git a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts new file mode 100644 index 0000000000..7e6fa528f1 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts @@ -0,0 +1,364 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity } from '@backstage/catalog-model'; +import { + jsonPlaceholderResolver, + PlaceholderProcessor, + PlaceholderResolver, + ResolverParams, + yamlPlaceholderResolver, +} from './PlaceholderProcessor'; +import { LocationProcessorRead } from './types'; + +describe('PlaceholderProcessor', () => { + it('returns placeholder-free data unchanged', async () => { + const input: Entity = { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n' }, + }; + const processor = new PlaceholderProcessor({ + foo: async () => 'replaced', + }); + await expect( + processor.processEntity( + input, + { type: 't', target: 'l' }, + jest.fn(), + jest.fn(), + ), + ).resolves.toBe(input); + }); + + it('replaces placeholders deep in the data', async () => { + const emit = jest.fn(); + const read = jest.fn(); + const upperResolver: PlaceholderResolver = jest.fn(async ({ value }) => + value!.toString().toUpperCase(), + ); + const processor = new PlaceholderProcessor({ + upper: upperResolver, + }); + + await expect( + processor.processEntity( + { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n' }, + spec: { a: [{ b: { $upper: 'text' } }] }, + }, + { type: 'fake', target: 'http://example.com' }, + emit, + read, + ), + ).resolves.toEqual({ + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n' }, + spec: { a: [{ b: 'TEXT' }] }, + }); + + expect(emit).not.toBeCalled(); + expect(read).not.toBeCalled(); + expect(upperResolver).toBeCalledWith({ + key: 'upper', + value: 'text', + location: { type: 'fake', target: 'http://example.com' }, + read, + }); + }); + + it('rejects multiple placeholders', async () => { + const emit = jest.fn(); + const read: LocationProcessorRead = jest.fn(); + const processor = new PlaceholderProcessor({ + foo: jest.fn(), + bar: jest.fn(), + }); + + await expect( + processor.processEntity( + { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n', x: { $foo: 'a', $bar: 'b' } }, + }, + { type: 'a', target: 'b' }, + emit, + read, + ), + ).rejects.toThrow( + 'Placeholders have to be on the form of a single $-prefixed key in an object', + ); + + expect(emit).not.toBeCalled(); + expect(read).not.toBeCalled(); + }); + + it('rejects unknown placeholders', async () => { + const emit = jest.fn(); + const read: LocationProcessorRead = jest.fn(); + const processor = new PlaceholderProcessor({ + bar: jest.fn(), + }); + + await expect( + processor.processEntity( + { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n', x: { $foo: 'a' } }, + }, + { type: 'a', target: 'b' }, + emit, + read, + ), + ).rejects.toThrow('Encountered unknown placeholder $foo'); + + expect(emit).not.toBeCalled(); + expect(read).not.toBeCalled(); + }); + + it('has builtin text support', async () => { + const emit = jest.fn(); + const read: LocationProcessorRead = jest + .fn() + .mockImplementation(async location => ({ + type: 'data', + location, + data: Buffer.from('TEXT', 'utf-8'), + })); + const processor = PlaceholderProcessor.default(); + + await expect( + processor.processEntity( + { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n' }, + spec: { data: { $text: '../file.txt' } }, + }, + { + type: 'github', + target: 'https://github.com/spotify/backstage/a/b/catalog-info.yaml', + }, + emit, + read, + ), + ).resolves.toEqual({ + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n' }, + spec: { data: 'TEXT' }, + }); + + expect(emit).not.toBeCalled(); + expect(read).toBeCalledWith({ + type: 'github', + target: 'https://github.com/spotify/backstage/a/file.txt', + }); + }); + + it('has builtin json support', async () => { + const emit = jest.fn(); + const read: LocationProcessorRead = jest + .fn() + .mockImplementation(async location => ({ + type: 'data', + location, + data: Buffer.from(JSON.stringify({ a: ['b', 7] }), 'utf-8'), + })); + const processor = PlaceholderProcessor.default(); + + await expect( + processor.processEntity( + { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n' }, + spec: { data: { $json: './file.json' } }, + }, + { + type: 'github', + target: 'https://github.com/spotify/backstage/a/b/catalog-info.yaml', + }, + emit, + read, + ), + ).resolves.toEqual({ + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n' }, + spec: { data: { a: ['b', 7] } }, + }); + + expect(emit).not.toBeCalled(); + expect(read).toBeCalledWith({ + type: 'github', + target: 'https://github.com/spotify/backstage/a/b/file.json', + }); + }); + + it('has builtin yaml support', async () => { + const emit = jest.fn(); + const read: LocationProcessorRead = jest + .fn() + .mockImplementation(async location => ({ + type: 'data', + location, + data: Buffer.from('foo:\n - bar: 7', 'utf-8'), + })); + const processor = PlaceholderProcessor.default(); + + await expect( + processor.processEntity( + { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n' }, + spec: { data: { $yaml: '../file.yaml' } }, + }, + { + type: 'github', + target: 'https://github.com/spotify/backstage/a/b/catalog-info.yaml', + }, + emit, + read, + ), + ).resolves.toEqual({ + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n' }, + spec: { data: { foo: [{ bar: 7 }] } }, + }); + + expect(emit).not.toBeCalled(); + expect(read).toBeCalledWith({ + type: 'github', + target: 'https://github.com/spotify/backstage/a/file.yaml', + }); + }); +}); + +describe('yamlPlaceholderResolver', () => { + let read: jest.MockedFunction; + let params: ResolverParams; + + beforeEach(() => { + read = jest.fn(); + params = { + key: 'a', + value: './file.yaml', + location: { + type: 'github', + target: 'https://github.com/spotify/backstage/a/b/catalog-info.yaml', + }, + read, + }; + }); + + it('parses valid yaml', async () => { + read.mockImplementation(async location => ({ + type: 'data', + location, + data: Buffer.from('foo:\n - bar: 7', 'utf-8'), + })); + + await expect(yamlPlaceholderResolver(params)).resolves.toEqual({ + foo: [{ bar: 7 }], + }); + }); + + it('rejects invalid yaml', async () => { + read.mockImplementation(async location => ({ + type: 'data', + location, + data: Buffer.from('a: 1\n----\n', 'utf-8'), + })); + + await expect(yamlPlaceholderResolver(params)).rejects.toThrow( + 'Placeholder $a found an error in the data at ./file.yaml, YAMLSemanticError: Implicit map keys need to be followed by map values', + ); + }); + + it('rejects multi-document yaml', async () => { + read.mockImplementation(async location => ({ + type: 'data', + location, + data: Buffer.from('foo: 1\n---\nbar: 2\n', 'utf-8'), + })); + + await expect(yamlPlaceholderResolver(params)).rejects.toThrow( + 'Placeholder $a expected to find exactly one document of data at ./file.yaml, found 2', + ); + }); + + it('parses valid json', async () => { + read.mockImplementation(async location => ({ + type: 'data', + location, + data: Buffer.from(JSON.stringify({ a: ['b', 7] }), 'utf-8'), + })); + + await expect(yamlPlaceholderResolver(params)).resolves.toEqual({ + a: ['b', 7], + }); + }); +}); + +describe('jsonPlaceholderResolver', () => { + let read: jest.MockedFunction; + let params: ResolverParams; + + beforeEach(() => { + read = jest.fn(); + params = { + key: 'a', + value: './file.json', + location: { + type: 'github', + target: 'https://github.com/spotify/backstage/a/b/catalog-info.yaml', + }, + read, + }; + }); + + it('parses valid json', async () => { + read.mockImplementation(async location => ({ + type: 'data', + location, + data: Buffer.from(JSON.stringify({ a: ['b', 7] }), 'utf-8'), + })); + + await expect(jsonPlaceholderResolver(params)).resolves.toEqual({ + a: ['b', 7], + }); + }); + + it('rejects invalid json', async () => { + read.mockImplementation(async location => ({ + type: 'data', + location, + data: Buffer.from('}', 'utf-8'), + })); + + await expect(jsonPlaceholderResolver(params)).rejects.toThrow( + 'Placeholder $a failed to parse JSON data at ./file.json, SyntaxError: Unexpected token } in JSON at position 0', + ); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts new file mode 100644 index 0000000000..e405e5a5df --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts @@ -0,0 +1,214 @@ +/* + * 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 { JsonValue } from '@backstage/config'; +import yaml from 'yaml'; +import { + LocationProcessor, + LocationProcessorEmit, + LocationProcessorRead, +} from './types'; + +export type ResolverParams = { + key: string; + value: JsonValue; + location: LocationSpec; + read: LocationProcessorRead; +}; + +export type PlaceholderResolver = ( + params: ResolverParams, +) => Promise; + +/** + * Traverses raw entity JSON looking for occurrences of $-prefixed placeholders + * that it then fills in with actual data. + */ +export class PlaceholderProcessor implements LocationProcessor { + static default() { + return new PlaceholderProcessor({ + json: jsonPlaceholderResolver, + yaml: yamlPlaceholderResolver, + text: textPlaceholderResolver, + }); + } + + constructor( + private readonly resolvers: Record, + ) {} + + async processEntity( + entity: Entity, + location: LocationSpec, + _emit: LocationProcessorEmit, + read: LocationProcessorRead, + ): Promise { + const process = async (data: any): Promise<[any, boolean]> => { + if (!data || !(data instanceof Object)) { + // Scalars can't have placeholders + return [data, false]; + } + + if (Array.isArray(data)) { + // We're an array - process all entries recursively + const items = await Promise.all(data.map(item => process(item))); + return items.every(([, changed]) => !changed) + ? [data, false] + : [items.map(([item]) => item), true]; + } + + const keys = Object.keys(data); + if (!keys.some(k => k.startsWith('$'))) { + // We're an object but no placeholders at this level - process all + // entries recursively + const entries = await Promise.all( + Object.entries(data).map(([k, v]) => + process(v).then(vp => [k, vp] as const), + ), + ); + return entries.every(([, [, changed]]) => !changed) + ? [data, false] + : [Object.fromEntries(entries.map(([k, [v]]) => [k, v])), true]; + } else if (keys.length !== 1) { + throw new Error( + 'Placeholders have to be on the form of a single $-prefixed key in an object', + ); + } + + const resolverKey = keys[0].substr(1); + const resolver = this.resolvers[resolverKey]; + if (!resolver) { + throw new Error(`Encountered unknown placeholder \$${resolverKey}`); + } + + return [ + await resolver({ + key: resolverKey, + value: data[keys[0]], + location, + read, + }), + true, + ]; + }; + + const [result] = await process(entity); + return result; + } +} + +/* + * Resolvers + */ + +export async function yamlPlaceholderResolver( + params: ResolverParams, +): Promise { + const text = await readTextLocation(params); + + let documents: yaml.Document.Parsed[]; + try { + documents = yaml.parseAllDocuments(text).filter(d => d); + } catch (e) { + throw new Error( + `Placeholder \$${params.key} failed to parse YAML data at ${params.value}, ${e}`, + ); + } + + if (documents.length !== 1) { + throw new Error( + `Placeholder \$${params.key} expected to find exactly one document of data at ${params.value}, found ${documents.length}`, + ); + } + + const document = documents[0]; + + if (document.errors?.length) { + throw new Error( + `Placeholder \$${params.key} found an error in the data at ${params.value}, ${document.errors[0]}`, + ); + } + + return document.toJSON(); +} + +export async function jsonPlaceholderResolver( + params: ResolverParams, +): Promise { + const text = await readTextLocation(params); + + try { + return JSON.parse(text); + } catch (e) { + throw new Error( + `Placeholder \$${params.key} failed to parse JSON data at ${params.value}, ${e}`, + ); + } +} + +export async function textPlaceholderResolver( + params: ResolverParams, +): Promise { + return await readTextLocation(params); +} + +/* + * Helpers + */ + +async function readTextLocation(params: ResolverParams): Promise { + const newLocation = relativeLocation(params); + + try { + const response = await params.read(newLocation); + if (response.type !== 'data') { + throw new Error(`Expected data, got ${response.type}`); + } + return response.data.toString('utf-8'); + } catch (e) { + throw new Error( + `Placeholder \$${params.key} could not read location ${params.value}, ${e}`, + ); + } +} + +function relativeLocation({ + key, + value, + location, +}: ResolverParams): LocationSpec { + if (typeof value !== 'string') { + throw new Error( + `Placeholder \$${key} expected a string value parameter, in the form of an absolute URL or a relative path`, + ); + } + + let url: URL; + try { + // The two-value form of the URL constructor handles relative paths for us + url = new URL(value, location.target); + } catch (e) { + throw new Error( + `Placeholder \$${key} could not form an URL out of ${location.target} and ${value}`, + ); + } + + return { + type: location.type, + target: url.toString(), + }; +} diff --git a/plugins/catalog-backend/src/ingestion/processors/types.ts b/plugins/catalog-backend/src/ingestion/processors/types.ts index 3aaa2d21f9..65e2fdbb0a 100644 --- a/plugins/catalog-backend/src/ingestion/processors/types.ts +++ b/plugins/catalog-backend/src/ingestion/processors/types.ts @@ -52,7 +52,7 @@ export type LocationProcessor = { * @param location The location that the entity came from * @param read Reads the contents of a location * @param emit A sink for auxiliary items resulting from the processing - * @returns The same entity or a modifid version of it + * @returns The same entity or a modified version of it */ processEntity?( entity: Entity, From 0ed398633ac2938e59ecb5a82081288509bb8ee7 Mon Sep 17 00:00:00 2001 From: Marek Calus Date: Mon, 28 Sep 2020 14:39:58 +0200 Subject: [PATCH 81/94] Integrate with travis-ci plugin --- app-config.yaml | 9 + packages/app/package.json | 2 +- .../app/src/components/catalog/EntityPage.tsx | 10 + plugins/firebase-functions | 1 + plugins/travis-ci | 1 + yarn.lock | 383 ++++++++++++++++-- 6 files changed, 374 insertions(+), 32 deletions(-) create mode 160000 plugins/firebase-functions create mode 160000 plugins/travis-ci diff --git a/app-config.yaml b/app-config.yaml index a29240fd9c..92f6905e8c 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -31,6 +31,15 @@ proxy: $secret: env: JENKINS_BASIC_AUTH_HEADER + '/travisci/api': + target: https://api.travis-ci.com + changeOrigin: true + headers: + Authorization: + $secret: + env: TRAVISCI_AUTH_TOKEN + travis-api-version: 3 + organization: name: Spotify diff --git a/packages/app/package.json b/packages/app/package.json index 77b1882b47..a24cda7c25 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -32,7 +32,7 @@ "@material-ui/icons": "^4.9.1", "@octokit/rest": "^18.0.0", "@roadiehq/backstage-plugin-github-pull-requests": "0.3.0", - "@roadiehq/backstage-plugin-travis-ci": "^0.1.4", + "@roadiehq/backstage-plugin-travis-ci": "^0.2.3", "history": "^5.0.0", "prop-types": "^15.7.2", "react": "^16.12.0", diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 79ca074c35..3438c01abf 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -13,6 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { + isPluginApplicableToEntity as isTravisCIAvailable, + RecentTravisCIBuildsWidget, + Router as TravisCIRouter, +} from '@roadiehq/backstage-plugin-travis-ci'; import { isPluginApplicableToEntity as isGitHubActionsAvailable, RecentWorkflowRunsCard, @@ -51,6 +56,8 @@ const CICDSwitcher = ({ entity }: { entity: Entity }) => { return ; case isCircleCIAvailable(entity): return ; + case isTravisCIAvailable(entity): + return ; default: return ( @@ -70,6 +77,9 @@ const RecentCICDRunsSwitcher = ({ entity }: { entity: Entity }) => { case isGitHubActionsAvailable(entity): content = ; break; + case isTravisCIAvailable(entity): + content = ; + break; default: content = null; } diff --git a/plugins/firebase-functions b/plugins/firebase-functions new file mode 160000 index 0000000000..6c3e441eec --- /dev/null +++ b/plugins/firebase-functions @@ -0,0 +1 @@ +Subproject commit 6c3e441eec0a189d41bba66089244e2545533244 diff --git a/plugins/travis-ci b/plugins/travis-ci new file mode 160000 index 0000000000..a7687c6196 --- /dev/null +++ b/plugins/travis-ci @@ -0,0 +1 @@ +Subproject commit a7687c61964c6d8f9759e8f438ca68fe7b0719db diff --git a/yarn.lock b/yarn.lock index 804eb9dc10..6cdf37f2bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1628,6 +1628,22 @@ resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz#8eed982e2ee6f7f4e44c253e12962980791efd46" integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA== +"@eslint/eslintrc@^0.1.3": + version "0.1.3" + resolved "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.1.3.tgz#7d1a2b2358552cc04834c0979bd4275362e37085" + integrity sha512-4YVwPkANLeNtRjMekzux1ci8hIaH5eGKktGqR0d3LWsKNn5B2X/1Z6Trxy7jQXl9EBGE6Yj02O+t09FMeRllaA== + dependencies: + ajv "^6.12.4" + debug "^4.1.1" + espree "^7.3.0" + globals "^12.1.0" + ignore "^4.0.6" + import-fresh "^3.2.1" + js-yaml "^3.13.1" + lodash "^4.17.19" + minimatch "^3.0.4" + strip-json-comments "^3.1.1" + "@evocateur/libnpmaccess@^3.1.2": version "3.1.2" resolved "https://registry.npmjs.org/@evocateur/libnpmaccess/-/libnpmaccess-3.1.2.tgz#ecf7f6ce6b004e9f942b098d92200be4a4b1c845" @@ -3209,7 +3225,7 @@ resolved "https://registry.npmjs.org/@material-icons/font/-/font-1.0.3.tgz#f722e5a69a03f20ef47d015cb69420bebeeaabe5" integrity sha512-aIRd0Z9b/HJ/O24KOaP7dNsXypMnXhpWrpLVYvQB/JesVqXYSbioYND200sR+C14a0LSCp+qWnWCnSXmN1hWGw== -"@material-ui/core@^4.11.0", "@material-ui/core@^4.9.1": +"@material-ui/core@^4.11.0", "@material-ui/core@^4.9.1", "@material-ui/core@^4.9.10": version "4.11.0" resolved "https://registry.npmjs.org/@material-ui/core/-/core-4.11.0.tgz#b69b26e4553c9e53f2bfaf1053e216a0af9be15a" integrity sha512-bYo9uIub8wGhZySHqLQ833zi4ZML+XCBE1XwJ8EuUVSpTWWG57Pm+YugQToJNFsEyiKFhPh8DPD0bgupz8n01g== @@ -3245,6 +3261,17 @@ prop-types "^15.7.2" react-is "^16.8.0" +"@material-ui/lab@^4.0.0-alpha.49": + version "4.0.0-alpha.56" + resolved "https://registry.npmjs.org/@material-ui/lab/-/lab-4.0.0-alpha.56.tgz#ff63080949b55b40625e056bbda05e130d216d34" + integrity sha512-xPlkK+z/6y/24ka4gVJgwPfoCF4RCh8dXb1BNE7MtF9bXEBLN/lBxNTK8VAa0qm3V2oinA6xtUIdcRh0aeRtVw== + dependencies: + "@babel/runtime" "^7.4.4" + "@material-ui/utils" "^4.10.2" + clsx "^1.0.4" + prop-types "^15.7.2" + react-is "^16.8.0" + "@material-ui/pickers@^3.2.2": version "3.2.10" resolved "https://registry.npmjs.org/@material-ui/pickers/-/pickers-3.2.10.tgz#19df024895876eb0ec7cd239bbaea595f703f0ae" @@ -3518,7 +3545,7 @@ dependencies: "@types/node" ">= 8" -"@open-draft/until@^1.0.3": +"@open-draft/until@^1.0.0", "@open-draft/until@^1.0.3": version "1.0.3" resolved "https://registry.npmjs.org/@open-draft/until/-/until-1.0.3.tgz#db9cc719191a62e7d9200f6e7bab21c5b848adca" integrity sha512-Aq58f5HiWdyDlFffbbSjAlv596h/cOnt2DO1w3DOC7OJ5EHs0hd/nycJfiu9RJbT6Yk6F1knnRRXNSpxoIVZ9Q== @@ -3599,6 +3626,11 @@ prop-types "^15.6.1" react-lifecycles-compat "^3.0.4" +"@rehooks/local-storage@^2.4.0": + version "2.4.0" + resolved "https://registry.npmjs.org/@rehooks/local-storage/-/local-storage-2.4.0.tgz#fb884b2b657cad5f77aa6ab60bb3532f0e0725d2" + integrity sha512-LoXDbEHsuIckVgBsFAv8SuU/M7memjyfWut9Zf36TQXqqCHBRFv8bweg9PymQCa1aWIMjNrZQflFdo55FDlXYg== + "@rjsf/core@^2.1.0": version "2.3.0" resolved "https://registry.npmjs.org/@rjsf/core/-/core-2.3.0.tgz#334c73d2262ef1a8cda477e238067af7336c5599" @@ -3644,26 +3676,6 @@ react-router-dom "6.0.0-beta.0" react-use "^15.3.3" -"@roadiehq/backstage-plugin-travis-ci@^0.1.4": - version "0.1.4" - resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-travis-ci/-/backstage-plugin-travis-ci-0.1.4.tgz#ddbb2ba5b9d47f474462ecbc8c769759578e8526" - integrity sha512-T1eJ0huhwZcRHjrDKWoAzlKBv8UbhXgrvE7wuHD7dCcmfQdln0jbn7cskOpTnVmrzGXWKQ6mDyKX3LfdEPYbAw== - dependencies: - "@backstage/core" "^0.1.1-alpha.18" - "@backstage/theme" "^0.1.1-alpha.18" - "@material-ui/core" "^4.9.1" - "@material-ui/icons" "^4.9.1" - "@material-ui/lab" "4.0.0-alpha.45" - date-fns "^2.15.0" - history "^5.0.0" - moment "^2.27.0" - react "^16.13.1" - react-dom "^16.13.1" - react-lazylog "^4.5.2" - react-router "6.0.0-beta.0" - react-router-dom "6.0.0-beta.0" - react-use "^15.3.3" - "@rollup/plugin-commonjs@^13.0.0": version "13.0.0" resolved "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-13.0.0.tgz#8a1d684ba6848afe8b9e3d85649d4b2f6f7217ec" @@ -3677,6 +3689,19 @@ magic-string "^0.25.2" resolve "^1.11.0" +"@rollup/plugin-commonjs@^14.0.0": + version "14.0.0" + resolved "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-14.0.0.tgz#4285f9ec2db686a31129e5a2b415c94aa1f836f0" + integrity sha512-+PSmD9ePwTAeU106i9FRdc+Zb3XUWyW26mo5Atr2mk82hor8+nPwkztEjFo8/B1fJKfaQDg9aM2bzQkjhi7zOw== + dependencies: + "@rollup/pluginutils" "^3.0.8" + commondir "^1.0.1" + estree-walker "^1.0.1" + glob "^7.1.2" + is-reference "^1.1.2" + magic-string "^0.25.2" + resolve "^1.11.0" + "@rollup/plugin-json@^4.0.2": version "4.1.0" resolved "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-4.1.0.tgz#54e09867ae6963c593844d8bd7a9c718294496f3" @@ -3684,7 +3709,7 @@ dependencies: "@rollup/pluginutils" "^3.0.8" -"@rollup/plugin-node-resolve@^8.1.0": +"@rollup/plugin-node-resolve@^8.1.0", "@rollup/plugin-node-resolve@^8.4.0": version "8.4.0" resolved "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-8.4.0.tgz#261d79a680e9dc3d86761c14462f24126ba83575" integrity sha512-LFqKdRLn0ShtQyf6SBYO69bGE1upV6wUhBX0vFOUnLAyzx5cwp8svA0eHUnu8+YU57XOkrMtfG63QOpQx25pHQ== @@ -4510,7 +4535,7 @@ lodash "^4.17.15" redent "^3.0.0" -"@testing-library/react-hooks@^3.3.0": +"@testing-library/react-hooks@^3.3.0", "@testing-library/react-hooks@^3.4.1": version "3.4.1" resolved "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-3.4.1.tgz#1f8ccd21208086ec228d9743fe40b69d0efcd7e5" integrity sha512-LbzvE7oKsVzuW1cxA/aOeNgeVvmHWG2p/WSzalIGyWuqZT3jVcNDT5KPEwy36sUYWde0Qsh32xqIUFXukeywXg== @@ -4743,6 +4768,11 @@ dependencies: "@types/express" "*" +"@types/cookie@^0.3.3": + version "0.3.3" + resolved "https://registry.npmjs.org/@types/cookie/-/cookie-0.3.3.tgz#85bc74ba782fb7aa3a514d11767832b0e3bc6803" + integrity sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow== + "@types/cookie@^0.4.0": version "0.4.0" resolved "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.0.tgz#14f854c0f93d326e39da6e3b6f34f7d37513d108" @@ -5016,6 +5046,14 @@ jest-diff "^25.2.1" pretty-format "^25.2.1" +"@types/jest@^25.2.2": + version "25.2.3" + resolved "https://registry.npmjs.org/@types/jest/-/jest-25.2.3.tgz#33d27e4c4716caae4eced355097a47ad363fdcaf" + integrity sha512-JXc1nK/tXHiDhV55dvfzqtmP4S3sy3T3ouV2tkViZgxY/zeUkcpQcQPGRlgF4KmWzWW5oiWYSZwtCB+2RsE4Fw== + dependencies: + jest-diff "^25.2.1" + pretty-format "^25.2.1" + "@types/jquery@^3.3.34": version "3.5.1" resolved "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.1.tgz#cebb057acf5071c40e439f30e840c57a30d406c3" @@ -5737,6 +5775,18 @@ regexpp "^3.0.0" tsutils "^3.17.1" +"@typescript-eslint/eslint-plugin@^3.8.0": + version "3.10.1" + resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-3.10.1.tgz#7e061338a1383f59edc204c605899f93dc2e2c8f" + integrity sha512-PQg0emRtzZFWq6PxBcdxRH3QIQiyFO3WCVpRL3fgj5oQS3CDs3AeAKfv4DxNhzn8ITdNJGJ4D3Qw8eAJf3lXeQ== + dependencies: + "@typescript-eslint/experimental-utils" "3.10.1" + debug "^4.1.1" + functional-red-black-tree "^1.0.1" + regexpp "^3.0.0" + semver "^7.3.2" + tsutils "^3.17.1" + "@typescript-eslint/experimental-utils@2.24.0", "@typescript-eslint/experimental-utils@^2.5.0": version "2.24.0" resolved "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-2.24.0.tgz#a5cb2ed89fedf8b59638dc83484eb0c8c35e1143" @@ -5746,6 +5796,17 @@ "@typescript-eslint/typescript-estree" "2.24.0" eslint-scope "^5.0.0" +"@typescript-eslint/experimental-utils@3.10.1": + version "3.10.1" + resolved "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-3.10.1.tgz#e179ffc81a80ebcae2ea04e0332f8b251345a686" + integrity sha512-DewqIgscDzmAfd5nOGe4zm6Bl7PKtMG2Ad0KG8CUZAHlXfAKTF9Ol5PXhiMh39yRL2ChRH1cuuUGOcVyyrhQIw== + dependencies: + "@types/json-schema" "^7.0.3" + "@typescript-eslint/types" "3.10.1" + "@typescript-eslint/typescript-estree" "3.10.1" + eslint-scope "^5.0.0" + eslint-utils "^2.0.0" + "@typescript-eslint/parser@^2.14.0": version "2.24.0" resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-2.24.0.tgz#2cf0eae6e6dd44d162486ad949c126b887f11eb8" @@ -5756,6 +5817,22 @@ "@typescript-eslint/typescript-estree" "2.24.0" eslint-visitor-keys "^1.1.0" +"@typescript-eslint/parser@^3.8.0": + version "3.10.1" + resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-3.10.1.tgz#1883858e83e8b442627e1ac6f408925211155467" + integrity sha512-Ug1RcWcrJP02hmtaXVS3axPPTTPnZjupqhgj+NnZ6BCkwSImWk/283347+x9wN+lqOdK9Eo3vsyiyDHgsmiEJw== + dependencies: + "@types/eslint-visitor-keys" "^1.0.0" + "@typescript-eslint/experimental-utils" "3.10.1" + "@typescript-eslint/types" "3.10.1" + "@typescript-eslint/typescript-estree" "3.10.1" + eslint-visitor-keys "^1.1.0" + +"@typescript-eslint/types@3.10.1": + version "3.10.1" + resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-3.10.1.tgz#1d7463fa7c32d8a23ab508a803ca2fe26e758727" + integrity sha512-+3+FCUJIahE9q0lDi1WleYzjCwJs5hIsbugIgnbB+dSCYUxl8L6PwmsyOPFZde2hc1DlTo/xnkOgiTLSyAbHiQ== + "@typescript-eslint/typescript-estree@2.24.0": version "2.24.0" resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-2.24.0.tgz#38bbc8bb479790d2f324797ffbcdb346d897c62a" @@ -5769,6 +5846,27 @@ semver "^6.3.0" tsutils "^3.17.1" +"@typescript-eslint/typescript-estree@3.10.1": + version "3.10.1" + resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-3.10.1.tgz#fd0061cc38add4fad45136d654408569f365b853" + integrity sha512-QbcXOuq6WYvnB3XPsZpIwztBoquEYLXh2MtwVU+kO8jgYCiv4G5xrSP/1wg4tkvrEE+esZVquIPX/dxPlePk1w== + dependencies: + "@typescript-eslint/types" "3.10.1" + "@typescript-eslint/visitor-keys" "3.10.1" + debug "^4.1.1" + glob "^7.1.6" + is-glob "^4.0.1" + lodash "^4.17.15" + semver "^7.3.2" + tsutils "^3.17.1" + +"@typescript-eslint/visitor-keys@3.10.1": + version "3.10.1" + resolved "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-3.10.1.tgz#cd4274773e3eb63b2e870ac602274487ecd1e931" + integrity sha512-9JgC82AaQeglebjZMgYR5wgmfUdUc+EitGUUMW8u2nDckaeimzW+VsoLV6FoimPv2id3VQzfjwBxEMVz08ameQ== + dependencies: + eslint-visitor-keys "^1.1.0" + "@webassemblyjs/ast@1.9.0": version "1.9.0" resolved "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz#bd850604b4042459a5a41cd7d338cbed695ed964" @@ -6046,6 +6144,11 @@ acorn@^7.1.1, acorn@^7.2.0: resolved "https://registry.npmjs.org/acorn/-/acorn-7.3.1.tgz#85010754db53c3fbaf3b9ea3e083aa5c5d147ffd" integrity sha512-tLc0wSnatxAQHVHUapaHdz72pi9KUyHjq5KyHjGg9Y8Ifdc79pTh2XvI6I1/chZbnM7QtNKzh66ooDogPZSleA== +acorn@^7.4.0: + version "7.4.0" + resolved "https://registry.npmjs.org/acorn/-/acorn-7.4.0.tgz#e1ad486e6c54501634c6c397c5c121daa383607c" + integrity sha512-+G7P8jJmCHr+S+cLfQxygbWhXy+8YTVGzAkpEbcLo2mLoL7tij/VG41QSHACSf5QgYRhMZYHuNc6drJaO0Da+w== + address@1.1.2, address@^1.0.1: version "1.1.2" resolved "https://registry.npmjs.org/address/-/address-1.1.2.tgz#bf1116c9c758c51b7a933d296b72c221ed9428b6" @@ -9238,7 +9341,7 @@ cyclist@^1.0.1: resolved "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk= -cypress@*, cypress@^4.2.0: +cypress@*, cypress@^4.11.0, cypress@^4.2.0: version "4.12.1" resolved "https://registry.npmjs.org/cypress/-/cypress-4.12.1.tgz#0ead1b9f4c0917d69d8b57f996b6e01fe693b6ec" integrity sha512-9SGIPEmqU8vuRA6xst2CMTYd9sCFCxKSzrHt0wr+w2iAQMCIIsXsQ5Gplns1sT6LDbZcmLv6uehabAOl3fhc9Q== @@ -10237,6 +10340,23 @@ es-abstract@^1.17.0, es-abstract@^1.17.0-next.0, es-abstract@^1.17.0-next.1, es- string.prototype.trimleft "^2.1.1" string.prototype.trimright "^2.1.1" +es-abstract@^1.17.5: + version "1.17.6" + resolved "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.6.tgz#9142071707857b2cacc7b89ecb670316c3e2d52a" + integrity sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw== + dependencies: + es-to-primitive "^1.2.1" + function-bind "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.1" + is-callable "^1.2.0" + is-regex "^1.1.0" + object-inspect "^1.7.0" + object-keys "^1.1.1" + object.assign "^4.1.0" + string.prototype.trimend "^1.0.1" + string.prototype.trimstart "^1.0.1" + es-array-method-boxes-properly@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz#873f3e84418de4ee19c5be752990b2e44718d09e" @@ -10327,6 +10447,11 @@ esbuild@0.6.3: resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.6.3.tgz#a957e22f2503c745793514388110f9b55b3a6f83" integrity sha512-4lHgz/EvGLRQnDYzzrvW+eilaPHim5pCLz4mP0k0QIalD/n8Ji2NwQwoIa1uX+yKkbn9R/FAZvaEbodjx55rDg== +esbuild@^0.6.18: + version "0.6.34" + resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.6.34.tgz#76565a60e006f45d5f273b6e59e61ed0816551f5" + integrity sha512-InRdL/Q96pUucPqovJzvuLhquZr6jOn81FDVwFjCKz1rYKIm9OdOC+7Fs4vr6x48vKBl5LzKgtjU39BUpO636A== + escalade@^3.0.1: version "3.0.2" resolved "https://registry.npmjs.org/escalade/-/escalade-3.0.2.tgz#6a580d70edb87880f22b4c91d0d56078df6962c4" @@ -10398,7 +10523,7 @@ eslint-module-utils@^2.1.1, eslint-module-utils@^2.6.0: debug "^2.6.9" pkg-dir "^2.0.0" -eslint-plugin-cypress@^2.10.3: +eslint-plugin-cypress@^2.10.3, eslint-plugin-cypress@^2.11.1: version "2.11.1" resolved "https://registry.npmjs.org/eslint-plugin-cypress/-/eslint-plugin-cypress-2.11.1.tgz#a945e2774b88211e2c706a059d431e262b5c2862" integrity sha512-MxMYoReSO5+IZMGgpBZHHSx64zYPSPTpXDwsgW7ChlJTF/sA+obqRbHplxD6sBStE+g4Mi0LCLkG4t9liu//mQ== @@ -10501,6 +10626,23 @@ eslint-plugin-react@^7.12.4: string.prototype.matchall "^4.0.2" xregexp "^4.3.0" +eslint-plugin-react@^7.20.5: + version "7.21.2" + resolved "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.21.2.tgz#3bd5d2c4c36d5a0428d0d6dda301ac9a84d681b2" + integrity sha512-j3XKvrK3rpBzveKFbgAeGsWb9uz6iUOrR0jixRfjwdFeGSRsXvVTFtHDQYCjsd1/6Z/xvb8Vy3LiI5Reo7fDrg== + dependencies: + array-includes "^3.1.1" + array.prototype.flatmap "^1.2.3" + doctrine "^2.1.0" + has "^1.0.3" + jsx-ast-utils "^2.4.1" + object.entries "^1.1.2" + object.fromentries "^2.0.2" + object.values "^1.1.1" + prop-types "^15.7.2" + resolve "^1.17.0" + string.prototype.matchall "^4.0.2" + eslint-scope@^4.0.3: version "4.0.3" resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.3.tgz#ca03833310f6889a3264781aa82e63eb9cfe7848" @@ -10517,6 +10659,14 @@ eslint-scope@^5.0.0, eslint-scope@^5.1.0: esrecurse "^4.1.0" estraverse "^4.1.1" +eslint-scope@^5.1.1: + version "5.1.1" + resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" + integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== + dependencies: + esrecurse "^4.3.0" + estraverse "^4.1.1" + eslint-utils@^1.4.3: version "1.4.3" resolved "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.4.3.tgz#74fec7c54d0776b6f67e0251040b5806564e981f" @@ -10531,11 +10681,23 @@ eslint-utils@^2.0.0: dependencies: eslint-visitor-keys "^1.1.0" +eslint-utils@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz#d2de5e03424e707dc10c74068ddedae708741b27" + integrity sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg== + dependencies: + eslint-visitor-keys "^1.1.0" + eslint-visitor-keys@^1.1.0, eslint-visitor-keys@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.2.0.tgz#74415ac884874495f78ec2a97349525344c981fa" integrity sha512-WFb4ihckKil6hu3Dp798xdzSfddwKKU3+nGniKF6HfeW6OLd2OUDEPP7TcHtB5+QXOKg2s6B2DaMPE1Nn/kxKQ== +eslint-visitor-keys@^1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz#30ebd1ef7c2fdff01c3a4f151044af25fab0523e" + integrity sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ== + eslint@^7.1.0: version "7.4.0" resolved "https://registry.npmjs.org/eslint/-/eslint-7.4.0.tgz#4e35a2697e6c1972f9d6ef2b690ad319f80f206f" @@ -10578,6 +10740,49 @@ eslint@^7.1.0: text-table "^0.2.0" v8-compile-cache "^2.0.3" +eslint@^7.6.0: + version "7.10.0" + resolved "https://registry.npmjs.org/eslint/-/eslint-7.10.0.tgz#494edb3e4750fb791133ca379e786a8f648c72b9" + integrity sha512-BDVffmqWl7JJXqCjAK6lWtcQThZB/aP1HXSH1JKwGwv0LQEdvpR7qzNrUT487RM39B5goWuboFad5ovMBmD8yA== + dependencies: + "@babel/code-frame" "^7.0.0" + "@eslint/eslintrc" "^0.1.3" + ajv "^6.10.0" + chalk "^4.0.0" + cross-spawn "^7.0.2" + debug "^4.0.1" + doctrine "^3.0.0" + enquirer "^2.3.5" + eslint-scope "^5.1.1" + eslint-utils "^2.1.0" + eslint-visitor-keys "^1.3.0" + espree "^7.3.0" + esquery "^1.2.0" + esutils "^2.0.2" + file-entry-cache "^5.0.1" + functional-red-black-tree "^1.0.1" + glob-parent "^5.0.0" + globals "^12.1.0" + ignore "^4.0.6" + import-fresh "^3.0.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + js-yaml "^3.13.1" + json-stable-stringify-without-jsonify "^1.0.1" + levn "^0.4.1" + lodash "^4.17.19" + minimatch "^3.0.4" + natural-compare "^1.4.0" + optionator "^0.9.1" + progress "^2.0.0" + regexpp "^3.1.0" + semver "^7.2.1" + strip-ansi "^6.0.0" + strip-json-comments "^3.1.0" + table "^5.2.3" + text-table "^0.2.0" + v8-compile-cache "^2.0.3" + esm@^3.2.25: version "3.2.25" resolved "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz#342c18c29d56157688ba5ce31f8431fbb795cc10" @@ -10592,6 +10797,15 @@ espree@^7.1.0: acorn-jsx "^5.2.0" eslint-visitor-keys "^1.2.0" +espree@^7.3.0: + version "7.3.0" + resolved "https://registry.npmjs.org/espree/-/espree-7.3.0.tgz#dc30437cf67947cf576121ebd780f15eeac72348" + integrity sha512-dksIWsvKCixn1yrEXO8UosNSxaDoSYpq9reEjZSbHLpT5hpaCAKTLBwq0RHtLrIr+c0ByiYzWT8KTMRzoRCNlw== + dependencies: + acorn "^7.4.0" + acorn-jsx "^5.2.0" + eslint-visitor-keys "^1.3.0" + esprima@^4.0.0, esprima@^4.0.1, esprima@~4.0.0: version "4.0.1" resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" @@ -10611,6 +10825,13 @@ esrecurse@^4.1.0: dependencies: estraverse "^4.1.0" +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + estraverse@^4.1.0, estraverse@^4.1.1, estraverse@^4.2.0: version "4.3.0" resolved "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" @@ -10621,6 +10842,11 @@ estraverse@^5.1.0: resolved "https://registry.npmjs.org/estraverse/-/estraverse-5.1.0.tgz#374309d39fd935ae500e7b92e8a6b4c720e59642" integrity sha512-FyohXK+R0vE+y1nHLoBM7ZTyqRpqAlhdZHCWIWEviFLiGB8b04H6bQs8G+XTthacvT8VuwvteiP7RJSxMs8UEw== +estraverse@^5.2.0: + version "5.2.0" + resolved "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz#307df42547e6cc7324d3cf03c155d5cdb8c53880" + integrity sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ== + estree-walker@^0.6.0, estree-walker@^0.6.1: version "0.6.1" resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz#53049143f40c6eb918b23671d1fe3219f3a1b362" @@ -12238,7 +12464,7 @@ graphql-upload@^8.0.2: http-errors "^1.7.3" object-path "^0.11.4" -graphql@15.3.0, graphql@^15.3.0: +graphql@15.3.0, graphql@^15.0.0, graphql@^15.3.0: version "15.3.0" resolved "https://registry.npmjs.org/graphql/-/graphql-15.3.0.tgz#3ad2b0caab0d110e3be4a5a9b2aa281e362b5278" integrity sha512-GTCJtzJmkFLWRfFJuoo9RWWa/FfamUHgiFosxi/X1Ani4AVWbeyBenZTNX6dM+7WSbbFfTo/25eh0LLkwHMw2w== @@ -12414,7 +12640,7 @@ he@^1.1.0, he@^1.2.0: resolved "https://registry.npmjs.org/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== -headers-utils@^1.2.0: +headers-utils@^1.1.9, headers-utils@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/headers-utils/-/headers-utils-1.2.0.tgz#5e10d1bc9d2bccf789547afca5b991a3167241e8" integrity sha512-4/BMXcWrJErw7JpM87gF8MNEXcIMLzepYZjNRv/P9ctgupl2Ywa3u1PgHtNhSRq84bHH9Ndlkdy7bSi+bZ9I9A== @@ -13212,6 +13438,11 @@ is-callable@^1.1.4, is-callable@^1.1.5: resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz#f7e46b596890456db74e7f6e976cb3273d06faab" integrity sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q== +is-callable@^1.2.0: + version "1.2.2" + resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.2.2.tgz#c7c6715cd22d4ddb48d3e19970223aceabb080d9" + integrity sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA== + is-ci@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" @@ -13503,7 +13734,7 @@ is-reference@^1.1.2: dependencies: "@types/estree" "0.0.39" -is-regex@^1.0.4, is-regex@^1.0.5, is-regex@^1.1.1: +is-regex@^1.0.4, is-regex@^1.0.5, is-regex@^1.1.0, is-regex@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/is-regex/-/is-regex-1.1.1.tgz#c6f98aacc546f6cec5468a07b7b153ab564a57b9" integrity sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg== @@ -14211,6 +14442,11 @@ jose@^2.0.2: dependencies: "@panva/asn1.js" "^1.0.0" +joycon@^2.2.5: + version "2.2.5" + resolved "https://registry.npmjs.org/joycon/-/joycon-2.2.5.tgz#8d4cf4cbb2544d7b7583c216fcdfec19f6be1615" + integrity sha512-YqvUxoOcVPnCp0VU1/56f+iKSdvIRJYPznH22BdXV3xMk75SFXhWeJkZ8C9XxUWt1b5x2X1SxuFygW1U0FmkEQ== + js-cookie@^2.2.1: version "2.2.1" resolved "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz#69e106dc5d5806894562902aa5baec3744e9b2b8" @@ -14638,6 +14874,14 @@ jsx-ast-utils@^2.2.1, jsx-ast-utils@^2.2.3: array-includes "^3.0.3" object.assign "^4.1.0" +jsx-ast-utils@^2.4.1: + version "2.4.1" + resolved "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-2.4.1.tgz#1114a4c1209481db06c690c2b4f488cc665f657e" + integrity sha512-z1xSldJ6imESSzOjd3NNkieVJKRlKYSOtMG8SFyCj2FIrvSaSuli/WjpBkEzCBoR9bYYYFgqJw61Xhu7Lcgk+w== + dependencies: + array-includes "^3.1.1" + object.assign "^4.1.0" + jwa@^1.4.1: version "1.4.1" resolved "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" @@ -16040,6 +16284,22 @@ ms@^2.0.0, ms@^2.1.1: resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +msw@^0.19.5: + version "0.19.5" + resolved "https://registry.npmjs.org/msw/-/msw-0.19.5.tgz#7d2a1a852ccf1644d3db6735d69fff6777aac33f" + integrity sha512-J5eQ++gDVZoHPC8gVXtWcakLjgmPipvFj/sEnlRV/WViXuiq2CamSqO3Wbh6H8bAmj+k2vUWCfcVT1HjMdKB2Q== + dependencies: + "@open-draft/until" "^1.0.0" + "@types/cookie" "^0.3.3" + chalk "^4.0.0" + cookie "^0.4.1" + graphql "^15.0.0" + headers-utils "^1.1.9" + node-match-path "^0.4.2" + node-request-interceptor "^0.2.5" + statuses "^2.0.0" + yargs "^15.3.1" + msw@^0.20.5: version "0.20.5" resolved "https://registry.npmjs.org/msw/-/msw-0.20.5.tgz#b6141080c0d8b17c451d9ca36c28cc47b4ac487a" @@ -16299,7 +16559,7 @@ node-libs-browser@^2.2.1: util "^0.11.0" vm-browserify "^1.0.1" -node-match-path@^0.4.4: +node-match-path@^0.4.2, node-match-path@^0.4.4: version "0.4.4" resolved "https://registry.npmjs.org/node-match-path/-/node-match-path-0.4.4.tgz#516a10926093c0cc6f237d020685b593b19baebb" integrity sha512-pBq9gp7TG0r0VXuy/oeZmQsjBSnYQo7G886Ly/B3azRwZuEtHCY155dzmfoKWcDPGgyfIGD8WKVC7h3+6y7yTg== @@ -16358,6 +16618,14 @@ node-releases@^1.1.52, node-releases@^1.1.58: resolved "https://registry.npmjs.org/node-releases/-/node-releases-1.1.60.tgz#6948bdfce8286f0b5d0e5a88e8384e954dfe7084" integrity sha512-gsO4vjEdQaTusZAEebUWp2a5d7dF5DYoIpDG7WySnk7BuZDW+GPpHXoXXuYawRBr/9t5q54tirPz79kFIWg4dA== +node-request-interceptor@^0.2.5: + version "0.2.6" + resolved "https://registry.npmjs.org/node-request-interceptor/-/node-request-interceptor-0.2.6.tgz#541278d7033bb6a8befb5dd793f83428cf6446a2" + integrity sha512-aJW1tPSM7nzuZFRe+C/KSz22GJO3CVFMxHHmMGX8Z+tjP7TCIVbzeckLFVfJG68BdVgrdOOP7Ejc57ag820eyA== + dependencies: + debug "^4.1.1" + headers-utils "^1.2.0" + node-request-interceptor@^0.3.5: version "0.3.5" resolved "https://registry.npmjs.org/node-request-interceptor/-/node-request-interceptor-0.3.5.tgz#4b26159617829c9a70643012c0fdc3ae4c78ae43" @@ -16668,6 +16936,15 @@ object.entries@^1.1.0, object.entries@^1.1.1: function-bind "^1.1.1" has "^1.0.3" +object.entries@^1.1.2: + version "1.1.2" + resolved "https://registry.npmjs.org/object.entries/-/object.entries-1.1.2.tgz#bc73f00acb6b6bb16c203434b10f9a7e797d3add" + integrity sha512-BQdB9qKmb/HyNdMNWVr7O3+z5MUIx3aiegEIJqjMBbBf0YT9RRxTJSim4mzFqtyr7PDAHigq0N9dO0m0tRakQA== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.5" + has "^1.0.3" + "object.fromentries@^2.0.0 || ^1.0.0", object.fromentries@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.2.tgz#4a09c9b9bb3843dd0f89acdb517a794d4f355ac9" @@ -19973,6 +20250,13 @@ rollup-plugin-dts@1.4.11: optionalDependencies: "@babel/code-frame" "^7.10.4" +rollup-plugin-dts@^1.4.10: + version "1.4.13" + resolved "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-1.4.13.tgz#4f086e84f4fdcc1f49160799ebc66f6b09db292b" + integrity sha512-7mxoQ6PcmCkBE5ZhrjGDL4k42XLy8BkSqpiRi1MipwiGs+7lwi4mQkp2afX+OzzLjJp/TGM8llfe8uayIUhPEw== + optionalDependencies: + "@babel/code-frame" "^7.10.4" + rollup-plugin-esbuild@^2.0.0: version "2.3.0" resolved "https://registry.npmjs.org/rollup-plugin-esbuild/-/rollup-plugin-esbuild-2.3.0.tgz#7c107d4508af80d30966a148b306cb7d5b36b258" @@ -19980,6 +20264,15 @@ rollup-plugin-esbuild@^2.0.0: dependencies: "@rollup/pluginutils" "^3.1.0" +rollup-plugin-esbuild@^2.4.2: + version "2.5.0" + resolved "https://registry.npmjs.org/rollup-plugin-esbuild/-/rollup-plugin-esbuild-2.5.0.tgz#5748352d0b688743ac64fc85fa86939e00098524" + integrity sha512-Tfi8Zynux67j4h4xEp/OvP2EDrKk/nM9Eghe1sT2+Nw4s6ei9vsqO1ajU+Y8EFfizMk3KEfYCicBKX6QLTW33w== + dependencies: + "@rollup/pluginutils" "^3.1.0" + joycon "^2.2.5" + strip-json-comments "^3.1.1" + rollup-plugin-image-files@^1.4.2: version "1.4.2" resolved "https://registry.npmjs.org/rollup-plugin-image-files/-/rollup-plugin-image-files-1.4.2.tgz#0a329723bace95168a9a6efdacb51370c14fbfe5" @@ -19988,7 +20281,7 @@ rollup-plugin-image-files@^1.4.2: rollup "0.64.1" rollup-pluginutils "2.4.1" -rollup-plugin-peer-deps-external@^2.2.2: +rollup-plugin-peer-deps-external@^2.2.2, rollup-plugin-peer-deps-external@^2.2.3: version "2.2.3" resolved "https://registry.npmjs.org/rollup-plugin-peer-deps-external/-/rollup-plugin-peer-deps-external-2.2.3.tgz#059a8aec1eefb48a475e9fcedc3b9e3deb521213" integrity sha512-W6IePXTExGXVDAlfZbNUUrx3GxUOZP248u5n4a4ID1XZMrbQ+uGeNiEfapvdzwx0qZi5DNH/hDLiPUP+pzFIxg== @@ -20062,6 +20355,13 @@ rollup@^0.63.4: "@types/estree" "0.0.39" "@types/node" "*" +rollup@^2.23.1: + version "2.28.2" + resolved "https://registry.npmjs.org/rollup/-/rollup-2.28.2.tgz#599ec4978144a82d8a8ec3d37670a8440cb04e4b" + integrity sha512-8txbsFBFLmm9Xdt4ByTOGa9Muonmc8MfNjnGAR8U8scJlF1ZW7AgNZa7aqBXaKtlvnYP/ab++fQIq9dB9NWUbg== + optionalDependencies: + fsevents "~2.1.2" + rsvp@^4.8.4: version "4.8.5" resolved "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734" @@ -21169,6 +21469,14 @@ string.prototype.padstart@^3.0.0: define-properties "^1.1.3" es-abstract "^1.17.0-next.1" +string.prototype.trimend@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz#85812a6b847ac002270f5808146064c995fb6913" + integrity sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.5" + string.prototype.trimleft@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.1.tgz#9bdb8ac6abd6d602b17a4ed321870d2f8dcefc74" @@ -21185,6 +21493,14 @@ string.prototype.trimright@^2.1.1: define-properties "^1.1.3" function-bind "^1.1.1" +string.prototype.trimstart@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz#14af6d9f34b053f7cfc89b72f8f2ee14b9039a54" + integrity sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.5" + string_decoder@^1.0.0, string_decoder@^1.1.1: version "1.3.0" resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" @@ -21287,6 +21603,11 @@ strip-json-comments@^3.1.0: resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.0.tgz#7638d31422129ecf4457440009fba03f9f9ac180" integrity sha512-e6/d0eBu7gHtdCqFt0xJr642LdToM5/cN4Qb9DbHjVx1CP5RyeM+zH7pbecEmDv/lBqb0QH+6Uqq75rxFPkM0w== +strip-json-comments@^3.1.1: + version "3.1.1" + resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + strip-json-comments@~2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" From d5de29c00fc89b0ad59e34a3fd824794080817ec Mon Sep 17 00:00:00 2001 From: Marek Calus Date: Mon, 28 Sep 2020 14:45:33 +0200 Subject: [PATCH 82/94] Clean up the repository --- plugins/firebase-functions | 1 - plugins/travis-ci | 1 - 2 files changed, 2 deletions(-) delete mode 160000 plugins/firebase-functions delete mode 160000 plugins/travis-ci diff --git a/plugins/firebase-functions b/plugins/firebase-functions deleted file mode 160000 index 6c3e441eec..0000000000 --- a/plugins/firebase-functions +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 6c3e441eec0a189d41bba66089244e2545533244 diff --git a/plugins/travis-ci b/plugins/travis-ci deleted file mode 160000 index a7687c6196..0000000000 --- a/plugins/travis-ci +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a7687c61964c6d8f9759e8f438ca68fe7b0719db From 2fbca83156bf5fa509a347cd5804e41d177b329a Mon Sep 17 00:00:00 2001 From: Matthew Clarke Date: Mon, 28 Sep 2020 14:16:41 +0100 Subject: [PATCH 83/94] Add initial kubernetes services and configmap UI support (#2635) * add initial kubernetes services and configmap support * revert accidental change * missed revert * prettier --- .../dice-roller/dice-roller-manifests.yaml | 20 +++ .../getKubernetesObjectsByServiceIdHandler.ts | 1 - .../components/ConfigMaps/ConfigMaps.test.tsx | 41 +++++ .../src/components/ConfigMaps/ConfigMaps.tsx | 51 ++++++ .../ConfigMaps/__fixtures__/configmap.json | 46 +++++ .../src/components/ConfigMaps/index.ts | 16 ++ .../KubernetesContent/KubernetesContent.tsx | 74 +++++++- .../src/components/Services/Services.test.tsx | 54 ++++++ .../src/components/Services/Services.tsx | 62 +++++++ .../Services/__fixtures__/services.json | 164 ++++++++++++++++++ .../src/components/Services/index.ts | 16 ++ 11 files changed, 536 insertions(+), 9 deletions(-) create mode 100644 plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.test.tsx create mode 100644 plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.tsx create mode 100644 plugins/kubernetes/src/components/ConfigMaps/__fixtures__/configmap.json create mode 100644 plugins/kubernetes/src/components/ConfigMaps/index.ts create mode 100644 plugins/kubernetes/src/components/Services/Services.test.tsx create mode 100644 plugins/kubernetes/src/components/Services/Services.tsx create mode 100644 plugins/kubernetes/src/components/Services/__fixtures__/services.json create mode 100644 plugins/kubernetes/src/components/Services/index.ts diff --git a/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml b/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml index f3a478f4d3..13fe26d051 100644 --- a/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml +++ b/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml @@ -88,6 +88,26 @@ spec: - protocol: TCP port: 80 targetPort: 9376 + name: port1 + - protocol: TCP + port: 81 + targetPort: 9377 + name: port2 + +--- +apiVersion: v1 +kind: Service +metadata: + name: dice-roller-lb + labels: + 'backstage.io/kubernetes-id': dice-roller +spec: + selector: + app: dice-roller + ports: + - port: 8765 + targetPort: 9376 + type: LoadBalancer --- apiVersion: v1 diff --git a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts index 52968d4c20..b2dde1a393 100644 --- a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts +++ b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts @@ -34,7 +34,6 @@ const DEFAULT_OBJECTS = new Set([ 'pods', 'services', 'configmaps', - 'secrets', 'deployments', 'replicasets', ]); diff --git a/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.test.tsx b/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.test.tsx new file mode 100644 index 0000000000..161a0ed487 --- /dev/null +++ b/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.test.tsx @@ -0,0 +1,41 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { ConfigMaps } from './ConfigMaps'; +import * as configmapFixture from './__fixtures__/configmap.json'; +import { wrapInTestApp } from '@backstage/test-utils'; + +describe('ConfigMaps', () => { + it('should render configmap', async () => { + const { getByText } = render( + wrapInTestApp( + , + ), + ); + + // title + expect(getByText('dice-roller')).toBeInTheDocument(); + expect(getByText('ConfigMap')).toBeInTheDocument(); + + // values + expect(getByText('Immutable')).toBeInTheDocument(); + expect(getByText('false')).toBeInTheDocument(); + expect(getByText('Data')).toBeInTheDocument(); + expect(getByText('Foo: bar')).toBeInTheDocument(); // TODO wish this wasn't upper case + }); +}); diff --git a/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.tsx b/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.tsx new file mode 100644 index 0000000000..f757d1c0fc --- /dev/null +++ b/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Grid } from '@material-ui/core'; +import { V1ConfigMap } from '@kubernetes/client-node'; +import { InfoCard, StructuredMetadataTable } from '@backstage/core'; + +type ConfigMapsProps = { + configMaps: V1ConfigMap[]; + children?: React.ReactNode; +}; + +export const ConfigMaps = ({ configMaps }: ConfigMapsProps) => { + return ( + + {configMaps.map((cm, i) => { + return ( + + +
+ +
+
+
+ ); + })} +
+ ); +}; diff --git a/plugins/kubernetes/src/components/ConfigMaps/__fixtures__/configmap.json b/plugins/kubernetes/src/components/ConfigMaps/__fixtures__/configmap.json new file mode 100644 index 0000000000..77ea844611 --- /dev/null +++ b/plugins/kubernetes/src/components/ConfigMaps/__fixtures__/configmap.json @@ -0,0 +1,46 @@ +[ + { + "data": { + "foo": "bar" + }, + "metadata": { + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"data\":{\"foo\":\"bar\"},\"kind\":\"ConfigMap\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"}}\n" + }, + "creationTimestamp": "2020-09-24T11:39:26.000Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "managedFields": [ + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:data": { + ".": {}, + "f:foo": {} + }, + "f:metadata": { + "f:annotations": { + ".": {}, + "f:kubectl.kubernetes.io/last-applied-configuration": {} + }, + "f:labels": { + ".": {}, + "f:backstage.io/kubernetes-id": {} + } + } + }, + "manager": "kubectl", + "operation": "Update", + "time": "2020-09-24T11:39:26.000Z" + } + ], + "name": "dice-roller", + "namespace": "default", + "resourceVersion": "503867", + "selfLink": "/api/v1/namespaces/default/configmaps/dice-roller", + "uid": "e9efe5ee-53b9-4422-aef2-877a03c73d5f" + } + } +] diff --git a/plugins/kubernetes/src/components/ConfigMaps/index.ts b/plugins/kubernetes/src/components/ConfigMaps/index.ts new file mode 100644 index 0000000000..b1c7d37a5a --- /dev/null +++ b/plugins/kubernetes/src/components/ConfigMaps/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { ConfigMaps } from './ConfigMaps'; diff --git a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx index d14a2dc4f7..315a5e0377 100644 --- a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx +++ b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx @@ -17,23 +17,33 @@ import React, { useEffect, useState } from 'react'; import { Grid } from '@material-ui/core'; import { + CardTab, Content, - InfoCard, Page, pageTheme, Progress, + TabbedCard, useApi, } from '@backstage/core'; import { Entity } from '@backstage/catalog-model'; import { kubernetesApiRef } from '../../api/types'; import { + ClusterObjects, FetchResponse, ObjectsByServiceIdResponse, } from '@backstage/plugin-kubernetes-backend'; import { DeploymentTables } from '../DeploymentTables'; import { DeploymentTriple } from '../../types/types'; +import { V1ConfigMap, V1Service } from '@kubernetes/client-node'; +import { Services } from '../Services'; +import { ConfigMaps } from '../ConfigMaps'; -const findDeployments = (fetchResponse: FetchResponse[]): DeploymentTriple => { +interface GroupedResponses extends DeploymentTriple { + services: V1Service[]; + configMaps: V1ConfigMap[]; +} + +const groupResponses = (fetchResponse: FetchResponse[]) => { return fetchResponse.reduce( (prev, next) => { switch (next.type) { @@ -46,6 +56,12 @@ const findDeployments = (fetchResponse: FetchResponse[]): DeploymentTriple => { case 'replicasets': prev.replicaSets.push(...next.resources); break; + case 'services': + prev.services.push(...next.resources); + break; + case 'configmaps': + prev.configMaps.push(...next.resources); + break; default: } return prev; @@ -54,7 +70,9 @@ const findDeployments = (fetchResponse: FetchResponse[]): DeploymentTriple => { pods: [], replicaSets: [], deployments: [], - } as DeploymentTriple, + services: [], + configMaps: [], + } as GroupedResponses, ); }; @@ -89,11 +107,7 @@ export const KubernetesContent = ({ entity }: KubernetesContentProps) => { {error !== undefined &&
{error}
} {kubernetesObjects?.items.map((item, i) => ( - - - + ))} @@ -101,3 +115,47 @@ export const KubernetesContent = ({ entity }: KubernetesContentProps) => { ); }; + +type ClusterProps = { + clusterObjects: ClusterObjects; + children?: React.ReactNode; +}; + +const Cluster = ({ clusterObjects }: ClusterProps) => { + const [selectedTab, setSelectedTab] = useState('one'); + + const handleChange = (_ev: any, newSelectedTab: string | number) => + setSelectedTab(newSelectedTab); + + const groupedResponses = groupResponses(clusterObjects.resources); + + const configMaps = groupedResponses.configMaps; + + return ( + <> + + + + + + + + {configMaps && ( + + + + )} + + + ); +}; diff --git a/plugins/kubernetes/src/components/Services/Services.test.tsx b/plugins/kubernetes/src/components/Services/Services.test.tsx new file mode 100644 index 0000000000..5e74499bc5 --- /dev/null +++ b/plugins/kubernetes/src/components/Services/Services.test.tsx @@ -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 React from 'react'; +import { render } from '@testing-library/react'; +import { Services } from './Services'; +import * as servicesFixture from './__fixtures__/services.json'; +import { wrapInTestApp } from '@backstage/test-utils'; + +describe('Services', () => { + it('should render 2 services', async () => { + const { getByText, getAllByText } = render( + wrapInTestApp(), + ); + + // common elements + expect(getAllByText('Service')).toHaveLength(2); + expect(getAllByText('Ports')).toHaveLength(2); + expect(getAllByText('Type')).toHaveLength(2); + expect(getAllByText('Protocol: TCP')).toHaveLength(3); + + // service 1 + expect(getByText('dice-roller')).toBeInTheDocument(); + expect(getByText('ClusterIP')).toBeInTheDocument(); + + expect(getByText('Name: port1')).toBeInTheDocument(); + expect(getByText('Port: 80')).toBeInTheDocument(); + expect(getByText('Target Port: 9376')).toBeInTheDocument(); + expect(getByText('Name: port1')).toBeInTheDocument(); + expect(getByText('Port: 81')).toBeInTheDocument(); + expect(getByText('Target Port: 9377')).toBeInTheDocument(); + expect(getByText('10.102.223.105')).toBeInTheDocument(); + + // service 2 + expect(getByText('dice-roller-lb')).toBeInTheDocument(); + expect(getByText('LoadBalancer')).toBeInTheDocument(); + expect(getByText('Node Port: 32276')).toBeInTheDocument(); + expect(getByText('Port: 8765')).toBeInTheDocument(); + expect(getByText('Target Port: 9378')).toBeInTheDocument(); + }); +}); diff --git a/plugins/kubernetes/src/components/Services/Services.tsx b/plugins/kubernetes/src/components/Services/Services.tsx new file mode 100644 index 0000000000..d0a4f71eae --- /dev/null +++ b/plugins/kubernetes/src/components/Services/Services.tsx @@ -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 React from 'react'; +import { Grid } from '@material-ui/core'; +import { V1Service } from '@kubernetes/client-node'; +import { InfoCard, StructuredMetadataTable } from '@backstage/core'; + +type ServicesProps = { + services: V1Service[]; + children?: React.ReactNode; +}; + +export const Services = ({ services }: ServicesProps) => { + return ( + + {services.map((s, i) => { + const metadata: any = {}; + + if (s.status?.loadBalancer?.ingress?.length ?? -1 > 0) { + metadata.loadbalancer = s.status?.loadBalancer; + } + + if (s.spec?.type === 'ClusterIP') { + metadata.clusterIP = s.spec.clusterIP; + } + + return ( + + +
+ +
+
+
+ ); + })} +
+ ); +}; diff --git a/plugins/kubernetes/src/components/Services/__fixtures__/services.json b/plugins/kubernetes/src/components/Services/__fixtures__/services.json new file mode 100644 index 0000000000..7f9b49a9f6 --- /dev/null +++ b/plugins/kubernetes/src/components/Services/__fixtures__/services.json @@ -0,0 +1,164 @@ +[ + { + "metadata": { + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"Service\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"},\"spec\":{\"ports\":[{\"name\":\"port1\",\"port\":80,\"protocol\":\"TCP\",\"targetPort\":9376},{\"name\":\"port2\",\"port\":81,\"protocol\":\"TCP\",\"targetPort\":9377}],\"selector\":{\"app\":\"dice-roller\"}}}\n" + }, + "creationTimestamp": "2020-09-23T12:00:55.000Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "managedFields": [ + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:annotations": { + ".": {}, + "f:kubectl.kubernetes.io/last-applied-configuration": {} + }, + "f:labels": { + ".": {}, + "f:backstage.io/kubernetes-id": {} + } + }, + "f:spec": { + "f:ports": { + ".": {}, + "k:{\"port\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:name": {}, + "f:port": {}, + "f:protocol": {}, + "f:targetPort": {} + }, + "k:{\"port\":81,\"protocol\":\"TCP\"}": { + ".": {}, + "f:name": {}, + "f:port": {}, + "f:protocol": {}, + "f:targetPort": {} + } + }, + "f:selector": { + ".": {}, + "f:app": {} + }, + "f:sessionAffinity": {}, + "f:type": {} + } + }, + "manager": "kubectl", + "operation": "Update", + "time": "2020-09-28T08:50:11.000Z" + } + ], + "name": "dice-roller", + "namespace": "default", + "resourceVersion": "665838", + "selfLink": "/api/v1/namespaces/default/services/dice-roller", + "uid": "ae9aff92-a525-4bc9-82dc-a0537bf8034c" + }, + "spec": { + "clusterIP": "10.102.223.105", + "ports": [ + { + "name": "port1", + "port": 80, + "protocol": "TCP", + "targetPort": 9376 + }, + { + "name": "port2", + "port": 81, + "protocol": "TCP", + "targetPort": 9377 + } + ], + "selector": { + "app": "dice-roller" + }, + "sessionAffinity": "None", + "type": "ClusterIP" + }, + "status": { + "loadBalancer": {} + } + }, + { + "metadata": { + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"Service\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller-lb\",\"namespace\":\"default\"},\"spec\":{\"ports\":[{\"port\":8765,\"targetPort\":9376}],\"selector\":{\"app\":\"dice-roller\"},\"type\":\"LoadBalancer\"}}\n" + }, + "creationTimestamp": "2020-09-28T08:51:21.000Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "managedFields": [ + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:annotations": { + ".": {}, + "f:kubectl.kubernetes.io/last-applied-configuration": {} + }, + "f:labels": { + ".": {}, + "f:backstage.io/kubernetes-id": {} + } + }, + "f:spec": { + "f:externalTrafficPolicy": {}, + "f:ports": { + ".": {}, + "k:{\"port\":8765,\"protocol\":\"TCP\"}": { + ".": {}, + "f:port": {}, + "f:protocol": {}, + "f:targetPort": {} + } + }, + "f:selector": { + ".": {}, + "f:app": {} + }, + "f:sessionAffinity": {}, + "f:type": {} + } + }, + "manager": "kubectl", + "operation": "Update", + "time": "2020-09-28T08:51:21.000Z" + } + ], + "name": "dice-roller-lb", + "namespace": "default", + "resourceVersion": "665998", + "selfLink": "/api/v1/namespaces/default/services/dice-roller-lb", + "uid": "5554da3b-2041-4403-8cf4-cd2ccae760f8" + }, + "spec": { + "clusterIP": "10.99.205.233", + "externalTrafficPolicy": "Cluster", + "ports": [ + { + "nodePort": 32276, + "port": 8765, + "protocol": "TCP", + "targetPort": 9378 + } + ], + "selector": { + "app": "dice-roller" + }, + "sessionAffinity": "None", + "type": "LoadBalancer" + }, + "status": { + "loadBalancer": {} + } + } +] diff --git a/plugins/kubernetes/src/components/Services/index.ts b/plugins/kubernetes/src/components/Services/index.ts new file mode 100644 index 0000000000..d52ebf5f14 --- /dev/null +++ b/plugins/kubernetes/src/components/Services/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { Services } from './Services'; From 15911e7a1a9cc40ee067dfc0905b3e0e3d5bdad7 Mon Sep 17 00:00:00 2001 From: blam Date: Mon, 28 Sep 2020 15:33:47 +0200 Subject: [PATCH 84/94] deps(esbuild): bumping esbuild to latest 0.7.7 --- packages/cli/package.json | 2 +- yarn.lock | 394 ++++---------------------------------- 2 files changed, 39 insertions(+), 357 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 6f65658539..46b02fe0c3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -54,7 +54,7 @@ "css-loader": "^3.5.3", "dashify": "^2.0.0", "diff": "^4.0.2", - "esbuild": "0.6.3", + "esbuild": "^0.7.7", "eslint": "^7.1.0", "eslint-formatter-friendly": "^7.0.0", "eslint-plugin-import": "^2.20.2", diff --git a/yarn.lock b/yarn.lock index 6cdf37f2bd..37bc7b25e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1628,22 +1628,6 @@ resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz#8eed982e2ee6f7f4e44c253e12962980791efd46" integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA== -"@eslint/eslintrc@^0.1.3": - version "0.1.3" - resolved "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.1.3.tgz#7d1a2b2358552cc04834c0979bd4275362e37085" - integrity sha512-4YVwPkANLeNtRjMekzux1ci8hIaH5eGKktGqR0d3LWsKNn5B2X/1Z6Trxy7jQXl9EBGE6Yj02O+t09FMeRllaA== - dependencies: - ajv "^6.12.4" - debug "^4.1.1" - espree "^7.3.0" - globals "^12.1.0" - ignore "^4.0.6" - import-fresh "^3.2.1" - js-yaml "^3.13.1" - lodash "^4.17.19" - minimatch "^3.0.4" - strip-json-comments "^3.1.1" - "@evocateur/libnpmaccess@^3.1.2": version "3.1.2" resolved "https://registry.npmjs.org/@evocateur/libnpmaccess/-/libnpmaccess-3.1.2.tgz#ecf7f6ce6b004e9f942b098d92200be4a4b1c845" @@ -3225,7 +3209,7 @@ resolved "https://registry.npmjs.org/@material-icons/font/-/font-1.0.3.tgz#f722e5a69a03f20ef47d015cb69420bebeeaabe5" integrity sha512-aIRd0Z9b/HJ/O24KOaP7dNsXypMnXhpWrpLVYvQB/JesVqXYSbioYND200sR+C14a0LSCp+qWnWCnSXmN1hWGw== -"@material-ui/core@^4.11.0", "@material-ui/core@^4.9.1", "@material-ui/core@^4.9.10": +"@material-ui/core@^4.11.0", "@material-ui/core@^4.9.1": version "4.11.0" resolved "https://registry.npmjs.org/@material-ui/core/-/core-4.11.0.tgz#b69b26e4553c9e53f2bfaf1053e216a0af9be15a" integrity sha512-bYo9uIub8wGhZySHqLQ833zi4ZML+XCBE1XwJ8EuUVSpTWWG57Pm+YugQToJNFsEyiKFhPh8DPD0bgupz8n01g== @@ -3261,17 +3245,6 @@ prop-types "^15.7.2" react-is "^16.8.0" -"@material-ui/lab@^4.0.0-alpha.49": - version "4.0.0-alpha.56" - resolved "https://registry.npmjs.org/@material-ui/lab/-/lab-4.0.0-alpha.56.tgz#ff63080949b55b40625e056bbda05e130d216d34" - integrity sha512-xPlkK+z/6y/24ka4gVJgwPfoCF4RCh8dXb1BNE7MtF9bXEBLN/lBxNTK8VAa0qm3V2oinA6xtUIdcRh0aeRtVw== - dependencies: - "@babel/runtime" "^7.4.4" - "@material-ui/utils" "^4.10.2" - clsx "^1.0.4" - prop-types "^15.7.2" - react-is "^16.8.0" - "@material-ui/pickers@^3.2.2": version "3.2.10" resolved "https://registry.npmjs.org/@material-ui/pickers/-/pickers-3.2.10.tgz#19df024895876eb0ec7cd239bbaea595f703f0ae" @@ -3545,7 +3518,7 @@ dependencies: "@types/node" ">= 8" -"@open-draft/until@^1.0.0", "@open-draft/until@^1.0.3": +"@open-draft/until@^1.0.3": version "1.0.3" resolved "https://registry.npmjs.org/@open-draft/until/-/until-1.0.3.tgz#db9cc719191a62e7d9200f6e7bab21c5b848adca" integrity sha512-Aq58f5HiWdyDlFffbbSjAlv596h/cOnt2DO1w3DOC7OJ5EHs0hd/nycJfiu9RJbT6Yk6F1knnRRXNSpxoIVZ9Q== @@ -3626,11 +3599,6 @@ prop-types "^15.6.1" react-lifecycles-compat "^3.0.4" -"@rehooks/local-storage@^2.4.0": - version "2.4.0" - resolved "https://registry.npmjs.org/@rehooks/local-storage/-/local-storage-2.4.0.tgz#fb884b2b657cad5f77aa6ab60bb3532f0e0725d2" - integrity sha512-LoXDbEHsuIckVgBsFAv8SuU/M7memjyfWut9Zf36TQXqqCHBRFv8bweg9PymQCa1aWIMjNrZQflFdo55FDlXYg== - "@rjsf/core@^2.1.0": version "2.3.0" resolved "https://registry.npmjs.org/@rjsf/core/-/core-2.3.0.tgz#334c73d2262ef1a8cda477e238067af7336c5599" @@ -3676,6 +3644,29 @@ react-router-dom "6.0.0-beta.0" react-use "^15.3.3" +"@roadiehq/backstage-plugin-travis-ci@^0.2.3": + version "0.2.3" + resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-travis-ci/-/backstage-plugin-travis-ci-0.2.3.tgz#2d1c5a7ed3eab4fdcf95243b73dee44c0cff5a57" + integrity sha512-Xi8vZFbcm+D4ykyU3I9hImAd22F1v6M1F/H9CJanRHqj5esJxnuCS2+Kf+w09bwb9DuM8/c68PuMlf3PMIsOwg== + dependencies: + "@backstage/catalog-model" "^0.1.1-alpha.22" + "@backstage/core" "^0.1.1-alpha.22" + "@backstage/core-api" "^0.1.1-alpha.22" + "@backstage/plugin-catalog" "^0.1.1-alpha.22" + "@backstage/theme" "^0.1.1-alpha.22" + "@material-ui/core" "^4.9.1" + "@material-ui/icons" "^4.9.1" + "@material-ui/lab" "4.0.0-alpha.45" + date-fns "^2.15.0" + history "^5.0.0" + moment "^2.27.0" + react "^16.13.1" + react-dom "^16.13.1" + react-lazylog "^4.5.2" + react-router "6.0.0-beta.0" + react-router-dom "6.0.0-beta.0" + react-use "^15.3.3" + "@rollup/plugin-commonjs@^13.0.0": version "13.0.0" resolved "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-13.0.0.tgz#8a1d684ba6848afe8b9e3d85649d4b2f6f7217ec" @@ -3689,19 +3680,6 @@ magic-string "^0.25.2" resolve "^1.11.0" -"@rollup/plugin-commonjs@^14.0.0": - version "14.0.0" - resolved "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-14.0.0.tgz#4285f9ec2db686a31129e5a2b415c94aa1f836f0" - integrity sha512-+PSmD9ePwTAeU106i9FRdc+Zb3XUWyW26mo5Atr2mk82hor8+nPwkztEjFo8/B1fJKfaQDg9aM2bzQkjhi7zOw== - dependencies: - "@rollup/pluginutils" "^3.0.8" - commondir "^1.0.1" - estree-walker "^1.0.1" - glob "^7.1.2" - is-reference "^1.1.2" - magic-string "^0.25.2" - resolve "^1.11.0" - "@rollup/plugin-json@^4.0.2": version "4.1.0" resolved "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-4.1.0.tgz#54e09867ae6963c593844d8bd7a9c718294496f3" @@ -3709,7 +3687,7 @@ dependencies: "@rollup/pluginutils" "^3.0.8" -"@rollup/plugin-node-resolve@^8.1.0", "@rollup/plugin-node-resolve@^8.4.0": +"@rollup/plugin-node-resolve@^8.1.0": version "8.4.0" resolved "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-8.4.0.tgz#261d79a680e9dc3d86761c14462f24126ba83575" integrity sha512-LFqKdRLn0ShtQyf6SBYO69bGE1upV6wUhBX0vFOUnLAyzx5cwp8svA0eHUnu8+YU57XOkrMtfG63QOpQx25pHQ== @@ -4535,7 +4513,7 @@ lodash "^4.17.15" redent "^3.0.0" -"@testing-library/react-hooks@^3.3.0", "@testing-library/react-hooks@^3.4.1": +"@testing-library/react-hooks@^3.3.0": version "3.4.1" resolved "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-3.4.1.tgz#1f8ccd21208086ec228d9743fe40b69d0efcd7e5" integrity sha512-LbzvE7oKsVzuW1cxA/aOeNgeVvmHWG2p/WSzalIGyWuqZT3jVcNDT5KPEwy36sUYWde0Qsh32xqIUFXukeywXg== @@ -4768,11 +4746,6 @@ dependencies: "@types/express" "*" -"@types/cookie@^0.3.3": - version "0.3.3" - resolved "https://registry.npmjs.org/@types/cookie/-/cookie-0.3.3.tgz#85bc74ba782fb7aa3a514d11767832b0e3bc6803" - integrity sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow== - "@types/cookie@^0.4.0": version "0.4.0" resolved "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.0.tgz#14f854c0f93d326e39da6e3b6f34f7d37513d108" @@ -5046,14 +5019,6 @@ jest-diff "^25.2.1" pretty-format "^25.2.1" -"@types/jest@^25.2.2": - version "25.2.3" - resolved "https://registry.npmjs.org/@types/jest/-/jest-25.2.3.tgz#33d27e4c4716caae4eced355097a47ad363fdcaf" - integrity sha512-JXc1nK/tXHiDhV55dvfzqtmP4S3sy3T3ouV2tkViZgxY/zeUkcpQcQPGRlgF4KmWzWW5oiWYSZwtCB+2RsE4Fw== - dependencies: - jest-diff "^25.2.1" - pretty-format "^25.2.1" - "@types/jquery@^3.3.34": version "3.5.1" resolved "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.1.tgz#cebb057acf5071c40e439f30e840c57a30d406c3" @@ -5775,18 +5740,6 @@ regexpp "^3.0.0" tsutils "^3.17.1" -"@typescript-eslint/eslint-plugin@^3.8.0": - version "3.10.1" - resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-3.10.1.tgz#7e061338a1383f59edc204c605899f93dc2e2c8f" - integrity sha512-PQg0emRtzZFWq6PxBcdxRH3QIQiyFO3WCVpRL3fgj5oQS3CDs3AeAKfv4DxNhzn8ITdNJGJ4D3Qw8eAJf3lXeQ== - dependencies: - "@typescript-eslint/experimental-utils" "3.10.1" - debug "^4.1.1" - functional-red-black-tree "^1.0.1" - regexpp "^3.0.0" - semver "^7.3.2" - tsutils "^3.17.1" - "@typescript-eslint/experimental-utils@2.24.0", "@typescript-eslint/experimental-utils@^2.5.0": version "2.24.0" resolved "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-2.24.0.tgz#a5cb2ed89fedf8b59638dc83484eb0c8c35e1143" @@ -5796,17 +5749,6 @@ "@typescript-eslint/typescript-estree" "2.24.0" eslint-scope "^5.0.0" -"@typescript-eslint/experimental-utils@3.10.1": - version "3.10.1" - resolved "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-3.10.1.tgz#e179ffc81a80ebcae2ea04e0332f8b251345a686" - integrity sha512-DewqIgscDzmAfd5nOGe4zm6Bl7PKtMG2Ad0KG8CUZAHlXfAKTF9Ol5PXhiMh39yRL2ChRH1cuuUGOcVyyrhQIw== - dependencies: - "@types/json-schema" "^7.0.3" - "@typescript-eslint/types" "3.10.1" - "@typescript-eslint/typescript-estree" "3.10.1" - eslint-scope "^5.0.0" - eslint-utils "^2.0.0" - "@typescript-eslint/parser@^2.14.0": version "2.24.0" resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-2.24.0.tgz#2cf0eae6e6dd44d162486ad949c126b887f11eb8" @@ -5817,22 +5759,6 @@ "@typescript-eslint/typescript-estree" "2.24.0" eslint-visitor-keys "^1.1.0" -"@typescript-eslint/parser@^3.8.0": - version "3.10.1" - resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-3.10.1.tgz#1883858e83e8b442627e1ac6f408925211155467" - integrity sha512-Ug1RcWcrJP02hmtaXVS3axPPTTPnZjupqhgj+NnZ6BCkwSImWk/283347+x9wN+lqOdK9Eo3vsyiyDHgsmiEJw== - dependencies: - "@types/eslint-visitor-keys" "^1.0.0" - "@typescript-eslint/experimental-utils" "3.10.1" - "@typescript-eslint/types" "3.10.1" - "@typescript-eslint/typescript-estree" "3.10.1" - eslint-visitor-keys "^1.1.0" - -"@typescript-eslint/types@3.10.1": - version "3.10.1" - resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-3.10.1.tgz#1d7463fa7c32d8a23ab508a803ca2fe26e758727" - integrity sha512-+3+FCUJIahE9q0lDi1WleYzjCwJs5hIsbugIgnbB+dSCYUxl8L6PwmsyOPFZde2hc1DlTo/xnkOgiTLSyAbHiQ== - "@typescript-eslint/typescript-estree@2.24.0": version "2.24.0" resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-2.24.0.tgz#38bbc8bb479790d2f324797ffbcdb346d897c62a" @@ -5846,27 +5772,6 @@ semver "^6.3.0" tsutils "^3.17.1" -"@typescript-eslint/typescript-estree@3.10.1": - version "3.10.1" - resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-3.10.1.tgz#fd0061cc38add4fad45136d654408569f365b853" - integrity sha512-QbcXOuq6WYvnB3XPsZpIwztBoquEYLXh2MtwVU+kO8jgYCiv4G5xrSP/1wg4tkvrEE+esZVquIPX/dxPlePk1w== - dependencies: - "@typescript-eslint/types" "3.10.1" - "@typescript-eslint/visitor-keys" "3.10.1" - debug "^4.1.1" - glob "^7.1.6" - is-glob "^4.0.1" - lodash "^4.17.15" - semver "^7.3.2" - tsutils "^3.17.1" - -"@typescript-eslint/visitor-keys@3.10.1": - version "3.10.1" - resolved "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-3.10.1.tgz#cd4274773e3eb63b2e870ac602274487ecd1e931" - integrity sha512-9JgC82AaQeglebjZMgYR5wgmfUdUc+EitGUUMW8u2nDckaeimzW+VsoLV6FoimPv2id3VQzfjwBxEMVz08ameQ== - dependencies: - eslint-visitor-keys "^1.1.0" - "@webassemblyjs/ast@1.9.0": version "1.9.0" resolved "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz#bd850604b4042459a5a41cd7d338cbed695ed964" @@ -6144,11 +6049,6 @@ acorn@^7.1.1, acorn@^7.2.0: resolved "https://registry.npmjs.org/acorn/-/acorn-7.3.1.tgz#85010754db53c3fbaf3b9ea3e083aa5c5d147ffd" integrity sha512-tLc0wSnatxAQHVHUapaHdz72pi9KUyHjq5KyHjGg9Y8Ifdc79pTh2XvI6I1/chZbnM7QtNKzh66ooDogPZSleA== -acorn@^7.4.0: - version "7.4.0" - resolved "https://registry.npmjs.org/acorn/-/acorn-7.4.0.tgz#e1ad486e6c54501634c6c397c5c121daa383607c" - integrity sha512-+G7P8jJmCHr+S+cLfQxygbWhXy+8YTVGzAkpEbcLo2mLoL7tij/VG41QSHACSf5QgYRhMZYHuNc6drJaO0Da+w== - address@1.1.2, address@^1.0.1: version "1.1.2" resolved "https://registry.npmjs.org/address/-/address-1.1.2.tgz#bf1116c9c758c51b7a933d296b72c221ed9428b6" @@ -9341,7 +9241,7 @@ cyclist@^1.0.1: resolved "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk= -cypress@*, cypress@^4.11.0, cypress@^4.2.0: +cypress@*, cypress@^4.2.0: version "4.12.1" resolved "https://registry.npmjs.org/cypress/-/cypress-4.12.1.tgz#0ead1b9f4c0917d69d8b57f996b6e01fe693b6ec" integrity sha512-9SGIPEmqU8vuRA6xst2CMTYd9sCFCxKSzrHt0wr+w2iAQMCIIsXsQ5Gplns1sT6LDbZcmLv6uehabAOl3fhc9Q== @@ -10340,23 +10240,6 @@ es-abstract@^1.17.0, es-abstract@^1.17.0-next.0, es-abstract@^1.17.0-next.1, es- string.prototype.trimleft "^2.1.1" string.prototype.trimright "^2.1.1" -es-abstract@^1.17.5: - version "1.17.6" - resolved "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.6.tgz#9142071707857b2cacc7b89ecb670316c3e2d52a" - integrity sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw== - dependencies: - es-to-primitive "^1.2.1" - function-bind "^1.1.1" - has "^1.0.3" - has-symbols "^1.0.1" - is-callable "^1.2.0" - is-regex "^1.1.0" - object-inspect "^1.7.0" - object-keys "^1.1.1" - object.assign "^4.1.0" - string.prototype.trimend "^1.0.1" - string.prototype.trimstart "^1.0.1" - es-array-method-boxes-properly@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz#873f3e84418de4ee19c5be752990b2e44718d09e" @@ -10442,15 +10325,10 @@ es6-weak-map@^2.0.2: es6-iterator "^2.0.3" es6-symbol "^3.1.1" -esbuild@0.6.3: - version "0.6.3" - resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.6.3.tgz#a957e22f2503c745793514388110f9b55b3a6f83" - integrity sha512-4lHgz/EvGLRQnDYzzrvW+eilaPHim5pCLz4mP0k0QIalD/n8Ji2NwQwoIa1uX+yKkbn9R/FAZvaEbodjx55rDg== - -esbuild@^0.6.18: - version "0.6.34" - resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.6.34.tgz#76565a60e006f45d5f273b6e59e61ed0816551f5" - integrity sha512-InRdL/Q96pUucPqovJzvuLhquZr6jOn81FDVwFjCKz1rYKIm9OdOC+7Fs4vr6x48vKBl5LzKgtjU39BUpO636A== +esbuild@^0.7.7: + version "0.7.7" + resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.7.7.tgz#fd86332d1c0a047231bd6da930666028c40c14bf" + integrity sha512-1Ub4BBsWwPdxkwjyuXdKrgMIZROZ82ULIRFclOzXXVrqKOv9rMDoShRf23WEbPfeEA94z/BScKyUOyooyQ3XnQ== escalade@^3.0.1: version "3.0.2" @@ -10523,7 +10401,7 @@ eslint-module-utils@^2.1.1, eslint-module-utils@^2.6.0: debug "^2.6.9" pkg-dir "^2.0.0" -eslint-plugin-cypress@^2.10.3, eslint-plugin-cypress@^2.11.1: +eslint-plugin-cypress@^2.10.3: version "2.11.1" resolved "https://registry.npmjs.org/eslint-plugin-cypress/-/eslint-plugin-cypress-2.11.1.tgz#a945e2774b88211e2c706a059d431e262b5c2862" integrity sha512-MxMYoReSO5+IZMGgpBZHHSx64zYPSPTpXDwsgW7ChlJTF/sA+obqRbHplxD6sBStE+g4Mi0LCLkG4t9liu//mQ== @@ -10626,23 +10504,6 @@ eslint-plugin-react@^7.12.4: string.prototype.matchall "^4.0.2" xregexp "^4.3.0" -eslint-plugin-react@^7.20.5: - version "7.21.2" - resolved "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.21.2.tgz#3bd5d2c4c36d5a0428d0d6dda301ac9a84d681b2" - integrity sha512-j3XKvrK3rpBzveKFbgAeGsWb9uz6iUOrR0jixRfjwdFeGSRsXvVTFtHDQYCjsd1/6Z/xvb8Vy3LiI5Reo7fDrg== - dependencies: - array-includes "^3.1.1" - array.prototype.flatmap "^1.2.3" - doctrine "^2.1.0" - has "^1.0.3" - jsx-ast-utils "^2.4.1" - object.entries "^1.1.2" - object.fromentries "^2.0.2" - object.values "^1.1.1" - prop-types "^15.7.2" - resolve "^1.17.0" - string.prototype.matchall "^4.0.2" - eslint-scope@^4.0.3: version "4.0.3" resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.3.tgz#ca03833310f6889a3264781aa82e63eb9cfe7848" @@ -10659,14 +10520,6 @@ eslint-scope@^5.0.0, eslint-scope@^5.1.0: esrecurse "^4.1.0" estraverse "^4.1.1" -eslint-scope@^5.1.1: - version "5.1.1" - resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" - integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== - dependencies: - esrecurse "^4.3.0" - estraverse "^4.1.1" - eslint-utils@^1.4.3: version "1.4.3" resolved "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.4.3.tgz#74fec7c54d0776b6f67e0251040b5806564e981f" @@ -10681,23 +10534,11 @@ eslint-utils@^2.0.0: dependencies: eslint-visitor-keys "^1.1.0" -eslint-utils@^2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz#d2de5e03424e707dc10c74068ddedae708741b27" - integrity sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg== - dependencies: - eslint-visitor-keys "^1.1.0" - eslint-visitor-keys@^1.1.0, eslint-visitor-keys@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.2.0.tgz#74415ac884874495f78ec2a97349525344c981fa" integrity sha512-WFb4ihckKil6hu3Dp798xdzSfddwKKU3+nGniKF6HfeW6OLd2OUDEPP7TcHtB5+QXOKg2s6B2DaMPE1Nn/kxKQ== -eslint-visitor-keys@^1.3.0: - version "1.3.0" - resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz#30ebd1ef7c2fdff01c3a4f151044af25fab0523e" - integrity sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ== - eslint@^7.1.0: version "7.4.0" resolved "https://registry.npmjs.org/eslint/-/eslint-7.4.0.tgz#4e35a2697e6c1972f9d6ef2b690ad319f80f206f" @@ -10740,49 +10581,6 @@ eslint@^7.1.0: text-table "^0.2.0" v8-compile-cache "^2.0.3" -eslint@^7.6.0: - version "7.10.0" - resolved "https://registry.npmjs.org/eslint/-/eslint-7.10.0.tgz#494edb3e4750fb791133ca379e786a8f648c72b9" - integrity sha512-BDVffmqWl7JJXqCjAK6lWtcQThZB/aP1HXSH1JKwGwv0LQEdvpR7qzNrUT487RM39B5goWuboFad5ovMBmD8yA== - dependencies: - "@babel/code-frame" "^7.0.0" - "@eslint/eslintrc" "^0.1.3" - ajv "^6.10.0" - chalk "^4.0.0" - cross-spawn "^7.0.2" - debug "^4.0.1" - doctrine "^3.0.0" - enquirer "^2.3.5" - eslint-scope "^5.1.1" - eslint-utils "^2.1.0" - eslint-visitor-keys "^1.3.0" - espree "^7.3.0" - esquery "^1.2.0" - esutils "^2.0.2" - file-entry-cache "^5.0.1" - functional-red-black-tree "^1.0.1" - glob-parent "^5.0.0" - globals "^12.1.0" - ignore "^4.0.6" - import-fresh "^3.0.0" - imurmurhash "^0.1.4" - is-glob "^4.0.0" - js-yaml "^3.13.1" - json-stable-stringify-without-jsonify "^1.0.1" - levn "^0.4.1" - lodash "^4.17.19" - minimatch "^3.0.4" - natural-compare "^1.4.0" - optionator "^0.9.1" - progress "^2.0.0" - regexpp "^3.1.0" - semver "^7.2.1" - strip-ansi "^6.0.0" - strip-json-comments "^3.1.0" - table "^5.2.3" - text-table "^0.2.0" - v8-compile-cache "^2.0.3" - esm@^3.2.25: version "3.2.25" resolved "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz#342c18c29d56157688ba5ce31f8431fbb795cc10" @@ -10797,15 +10595,6 @@ espree@^7.1.0: acorn-jsx "^5.2.0" eslint-visitor-keys "^1.2.0" -espree@^7.3.0: - version "7.3.0" - resolved "https://registry.npmjs.org/espree/-/espree-7.3.0.tgz#dc30437cf67947cf576121ebd780f15eeac72348" - integrity sha512-dksIWsvKCixn1yrEXO8UosNSxaDoSYpq9reEjZSbHLpT5hpaCAKTLBwq0RHtLrIr+c0ByiYzWT8KTMRzoRCNlw== - dependencies: - acorn "^7.4.0" - acorn-jsx "^5.2.0" - eslint-visitor-keys "^1.3.0" - esprima@^4.0.0, esprima@^4.0.1, esprima@~4.0.0: version "4.0.1" resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" @@ -10825,13 +10614,6 @@ esrecurse@^4.1.0: dependencies: estraverse "^4.1.0" -esrecurse@^4.3.0: - version "4.3.0" - resolved "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" - integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== - dependencies: - estraverse "^5.2.0" - estraverse@^4.1.0, estraverse@^4.1.1, estraverse@^4.2.0: version "4.3.0" resolved "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" @@ -10842,11 +10624,6 @@ estraverse@^5.1.0: resolved "https://registry.npmjs.org/estraverse/-/estraverse-5.1.0.tgz#374309d39fd935ae500e7b92e8a6b4c720e59642" integrity sha512-FyohXK+R0vE+y1nHLoBM7ZTyqRpqAlhdZHCWIWEviFLiGB8b04H6bQs8G+XTthacvT8VuwvteiP7RJSxMs8UEw== -estraverse@^5.2.0: - version "5.2.0" - resolved "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz#307df42547e6cc7324d3cf03c155d5cdb8c53880" - integrity sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ== - estree-walker@^0.6.0, estree-walker@^0.6.1: version "0.6.1" resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz#53049143f40c6eb918b23671d1fe3219f3a1b362" @@ -12464,7 +12241,7 @@ graphql-upload@^8.0.2: http-errors "^1.7.3" object-path "^0.11.4" -graphql@15.3.0, graphql@^15.0.0, graphql@^15.3.0: +graphql@15.3.0, graphql@^15.3.0: version "15.3.0" resolved "https://registry.npmjs.org/graphql/-/graphql-15.3.0.tgz#3ad2b0caab0d110e3be4a5a9b2aa281e362b5278" integrity sha512-GTCJtzJmkFLWRfFJuoo9RWWa/FfamUHgiFosxi/X1Ani4AVWbeyBenZTNX6dM+7WSbbFfTo/25eh0LLkwHMw2w== @@ -12640,7 +12417,7 @@ he@^1.1.0, he@^1.2.0: resolved "https://registry.npmjs.org/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== -headers-utils@^1.1.9, headers-utils@^1.2.0: +headers-utils@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/headers-utils/-/headers-utils-1.2.0.tgz#5e10d1bc9d2bccf789547afca5b991a3167241e8" integrity sha512-4/BMXcWrJErw7JpM87gF8MNEXcIMLzepYZjNRv/P9ctgupl2Ywa3u1PgHtNhSRq84bHH9Ndlkdy7bSi+bZ9I9A== @@ -13438,11 +13215,6 @@ is-callable@^1.1.4, is-callable@^1.1.5: resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz#f7e46b596890456db74e7f6e976cb3273d06faab" integrity sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q== -is-callable@^1.2.0: - version "1.2.2" - resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.2.2.tgz#c7c6715cd22d4ddb48d3e19970223aceabb080d9" - integrity sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA== - is-ci@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" @@ -13734,7 +13506,7 @@ is-reference@^1.1.2: dependencies: "@types/estree" "0.0.39" -is-regex@^1.0.4, is-regex@^1.0.5, is-regex@^1.1.0, is-regex@^1.1.1: +is-regex@^1.0.4, is-regex@^1.0.5, is-regex@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/is-regex/-/is-regex-1.1.1.tgz#c6f98aacc546f6cec5468a07b7b153ab564a57b9" integrity sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg== @@ -14442,11 +14214,6 @@ jose@^2.0.2: dependencies: "@panva/asn1.js" "^1.0.0" -joycon@^2.2.5: - version "2.2.5" - resolved "https://registry.npmjs.org/joycon/-/joycon-2.2.5.tgz#8d4cf4cbb2544d7b7583c216fcdfec19f6be1615" - integrity sha512-YqvUxoOcVPnCp0VU1/56f+iKSdvIRJYPznH22BdXV3xMk75SFXhWeJkZ8C9XxUWt1b5x2X1SxuFygW1U0FmkEQ== - js-cookie@^2.2.1: version "2.2.1" resolved "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz#69e106dc5d5806894562902aa5baec3744e9b2b8" @@ -14874,14 +14641,6 @@ jsx-ast-utils@^2.2.1, jsx-ast-utils@^2.2.3: array-includes "^3.0.3" object.assign "^4.1.0" -jsx-ast-utils@^2.4.1: - version "2.4.1" - resolved "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-2.4.1.tgz#1114a4c1209481db06c690c2b4f488cc665f657e" - integrity sha512-z1xSldJ6imESSzOjd3NNkieVJKRlKYSOtMG8SFyCj2FIrvSaSuli/WjpBkEzCBoR9bYYYFgqJw61Xhu7Lcgk+w== - dependencies: - array-includes "^3.1.1" - object.assign "^4.1.0" - jwa@^1.4.1: version "1.4.1" resolved "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" @@ -16284,22 +16043,6 @@ ms@^2.0.0, ms@^2.1.1: resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -msw@^0.19.5: - version "0.19.5" - resolved "https://registry.npmjs.org/msw/-/msw-0.19.5.tgz#7d2a1a852ccf1644d3db6735d69fff6777aac33f" - integrity sha512-J5eQ++gDVZoHPC8gVXtWcakLjgmPipvFj/sEnlRV/WViXuiq2CamSqO3Wbh6H8bAmj+k2vUWCfcVT1HjMdKB2Q== - dependencies: - "@open-draft/until" "^1.0.0" - "@types/cookie" "^0.3.3" - chalk "^4.0.0" - cookie "^0.4.1" - graphql "^15.0.0" - headers-utils "^1.1.9" - node-match-path "^0.4.2" - node-request-interceptor "^0.2.5" - statuses "^2.0.0" - yargs "^15.3.1" - msw@^0.20.5: version "0.20.5" resolved "https://registry.npmjs.org/msw/-/msw-0.20.5.tgz#b6141080c0d8b17c451d9ca36c28cc47b4ac487a" @@ -16559,7 +16302,7 @@ node-libs-browser@^2.2.1: util "^0.11.0" vm-browserify "^1.0.1" -node-match-path@^0.4.2, node-match-path@^0.4.4: +node-match-path@^0.4.4: version "0.4.4" resolved "https://registry.npmjs.org/node-match-path/-/node-match-path-0.4.4.tgz#516a10926093c0cc6f237d020685b593b19baebb" integrity sha512-pBq9gp7TG0r0VXuy/oeZmQsjBSnYQo7G886Ly/B3azRwZuEtHCY155dzmfoKWcDPGgyfIGD8WKVC7h3+6y7yTg== @@ -16618,14 +16361,6 @@ node-releases@^1.1.52, node-releases@^1.1.58: resolved "https://registry.npmjs.org/node-releases/-/node-releases-1.1.60.tgz#6948bdfce8286f0b5d0e5a88e8384e954dfe7084" integrity sha512-gsO4vjEdQaTusZAEebUWp2a5d7dF5DYoIpDG7WySnk7BuZDW+GPpHXoXXuYawRBr/9t5q54tirPz79kFIWg4dA== -node-request-interceptor@^0.2.5: - version "0.2.6" - resolved "https://registry.npmjs.org/node-request-interceptor/-/node-request-interceptor-0.2.6.tgz#541278d7033bb6a8befb5dd793f83428cf6446a2" - integrity sha512-aJW1tPSM7nzuZFRe+C/KSz22GJO3CVFMxHHmMGX8Z+tjP7TCIVbzeckLFVfJG68BdVgrdOOP7Ejc57ag820eyA== - dependencies: - debug "^4.1.1" - headers-utils "^1.2.0" - node-request-interceptor@^0.3.5: version "0.3.5" resolved "https://registry.npmjs.org/node-request-interceptor/-/node-request-interceptor-0.3.5.tgz#4b26159617829c9a70643012c0fdc3ae4c78ae43" @@ -16936,15 +16671,6 @@ object.entries@^1.1.0, object.entries@^1.1.1: function-bind "^1.1.1" has "^1.0.3" -object.entries@^1.1.2: - version "1.1.2" - resolved "https://registry.npmjs.org/object.entries/-/object.entries-1.1.2.tgz#bc73f00acb6b6bb16c203434b10f9a7e797d3add" - integrity sha512-BQdB9qKmb/HyNdMNWVr7O3+z5MUIx3aiegEIJqjMBbBf0YT9RRxTJSim4mzFqtyr7PDAHigq0N9dO0m0tRakQA== - dependencies: - define-properties "^1.1.3" - es-abstract "^1.17.5" - has "^1.0.3" - "object.fromentries@^2.0.0 || ^1.0.0", object.fromentries@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.2.tgz#4a09c9b9bb3843dd0f89acdb517a794d4f355ac9" @@ -20250,13 +19976,6 @@ rollup-plugin-dts@1.4.11: optionalDependencies: "@babel/code-frame" "^7.10.4" -rollup-plugin-dts@^1.4.10: - version "1.4.13" - resolved "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-1.4.13.tgz#4f086e84f4fdcc1f49160799ebc66f6b09db292b" - integrity sha512-7mxoQ6PcmCkBE5ZhrjGDL4k42XLy8BkSqpiRi1MipwiGs+7lwi4mQkp2afX+OzzLjJp/TGM8llfe8uayIUhPEw== - optionalDependencies: - "@babel/code-frame" "^7.10.4" - rollup-plugin-esbuild@^2.0.0: version "2.3.0" resolved "https://registry.npmjs.org/rollup-plugin-esbuild/-/rollup-plugin-esbuild-2.3.0.tgz#7c107d4508af80d30966a148b306cb7d5b36b258" @@ -20264,15 +19983,6 @@ rollup-plugin-esbuild@^2.0.0: dependencies: "@rollup/pluginutils" "^3.1.0" -rollup-plugin-esbuild@^2.4.2: - version "2.5.0" - resolved "https://registry.npmjs.org/rollup-plugin-esbuild/-/rollup-plugin-esbuild-2.5.0.tgz#5748352d0b688743ac64fc85fa86939e00098524" - integrity sha512-Tfi8Zynux67j4h4xEp/OvP2EDrKk/nM9Eghe1sT2+Nw4s6ei9vsqO1ajU+Y8EFfizMk3KEfYCicBKX6QLTW33w== - dependencies: - "@rollup/pluginutils" "^3.1.0" - joycon "^2.2.5" - strip-json-comments "^3.1.1" - rollup-plugin-image-files@^1.4.2: version "1.4.2" resolved "https://registry.npmjs.org/rollup-plugin-image-files/-/rollup-plugin-image-files-1.4.2.tgz#0a329723bace95168a9a6efdacb51370c14fbfe5" @@ -20281,7 +19991,7 @@ rollup-plugin-image-files@^1.4.2: rollup "0.64.1" rollup-pluginutils "2.4.1" -rollup-plugin-peer-deps-external@^2.2.2, rollup-plugin-peer-deps-external@^2.2.3: +rollup-plugin-peer-deps-external@^2.2.2: version "2.2.3" resolved "https://registry.npmjs.org/rollup-plugin-peer-deps-external/-/rollup-plugin-peer-deps-external-2.2.3.tgz#059a8aec1eefb48a475e9fcedc3b9e3deb521213" integrity sha512-W6IePXTExGXVDAlfZbNUUrx3GxUOZP248u5n4a4ID1XZMrbQ+uGeNiEfapvdzwx0qZi5DNH/hDLiPUP+pzFIxg== @@ -20355,13 +20065,6 @@ rollup@^0.63.4: "@types/estree" "0.0.39" "@types/node" "*" -rollup@^2.23.1: - version "2.28.2" - resolved "https://registry.npmjs.org/rollup/-/rollup-2.28.2.tgz#599ec4978144a82d8a8ec3d37670a8440cb04e4b" - integrity sha512-8txbsFBFLmm9Xdt4ByTOGa9Muonmc8MfNjnGAR8U8scJlF1ZW7AgNZa7aqBXaKtlvnYP/ab++fQIq9dB9NWUbg== - optionalDependencies: - fsevents "~2.1.2" - rsvp@^4.8.4: version "4.8.5" resolved "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734" @@ -21469,14 +21172,6 @@ string.prototype.padstart@^3.0.0: define-properties "^1.1.3" es-abstract "^1.17.0-next.1" -string.prototype.trimend@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz#85812a6b847ac002270f5808146064c995fb6913" - integrity sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g== - dependencies: - define-properties "^1.1.3" - es-abstract "^1.17.5" - string.prototype.trimleft@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.1.tgz#9bdb8ac6abd6d602b17a4ed321870d2f8dcefc74" @@ -21493,14 +21188,6 @@ string.prototype.trimright@^2.1.1: define-properties "^1.1.3" function-bind "^1.1.1" -string.prototype.trimstart@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz#14af6d9f34b053f7cfc89b72f8f2ee14b9039a54" - integrity sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw== - dependencies: - define-properties "^1.1.3" - es-abstract "^1.17.5" - string_decoder@^1.0.0, string_decoder@^1.1.1: version "1.3.0" resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" @@ -21603,11 +21290,6 @@ strip-json-comments@^3.1.0: resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.0.tgz#7638d31422129ecf4457440009fba03f9f9ac180" integrity sha512-e6/d0eBu7gHtdCqFt0xJr642LdToM5/cN4Qb9DbHjVx1CP5RyeM+zH7pbecEmDv/lBqb0QH+6Uqq75rxFPkM0w== -strip-json-comments@^3.1.1: - version "3.1.1" - resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" - integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== - strip-json-comments@~2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" From 2e8d01a10c759b1031f89d26d83539f5017b5f96 Mon Sep 17 00:00:00 2001 From: blam Date: Mon, 28 Sep 2020 16:05:13 +0200 Subject: [PATCH 85/94] Try to add msbuild so we can build from source if the binary download fails for nodev14 --- .github/workflows/e2e-win.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/e2e-win.yml b/.github/workflows/e2e-win.yml index e8942e4451..c48d779962 100644 --- a/.github/workflows/e2e-win.yml +++ b/.github/workflows/e2e-win.yml @@ -31,6 +31,8 @@ jobs: uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} + - name: Add msbuild to PATH + uses: microsoft/setup-msbuild@v1.0.1 - name: yarn install run: yarn install --frozen-lockfile From f1e96f2f9c5a5e9cab068a275f97f621d3f0f563 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 28 Sep 2020 17:52:28 +0200 Subject: [PATCH 86/94] github/workflows: include node version in modules cache key --- .github/workflows/chromatic-storybook-test.yml | 2 +- .github/workflows/ci.yml | 2 +- .github/workflows/e2e.yml | 2 +- .github/workflows/master.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/chromatic-storybook-test.yml b/.github/workflows/chromatic-storybook-test.yml index 0bcaf400d0..bb14e1c38b 100644 --- a/.github/workflows/chromatic-storybook-test.yml +++ b/.github/workflows/chromatic-storybook-test.yml @@ -26,7 +26,7 @@ jobs: uses: actions/cache@v2 with: path: '**/node_modules' - key: ${{ runner.os }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} + key: ${{ runner.os }}-v${{ matrix.node-version }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} - name: find location of global yarn cache id: yarn-cache if: steps.cache-modules.outputs.cache-hit != 'true' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 845cec0b0d..65e092676d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: path: '**/node_modules' # We use both yarn.lock and package.json as cache keys to ensure that # changes to local monorepo packages bust the cache. - key: ${{ runner.os }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} + key: ${{ runner.os }}-v${{ matrix.node-version }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} # If we get a cache hit for node_modules, there's no need to bring in the global # yarn cache or run yarn install, as all dependencies will be installed already. diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 33fd506f83..e280bf59a3 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -47,7 +47,7 @@ jobs: uses: actions/cache@v2 with: path: '**/node_modules' - key: ${{ runner.os }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} + key: ${{ runner.os }}-v${{ matrix.node-version }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} - name: find location of global yarn cache id: yarn-cache if: steps.cache-modules.outputs.cache-hit != 'true' diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 3aa44cbf59..f5029bf836 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -30,7 +30,7 @@ jobs: uses: actions/cache@v2 with: path: '**/node_modules' - key: ${{ runner.os }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} + key: ${{ runner.os }}-v${{ matrix.node-version }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} - name: find location of global yarn cache id: yarn-cache if: steps.cache-modules.outputs.cache-hit != 'true' From 5a2b4b196659591a5fa1468e77acb5e2dba7c883 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 28 Sep 2020 13:02:51 +0200 Subject: [PATCH 87/94] feat(catalog-backend): simplify the read function in processors --- .../src/ingestion/LocationReaders.ts | 31 +++- .../ApiDefinitionAtLocationProcessor.test.ts | 51 +------ .../ApiDefinitionAtLocationProcessor.ts | 15 +- .../processors/PlaceholderProcessor.test.ts | 143 ++++++------------ .../processors/PlaceholderProcessor.ts | 7 +- .../src/ingestion/processors/types.ts | 6 +- 6 files changed, 81 insertions(+), 172 deletions(-) diff --git a/plugins/catalog-backend/src/ingestion/LocationReaders.ts b/plugins/catalog-backend/src/ingestion/LocationReaders.ts index 14b7f727e4..dd3f9eacda 100644 --- a/plugins/catalog-backend/src/ingestion/LocationReaders.ts +++ b/plugins/catalog-backend/src/ingestion/LocationReaders.ts @@ -258,10 +258,9 @@ export class LocationReaders implements LocationReader { } } - private async readLocation( - location: LocationSpec, - ): Promise { - let locationResult: LocationProcessorResult | undefined; + private async readLocation(location: LocationSpec): Promise { + let data: Buffer | undefined = undefined; + let error: Error | undefined = undefined; await this.handleLocation( { @@ -269,13 +268,29 @@ export class LocationReaders implements LocationReader { location, optional: false, }, - r => (locationResult = r), + output => { + if (output.type === 'error' && !error) { + error = output.error; + } else if (output.type === 'data') { + if (data) { + if (!error) { + error = new Error( + 'More than one piece of data loaded unexpectedly', + ); + } + } else { + data = output.data; + } + } + }, ); - if (!locationResult) { - throw new Error('No location loaded'); + if (error) { + throw error; + } else if (!data) { + throw new Error('No data loaded'); } - return locationResult; + return data; } } diff --git a/plugins/catalog-backend/src/ingestion/processors/ApiDefinitionAtLocationProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/ApiDefinitionAtLocationProcessor.test.ts index ef6d348ad5..6ae6562339 100644 --- a/plugins/catalog-backend/src/ingestion/processors/ApiDefinitionAtLocationProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/ApiDefinitionAtLocationProcessor.test.ts @@ -14,9 +14,7 @@ import { ApiEntity, Entity, LocationSpec } from '@backstage/catalog-model'; * See the License for the specific language governing permissions and * limitations under the License. */ - import { ApiDefinitionAtLocationProcessor } from './ApiDefinitionAtLocationProcessor'; -import { LocationProcessorResult } from './types'; describe('ApiDefinitionAtLocationProcessor', () => { let processor: ApiDefinitionAtLocationProcessor; @@ -45,11 +43,7 @@ describe('ApiDefinitionAtLocationProcessor', () => { }); it('should skip entities without annotation', async () => { - const read = jest.fn( - (): Promise => { - throw new Error(); - }, - ); + const read = jest.fn().mockRejectedValue(new Error('boo')); const generated = (await processor.processEntity( entity, @@ -67,14 +61,7 @@ describe('ApiDefinitionAtLocationProcessor', () => { 'url:http://example.com/openapi.yaml', }; - const read = jest.fn( - (l: LocationSpec): Promise => - Promise.resolve({ - type: 'data', - data: Buffer.from('Hello'), - location: l, - }), - ); + const read = jest.fn().mockResolvedValue(Buffer.from('Hello')); const generated = (await processor.processEntity( entity, @@ -95,38 +82,12 @@ describe('ApiDefinitionAtLocationProcessor', () => { 'backstage.io/definition-at-location': 'missing', }; - const read = jest.fn( - (l: LocationSpec): Promise => - Promise.resolve({ - type: 'error', - error: new Error('Failed to load location'), - location: l, - }), - ); + const read = jest + .fn() + .mockRejectedValue(new Error('Failed to load location')); await expect( processor.processEntity(entity, location, () => {}, read), - ).rejects.toThrow('Failed to read location: Failed to load location'); - }); - - it('should throw errors if location read has wrong type', async () => { - entity.metadata.annotations = { - 'backstage.io/definition-at-location': 'wrong', - }; - - const read = jest.fn( - (l: LocationSpec): Promise => - Promise.resolve({ - type: 'location', - optional: false, - location: l, - }), - ); - - await expect( - processor.processEntity(entity, location, () => {}, read), - ).rejects.toThrow( - `Only supports location processor results of type 'data', but got 'location'`, - ); + ).rejects.toThrow('Failed to load location'); }); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/ApiDefinitionAtLocationProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/ApiDefinitionAtLocationProcessor.ts index c359e78292..e64d112e9c 100644 --- a/plugins/catalog-backend/src/ingestion/processors/ApiDefinitionAtLocationProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/ApiDefinitionAtLocationProcessor.ts @@ -41,19 +41,8 @@ export class ApiDefinitionAtLocationProcessor implements LocationProcessor { const reference = entity.metadata.annotations[DEFINITION_AT_LOCATION_ANNOTATION]; const { type, target } = extractReference(reference); - const result = await read({ type, target }); - - if (result.type === 'error') { - throw new Error(`Failed to read location: ${result.error.message}`); - } - - if (result.type !== 'data') { - throw new Error( - `Only supports location processor results of type 'data', but got '${result.type}'`, - ); - } - - const definition = result.data.toString(); + const data = await read({ type, target }); + const definition = data.toString(); const apiEntity = entity as ApiEntity; apiEntity.spec.definition = definition; diff --git a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts index 7e6fa528f1..d00e4b7b6f 100644 --- a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts @@ -22,9 +22,16 @@ import { ResolverParams, yamlPlaceholderResolver, } from './PlaceholderProcessor'; -import { LocationProcessorRead } from './types'; +import { LocationProcessorEmit, LocationProcessorRead } from './types'; describe('PlaceholderProcessor', () => { + const emit: LocationProcessorEmit = jest.fn(); + const read: jest.MockedFunction = jest.fn(); + + beforeEach(() => { + jest.resetAllMocks(); + }); + it('returns placeholder-free data unchanged', async () => { const input: Entity = { apiVersion: 'a', @@ -35,18 +42,11 @@ describe('PlaceholderProcessor', () => { foo: async () => 'replaced', }); await expect( - processor.processEntity( - input, - { type: 't', target: 'l' }, - jest.fn(), - jest.fn(), - ), + processor.processEntity(input, { type: 't', target: 'l' }, emit, read), ).resolves.toBe(input); }); it('replaces placeholders deep in the data', async () => { - const emit = jest.fn(); - const read = jest.fn(); const upperResolver: PlaceholderResolver = jest.fn(async ({ value }) => value!.toString().toUpperCase(), ); @@ -84,8 +84,6 @@ describe('PlaceholderProcessor', () => { }); it('rejects multiple placeholders', async () => { - const emit = jest.fn(); - const read: LocationProcessorRead = jest.fn(); const processor = new PlaceholderProcessor({ foo: jest.fn(), bar: jest.fn(), @@ -111,8 +109,6 @@ describe('PlaceholderProcessor', () => { }); it('rejects unknown placeholders', async () => { - const emit = jest.fn(); - const read: LocationProcessorRead = jest.fn(); const processor = new PlaceholderProcessor({ bar: jest.fn(), }); @@ -135,14 +131,7 @@ describe('PlaceholderProcessor', () => { }); it('has builtin text support', async () => { - const emit = jest.fn(); - const read: LocationProcessorRead = jest - .fn() - .mockImplementation(async location => ({ - type: 'data', - location, - data: Buffer.from('TEXT', 'utf-8'), - })); + read.mockResolvedValue(Buffer.from('TEXT', 'utf-8')); const processor = PlaceholderProcessor.default(); await expect( @@ -175,14 +164,9 @@ describe('PlaceholderProcessor', () => { }); it('has builtin json support', async () => { - const emit = jest.fn(); - const read: LocationProcessorRead = jest - .fn() - .mockImplementation(async location => ({ - type: 'data', - location, - data: Buffer.from(JSON.stringify({ a: ['b', 7] }), 'utf-8'), - })); + read.mockResolvedValue( + Buffer.from(JSON.stringify({ a: ['b', 7] }), 'utf-8'), + ); const processor = PlaceholderProcessor.default(); await expect( @@ -215,14 +199,7 @@ describe('PlaceholderProcessor', () => { }); it('has builtin yaml support', async () => { - const emit = jest.fn(); - const read: LocationProcessorRead = jest - .fn() - .mockImplementation(async location => ({ - type: 'data', - location, - data: Buffer.from('foo:\n - bar: 7', 'utf-8'), - })); + read.mockResolvedValue(Buffer.from('foo:\n - bar: 7', 'utf-8')); const processor = PlaceholderProcessor.default(); await expect( @@ -256,65 +233,46 @@ describe('PlaceholderProcessor', () => { }); describe('yamlPlaceholderResolver', () => { - let read: jest.MockedFunction; - let params: ResolverParams; + const read: jest.MockedFunction = jest.fn(); + const params: ResolverParams = { + key: 'a', + value: './file.yaml', + location: { + type: 'github', + target: 'https://github.com/spotify/backstage/a/b/catalog-info.yaml', + }, + read, + }; beforeEach(() => { - read = jest.fn(); - params = { - key: 'a', - value: './file.yaml', - location: { - type: 'github', - target: 'https://github.com/spotify/backstage/a/b/catalog-info.yaml', - }, - read, - }; + jest.resetAllMocks(); }); it('parses valid yaml', async () => { - read.mockImplementation(async location => ({ - type: 'data', - location, - data: Buffer.from('foo:\n - bar: 7', 'utf-8'), - })); - + read.mockResolvedValue(Buffer.from('foo:\n - bar: 7', 'utf-8')); await expect(yamlPlaceholderResolver(params)).resolves.toEqual({ foo: [{ bar: 7 }], }); }); it('rejects invalid yaml', async () => { - read.mockImplementation(async location => ({ - type: 'data', - location, - data: Buffer.from('a: 1\n----\n', 'utf-8'), - })); - + read.mockResolvedValue(Buffer.from('a: 1\n----\n', 'utf-8')); await expect(yamlPlaceholderResolver(params)).rejects.toThrow( 'Placeholder $a found an error in the data at ./file.yaml, YAMLSemanticError: Implicit map keys need to be followed by map values', ); }); it('rejects multi-document yaml', async () => { - read.mockImplementation(async location => ({ - type: 'data', - location, - data: Buffer.from('foo: 1\n---\nbar: 2\n', 'utf-8'), - })); - + read.mockResolvedValue(Buffer.from('foo: 1\n---\nbar: 2\n', 'utf-8')); await expect(yamlPlaceholderResolver(params)).rejects.toThrow( 'Placeholder $a expected to find exactly one document of data at ./file.yaml, found 2', ); }); it('parses valid json', async () => { - read.mockImplementation(async location => ({ - type: 'data', - location, - data: Buffer.from(JSON.stringify({ a: ['b', 7] }), 'utf-8'), - })); - + read.mockResolvedValue( + Buffer.from(JSON.stringify({ a: ['b', 7] }), 'utf-8'), + ); await expect(yamlPlaceholderResolver(params)).resolves.toEqual({ a: ['b', 7], }); @@ -322,41 +280,32 @@ describe('yamlPlaceholderResolver', () => { }); describe('jsonPlaceholderResolver', () => { - let read: jest.MockedFunction; - let params: ResolverParams; + const read: jest.MockedFunction = jest.fn(); + const params: ResolverParams = { + key: 'a', + value: './file.json', + location: { + type: 'github', + target: 'https://github.com/spotify/backstage/a/b/catalog-info.yaml', + }, + read, + }; beforeEach(() => { - read = jest.fn(); - params = { - key: 'a', - value: './file.json', - location: { - type: 'github', - target: 'https://github.com/spotify/backstage/a/b/catalog-info.yaml', - }, - read, - }; + jest.resetAllMocks(); }); it('parses valid json', async () => { - read.mockImplementation(async location => ({ - type: 'data', - location, - data: Buffer.from(JSON.stringify({ a: ['b', 7] }), 'utf-8'), - })); - + read.mockResolvedValue( + Buffer.from(JSON.stringify({ a: ['b', 7] }), 'utf-8'), + ); await expect(jsonPlaceholderResolver(params)).resolves.toEqual({ a: ['b', 7], }); }); it('rejects invalid json', async () => { - read.mockImplementation(async location => ({ - type: 'data', - location, - data: Buffer.from('}', 'utf-8'), - })); - + read.mockResolvedValue(Buffer.from('}', 'utf-8')); await expect(jsonPlaceholderResolver(params)).rejects.toThrow( 'Placeholder $a failed to parse JSON data at ./file.json, SyntaxError: Unexpected token } in JSON at position 0', ); diff --git a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts index e405e5a5df..65433b1849 100644 --- a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts @@ -174,11 +174,8 @@ async function readTextLocation(params: ResolverParams): Promise { const newLocation = relativeLocation(params); try { - const response = await params.read(newLocation); - if (response.type !== 'data') { - throw new Error(`Expected data, got ${response.type}`); - } - return response.data.toString('utf-8'); + const data = await params.read(newLocation); + return data.toString('utf-8'); } catch (e) { throw new Error( `Placeholder \$${params.key} could not read location ${params.value}, ${e}`, diff --git a/plugins/catalog-backend/src/ingestion/processors/types.ts b/plugins/catalog-backend/src/ingestion/processors/types.ts index 65e2fdbb0a..66359b328b 100644 --- a/plugins/catalog-backend/src/ingestion/processors/types.ts +++ b/plugins/catalog-backend/src/ingestion/processors/types.ts @@ -66,7 +66,7 @@ export type LocationProcessor = { * * @param error The error * @param location The location where the error occurred - * @param emit A sink for items resulting from this handilng + * @param emit A sink for items resulting from this handling * @returns Nothing */ handleError?( @@ -110,6 +110,4 @@ export type LocationProcessorResult = | LocationProcessorEntityResult | LocationProcessorErrorResult; -export type LocationProcessorRead = ( - location: LocationSpec, -) => Promise; +export type LocationProcessorRead = (location: LocationSpec) => Promise; From d66c6a7dcbf5bc682b248d08be5ddb938044b562 Mon Sep 17 00:00:00 2001 From: Matthew Clarke Date: Mon, 28 Sep 2020 19:32:35 +0100 Subject: [PATCH 88/94] k8s add hpa and ingresses UI (#2640) * k8s add hpa and ingresses UI * add todo --- .../examples/dice-roller/README.md | 4 +- .../dice-roller/dice-roller-manifests.yaml | 35 ++++++++ .../src/service/KubernetesClientProvider.ts | 50 ++++++++++- .../src/service/KubernetesFetcher.test.ts | 2 + .../src/service/KubernetesFetcher.ts | 75 +++++++++++----- .../getKubernetesObjectsByServiceIdHandler.ts | 2 + plugins/kubernetes-backend/src/types/types.ts | 28 +++--- .../components/ConfigMaps/ConfigMaps.test.tsx | 2 +- .../src/components/ConfigMaps/ConfigMaps.tsx | 2 +- .../HorizontalPodAutoscalers.test.tsx | 52 +++++++++++ .../HorizontalPodAutoscalers.tsx | 64 ++++++++++++++ .../horizontalpodautoscalers.json | 81 +++++++++++++++++ .../HorizontalPodAutoscalers/index.ts | 16 ++++ .../components/Ingresses/Ingresses.test.tsx | 47 ++++++++++ .../src/components/Ingresses/Ingresses.tsx | 51 +++++++++++ .../Ingresses/__fixtures__/ingress.json | 87 +++++++++++++++++++ .../src/components/Ingresses/index.ts | 16 ++++ .../KubernetesContent/KubernetesContent.tsx | 34 +++++++- plugins/kubernetes/src/utils.ts | 20 +++++ 19 files changed, 632 insertions(+), 36 deletions(-) create mode 100644 plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalers.test.tsx create mode 100644 plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalers.tsx create mode 100644 plugins/kubernetes/src/components/HorizontalPodAutoscalers/__fixtures__/horizontalpodautoscalers.json create mode 100644 plugins/kubernetes/src/components/HorizontalPodAutoscalers/index.ts create mode 100644 plugins/kubernetes/src/components/Ingresses/Ingresses.test.tsx create mode 100644 plugins/kubernetes/src/components/Ingresses/Ingresses.tsx create mode 100644 plugins/kubernetes/src/components/Ingresses/__fixtures__/ingress.json create mode 100644 plugins/kubernetes/src/components/Ingresses/index.ts create mode 100644 plugins/kubernetes/src/utils.ts diff --git a/plugins/kubernetes-backend/examples/dice-roller/README.md b/plugins/kubernetes-backend/examples/dice-roller/README.md index b64e2df37f..47fc345c73 100644 --- a/plugins/kubernetes-backend/examples/dice-roller/README.md +++ b/plugins/kubernetes-backend/examples/dice-roller/README.md @@ -7,7 +7,9 @@ An app to roll dice (it doesn't actually do that). ## Prerequisites - kubectl installed -- Minikube installed +- Minikube installed, with the following addons + - metrics-server + - ingress - jq installed - Backstage locally built and ready to run diff --git a/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml b/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml index 13fe26d051..50956bd7d3 100644 --- a/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml +++ b/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml @@ -53,6 +53,41 @@ spec: ports: - containerPort: 82 +--- +apiVersion: autoscaling/v1 +kind: HorizontalPodAutoscaler +metadata: + name: dice-roller + labels: + 'backstage.io/kubernetes-id': dice-roller +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: dice-roller + minReplicas: 10 + maxReplicas: 15 + targetCPUUtilizationPercentage: 50 + +--- +apiVersion: networking.k8s.io/v1beta1 +kind: Ingress +metadata: + name: dice-roller + annotations: + nginx.ingress.kubernetes.io/rewrite-target: /$1 + labels: + 'backstage.io/kubernetes-id': dice-roller +spec: + rules: + - host: nginx + http: + paths: + - path: / + backend: + serviceName: dice-roller + servicePort: 80 + --- apiVersion: v1 kind: ConfigMap diff --git a/plugins/kubernetes-backend/src/service/KubernetesClientProvider.ts b/plugins/kubernetes-backend/src/service/KubernetesClientProvider.ts index c3986e3ff7..8b12d53afc 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesClientProvider.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesClientProvider.ts @@ -15,7 +15,13 @@ */ import { ClusterDetails } from '..'; -import { AppsV1Api, CoreV1Api, KubeConfig } from '@kubernetes/client-node'; +import { + AppsV1Api, + AutoscalingV1Api, + CoreV1Api, + KubeConfig, + NetworkingV1beta1Api, +} from '@kubernetes/client-node'; export class KubernetesClientProvider { private readonly coreClientMap: { @@ -26,9 +32,19 @@ export class KubernetesClientProvider { [key: string]: AppsV1Api; }; + private readonly autoscalingClientMap: { + [key: string]: AutoscalingV1Api; + }; + + private readonly networkingBeta1ClientMap: { + [key: string]: NetworkingV1beta1Api; + }; + constructor() { this.coreClientMap = {}; this.appsClientMap = {}; + this.autoscalingClientMap = {}; + this.networkingBeta1ClientMap = {}; } // visible for testing @@ -93,4 +109,36 @@ export class KubernetesClientProvider { return client; } + + getAutoscalingClientByClusterDetails(clusterDetails: ClusterDetails) { + const clientMapKey = clusterDetails.name; + + if (this.autoscalingClientMap.hasOwnProperty(clientMapKey)) { + return this.autoscalingClientMap[clientMapKey]; + } + + const kc = this.getKubeConfig(clusterDetails); + + const client = kc.makeApiClient(AutoscalingV1Api); + + this.autoscalingClientMap[clientMapKey] = client; + + return client; + } + + getNetworkingBeta1Client(clusterDetails: ClusterDetails) { + const clientMapKey = clusterDetails.name; + + if (this.networkingBeta1ClientMap.hasOwnProperty(clientMapKey)) { + return this.networkingBeta1ClientMap[clientMapKey]; + } + + const kc = this.getKubeConfig(clusterDetails); + + const client = kc.makeApiClient(NetworkingV1beta1Api); + + this.networkingBeta1ClientMap[clientMapKey] = client; + + return client; + } } diff --git a/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts b/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts index 359e7c5806..0109b12f36 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts @@ -31,6 +31,8 @@ describe('KubernetesClientProvider', () => { const kubernetesClientProvider: any = { getCoreClientByClusterDetails: jest.fn(() => clientMock), getAppsClientByClusterDetails: jest.fn(() => clientMock), + getAutoscalingClientByClusterDetails: jest.fn(() => clientMock), + getNetworkingBeta1Client: jest.fn(() => clientMock), }; const sut = new KubernetesClientBasedFetcher({ diff --git a/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts b/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts index f6d3edb5e9..8890db7b13 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts @@ -16,12 +16,15 @@ import { AppsV1Api, + AutoscalingV1Api, CoreV1Api, + ExtensionsV1beta1Ingress, + NetworkingV1beta1Api, V1ConfigMap, V1Deployment, + V1HorizontalPodAutoscaler, V1Pod, V1ReplicaSet, - V1Secret, } from '@kubernetes/client-node'; import { KubernetesClientProvider } from './KubernetesClientProvider'; import { V1Service } from '@kubernetes/client-node/dist/gen/model/v1Service'; @@ -36,6 +39,8 @@ import { export interface Clients { core: CoreV1Api; apps: AppsV1Api; + autoscaling: AutoscalingV1Api; + networkingBeta1: NetworkingV1beta1Api; } export interface KubernetesClientBasedFetcherOptions { @@ -67,6 +72,7 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { ); } + // TODO could probably do with a tidy up private fetchByObjectType( serviceId: string, clusterDetails: ClusterDetails, @@ -93,13 +99,18 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { serviceId, clusterDetails, ).then(r => ({ type: type, resources: r })); - case 'secrets': - return this.fetchSecretsByServiceId( + case 'services': + return this.fetchServicesByServiceId( serviceId, clusterDetails, ).then(r => ({ type: type, resources: r })); - case 'services': - return this.fetchServicesByServiceId( + case 'horizontalpodautoscalers': + return this.fetchHorizontalPodAutoscalersByServiceId( + serviceId, + clusterDetails, + ).then(r => ({ type: type, resources: r })); + case 'ingresses': + return this.fetchIngressesByServiceId( serviceId, clusterDetails, ).then(r => ({ type: type, resources: r })); @@ -119,9 +130,15 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { const apps = this.kubernetesClientProvider.getAppsClientByClusterDetails( clusterDetails, ); + const autoscaling = this.kubernetesClientProvider.getAutoscalingClientByClusterDetails( + clusterDetails, + ); + const networkingBeta1 = this.kubernetesClientProvider.getNetworkingBeta1Client( + clusterDetails, + ); this.logger.debug(`calling cluster=${clusterDetails.name}`); - return fn({ core, apps }).then(result => { + return fn({ core, apps, autoscaling, networkingBeta1 }).then(result => { return result.body.items; }); } @@ -168,20 +185,6 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { ); } - private fetchSecretsByServiceId( - serviceId: string, - clusterDetails: ClusterDetails, - ): Promise> { - return this.singleClusterFetch(clusterDetails, ({ core }) => - core.listSecretForAllNamespaces( - false, - '', - '', - `backstage.io/kubernetes-id=${serviceId}`, - ), - ); - } - private fetchDeploymentsByServiceId( serviceId: string, clusterDetails: ClusterDetails, @@ -209,4 +212,36 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { ), ); } + + private fetchHorizontalPodAutoscalersByServiceId( + serviceId: string, + clusterDetails: ClusterDetails, + ): Promise> { + return this.singleClusterFetch( + clusterDetails, + ({ autoscaling }) => + autoscaling.listHorizontalPodAutoscalerForAllNamespaces( + false, + '', + '', + `backstage.io/kubernetes-id=${serviceId}`, + ), + ); + } + + private fetchIngressesByServiceId( + serviceId: string, + clusterDetails: ClusterDetails, + ): Promise> { + return this.singleClusterFetch( + clusterDetails, + ({ networkingBeta1 }) => + networkingBeta1.listIngressForAllNamespaces( + false, + '', + '', + `backstage.io/kubernetes-id=${serviceId}`, + ), + ); + } } diff --git a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts index b2dde1a393..552e083bd7 100644 --- a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts +++ b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts @@ -36,6 +36,8 @@ const DEFAULT_OBJECTS = new Set([ 'configmaps', 'deployments', 'replicasets', + 'horizontalpodautoscalers', + 'ingresses', ]); export const handleGetKubernetesObjectsByServiceId: GetKubernetesObjectsByServiceIdHandler = async ( diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index 2c69fd67a0..e469b2b1b5 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -15,11 +15,12 @@ */ import { + ExtensionsV1beta1Ingress, V1ConfigMap, V1Deployment, + V1HorizontalPodAutoscaler, V1Pod, V1ReplicaSet, - V1Secret, V1Service, } from '@kubernetes/client-node'; @@ -43,9 +44,10 @@ export type FetchResponse = | PodFetchResponse | ServiceFetchResponse | ConfigMapFetchResponse - | SecretFetchResponse | DeploymentFetchResponse - | ReplicaSetsFetchResponse; + | ReplicaSetsFetchResponse + | HorizontalPodAutoscalersFetchResponse + | IngressesFetchResponse; // TODO fairly sure there's a easier way to do this @@ -53,9 +55,10 @@ export type KubernetesObjectTypes = | 'pods' | 'services' | 'configmaps' - | 'secrets' | 'deployments' - | 'replicasets'; + | 'replicasets' + | 'horizontalpodautoscalers' + | 'ingresses'; export interface PodFetchResponse { type: 'pods'; @@ -72,11 +75,6 @@ export interface ConfigMapFetchResponse { resources: Array; } -export interface SecretFetchResponse { - type: 'secrets'; - resources: Array; -} - export interface DeploymentFetchResponse { type: 'deployments'; resources: Array; @@ -87,6 +85,16 @@ export interface ReplicaSetsFetchResponse { resources: Array; } +export interface HorizontalPodAutoscalersFetchResponse { + type: 'horizontalpodautoscalers'; + resources: Array; +} + +export interface IngressesFetchResponse { + type: 'ingresses'; + resources: Array; +} + // Fetches information from a kubernetes cluster using the cluster details object // to target a specific cluster export interface KubernetesFetcher { diff --git a/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.test.tsx b/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.test.tsx index 161a0ed487..35b8f2143a 100644 --- a/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.test.tsx +++ b/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.test.tsx @@ -30,7 +30,7 @@ describe('ConfigMaps', () => { // title expect(getByText('dice-roller')).toBeInTheDocument(); - expect(getByText('ConfigMap')).toBeInTheDocument(); + expect(getByText('Config Map')).toBeInTheDocument(); // values expect(getByText('Immutable')).toBeInTheDocument(); diff --git a/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.tsx b/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.tsx index f757d1c0fc..5a013eb2c6 100644 --- a/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.tsx +++ b/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.tsx @@ -32,7 +32,7 @@ export const ConfigMaps = ({ configMaps }: ConfigMapsProps) => {
{ + it('should render horizontalpodautoscaler', async () => { + const { getByText, getAllByText } = render( + wrapInTestApp( + , + ), + ); + + // title + expect(getByText('dice-roller')).toBeInTheDocument(); + expect(getByText('Horizontal Pod Autoscaler')).toBeInTheDocument(); + + expect(getByText('Scaling Target')).toBeInTheDocument(); + expect(getByText('Api Version: apps/v1')).toBeInTheDocument(); + expect(getByText('Kind: Deployment')).toBeInTheDocument(); + expect(getByText('Name: dice-roller')).toBeInTheDocument(); + expect(getByText('Min Replicas')).toBeInTheDocument(); + expect(getByText('Max Replicas')).toBeInTheDocument(); + expect(getByText('15')).toBeInTheDocument(); + expect(getByText('Current Replicas')).toBeInTheDocument(); + expect(getByText('Desired Replicas')).toBeInTheDocument(); + expect(getByText('0')).toBeInTheDocument(); + expect(getByText('Target CPU Utilization Percentage')).toBeInTheDocument(); + expect(getByText('50')).toBeInTheDocument(); + expect(getByText('Current CPU Utilization Percentage')).toBeInTheDocument(); + expect(getByText('Last Scale Time')).toBeInTheDocument(); + expect(getAllByText('unknown')).toHaveLength(2); + expect(getAllByText('10')).toHaveLength(2); + }); +}); diff --git a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalers.tsx b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalers.tsx new file mode 100644 index 0000000000..b4540840d2 --- /dev/null +++ b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalers.tsx @@ -0,0 +1,64 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Grid } from '@material-ui/core'; +import { V1HorizontalPodAutoscaler } from '@kubernetes/client-node'; +import { InfoCard, StructuredMetadataTable } from '@backstage/core'; +import { orUnknown } from '../../utils'; + +type HorizontalPodAutoscalersProps = { + hpas: V1HorizontalPodAutoscaler[]; + children?: React.ReactNode; +}; + +export const HorizontalPodAutoscalers = ({ + hpas, +}: HorizontalPodAutoscalersProps) => { + return ( + + {hpas.map((hpa, i) => { + return ( + + +
+ +
+
+
+ ); + })} +
+ ); +}; diff --git a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/__fixtures__/horizontalpodautoscalers.json b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/__fixtures__/horizontalpodautoscalers.json new file mode 100644 index 0000000000..6bc6028246 --- /dev/null +++ b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/__fixtures__/horizontalpodautoscalers.json @@ -0,0 +1,81 @@ +[ + { + "metadata": { + "annotations": { + "autoscaling.alpha.kubernetes.io/conditions": "[{\"type\":\"AbleToScale\",\"status\":\"True\",\"lastTransitionTime\":\"2020-09-28T13:28:15Z\",\"reason\":\"SucceededGetScale\",\"message\":\"the HPA controller was able to get the target's current scale\"},{\"type\":\"ScalingActive\",\"status\":\"False\",\"lastTransitionTime\":\"2020-09-28T13:28:15Z\",\"reason\":\"FailedGetResourceMetric\",\"message\":\"the HPA was unable to compute the replica count: unable to get metrics for resource cpu: unable to fetch metrics from resource metrics API: the server could not find the requested resource (get pods.metrics.k8s.io)\"}]", + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"autoscaling/v1\",\"kind\":\"HorizontalPodAutoscaler\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"},\"spec\":{\"maxReplicas\":15,\"minReplicas\":10,\"scaleTargetRef\":{\"apiVersion\":\"apps/v1\",\"kind\":\"Deployment\",\"name\":\"dice-roller\"},\"targetCPUUtilizationPercentage\":50}}\n" + }, + "creationTimestamp": "2020-09-28T13:28:00.000Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "managedFields": [ + { + "apiVersion": "autoscaling/v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:annotations": { + "f:autoscaling.alpha.kubernetes.io/conditions": {} + } + }, + "f:status": { + "f:currentReplicas": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-28T13:28:15.000Z" + }, + { + "apiVersion": "autoscaling/v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:annotations": { + ".": {}, + "f:kubectl.kubernetes.io/last-applied-configuration": {} + }, + "f:labels": { + ".": {}, + "f:backstage.io/kubernetes-id": {} + } + }, + "f:spec": { + "f:maxReplicas": {}, + "f:minReplicas": {}, + "f:scaleTargetRef": { + "f:apiVersion": {}, + "f:kind": {}, + "f:name": {} + }, + "f:targetCPUUtilizationPercentage": {} + } + }, + "manager": "kubectl", + "operation": "Update", + "time": "2020-09-28T13:28:21.000Z" + } + ], + "name": "dice-roller", + "namespace": "default", + "resourceVersion": "698957", + "selfLink": "/apis/autoscaling/v1/namespaces/default/horizontalpodautoscalers/dice-roller", + "uid": "a70c8a90-5605-4d7d-adea-05cfb8d9d446" + }, + "spec": { + "maxReplicas": 15, + "minReplicas": 10, + "scaleTargetRef": { + "apiVersion": "apps/v1", + "kind": "Deployment", + "name": "dice-roller" + }, + "targetCPUUtilizationPercentage": 50 + }, + "status": { + "currentReplicas": 10, + "desiredReplicas": 0 + } + } +] diff --git a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/index.ts b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/index.ts new file mode 100644 index 0000000000..3eebbe9813 --- /dev/null +++ b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { HorizontalPodAutoscalers } from './HorizontalPodAutoscalers'; diff --git a/plugins/kubernetes/src/components/Ingresses/Ingresses.test.tsx b/plugins/kubernetes/src/components/Ingresses/Ingresses.test.tsx new file mode 100644 index 0000000000..f2507a3f39 --- /dev/null +++ b/plugins/kubernetes/src/components/Ingresses/Ingresses.test.tsx @@ -0,0 +1,47 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import * as ingressesFixture from './__fixtures__/ingress.json'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { Ingresses } from './Ingresses'; + +describe('Ingresses', () => { + it('should render ingress', async () => { + const { getByText } = render( + wrapInTestApp( + , + ), + ); + + // title + expect(getByText('dice-roller')).toBeInTheDocument(); + expect(getByText('Ingress')).toBeInTheDocument(); + + // values + expect(getByText('Backend')).toBeInTheDocument(); + expect(getByText('Ip: 192.168.64.2')).toBeInTheDocument(); + expect(getByText('Rules')).toBeInTheDocument(); + expect(getByText('Host: nginx')).toBeInTheDocument(); + expect(getByText('Http:')).toBeInTheDocument(); + expect(getByText('Paths:')).toBeInTheDocument(); + expect(getByText('Service Name: dice-roller')).toBeInTheDocument(); + expect(getByText('Service Port: 80')).toBeInTheDocument(); + expect(getByText('Path: /')).toBeInTheDocument(); + expect(getByText('Path Type: ImplementationSpecific')).toBeInTheDocument(); + }); +}); diff --git a/plugins/kubernetes/src/components/Ingresses/Ingresses.tsx b/plugins/kubernetes/src/components/Ingresses/Ingresses.tsx new file mode 100644 index 0000000000..37932601a1 --- /dev/null +++ b/plugins/kubernetes/src/components/Ingresses/Ingresses.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Grid } from '@material-ui/core'; +import { ExtensionsV1beta1Ingress } from '@kubernetes/client-node'; +import { InfoCard, StructuredMetadataTable } from '@backstage/core'; + +type IngressesProps = { + ingresses: ExtensionsV1beta1Ingress[]; + children?: React.ReactNode; +}; + +export const Ingresses = ({ ingresses }: IngressesProps) => { + return ( + + {ingresses.map((ingress, i) => { + return ( + + +
+ +
+
+
+ ); + })} +
+ ); +}; diff --git a/plugins/kubernetes/src/components/Ingresses/__fixtures__/ingress.json b/plugins/kubernetes/src/components/Ingresses/__fixtures__/ingress.json new file mode 100644 index 0000000000..fd0bc5ec43 --- /dev/null +++ b/plugins/kubernetes/src/components/Ingresses/__fixtures__/ingress.json @@ -0,0 +1,87 @@ +[ + { + "metadata": { + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"networking.k8s.io/v1beta1\",\"kind\":\"Ingress\",\"metadata\":{\"annotations\":{\"nginx.ingress.kubernetes.io/rewrite-target\":\"/$1\"},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"},\"spec\":{\"rules\":[{\"host\":\"nginx\",\"http\":{\"paths\":[{\"backend\":{\"serviceName\":\"dice-roller\",\"servicePort\":80},\"path\":\"/\"}]}}]}}\n", + "nginx.ingress.kubernetes.io/rewrite-target": "/$1" + }, + "creationTimestamp": "2020-09-28T13:28:00.000Z", + "generation": 1, + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "managedFields": [ + { + "apiVersion": "networking.k8s.io/v1beta1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:annotations": { + ".": {}, + "f:kubectl.kubernetes.io/last-applied-configuration": {}, + "f:nginx.ingress.kubernetes.io/rewrite-target": {} + }, + "f:labels": { + ".": {}, + "f:backstage.io/kubernetes-id": {} + } + }, + "f:spec": { + "f:rules": {} + } + }, + "manager": "kubectl", + "operation": "Update", + "time": "2020-09-28T13:28:21.000Z" + }, + { + "apiVersion": "networking.k8s.io/v1beta1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:status": { + "f:loadBalancer": { + "f:ingress": {} + } + } + }, + "manager": "nginx-ingress-controller", + "operation": "Update", + "time": "2020-09-28T13:28:40.000Z" + } + ], + "name": "dice-roller", + "namespace": "default", + "resourceVersion": "699017", + "selfLink": "/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/dice-roller", + "uid": "e96994c0-49b9-4c1c-8ce0-72c5336fe960" + }, + "spec": { + "rules": [ + { + "host": "nginx", + "http": { + "paths": [ + { + "backend": { + "serviceName": "dice-roller", + "servicePort": 80 + }, + "path": "/", + "pathType": "ImplementationSpecific" + } + ] + } + } + ] + }, + "status": { + "loadBalancer": { + "ingress": [ + { + "ip": "192.168.64.2" + } + ] + } + } + } +] diff --git a/plugins/kubernetes/src/components/Ingresses/index.ts b/plugins/kubernetes/src/components/Ingresses/index.ts new file mode 100644 index 0000000000..ecc67480db --- /dev/null +++ b/plugins/kubernetes/src/components/Ingresses/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { Ingresses } from './Ingresses'; diff --git a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx index 315a5e0377..b926d92dc7 100644 --- a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx +++ b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx @@ -34,15 +34,25 @@ import { } from '@backstage/plugin-kubernetes-backend'; import { DeploymentTables } from '../DeploymentTables'; import { DeploymentTriple } from '../../types/types'; -import { V1ConfigMap, V1Service } from '@kubernetes/client-node'; +import { + ExtensionsV1beta1Ingress, + V1ConfigMap, + V1HorizontalPodAutoscaler, + V1Service, +} from '@kubernetes/client-node'; import { Services } from '../Services'; import { ConfigMaps } from '../ConfigMaps'; +import { Ingresses } from '../Ingresses'; +import { HorizontalPodAutoscalers } from '../HorizontalPodAutoscalers'; interface GroupedResponses extends DeploymentTriple { services: V1Service[]; configMaps: V1ConfigMap[]; + horizontalPodAutoscalers: V1HorizontalPodAutoscaler[]; + ingresses: ExtensionsV1beta1Ingress[]; } +// TODO this could probably be a lodash groupBy const groupResponses = (fetchResponse: FetchResponse[]) => { return fetchResponse.reduce( (prev, next) => { @@ -62,6 +72,12 @@ const groupResponses = (fetchResponse: FetchResponse[]) => { case 'configmaps': prev.configMaps.push(...next.resources); break; + case 'horizontalpodautoscalers': + prev.horizontalPodAutoscalers.push(...next.resources); + break; + case 'ingresses': + prev.ingresses.push(...next.resources); + break; default: } return prev; @@ -72,6 +88,8 @@ const groupResponses = (fetchResponse: FetchResponse[]) => { deployments: [], services: [], configMaps: [], + horizontalPodAutoscalers: [], + ingresses: [], } as GroupedResponses, ); }; @@ -130,6 +148,8 @@ const Cluster = ({ clusterObjects }: ClusterProps) => { const groupedResponses = groupResponses(clusterObjects.resources); const configMaps = groupedResponses.configMaps; + const hpas = groupedResponses.horizontalPodAutoscalers; + const ingresses = groupedResponses.ingresses; return ( <> @@ -151,10 +171,20 @@ const Cluster = ({ clusterObjects }: ClusterProps) => { {configMaps && ( - + )} + {hpas && ( + + + + )} + {ingresses && ( + + + + )} ); diff --git a/plugins/kubernetes/src/utils.ts b/plugins/kubernetes/src/utils.ts new file mode 100644 index 0000000000..e038b1af8b --- /dev/null +++ b/plugins/kubernetes/src/utils.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ + +// Display the value unknown rather than 'undefined' +export function orUnknown(val: T | undefined): T | string { + return val ?? 'unknown'; +} From a4281932fb472a02543c3ebf5375d1c86003d7f0 Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Mon, 28 Sep 2020 15:20:31 -0400 Subject: [PATCH 89/94] add cost insights plugin --- .github/CODEOWNERS | 1 + app-config.yaml | 20 + packages/app/package.json | 1 + packages/app/src/apis.ts | 6 + packages/app/src/components/Root/Root.tsx | 2 + packages/app/src/plugins.ts | 1 + .../ExampleCostInsightsClient.ts | 209 ++++++++ .../app/src/plugins/cost-insights/index.ts | 17 + plugins/cost-insights/.eslintrc.js | 3 + plugins/cost-insights/.npmrc | 1 + plugins/cost-insights/README.md | 106 ++++ plugins/cost-insights/dev/index.tsx | 20 + plugins/cost-insights/package.json | 59 +++ .../cost-insights/src/api/CostInsightsApi.ts | 115 +++++ plugins/cost-insights/src/api/index.ts | 17 + .../AlertActionCard.test.tsx | 50 ++ .../AlertActionCardList/AlertActionCard.tsx | 49 ++ .../AlertActionCardList.tsx | 36 ++ .../components/AlertActionCardList/index.ts | 17 + .../AlertInsights/AlertInsights.tsx | 51 ++ .../AlertInsights/AlertInsightsHeader.tsx | 47 ++ .../AlertInsights/AlertInsightsSection.tsx | 62 +++ .../AlertInsightsSectionHeader.tsx | 55 +++ .../src/components/AlertInsights/index.ts | 17 + .../AlertInstructionsLayout.tsx | 61 +++ .../AlertInstructionsLayout/index.ts | 17 + .../src/components/BarChart/BarChart.test.tsx | 191 ++++++++ .../src/components/BarChart/BarChart.tsx | 175 +++++++ .../src/components/BarChart/BarChartLabel.tsx | 51 ++ .../components/BarChart/BarChartStepper.tsx | 115 +++++ .../BarChart/BarChartStepperButton.tsx | 46 ++ .../src/components/BarChart/BarChartSteps.tsx | 52 ++ .../src/components/BarChart/BarChartTick.tsx | 48 ++ .../src/components/BarChart/index.ts | 17 + .../CopyUrlToClipboard/CopyUrlToClipboard.tsx | 70 +++ .../components/CopyUrlToClipboard/index.ts | 17 + .../components/CostGrowth/CostGrowth.test.tsx | 113 +++++ .../src/components/CostGrowth/CostGrowth.tsx | 74 +++ .../src/components/CostGrowth/index.ts | 17 + .../CostInsightsHeader.test.tsx | 95 ++++ .../CostInsightsHeader/CostInsightsHeader.tsx | 136 ++++++ .../components/CostInsightsHeader/index.ts | 17 + .../CostInsightsLayout/CostInsightsLayout.tsx | 59 +++ .../components/CostInsightsLayout/index.ts | 17 + .../CostInsightsNavigation.test.tsx | 79 +++ .../CostInsightsNavigation.tsx | 97 ++++ .../CostInsightsNavigation/index.ts | 17 + .../CostInsightsPage/CostInsightsPage.tsx | 249 ++++++++++ .../CostInsightsPage/CostInsightsPageRoot.tsx | 45 ++ .../CostInsightsThemeProvider.tsx | 50 ++ .../src/components/CostInsightsPage/index.ts | 17 + .../components/CostInsightsPage/selector.tsx | 42 ++ .../CostInsightsSupportButton.tsx | 28 ++ .../CostInsightsSupportButton/index.ts | 17 + .../CostInsightsTabs.test.tsx | 104 ++++ .../CostInsightsTabs/CostInsightsTabs.tsx | 111 +++++ .../src/components/CostInsightsTabs/index.ts | 17 + .../components/CostInsightsTabs/selector.ts | 50 ++ .../CostOverviewCard/CostOverviewCard.tsx | 93 ++++ .../CostOverviewCard/CostOverviewFooter.tsx | 36 ++ .../CostOverviewCard/CostOverviewHeader.tsx | 53 ++ .../src/components/CostOverviewCard/index.ts | 17 + .../components/CostOverviewCard/selector.tsx | 46 ++ .../CostOverviewChart.test.tsx | 50 ++ .../CostOverviewChart/CostOverviewChart.tsx | 120 +++++ .../CostOverviewChart/CostOverviewTooltip.tsx | 45 ++ .../src/components/CostOverviewChart/index.ts | 17 + .../CostOverviewChartLegend.test.tsx | 40 ++ .../CostOverviewChartLegend.tsx | 48 ++ .../CostOverviewChartLegend/index.ts | 17 + .../CurrencySelect/CurrencySelect.tsx | 75 +++ .../src/components/CurrencySelect/index.ts | 17 + .../LabelDataflowInstructionsPage.tsx | 96 ++++ .../LabelDataflowInstructionsPage/index.ts | 17 + .../src/components/LegendItem/LegendItem.tsx | 78 +++ .../src/components/LegendItem/index.ts | 17 + .../MetricSelect/MetricSelect.test.tsx | 71 +++ .../components/MetricSelect/MetricSelect.tsx | 67 +++ .../src/components/MetricSelect/index.ts | 17 + .../PeriodSelect/PeriodSelect.test.tsx | 84 ++++ .../components/PeriodSelect/PeriodSelect.tsx | 100 ++++ .../src/components/PeriodSelect/index.ts | 17 + .../ProductInsights/ProductInsights.tsx | 43 ++ .../src/components/ProductInsights/index.ts | 17 + .../ProductInsightsCard.test.tsx | 167 +++++++ .../ProductInsightsCard.tsx | 147 ++++++ .../components/ProductInsightsCard/index.ts | 17 + .../ProductInsightsCard/selector.ts | 56 +++ .../ProjectGrowthAlertCard.test.tsx | 85 ++++ .../ProjectGrowthAlertCard.tsx | 60 +++ .../ProjectGrowthAlertCard/index.ts | 17 + .../ProjectGrowthInstructionsPage.tsx | 212 ++++++++ .../ProjectGrowthInstructionsPage/index.ts | 17 + .../ProjectSelect/ProjectSelect.test.tsx | 68 +++ .../ProjectSelect/ProjectSelect.tsx | 70 +++ .../src/components/ProjectSelect/index.ts | 17 + .../ResourceGrowthBarChart.test.tsx | 41 ++ .../ResourceGrowthBarChart.tsx | 97 ++++ .../ResourceGrowthBarChart/index.ts | 17 + .../ResourceGrowthBarChartLegend.test.tsx | 93 ++++ .../ResourceGrowthBarChartLegend.tsx | 72 +++ .../ResourceGrowthBarChartLegend/index.ts | 17 + .../src/components/Tooltip/Tooltip.test.tsx | 59 +++ .../src/components/Tooltip/Tooltip.tsx | 54 +++ .../src/components/Tooltip/TooltipItem.tsx | 49 ++ .../src/components/Tooltip/index.ts | 18 + .../UnlabeledDataflowAlertCard.test.tsx | 66 +++ .../UnlabeledDataflowAlertCard.tsx | 51 ++ .../UnlabeledDataflowAlertCard/index.ts | 17 + .../UnlabeledDataflowBarChart.tsx | 73 +++ .../UnlabeledDataflowBarChart/index.ts | 17 + .../UnlabeledDataflowBarChartLegend.test.tsx | 34 ++ .../UnlabeledDataflowBarChartLegend.tsx | 59 +++ .../UnlabeledDataflowBarChartLegend/index.ts | 17 + .../WhyCostsMatter/WhyCostsMatter.tsx | 77 +++ .../src/components/WhyCostsMatter/index.ts | 17 + plugins/cost-insights/src/hooks/index.ts | 24 + plugins/cost-insights/src/hooks/useConfig.tsx | 162 +++++++ .../cost-insights/src/hooks/useCurrency.tsx | 60 +++ .../cost-insights/src/hooks/useFilters.tsx | 148 ++++++ plugins/cost-insights/src/hooks/useGroups.tsx | 84 ++++ .../cost-insights/src/hooks/useLoading.tsx | 107 ++++ .../src/hooks/useQueryParams.tsx | 35 ++ plugins/cost-insights/src/hooks/useScroll.tsx | 126 +++++ plugins/cost-insights/src/index.ts | 19 + plugins/cost-insights/src/plugin.test.ts | 23 + plugins/cost-insights/src/plugin.ts | 45 ++ plugins/cost-insights/src/setupTests.ts | 19 + plugins/cost-insights/src/types/Alert.ts | 77 +++ .../src/types/ChangeStatistic.ts | 48 ++ plugins/cost-insights/src/types/Cost.ts | 26 + plugins/cost-insights/src/types/Currency.ts | 78 +++ .../src/types/DateAggregation.ts | 20 + .../cost-insights/src/types/Duration.test.ts | 32 ++ plugins/cost-insights/src/types/Duration.ts | 85 ++++ plugins/cost-insights/src/types/Entity.ts | 24 + plugins/cost-insights/src/types/Filters.ts | 44 ++ plugins/cost-insights/src/types/Group.ts | 19 + plugins/cost-insights/src/types/Icon.ts | 29 ++ plugins/cost-insights/src/types/Loading.ts | 63 +++ plugins/cost-insights/src/types/Maybe.ts | 45 ++ plugins/cost-insights/src/types/Metric.ts | 22 + plugins/cost-insights/src/types/Product.ts | 30 ++ plugins/cost-insights/src/types/Project.ts | 19 + plugins/cost-insights/src/types/Theme.ts | 47 ++ plugins/cost-insights/src/types/Trendline.ts | 19 + plugins/cost-insights/src/types/index.ts | 33 ++ plugins/cost-insights/src/utils/alerts.tsx | 82 ++++ .../src/utils/formatters.test.ts | 50 ++ plugins/cost-insights/src/utils/formatters.ts | 126 +++++ plugins/cost-insights/src/utils/grammar.ts | 47 ++ plugins/cost-insights/src/utils/graphs.tsx | 31 ++ plugins/cost-insights/src/utils/history.ts | 23 + plugins/cost-insights/src/utils/mockData.ts | 194 ++++++++ .../cost-insights/src/utils/navigation.tsx | 74 +++ plugins/cost-insights/src/utils/sort.test.ts | 32 ++ plugins/cost-insights/src/utils/sort.ts | 23 + plugins/cost-insights/src/utils/styles.ts | 455 ++++++++++++++++++ plugins/cost-insights/src/utils/tests.tsx | 86 ++++ .../src/components/ExplorePluginPage.tsx | 7 + yarn.lock | 192 +++++++- 161 files changed, 9477 insertions(+), 6 deletions(-) create mode 100644 packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts create mode 100644 packages/app/src/plugins/cost-insights/index.ts create mode 100644 plugins/cost-insights/.eslintrc.js create mode 100644 plugins/cost-insights/.npmrc create mode 100644 plugins/cost-insights/README.md create mode 100644 plugins/cost-insights/dev/index.tsx create mode 100644 plugins/cost-insights/package.json create mode 100644 plugins/cost-insights/src/api/CostInsightsApi.ts create mode 100644 plugins/cost-insights/src/api/index.ts create mode 100644 plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.test.tsx create mode 100644 plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.tsx create mode 100644 plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx create mode 100644 plugins/cost-insights/src/components/AlertActionCardList/index.ts create mode 100644 plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx create mode 100644 plugins/cost-insights/src/components/AlertInsights/AlertInsightsHeader.tsx create mode 100644 plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx create mode 100644 plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx create mode 100644 plugins/cost-insights/src/components/AlertInsights/index.ts create mode 100644 plugins/cost-insights/src/components/AlertInstructionsLayout/AlertInstructionsLayout.tsx create mode 100644 plugins/cost-insights/src/components/AlertInstructionsLayout/index.ts create mode 100644 plugins/cost-insights/src/components/BarChart/BarChart.test.tsx create mode 100644 plugins/cost-insights/src/components/BarChart/BarChart.tsx create mode 100644 plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx create mode 100644 plugins/cost-insights/src/components/BarChart/BarChartStepper.tsx create mode 100644 plugins/cost-insights/src/components/BarChart/BarChartStepperButton.tsx create mode 100644 plugins/cost-insights/src/components/BarChart/BarChartSteps.tsx create mode 100644 plugins/cost-insights/src/components/BarChart/BarChartTick.tsx create mode 100644 plugins/cost-insights/src/components/BarChart/index.ts create mode 100644 plugins/cost-insights/src/components/CopyUrlToClipboard/CopyUrlToClipboard.tsx create mode 100644 plugins/cost-insights/src/components/CopyUrlToClipboard/index.ts create mode 100644 plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx create mode 100644 plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx create mode 100644 plugins/cost-insights/src/components/CostGrowth/index.ts create mode 100644 plugins/cost-insights/src/components/CostInsightsHeader/CostInsightsHeader.test.tsx create mode 100644 plugins/cost-insights/src/components/CostInsightsHeader/CostInsightsHeader.tsx create mode 100644 plugins/cost-insights/src/components/CostInsightsHeader/index.ts create mode 100644 plugins/cost-insights/src/components/CostInsightsLayout/CostInsightsLayout.tsx create mode 100644 plugins/cost-insights/src/components/CostInsightsLayout/index.ts create mode 100644 plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.test.tsx create mode 100644 plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx create mode 100644 plugins/cost-insights/src/components/CostInsightsNavigation/index.ts create mode 100644 plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx create mode 100644 plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPageRoot.tsx create mode 100644 plugins/cost-insights/src/components/CostInsightsPage/CostInsightsThemeProvider.tsx create mode 100644 plugins/cost-insights/src/components/CostInsightsPage/index.ts create mode 100644 plugins/cost-insights/src/components/CostInsightsPage/selector.tsx create mode 100644 plugins/cost-insights/src/components/CostInsightsSupportButton/CostInsightsSupportButton.tsx create mode 100644 plugins/cost-insights/src/components/CostInsightsSupportButton/index.ts create mode 100644 plugins/cost-insights/src/components/CostInsightsTabs/CostInsightsTabs.test.tsx create mode 100644 plugins/cost-insights/src/components/CostInsightsTabs/CostInsightsTabs.tsx create mode 100644 plugins/cost-insights/src/components/CostInsightsTabs/index.ts create mode 100644 plugins/cost-insights/src/components/CostInsightsTabs/selector.ts create mode 100644 plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx create mode 100644 plugins/cost-insights/src/components/CostOverviewCard/CostOverviewFooter.tsx create mode 100644 plugins/cost-insights/src/components/CostOverviewCard/CostOverviewHeader.tsx create mode 100644 plugins/cost-insights/src/components/CostOverviewCard/index.ts create mode 100644 plugins/cost-insights/src/components/CostOverviewCard/selector.tsx create mode 100644 plugins/cost-insights/src/components/CostOverviewChart/CostOverviewChart.test.tsx create mode 100644 plugins/cost-insights/src/components/CostOverviewChart/CostOverviewChart.tsx create mode 100644 plugins/cost-insights/src/components/CostOverviewChart/CostOverviewTooltip.tsx create mode 100644 plugins/cost-insights/src/components/CostOverviewChart/index.ts create mode 100644 plugins/cost-insights/src/components/CostOverviewChartLegend/CostOverviewChartLegend.test.tsx create mode 100644 plugins/cost-insights/src/components/CostOverviewChartLegend/CostOverviewChartLegend.tsx create mode 100644 plugins/cost-insights/src/components/CostOverviewChartLegend/index.ts create mode 100644 plugins/cost-insights/src/components/CurrencySelect/CurrencySelect.tsx create mode 100644 plugins/cost-insights/src/components/CurrencySelect/index.ts create mode 100644 plugins/cost-insights/src/components/LabelDataflowInstructionsPage/LabelDataflowInstructionsPage.tsx create mode 100644 plugins/cost-insights/src/components/LabelDataflowInstructionsPage/index.ts create mode 100644 plugins/cost-insights/src/components/LegendItem/LegendItem.tsx create mode 100644 plugins/cost-insights/src/components/LegendItem/index.ts create mode 100644 plugins/cost-insights/src/components/MetricSelect/MetricSelect.test.tsx create mode 100644 plugins/cost-insights/src/components/MetricSelect/MetricSelect.tsx create mode 100644 plugins/cost-insights/src/components/MetricSelect/index.ts create mode 100644 plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx create mode 100644 plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx create mode 100644 plugins/cost-insights/src/components/PeriodSelect/index.ts create mode 100644 plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx create mode 100644 plugins/cost-insights/src/components/ProductInsights/index.ts create mode 100644 plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx create mode 100644 plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx create mode 100644 plugins/cost-insights/src/components/ProductInsightsCard/index.ts create mode 100644 plugins/cost-insights/src/components/ProductInsightsCard/selector.ts create mode 100644 plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx create mode 100644 plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx create mode 100644 plugins/cost-insights/src/components/ProjectGrowthAlertCard/index.ts create mode 100644 plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx create mode 100644 plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/index.ts create mode 100644 plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx create mode 100644 plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx create mode 100644 plugins/cost-insights/src/components/ProjectSelect/index.ts create mode 100644 plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.test.tsx create mode 100644 plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.tsx create mode 100644 plugins/cost-insights/src/components/ResourceGrowthBarChart/index.ts create mode 100644 plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.test.tsx create mode 100644 plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.tsx create mode 100644 plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/index.ts create mode 100644 plugins/cost-insights/src/components/Tooltip/Tooltip.test.tsx create mode 100644 plugins/cost-insights/src/components/Tooltip/Tooltip.tsx create mode 100644 plugins/cost-insights/src/components/Tooltip/TooltipItem.tsx create mode 100644 plugins/cost-insights/src/components/Tooltip/index.ts create mode 100644 plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.test.tsx create mode 100644 plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.tsx create mode 100644 plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/index.ts create mode 100644 plugins/cost-insights/src/components/UnlabeledDataflowBarChart/UnlabeledDataflowBarChart.tsx create mode 100644 plugins/cost-insights/src/components/UnlabeledDataflowBarChart/index.ts create mode 100644 plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/UnlabeledDataflowBarChartLegend.test.tsx create mode 100644 plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/UnlabeledDataflowBarChartLegend.tsx create mode 100644 plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/index.ts create mode 100644 plugins/cost-insights/src/components/WhyCostsMatter/WhyCostsMatter.tsx create mode 100644 plugins/cost-insights/src/components/WhyCostsMatter/index.ts create mode 100644 plugins/cost-insights/src/hooks/index.ts create mode 100644 plugins/cost-insights/src/hooks/useConfig.tsx create mode 100644 plugins/cost-insights/src/hooks/useCurrency.tsx create mode 100644 plugins/cost-insights/src/hooks/useFilters.tsx create mode 100644 plugins/cost-insights/src/hooks/useGroups.tsx create mode 100644 plugins/cost-insights/src/hooks/useLoading.tsx create mode 100644 plugins/cost-insights/src/hooks/useQueryParams.tsx create mode 100644 plugins/cost-insights/src/hooks/useScroll.tsx create mode 100644 plugins/cost-insights/src/index.ts create mode 100644 plugins/cost-insights/src/plugin.test.ts create mode 100644 plugins/cost-insights/src/plugin.ts create mode 100644 plugins/cost-insights/src/setupTests.ts create mode 100644 plugins/cost-insights/src/types/Alert.ts create mode 100644 plugins/cost-insights/src/types/ChangeStatistic.ts create mode 100644 plugins/cost-insights/src/types/Cost.ts create mode 100644 plugins/cost-insights/src/types/Currency.ts create mode 100644 plugins/cost-insights/src/types/DateAggregation.ts create mode 100644 plugins/cost-insights/src/types/Duration.test.ts create mode 100644 plugins/cost-insights/src/types/Duration.ts create mode 100644 plugins/cost-insights/src/types/Entity.ts create mode 100644 plugins/cost-insights/src/types/Filters.ts create mode 100644 plugins/cost-insights/src/types/Group.ts create mode 100644 plugins/cost-insights/src/types/Icon.ts create mode 100644 plugins/cost-insights/src/types/Loading.ts create mode 100644 plugins/cost-insights/src/types/Maybe.ts create mode 100644 plugins/cost-insights/src/types/Metric.ts create mode 100644 plugins/cost-insights/src/types/Product.ts create mode 100644 plugins/cost-insights/src/types/Project.ts create mode 100644 plugins/cost-insights/src/types/Theme.ts create mode 100644 plugins/cost-insights/src/types/Trendline.ts create mode 100644 plugins/cost-insights/src/types/index.ts create mode 100644 plugins/cost-insights/src/utils/alerts.tsx create mode 100644 plugins/cost-insights/src/utils/formatters.test.ts create mode 100644 plugins/cost-insights/src/utils/formatters.ts create mode 100644 plugins/cost-insights/src/utils/grammar.ts create mode 100644 plugins/cost-insights/src/utils/graphs.tsx create mode 100644 plugins/cost-insights/src/utils/history.ts create mode 100644 plugins/cost-insights/src/utils/mockData.ts create mode 100644 plugins/cost-insights/src/utils/navigation.tsx create mode 100644 plugins/cost-insights/src/utils/sort.test.ts create mode 100644 plugins/cost-insights/src/utils/sort.ts create mode 100644 plugins/cost-insights/src/utils/styles.ts create mode 100644 plugins/cost-insights/src/utils/tests.tsx diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index de0655cdbc..0aebb18a41 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -6,6 +6,7 @@ * @spotify/backstage-core /docs/features/techdocs @spotify/techdocs-core +/plugins/cost-insights @spotify/silver-lining /plugins/techdocs @spotify/techdocs-core /plugins/techdocs-backend @spotify/techdocs-core /packages/techdocs-cli @spotify/techdocs-core diff --git a/app-config.yaml b/app-config.yaml index a29240fd9c..e54b3b9e60 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -215,3 +215,23 @@ auth: tenantId: $secret: env: AUTH_MICROSOFT_TENANT_ID +costInsights: + engineerCost: 200000 + products: + computeEngine: + name: Compute Engine + icon: compute + cloudDataflow: + name: Cloud Dataflow + icon: data + cloudStorage: + name: Cloud Storage + icon: storage + bigQuery: + name: Big Query + icon: search + metrics: + dailyCost: + name: Your Company's Daily Cost + DAU: + name: Cost Per DAU diff --git a/packages/app/package.json b/packages/app/package.json index 77b1882b47..44d18b833d 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -10,6 +10,7 @@ "@backstage/plugin-api-docs": "^0.1.1-alpha.23", "@backstage/plugin-catalog": "^0.1.1-alpha.23", "@backstage/plugin-circleci": "^0.1.1-alpha.23", + "@backstage/plugin-cost-insights": "^0.1.1-alpha.23", "@backstage/plugin-explore": "^0.1.1-alpha.23", "@backstage/plugin-gcp-projects": "^0.1.1-alpha.23", "@backstage/plugin-github-actions": "^0.1.1-alpha.23", diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index 581a591152..c129bebfa2 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -29,11 +29,15 @@ import { TravisCIApi, travisCIApiRef, } from '@roadiehq/backstage-plugin-travis-ci'; + import { GithubPullRequestsClient, githubPullRequestsApiRef, } from '@roadiehq/backstage-plugin-github-pull-requests'; +import { costInsightsApiRef } from '@backstage/plugin-cost-insights'; +import { ExampleCostInsightsClient } from './plugins/cost-insights'; + export const apis = [ createApiFactory({ api: graphQlBrowseApiRef, @@ -54,6 +58,8 @@ export const apis = [ ]), }), + createApiFactory(costInsightsApiRef, new ExampleCostInsightsClient()), + // TODO: move to plugins createApiFactory(travisCIApiRef, new TravisCIApi()), createApiFactory(githubPullRequestsApiRef, new GithubPullRequestsClient()), diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 03e24cf4ae..45ffca7801 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -23,6 +23,7 @@ import RuleIcon from '@material-ui/icons/AssignmentTurnedIn'; import MapIcon from '@material-ui/icons/MyLocation'; import LibraryBooks from '@material-ui/icons/LibraryBooks'; import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; +import MoneyIcon from '@material-ui/icons/MonetizationOn'; import LogoFull from './LogoFull'; import LogoIcon from './LogoIcon'; import { @@ -94,6 +95,7 @@ const Root: FC<{}> = ({ children }) => ( + { + return new Promise(resolve => setTimeout(resolve, 0, res)); + } + + async getUserGroups(userId: string): Promise { + const groups: Group[] = await this.request({ userId }, [ + { id: 'pied-piper' }, + ]); + + return groups; + } + + async getGroupProjects(group: string): Promise { + const projects: Project[] = await this.request({ group }, [ + { id: 'project-a' }, + { id: 'project-b' }, + { id: 'project-c' }, + ]); + + return projects; + } + + async getGroupDailyCost( + group: string, + metric: string | null, + intervals: string, + ): Promise { + const groupDailyCost: Cost = await this.request( + { group, metric, intervals }, + { + id: metric, // costs with null ids will appear as "All Projects" in Cost Overview panel + aggregation: [ + { date: '2020-08-01', amount: 75_000 / (metric ? 200_000 : 1) }, + { date: '2020-08-02', amount: 120_000 / (metric ? 200_000 : 1) }, + { date: '2020-08-03', amount: 110_000 / (metric ? 200_000 : 1) }, + { date: '2020-08-04', amount: 90_000 / (metric ? 200_000 : 1) }, + { date: '2020-08-05', amount: 80_000 / (metric ? 200_000 : 1) }, + { date: '2020-08-06', amount: 85_000 / (metric ? 200_000 : 1) }, + { date: '2020-08-07', amount: 82_500 / (metric ? 200_000 : 1) }, + { date: '2020-08-08', amount: 100_000 / (metric ? 200_000 : 1) }, + { date: '2020-08-09', amount: 130_000 / (metric ? 200_000 : 1) }, + { date: '2020-08-10', amount: 140_000 / (metric ? 200_000 : 1) }, + ], + change: { + ratio: 0.86, + amount: 65_000, + }, + trendline: { + slope: 0, + intercept: 90_000, + }, + }, + ); + + return groupDailyCost; + } + + async getProjectDailyCost( + project: string, + metric: string | null, + intervals: string, + ): Promise { + const projectDailyCost: Cost = await this.request( + { project, metric, intervals }, + { + id: 'project-a', + aggregation: [ + { date: '2020-08-01', amount: 1000 }, + { date: '2020-08-02', amount: 2000 }, + { date: '2020-08-03', amount: 3000 }, + { date: '2020-08-04', amount: 4000 }, + { date: '2020-08-05', amount: 5000 }, + { date: '2020-08-06', amount: 6000 }, + { date: '2020-08-07', amount: 7000 }, + { date: '2020-08-08', amount: 8000 }, + { date: '2020-08-09', amount: 9000 }, + { date: '2020-08-10', amount: 10_000 }, + ], + change: { + ratio: 0.5, + amount: 10000, + }, + trendline: { + slope: 0, + intercept: 0, + }, + }, + ); + + return projectDailyCost; + } + + async getProductInsights( + product: string, + group: string, + duration: Duration, + ): Promise { + const productInsights: ProductCost = await this.request( + { product, group, duration }, + { + aggregation: [200_000, 250_000], + change: { + ratio: 0.2, + amount: 50_000, + }, + entities: [ + { + id: null, // entities with null ids will be appear as "Unlabeled" in product panels + aggregation: [45_000, 50_000], + }, + { + id: 'entity-a', + aggregation: [15_000, 20_000], + }, + { + id: 'entity-b', + aggregation: [20_000, 30_000], + }, + { + id: 'entity-c', + aggregation: [18_000, 25_000], + }, + { + id: 'entity-d', + aggregation: [36_000, 42_000], + }, + { + id: 'entity-e', + aggregation: [0, 10_000], + }, + { + id: 'entity-f', + aggregation: [17_000, 19_000], + }, + { + id: 'entity-g', + aggregation: [49_000, 30_000], + }, + { + id: 'entity-h', + aggregation: [0, 34_000], + }, + ], + }, + ); + + return productInsights; + } + + async getAlerts(group: string): Promise { + const alerts: Alert[] = await this.request({ group }, [ + { + id: 'projectGrowth', + project: 'example-project', + periodStart: 'Q1 2020', + periodEnd: 'Q2 2020', + aggregation: [60_000, 120_000], + change: { + ratio: 1, + amount: 60000, + }, + products: [ + { + id: 'Compute Engine', + aggregation: [58_000, 118_000], + }, + { + id: 'Cloud Dataflow', + aggregation: [1200, 1500], + }, + { + id: 'Cloud Storage', + aggregation: [800, 500], + }, + ], + }, + ]); + + return alerts; + } +} diff --git a/packages/app/src/plugins/cost-insights/index.ts b/packages/app/src/plugins/cost-insights/index.ts new file mode 100644 index 0000000000..47d540049f --- /dev/null +++ b/packages/app/src/plugins/cost-insights/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ExampleCostInsightsClient } from './ExampleCostInsightsClient'; diff --git a/plugins/cost-insights/.eslintrc.js b/plugins/cost-insights/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/cost-insights/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/cost-insights/.npmrc b/plugins/cost-insights/.npmrc new file mode 100644 index 0000000000..214c29d139 --- /dev/null +++ b/plugins/cost-insights/.npmrc @@ -0,0 +1 @@ +registry=https://registry.npmjs.org/ diff --git a/plugins/cost-insights/README.md b/plugins/cost-insights/README.md new file mode 100644 index 0000000000..21156c10b3 --- /dev/null +++ b/plugins/cost-insights/README.md @@ -0,0 +1,106 @@ +# Cost Insights + +Cost Insights is a plugin to help engineers visualize, understand and optimize their cloud costs. The Cost Insights page shows daily cost data for a team, trends over time, and comparisons with the business metrics you care about. + +At Spotify, we find that cloud costs are optimized organically when: + +- Engineers see cost data in their daily work (that is, in Backstage). +- It's clear when cloud costs need attention. +- The data is shown in software terms familiar to them. +- Alerts and recommendations are targeted and actionable. + +Cost Insights shows trends over time, at the granularity of your software deployments - rather than the cloud provider's concepts. It can be used to troubleshoot cost anomalies, and promote cost-saving infrastructure migrations. + +## Install + +```bash +yarn add @backstage/plugin-cost-insights +``` + +## Setup + +1. Configure `app-config.yaml`. See [Configuration](#configuration). + +2. Create a CostInsights client. Clients must implement the CostInsightsApi interface. See the [API file](https://github.com/spotify/backstage/plugins/cost-insights/src/api/CostInsightsApi.ts) for required methods and documentation. + +```ts +// path/to/CostInsightsClient.ts +import { CostInsightsApi } from '@backstage/plugin-cost-insights'; + +export class CostInsightsClient implements CostInsightsApi { ... } +``` + +3. Import the client and the CostInsights plugin API to your Backstage instance. + +```ts +// packages/app/src/api.ts +import { createApiFactory } from '@backstage/core'; +import { costInsightsApiRef } from '@backstage/plugin-cost-insights'; +import { CostInsightsClient } from './path/to/file'; + +export const apis = [ + createApiFactory({ + api: costInsightsApiRef, + deps: {}, + factory: () => new CostInsightsClient(), + }), +]; +``` + +4. Add cost-insights to your Backstage plugins. + +```ts +// packages/app/src/plugins.ts +export { plugin as CostInsights } from '@backstage/plugin-cost-insights'; +``` + +## Configuration + +Cost Insights has only two required configuration fields: a map of cloud `products` for showing cost breakdowns and `engineerCost` - the average yearly cost of an engineer including benefits. Products must be defined as keys on the `products` field. + +You can optionally supply a product `icon` to display in Cost Insights navigation. See the [type file](https://github.com/spotify/backstage/plugins/cost-insights/types/Icon.ts) for supported types and Material UI icon [mappings](https://github.com/spotify/backstage/plugins/cost-insights/utils/navigation.ts). + +**Note:** Product keys should be unique and camelCased. Backstage does not support underscores in configuration keys. + +### Basic + +```yaml +## ./app-config.yaml +costInsights: + engineerCost: 200000 + products: + productA: + name: Some Cloud Product ## required + icon: storage + productB: + name: Some Other Cloud Product + icon: data +``` + +### Metrics (Optional) + +In the `Cost Overview` panel, users can choose from a dropdown of business metrics to see costs as they relate to a metric, such as daily active users. Metrics must be defined as keys on the `metrics` field. A user-friendly name is **required**. Metrics will be provided to the `getDailyCost` and `getProjectCosts` API methods via the `metric` parameter. + +**Note:** Cost Insights displays daily cost without a metric by default. The dropdown text for this default can be overriden by assigning it a value on the `dailyCost` field. + +```yaml +## ./app-config.yaml +costInsights: + engineerCost: 200000 + products: + productA: + name: Some Cloud Product + icon: storage + productB: + name: Some Other Cloud Product + icon: data + metrics: + dailyCost: + name: Earth Rotation + metricA: + name: Metric A ## required + metricB: + name: Metric B + metricC: + name: Metric C +``` diff --git a/plugins/cost-insights/dev/index.tsx b/plugins/cost-insights/dev/index.tsx new file mode 100644 index 0000000000..812a5585d4 --- /dev/null +++ b/plugins/cost-insights/dev/index.tsx @@ -0,0 +1,20 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createDevApp } from '@backstage/dev-utils'; +import { plugin } from '../src/plugin'; + +createDevApp().registerPlugin(plugin).render(); diff --git a/plugins/cost-insights/package.json b/plugins/cost-insights/package.json new file mode 100644 index 0000000000..96a74cb746 --- /dev/null +++ b/plugins/cost-insights/package.json @@ -0,0 +1,59 @@ +{ + "name": "@backstage/plugin-cost-insights", + "version": "0.1.1-alpha.23", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "build": "backstage-cli plugin:build", + "start": "backstage-cli plugin:serve", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "diff": "backstage-cli plugin:diff", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/config": "^0.1.1-alpha.23", + "@backstage/core": "^0.1.1-alpha.23", + "@backstage/test-utils": "^0.1.1-alpha.23", + "@backstage/theme": "^0.1.1-alpha.23", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", + "@material-ui/styles": "^4.9.6", + "canvas": "^2.6.1", + "classnames": "^2.2.6", + "history": "^5.0.0", + "moment": "^2.27.0", + "qs": "^6.9.4", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-router-dom": "6.0.0-beta.0", + "react-use": "^15.3.3", + "recharts": "^1.8.5" + }, + "devDependencies": { + "@backstage/cli": "^0.1.1-alpha.23", + "@backstage/dev-utils": "^0.1.1-alpha.23", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^10.4.1", + "@testing-library/user-event": "^12.0.7", + "@types/jest": "^26.0.7", + "@types/node": "^12.0.0", + "@types/recharts": "^1.8.14", + "jest-fetch-mock": "^3.0.3", + "msw": "^0.20.5", + "node-fetch": "^2.6.1" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/cost-insights/src/api/CostInsightsApi.ts b/plugins/cost-insights/src/api/CostInsightsApi.ts new file mode 100644 index 0000000000..b7628e2f9e --- /dev/null +++ b/plugins/cost-insights/src/api/CostInsightsApi.ts @@ -0,0 +1,115 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createApiRef } from '@backstage/core'; +import { Alert, Cost, Duration, Group, Project, ProductCost } from '../types'; + +export type CostInsightsApi = { + /** + * Get a list of groups the given user belongs to. These may be LDAP groups or similar + * organizational groups. Cost Insights is designed to show costs based on group membership; + * if a user has multiple groups, they are able to switch between groups to see costs for each. + * + * This method should be removed once the Backstage identity plugin provides the same concept. + */ + getUserGroups(userId: string): Promise; + + /** + * Get a list of cloud billing entities that belong to this group (projects in GCP, AWS has a + * similar concept in billing accounts). These act as filters for the displayed costs, users can + * choose whether they see all costs for a group, or those from a particular owned project. + */ + getGroupProjects(group: string): Promise; + + /** + * Get daily cost aggregations for a given group and interval timeframe. + * + * The return type includes an array of daily cost aggregations as well as statistics about the + * change in cost over the intervals. Calculating these statistics requires us to bucket costs + * into two or more time periods, hence a repeating interval format rather than just a start and + * end date. + * + * The rate of change in this comparison allows teams to reason about their cost growth (or + * reduction) and compare it to metrics important to the business. + * + * @param group The group id from getUserGroups or query parameters + * @param metric A metric from the cost-insights configuration in app-config.yaml. The backend + * should divide the actual daily cost by the corresponding metric for the same date. + * @param intervals An ISO 8601 repeating interval string, such as R2/P1M/2020-09-01 + * https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals + */ + getGroupDailyCost( + group: string, + metric: string | null, + intervals: string, + ): Promise; + + /** + * Get daily cost aggregations for a given billing entity (project in GCP, AWS has a similar + * concept in billing accounts) and interval timeframe. + * + * The return type includes an array of daily cost aggregations as well as statistics about the + * change in cost over the intervals. Calculating these statistics requires us to bucket costs + * into two or more time periods, hence a repeating interval format rather than just a start and + * end date. + * + * The rate of change in this comparison allows teams to reason about the project's cost growth + * (or reduction) and compare it to metrics important to the business. + * + * @param project The project id from getGroupProjects or query parameters + * @param metric A metric from the cost-insights configuration in app-config.yaml. The backend + * should divide the actual daily cost by the corresponding metric for the same date. + * @param intervals An ISO 8601 repeating interval string, such as R2/P1M/2020-09-01 + * https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals + */ + getProjectDailyCost( + project: string, + metric: string | null, + intervals: string, + ): Promise; + + /** + * Get cost aggregations for a particular cloud product and interval timeframe. This includes + * total cost for the product, as well as a breakdown of particular entities that incurred cost + * in this product. The type of entity depends on the product - it may be deployed services, + * storage buckets, managed database instances, etc. + * + * The time period is supplied as a Duration rather than intervals, since this is always expected + * to return data for two bucketed time period (e.g. month vs month, or quarter vs quarter). + * + * @param product The product from the cost-insights configuration in app-config.yaml + * @param group + * @param duration A time duration, such as P1M. See the Duration type for a detailed explanation + * of how the durations are interpreted in Cost Insights. + */ + getProductInsights( + product: string, + group: string, + duration: Duration, + ): Promise; + + /** + * Get current cost alerts for a given group. These show up as Action Items for the group on the + * Cost Insights page. Alerts may include cost-saving recommendations, such as infrastructure + * migrations, or cost-related warnings, such as an unexpected billing anomaly. + */ + getAlerts(group: string): Promise; +}; + +export const costInsightsApiRef = createApiRef({ + id: 'plugin.costinsights.service', + description: 'Provides cost data and alerts for the cost-insights plugin', +}); diff --git a/plugins/cost-insights/src/api/index.ts b/plugins/cost-insights/src/api/index.ts new file mode 100644 index 0000000000..d231570e9b --- /dev/null +++ b/plugins/cost-insights/src/api/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './CostInsightsApi'; diff --git a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.test.tsx b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.test.tsx new file mode 100644 index 0000000000..6e6f935137 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.test.tsx @@ -0,0 +1,50 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { renderInTestApp } from '@backstage/test-utils'; +import AlertActionCard from './AlertActionCard'; +import { AlertType, ProjectGrowthAlert } from '../../types'; +import { getAlertText } from '../../utils/alerts'; +import { MockScrollProvider } from '../../utils/tests'; + +const alert = { + id: AlertType.ProjectGrowth, + aggregation: [500000.8, 970502.8], + project: 'test-project', + periodStart: '2019-10-01', + periodEnd: '2020-03-31', + change: { ratio: 120, amount: 120000 }, + products: [], +} as ProjectGrowthAlert; + +describe('', () => { + it('Renders an alert', async () => { + const rendered = await renderInTestApp( + + , + , + ); + + expect(rendered.getByText('1')).toBeInTheDocument(); + const text = getAlertText(alert); + expect(text).toBeDefined(); + if (text) { + expect(rendered.getByText(text.title)).toBeInTheDocument(); + expect(rendered.getByText(text.subtitle)).toBeInTheDocument(); + } + }); +}); diff --git a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.tsx b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.tsx new file mode 100644 index 0000000000..f568f999f8 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.tsx @@ -0,0 +1,49 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { Avatar, Card, CardHeader } from '@material-ui/core'; +import { useScroll } from '../../hooks'; +import { Alert } from '../../types'; +import { getAlertText, getAlertNavigation } from '../../utils/alerts'; +import { + useAlertActionCardStyles as useStyles, + useAlertActionCardHeader as useHeaderStyles, +} from '../../utils/styles'; + +type AlertActionCardProps = { + alert: Alert; + number: number; +}; + +const AlertActionCard = ({ alert, number }: AlertActionCardProps) => { + const { scrollIntoView } = useScroll(getAlertNavigation(alert, number)); + const headerClasses = useHeaderStyles(); + const text = getAlertText(alert); + const classes = useStyles(); + + return ( + + {number}} + title={text?.title} + subheader={text?.subtitle} + /> + + ); +}; + +export default AlertActionCard; diff --git a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx new file mode 100644 index 0000000000..4ddc26e040 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx @@ -0,0 +1,36 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { FC, Fragment } from 'react'; +import { Paper, Divider } from '@material-ui/core'; +import AlertActionCard from './AlertActionCard'; +import { Alert } from '../../types'; + +type AlertActionCardList = { + alerts: Array; +}; + +const AlertActionCardList: FC = ({ alerts }) => ( + + {alerts.map((alert, index) => ( + + + {index < alerts.length - 1 && } + + ))} + +); + +export default AlertActionCardList; diff --git a/plugins/cost-insights/src/components/AlertActionCardList/index.ts b/plugins/cost-insights/src/components/AlertActionCardList/index.ts new file mode 100644 index 0000000000..cccffd2985 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertActionCardList/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './AlertActionCardList'; diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx new file mode 100644 index 0000000000..59dac3a758 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Grid } from '@material-ui/core'; +import AlertInsightsSection from './AlertInsightsSection'; +import AlertInsightsHeader from './AlertInsightsHeader'; +import { Alert } from '../../types'; +import { renderAlert } from '../../utils/alerts'; + +const title = "Your team's action items"; +const subtitle = + 'This section outlines suggested action items your team can address to improve cloud costs.'; + +type AlertInsightsProps = { + alerts: Array; +}; + +const AlertInsights = ({ alerts }: AlertInsightsProps) => ( + + + + + + {alerts.map((alert, index) => ( + + + + ))} + + +); + +export default AlertInsights; diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsHeader.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsHeader.tsx new file mode 100644 index 0000000000..b08ea01730 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsHeader.tsx @@ -0,0 +1,47 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Box, Typography } from '@material-ui/core'; +import { useCostInsightsStyles as useStyles } from '../../utils/styles'; +import { useScroll } from '../../hooks'; +import { DefaultNavigation } from '../../utils/navigation'; + +type AlertInsightsHeaderProps = { + title: string; + subtitle: string; +}; + +const AlertInsightsHeader = ({ title, subtitle }: AlertInsightsHeaderProps) => { + const classes = useStyles(); + const { ScrollAnchor } = useScroll(DefaultNavigation.AlertInsightsHeader); + return ( + + + + {title}{' '} + + 🎯 + + + + {subtitle} + + + ); +}; + +export default AlertInsightsHeader; diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx new file mode 100644 index 0000000000..c00d924cf2 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx @@ -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 React from 'react'; +import { Box, Button } from '@material-ui/core'; +import AlertInsightsSectionHeader from './AlertInsightsSectionHeader'; +import { + getAlertButtonText, + getAlertText, + getAlertUrl, +} from '../../utils/alerts'; +import { Alert, Currency } from '../../types'; +import { useCurrency } from '../../hooks'; + +type AlertInsightsSectionProps = { + alert: Alert; + number: number; + render: (alert: Alert, currency: Currency) => JSX.Element; +}; + +const AlertInsightsSection = ({ + alert, + number, + render, +}: AlertInsightsSectionProps) => { + const [currency] = useCurrency(); + const text = getAlertText(alert); + const url = getAlertUrl(alert); + const buttonText = getAlertButtonText(alert); + + return ( + + + + + {/* */} + + {render(alert, currency)} + + ); +}; + +export default AlertInsightsSection; diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx new file mode 100644 index 0000000000..5d5f94b9be --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx @@ -0,0 +1,55 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Avatar, Box, Typography, Grid } from '@material-ui/core'; +import { Alert } from '../../types'; +import { getAlertNavigation } from '../../utils/alerts'; +import { useAlertInsightsSectionStyles as useStyles } from '../../utils/styles'; +import { useScroll } from '../../hooks'; + +type AlertInsightsSectionHeaderProps = { + alert: Alert; + number: number; + title: string; + subtitle: string; +}; + +const AlertInsightsSectionHeader = ({ + alert, + number, + title, + subtitle, +}: AlertInsightsSectionHeaderProps) => { + const { ScrollAnchor } = useScroll(getAlertNavigation(alert, number)); + const classes = useStyles(); + return ( + + + + + {number} + + + {title} + {subtitle} + + + + ); +}; + +export default AlertInsightsSectionHeader; diff --git a/plugins/cost-insights/src/components/AlertInsights/index.ts b/plugins/cost-insights/src/components/AlertInsights/index.ts new file mode 100644 index 0000000000..5ed33f4e39 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './AlertInsights'; diff --git a/plugins/cost-insights/src/components/AlertInstructionsLayout/AlertInstructionsLayout.tsx b/plugins/cost-insights/src/components/AlertInstructionsLayout/AlertInstructionsLayout.tsx new file mode 100644 index 0000000000..0a422c335a --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInstructionsLayout/AlertInstructionsLayout.tsx @@ -0,0 +1,61 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ReactNode } from 'react'; +import { Box, Button, Container, makeStyles } from '@material-ui/core'; +import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'; +import { Header, Page, pageTheme } from '@backstage/core'; +import { CostInsightsThemeProvider } from '../CostInsightsPage/CostInsightsThemeProvider'; + +const useStyles = makeStyles(theme => ({ + root: { + gridArea: 'pageContent', + padding: theme.spacing(4), + }, +})); + +type AlertInstructionsLayoutProps = { + title: string; + children: ReactNode; +}; + +const AlertInstructionsLayout = ({ + title, + children, +}: AlertInstructionsLayoutProps) => { + const classes = useStyles(); + return ( + + +
+ + + + + {children} + + + + ); +}; + +export default AlertInstructionsLayout; diff --git a/plugins/cost-insights/src/components/AlertInstructionsLayout/index.ts b/plugins/cost-insights/src/components/AlertInstructionsLayout/index.ts new file mode 100644 index 0000000000..4063fe06d9 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInstructionsLayout/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './AlertInstructionsLayout'; diff --git a/plugins/cost-insights/src/components/BarChart/BarChart.test.tsx b/plugins/cost-insights/src/components/BarChart/BarChart.test.tsx new file mode 100644 index 0000000000..a39c5bafc2 --- /dev/null +++ b/plugins/cost-insights/src/components/BarChart/BarChart.test.tsx @@ -0,0 +1,191 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { TooltipPayload } from 'recharts'; +import { fireEvent } from '@testing-library/react'; +import BarChart, { BarChartProps } from './BarChart'; +import { BarChartData, ResourceData } from '../../types'; +import { createMockEntity } from '../../utils/mockData'; +import { resourceSort } from '../../utils/sort'; +import { renderInTestApp } from '@backstage/test-utils'; +import { TooltipItemProps } from '../Tooltip'; +import { costInsightsLightTheme } from '../../utils/styles'; + +const MockEntities = [...Array(10)].map((_, index) => + createMockEntity(() => ({ + id: `test-id-${index + 1}`, + // grow resource costs linearly for testing + aggregation: [index * 1000, (index + 1) * 1000], + })), +); + +const MockBarChartData: BarChartData = { + previousFill: costInsightsLightTheme.palette.yellow, + currentFill: costInsightsLightTheme.palette.darkBlue, + previousName: 'Last Period', + currentName: 'Current Period', +}; + +const MockResources: ResourceData[] = MockEntities.map(entity => ({ + name: entity.id, + previous: entity.aggregation[0], + current: entity.aggregation[1], +})); + +const MockTooltipItem = (payload: TooltipPayload): TooltipItemProps => ({ + label: payload.name, + value: payload.value as string, + fill: payload.fill as string, +}); + +const renderWithProps = ({ + responsive = false, + displayAmount = 6, + barChartData = MockBarChartData, + getTooltipItem = MockTooltipItem, + resources = MockResources, +}: BarChartProps) => { + return renderInTestApp( + , + ); +}; + +describe('', () => { + it('Renders without exploding', async () => { + const rendered = await renderWithProps({} as BarChartProps); + expect(rendered.getByText('test-id-10')).toBeInTheDocument(); + }); + + it('Should display only 6 resources by default, sorted by cost', async () => { + const rendered = await renderWithProps({} as BarChartProps); + + MockResources.sort(resourceSort).forEach((resource, index) => { + if (index < 6) { + expect(rendered.getByText(resource.name!)).toBeInTheDocument(); + } else { + expect(rendered.queryByText(resource.name!)).not.toBeInTheDocument(); + } + }); + }); + + describe('Stepper', () => { + it('should not display stepper if displaying less than 6 resources', async () => { + const rendered = await renderWithProps({ + resources: MockResources.slice(0, 3), + } as BarChartProps); + expect( + rendered.queryByTestId('bar-chart-stepper'), + ).not.toBeInTheDocument(); + }); + + it('should display stepper if displaying more than 6 resources', async () => { + const rendered = await renderWithProps({} as BarChartProps); + expect(rendered.queryByTestId('bar-chart-stepper')).toBeInTheDocument(); + }); + + it('should display the next step button if resources are remaining', async () => { + const rendered = await renderWithProps({} as BarChartProps); + fireEvent.mouseEnter(rendered.getByTestId('bar-chart-wrapper')); + expect( + rendered.queryByTestId('bar-chart-stepper-button-back'), + ).not.toBeInTheDocument(); + expect( + rendered.queryByTestId('bar-chart-stepper-button-next'), + ).toBeInTheDocument(); + }); + + it('should display the correct amount of dots for the amount of resources', async () => { + const rendered = await renderWithProps({} as BarChartProps); + fireEvent.mouseEnter(rendered.getByTestId('bar-chart-wrapper')); + expect(rendered.queryAllByTestId('bar-chart-step').length).toEqual(2); + }); + + it('should not display the stepper or stepper buttons when the amount of resources is equal to the displayMax', async () => { + const MockEqualEntities = [...Array(5)].map(createMockEntity); + const MockEqualResources = MockEqualEntities.map(entity => ({ + name: entity.id, + previous: entity.aggregation[0], + current: entity.aggregation[1], + })); + + const rendered = await renderWithProps({ + displayAmount: 5, + resources: MockEqualResources, + } as BarChartProps); + expect( + rendered.queryByTestId('bar-chart-stepper'), + ).not.toBeInTheDocument(); + expect( + rendered.queryByTestId('bar-chart-stepper-button-back'), + ).not.toBeInTheDocument(); + expect( + rendered.queryByTestId('bar-chart-stepper-button-next'), + ).not.toBeInTheDocument(); + }); + + it('should display the correct resources while scrolling', async () => { + const rendered = await renderWithProps({ + displayAmount: 7, + } as BarChartProps); + + const sortedByCost = MockResources.sort(resourceSort); + + sortedByCost.slice(0, 7).forEach(resource => { + expect(rendered.getByText(resource.name!)).toBeInTheDocument(); + }); + + fireEvent.mouseEnter(rendered.getByTestId('bar-chart-wrapper')); + fireEvent.click(rendered.getByTestId('bar-chart-stepper-button-next')); + + sortedByCost.slice(7, 12).forEach(resource => { + expect(rendered.getByText(resource.name!)).toBeInTheDocument(); + }); + }); + + it('should display the correct amount of dots for a large amount of resources while scrolling', async () => { + const MockLargeEntities = [...Array(68)].map(createMockEntity); + const MockLargeResources = MockLargeEntities.map(entity => ({ + name: entity.id, + previous: entity.aggregation[0], + current: entity.aggregation[1], + })); + const rendered = await renderWithProps({ + resources: MockLargeResources, + } as BarChartProps); + + fireEvent.mouseEnter(rendered.getByTestId('bar-chart-wrapper')); + + const NextButton = rendered.getByTestId('bar-chart-stepper-button-next'); + + [...Array(9)].forEach(() => { + fireEvent.click(NextButton); + expect(rendered.queryAllByTestId('bar-chart-step').length).toEqual(10); + }); + + [...Array(2)].forEach(() => { + fireEvent.click(NextButton); + expect(rendered.queryAllByTestId('bar-chart-step').length).toEqual(2); + }); + }); + }); +}); diff --git a/plugins/cost-insights/src/components/BarChart/BarChart.tsx b/plugins/cost-insights/src/components/BarChart/BarChart.tsx new file mode 100644 index 0000000000..2f923304a8 --- /dev/null +++ b/plugins/cost-insights/src/components/BarChart/BarChart.tsx @@ -0,0 +1,175 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState, useCallback } from 'react'; +import { + Bar, + BarChart as RechartsBarChart, + CartesianGrid, + ContentRenderer, + TooltipProps, + ResponsiveContainer, + Tooltip as RechartsTooltip, + XAxis, + YAxis, + TooltipPayload, +} from 'recharts'; +import { Box, useTheme } from '@material-ui/core'; +import BarChartTick from './BarChartTick'; +import BarChartStepper from './BarChartStepper'; +import Tooltip, { TooltipItemProps } from '../Tooltip'; + +import { currencyFormatter } from '../../utils/formatters'; +import { + BarChartData, + Maybe, + notEmpty, + ResourceData, + DataKey, + CostInsightsTheme, +} from '../../types'; +import { useBarChartStyles } from '../../utils/styles'; +import { resourceSort } from '../../utils/sort'; + +export type BarChartProps = { + responsive?: boolean; + displayAmount?: number; + barChartData: BarChartData; + getTooltipItem: (payload: TooltipPayload) => Maybe; + resources: ResourceData[]; +}; + +const BarChart = ({ + responsive = true, + displayAmount = 6, + barChartData, + getTooltipItem, + resources, +}: BarChartProps) => { + const theme = useTheme(); + const styles = useBarChartStyles(theme); + const [activeChart, setActiveChart] = useState(false); + const [stepWindow, setStepWindow] = useState(() => [0, displayAmount]); + + const { previousFill, currentFill, previousName, currentName } = barChartData; + + const [stepStart, stepEnd] = stepWindow; + const steps = Math.ceil(resources.length / displayAmount); + const disableStepper = resources.length <= displayAmount; + + const sortedResources = resources + .sort(resourceSort) + .slice(stepStart, stepEnd); + + // Pin the domain to the largest value in the series. + // Intentially redundant - This could simply be derived from the first element in the already sorted list, + // but that may not be the case in the future when custom sorting is implemented. + const globalResourcesMax = resources.reduce( + (max, r: ResourceData) => Math.max(max, r.current, r.previous), + 0, + ); + + const onStepChange = useCallback( + (activeStep: number) => { + const start = activeStep * displayAmount; + const end = start + displayAmount; + if (end > resources.length) { + setStepWindow([start, resources.length]); + } else { + setStepWindow([start, end]); + } + }, + [setStepWindow, resources, displayAmount], + ); + + const handleChartEnter = () => setActiveChart(true); + + const handleChartLeave = () => setActiveChart(false); + + const renderTooltipContent: ContentRenderer = ({ + label, + payload, + }) => { + if (!(payload && typeof label === 'string')) return [null, null]; + const items = payload.map(getTooltipItem).filter(notEmpty); + return ; + }; + + return ( + + {/* Setting fixed values for height and width generates a console warning in testing but enables ResponsiveContainer to render its children. */} + + + + + + 0, globalResourcesMax]} + tick={{ fill: styles.axis.fill }} + /> + + + + + {!disableStepper && ( + + )} + + ); +}; + +export default BarChart; diff --git a/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx b/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx new file mode 100644 index 0000000000..3e12c18f90 --- /dev/null +++ b/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Box } from '@material-ui/core'; +import { useBarChartLabelStyles } from '../../utils/styles'; + +type BarChartLabel = { + x: number; + y: number; + height: number; + width: number; + children?: React.ReactNode; +}; + +const BarChartLabel = ({ x, y, height, width, children }: BarChartLabel) => { + const classes = useBarChartLabelStyles(); + const translateX = width * -0.5; + const childArray = React.Children.toArray(children); + + return ( + + + {childArray[0]} + {childArray.slice(1)} + + + ); +}; + +export default BarChartLabel; diff --git a/plugins/cost-insights/src/components/BarChart/BarChartStepper.tsx b/plugins/cost-insights/src/components/BarChart/BarChartStepper.tsx new file mode 100644 index 0000000000..27f7a589f3 --- /dev/null +++ b/plugins/cost-insights/src/components/BarChart/BarChartStepper.tsx @@ -0,0 +1,115 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect, useState } from 'react'; +import { Paper, Slide } from '@material-ui/core'; +import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'; +import ChevronRightIcon from '@material-ui/icons/ChevronRight'; +import BarChartStepperButton from './BarChartStepperButton'; +import BarChartSteps from './BarChartSteps'; +import { useBarChartStepperStyles } from '../../utils/styles'; + +type BarChartStepperProps = { + disableScroll: boolean; + steps: number; + onChange: (activeStep: number) => void; +}; + +const BarChartStepper = ({ + steps, + disableScroll, + onChange, +}: BarChartStepperProps) => { + const classes = useBarChartStepperStyles(); + const [activeStep, setActiveStep] = useState(0); + + /* + * This calc determines how many active steps to display in the stepper. + * If the chart is displaying a large amount of resources, + * the total dots are truncated to 10. As the user clicks forward, + * eventually, there might be resources "left over" in excess of the ten dot limit. + * Once the user has reached that threshold, the difference should appear constant + * as the user clicks through the remaining resources and no extra dots should be displayed. + */ + + const diff = steps % 10; + const stepsRemaining = steps - activeStep <= diff ? diff : steps; + const displayedStep = activeStep % 10; + + useEffect(() => { + onChange(activeStep); + }, [activeStep, onChange]); + + const handleNext = () => { + setActiveStep(prevStep => prevStep + 1); + }; + + const handleBack = () => { + setActiveStep(prevStep => prevStep - 1); + }; + + const handleClick = (index: number) => { + setActiveStep(prevStep => { + const offset = index - (prevStep % 10); + return prevStep + offset; + }); + }; + + return ( + + + + + + + + + + + + + + ); +}; + +export default BarChartStepper; diff --git a/plugins/cost-insights/src/components/BarChart/BarChartStepperButton.tsx b/plugins/cost-insights/src/components/BarChart/BarChartStepperButton.tsx new file mode 100644 index 0000000000..b668ffaa5c --- /dev/null +++ b/plugins/cost-insights/src/components/BarChart/BarChartStepperButton.tsx @@ -0,0 +1,46 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { forwardRef, Ref } from 'react'; +import { ButtonBase, ButtonBaseProps } from '@material-ui/core'; +import { useBarChartStepperButtonStyles as useStyles } from '../../utils/styles'; + +interface BarChartStepperButtonProps extends ButtonBaseProps { + name: string; + children?: React.ReactNode; +} + +const BarChartStepperButton = forwardRef( + ( + { name, children, ...buttonBaseProps }: BarChartStepperButtonProps, + ref: Ref, + ) => { + const classes = useStyles(); + return ( + + {children} + + ); + }, +); + +export default BarChartStepperButton; diff --git a/plugins/cost-insights/src/components/BarChart/BarChartSteps.tsx b/plugins/cost-insights/src/components/BarChart/BarChartSteps.tsx new file mode 100644 index 0000000000..1ca0c7082a --- /dev/null +++ b/plugins/cost-insights/src/components/BarChart/BarChartSteps.tsx @@ -0,0 +1,52 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { ButtonBase } from '@material-ui/core'; +import { useBarChartStepperStyles as useStyles } from '../../utils/styles'; + +export type BarChartSteps = { + steps: number; + activeStep: number; + onClick: (index: number) => void; +}; + +const BarChartSteps = ({ steps, activeStep, onClick }: BarChartSteps) => { + const classes = useStyles(); + const handleOnClick = (index: number) => ( + event: React.MouseEvent, + ) => { + event.preventDefault(); + onClick(index); + }; + + return ( +
+ {[...new Array(steps)].map((_, index) => ( + +
+ + ))} +
+ ); +}; + +export default BarChartSteps; diff --git a/plugins/cost-insights/src/components/BarChart/BarChartTick.tsx b/plugins/cost-insights/src/components/BarChart/BarChartTick.tsx new file mode 100644 index 0000000000..99158c4573 --- /dev/null +++ b/plugins/cost-insights/src/components/BarChart/BarChartTick.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import BarChartLabel from './BarChartLabel'; + +type BarChartTickProps = { + x: number; + y: number; + height: number; + width: number; + payload: { + value: any; + }; + visibleTicksCount: number; +}; + +export const BarChartTick = ({ + x, + y, + height, + width, + payload, + visibleTicksCount, +}: BarChartTickProps) => { + const gutterWidth = 5; + const labelWidth = width / visibleTicksCount - gutterWidth * 2; + return ( + + {!payload.value ? 'Unlabeled' : payload.value} + + ); +}; + +export default BarChartTick; diff --git a/plugins/cost-insights/src/components/BarChart/index.ts b/plugins/cost-insights/src/components/BarChart/index.ts new file mode 100644 index 0000000000..255bd2b3d8 --- /dev/null +++ b/plugins/cost-insights/src/components/BarChart/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './BarChart'; diff --git a/plugins/cost-insights/src/components/CopyUrlToClipboard/CopyUrlToClipboard.tsx b/plugins/cost-insights/src/components/CopyUrlToClipboard/CopyUrlToClipboard.tsx new file mode 100644 index 0000000000..c801eb86a5 --- /dev/null +++ b/plugins/cost-insights/src/components/CopyUrlToClipboard/CopyUrlToClipboard.tsx @@ -0,0 +1,70 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect, useState } from 'react'; +import { useLocation } from 'react-router-dom'; +import { useCopyToClipboard } from 'react-use'; +import { Tooltip, IconButton } from '@material-ui/core'; +import AssignmentOutlinedIcon from '@material-ui/icons/AssignmentOutlined'; +import AssignmentTurnedInOutlinedIcon from '@material-ui/icons/AssignmentTurnedInOutlined'; +import SentimentVeryDissatisfiedIcon from '@material-ui/icons/SentimentVeryDissatisfied'; + +const ClipboardMessage = { + default: 'Copy URL to clipboard', + success: 'Copied!', + error: "Couldn't copy to clipboard", +}; + +const CopyUrlToClipboard = () => { + const location = useLocation(); + const [state, copyToClipboard] = useCopyToClipboard(); + const [copied, setCopied] = useState(false); + + const origin = window.location.origin; + const pathname = location.pathname; + const search = location.search; + const url = `${origin}${pathname}${search}`; + + useEffect(() => { + if (state.error) { + setCopied(false); + } else if (state.value) { + setCopied(true); + setTimeout(setCopied, 1500, false); + } + }, [state]); + + let text = ClipboardMessage.default; + let Icon = AssignmentOutlinedIcon; + + if (state.error) { + text = ClipboardMessage.error; + Icon = SentimentVeryDissatisfiedIcon; + } else if (copied) { + text = ClipboardMessage.success; + Icon = AssignmentTurnedInOutlinedIcon; + } + + return ( + + copyToClipboard(url)}> + + + + ); +}; + +export default CopyUrlToClipboard; diff --git a/plugins/cost-insights/src/components/CopyUrlToClipboard/index.ts b/plugins/cost-insights/src/components/CopyUrlToClipboard/index.ts new file mode 100644 index 0000000000..15e2318c56 --- /dev/null +++ b/plugins/cost-insights/src/components/CopyUrlToClipboard/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './CopyUrlToClipboard'; diff --git a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx new file mode 100644 index 0000000000..6302d9929f --- /dev/null +++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx @@ -0,0 +1,113 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ReactNode } from 'react'; +import { renderInTestApp } from '@backstage/test-utils'; +import CostGrowth from './CostGrowth'; +import { + defaultCurrencies, + Currency, + CurrencyType, + Duration, + findAlways, +} from '../../types'; +import { MockConfigProvider, MockCurrencyProvider } from '../../utils/tests'; + +const engineers = findAlways(defaultCurrencies, c => c.kind === null); +const usd = findAlways(defaultCurrencies, c => c.kind === CurrencyType.USD); +const carbon = findAlways( + defaultCurrencies, + c => c.kind === CurrencyType.CarbonOffsetTons, +); + +const MockContext = ({ + children, + currency, + engineerCost, +}: { + children: ReactNode; + currency: Currency; + engineerCost: number; +}) => ( + + + {children} + + +); + +describe.each` + engineerCost | ratio | amount | expected + ${200_000} | ${0} | ${0} | ${'Negligible'} + ${200_000} | ${0} | ${8_333} | ${'Negligible'} + ${200_000} | ${0.000000001} | ${8_334} | ${`0% or ~1 ${engineers.unit}`} + ${200_000} | ${-0.000000001} | ${10_000} | ${`0% or ~1 ${engineers.unit}`} + ${200_000} | ${-0.8} | ${10_000} | ${`80% or ~1 ${engineers.unit}`} + ${200_000} | ${3} | ${600_000} | ${`300% or ~36 ${engineers.unit}s`} +`('', ({ engineerCost, ratio, amount, expected }) => { + it(`formats ${engineers.unit}s correctly for ${expected}`, async () => { + const { getByText } = await renderInTestApp( + + + , + ); + expect(getByText(expected)).toBeInTheDocument(); + }); +}); + +describe.each` + engineerCost | ratio | amount | expected + ${200_000} | ${0} | ${0} | ${'Negligible'} + ${200_000} | ${0} | ${8_333} | ${'Negligible'} + ${200_000} | ${0.000000001} | ${8_334} | ${'0% or ~$8,334'} + ${200_000} | ${-0.000000001} | ${10_000} | ${'0% or ~$10,000'} + ${200_000} | ${-0.8} | ${10_000} | ${'80% or ~$10,000'} + ${200_000} | ${3} | ${600_000} | ${'300% or ~$600,000'} +`('', ({ engineerCost, ratio, amount, expected }) => { + it(`formats ${usd.unit}s correctly for ${expected}`, async () => { + const { getByText } = await renderInTestApp( + + + , + ); + expect(getByText(expected)).toBeInTheDocument(); + }); +}); + +describe.each` + engineerCost | ratio | amount | expected + ${200_000} | ${0} | ${0} | ${'Negligible'} + ${200_000} | ${0} | ${8_333} | ${'Negligible'} + ${200_000} | ${0.000000001} | ${8_334} | ${`0% or ~2,381 ${carbon.unit}s`} + ${200_000} | ${-0.000000001} | ${10_000} | ${`0% or ~2,857 ${carbon.unit}s`} + ${200_000} | ${-0.8} | ${10_000} | ${`80% or ~2,857 ${carbon.unit}s`} + ${200_000} | ${3} | ${600_000} | ${`300% or ~171,429 ${carbon.unit}s`} +`('', ({ engineerCost, ratio, amount, expected }) => { + it(`formats ${carbon.unit}s correctly for ${expected}`, async () => { + const { getByText } = await renderInTestApp( + + + , + ); + expect(getByText(expected)).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx new file mode 100644 index 0000000000..0c0db2d62c --- /dev/null +++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx @@ -0,0 +1,74 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import classnames from 'classnames'; +import { + ChangeStatistic, + CurrencyType, + Duration, + EngineerThreshold, + Growth, + growthOf, + rateOf, +} from '../../types'; +import { useCostGrowthStyles as useStyles } from '../../utils/styles'; +import { formatPercent, formatCurrency } from '../../utils/formatters'; +import { indefiniteArticleOf } from '../../utils/grammar'; +import { useConfig, useCurrency } from '../../hooks'; + +export type CostGrowthProps = { + change: ChangeStatistic; + duration: Duration; +}; + +const CostGrowth = ({ change, duration }: CostGrowthProps) => { + const styles = useStyles(); + const { engineerCost } = useConfig(); + const [currency] = useCurrency(); + + // Only display costs in absolute values + const amount = Math.abs(change.amount); + const ratio = Math.abs(change.ratio); + + const rate = rateOf(engineerCost, duration); + const engineers = amount / rate; + const converted = amount / (currency.rate ?? rate); + + // Determine if growth is significant enough to highlight + const growth = growthOf(engineers, change.ratio); + const classes = classnames({ + [styles.excess]: growth === Growth.Excess, + [styles.savings]: growth === Growth.Savings, + }); + + const percent = formatPercent(ratio); + + let cost = `${percent} or ~${formatCurrency(converted, currency.unit)}`; + // Always display the converted value but use the cost in engineers + // to determine negligibility, as costs should be time-period aware + if (engineers < EngineerThreshold) { + cost = 'Negligible'; + } else if (currency.kind === CurrencyType.USD) { + cost = `${percent} or ~${currency.prefix}${formatCurrency(converted)}`; + } else if (amount < 1) { + cost = `less than ${indefiniteArticleOf(['a', 'an'], currency.unit)}`; + } + + return {cost}; +}; + +export default CostGrowth; diff --git a/plugins/cost-insights/src/components/CostGrowth/index.ts b/plugins/cost-insights/src/components/CostGrowth/index.ts new file mode 100644 index 0000000000..3cd809ee49 --- /dev/null +++ b/plugins/cost-insights/src/components/CostGrowth/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './CostGrowth'; diff --git a/plugins/cost-insights/src/components/CostInsightsHeader/CostInsightsHeader.test.tsx b/plugins/cost-insights/src/components/CostInsightsHeader/CostInsightsHeader.test.tsx new file mode 100644 index 0000000000..8af5091188 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsHeader/CostInsightsHeader.test.tsx @@ -0,0 +1,95 @@ +/* + * 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 CostInsightsHeader from './CostInsightsHeader'; +import { renderInTestApp } from '@backstage/test-utils'; +import { + ApiProvider, + ApiRegistry, + IdentityApi, + identityApiRef, +} from '@backstage/core'; +import React from 'react'; + +describe('', () => { + const identityApi: Partial = { + getProfile: () => ({ + email: 'test-email@example.com', + displayName: 'User 1', + }), + }; + + const apis = ApiRegistry.from([[identityApiRef, identityApi]]); + + it('Shows nothing to do when no alerts exist', async () => { + const rendered = await renderInTestApp( + + + , + ); + + expect(rendered.queryByText(/doing great/)).toBeInTheDocument(); + }); + + it('Shows work to do when alerts > 1', async () => { + const rendered = await renderInTestApp( + + + , + ); + expect(rendered.queryByText(/few things/)).toBeInTheDocument(); + }); + + it('Handles grammar with a single alert', async () => { + const rendered = await renderInTestApp( + + + , + ); + + expect(rendered.queryByText(/things/)).not.toBeInTheDocument(); + expect(rendered.queryByText(/one thing/)).toBeInTheDocument(); + }); + + it('Shows no costs when hasCostData is false', async () => { + const rendered = await renderInTestApp( + + + , + ); + expect(rendered.queryByText(/this is awkward/)).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/CostInsightsHeader/CostInsightsHeader.tsx b/plugins/cost-insights/src/components/CostInsightsHeader/CostInsightsHeader.tsx new file mode 100644 index 0000000000..57fa26b5fc --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsHeader/CostInsightsHeader.tsx @@ -0,0 +1,136 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Typography } from '@material-ui/core'; +import { identityApiRef, ProfileInfo, useApi } from '@backstage/core'; +import { useCostInsightsStyles } from '../../utils/styles'; +import { Group } from '../../types'; + +function name(profile: ProfileInfo | undefined): string { + return profile?.displayName || 'Mysterious Stranger'; +} + +type CostInsightsHeaderProps = { + owner: string; + groups: Group[]; + hasCostData: boolean; + alerts: number; +}; + +const CostInsightsHeader = (props: CostInsightsHeaderProps) => { + if (!props.hasCostData) { + return ; + } + if (props.alerts) { + return ; + } + return ; +}; + +const CostInsightsHeaderNoData = ({ + owner, + groups, +}: CostInsightsHeaderProps) => { + const profile = useApi(identityApiRef).getProfile(); + const classes = useCostInsightsStyles(); + const hasMultipleGroups = groups.length > 1; + + return ( + <> + + + 😳 + {' '} + Well this is awkward + + + Hey, {name(profile)}! {owner} doesn't seem to have any + cloud costs. + + {hasMultipleGroups && ( + + Maybe we picked the wrong team, choose another from the menu above? + + )} + + ); +}; + +const CostInsightsHeaderAlerts = ({ + owner, + alerts, +}: CostInsightsHeaderProps) => { + const profile = useApi(identityApiRef).getProfile(); + const classes = useCostInsightsStyles(); + + return ( + <> + + + 🔎 + {' '} + You have {alerts} thing{alerts > 1 && 's'} to look into + + + Hey, {name(profile)}! We've identified{' '} + {alerts > 1 ? 'a few things ' : 'one thing '} + {owner} should look into next. + + + ); +}; + +const CostInsightsHeaderNoAlerts = ({ owner }: CostInsightsHeaderProps) => { + const profile = useApi(identityApiRef).getProfile(); + const classes = useCostInsightsStyles(); + + return ( + <> + + + 👍 + {' '} + Your team is doing great + + + Hey, {name(profile)}! {owner} is doing well. No major + changes this month. + + + ); +}; + +export const CostInsightsHeaderNoGroups = () => { + const profile = useApi(identityApiRef).getProfile(); + const classes = useCostInsightsStyles(); + return ( + <> + + + 😳 + {' '} + Well this is awkward + + + Hey, {name(profile)}! It doesn't look like you belong to any + teams. + + + ); +}; + +export default CostInsightsHeader; diff --git a/plugins/cost-insights/src/components/CostInsightsHeader/index.ts b/plugins/cost-insights/src/components/CostInsightsHeader/index.ts new file mode 100644 index 0000000000..8e7ddcc9c8 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsHeader/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default, CostInsightsHeaderNoGroups } from './CostInsightsHeader'; diff --git a/plugins/cost-insights/src/components/CostInsightsLayout/CostInsightsLayout.tsx b/plugins/cost-insights/src/components/CostInsightsLayout/CostInsightsLayout.tsx new file mode 100644 index 0000000000..61bf7ac22f --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsLayout/CostInsightsLayout.tsx @@ -0,0 +1,59 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { makeStyles } from '@material-ui/core'; +import { Header, Page, pageTheme } from '@backstage/core'; +import { Group } from '../../types'; +import CostInsightsTabs from '../CostInsightsTabs'; + +const useStyles = makeStyles(theme => ({ + root: { + gridArea: 'pageContent', + }, + header: { + boxShadow: 'none', + }, + content: { + padding: theme.spacing(4), + }, +})); + +type CostInsightsLayoutProps = { + title?: string; + groups: Group[]; + children?: React.ReactNode; +}; + +const CostInsightsLayout = ({ groups, children }: CostInsightsLayoutProps) => { + const classes = useStyles(); + return ( + +
+
+ +
{children}
+
+ + ); +}; + +export default CostInsightsLayout; diff --git a/plugins/cost-insights/src/components/CostInsightsLayout/index.ts b/plugins/cost-insights/src/components/CostInsightsLayout/index.ts new file mode 100644 index 0000000000..dd35eb2855 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsLayout/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './CostInsightsLayout'; diff --git a/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.test.tsx b/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.test.tsx new file mode 100644 index 0000000000..3ec2828029 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.test.tsx @@ -0,0 +1,79 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { default as HappyFace } from '@material-ui/icons/SentimentSatisfiedAlt'; +import { renderInTestApp } from '@backstage/test-utils'; +import CostInsightsNavigation from './CostInsightsNavigation'; +import { defaultCurrencies, Metric, Product, Icon } from '../../types'; +import { MockConfigProvider, MockScrollProvider } from '../../utils/tests'; +import { getDefaultNavigationItems } from '../../utils/navigation'; + +const mockIcons: Icon[] = [ + { + kind: 'some-product', + component: , + }, +]; + +const mockProducts: Product[] = [ + { + kind: 'some-product', + name: 'Some Product', + }, +]; + +const mockMetrics: Metric[] = [ + { + kind: 'some-metric', + name: 'Some Metric', + }, +]; + +const renderWrapped = (children: React.ReactNode) => + renderInTestApp( + + {children} + , + ); + +describe('', () => { + it('should render each navigation item', async () => { + const { getByText } = await renderWrapped( + , + ); + getDefaultNavigationItems(3) + .map(item => item.title) + .concat(mockProducts.map(p => p.name)) + .forEach(name => expect(getByText(name)).toBeInTheDocument()); + }); + + it('should not display action items navigation if there are no action items', async () => { + const rendered = await renderWrapped(); + expect(rendered.queryByText(/Action Items/)).not.toBeInTheDocument(); + }); + + it('should display the correct amount of action items in the badge', async () => { + const rendered = await renderWrapped(); + expect(rendered.getByText(/3/)).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx b/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx new file mode 100644 index 0000000000..fdbabe0402 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx @@ -0,0 +1,97 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + MenuList, + MenuItem, + ListItemIcon, + ListItemText, + Typography, + Badge, +} from '@material-ui/core'; +import { useNavigationStyles } from '../../utils/styles'; +import { useConfig, useScroll } from '../../hooks'; +import { findAlways } from '../../types'; +import { + DefaultNavigation, + NavigationItem, + getDefaultNavigationItems, +} from '../../utils/navigation'; + +type CostInsightsNavigationProps = { + alerts: number; +}; + +const CostInsightsNavigation = ({ alerts }: CostInsightsNavigationProps) => { + const classes = useNavigationStyles(); + const { products, icons } = useConfig(); + + const productNavigationItems: NavigationItem[] = products.map(product => ({ + navigation: product.kind, + icon: findAlways(icons, i => i.kind === product.kind).component, + title: product.name, + })); + + const navigationItems = getDefaultNavigationItems(alerts).concat( + productNavigationItems, + ); + + return ( + + {navigationItems.map((item: NavigationItem) => ( + + {React.cloneElement(item.icon, { + className: classes.navigationIcon, + })} + + ) : ( + React.cloneElement(item.icon, { + className: classes.navigationIcon, + }) + ) + } + title={item.title} + /> + ))} + + ); +}; + +const NavigationMenuItem = ({ navigation, icon, title }: NavigationItem) => { + const classes = useNavigationStyles(); + const { scrollIntoView } = useScroll(navigation); + return ( + + {icon} + {title}} + /> + + ); +}; + +export default CostInsightsNavigation; diff --git a/plugins/cost-insights/src/components/CostInsightsNavigation/index.ts b/plugins/cost-insights/src/components/CostInsightsNavigation/index.ts new file mode 100644 index 0000000000..6c32ba7677 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsNavigation/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './CostInsightsNavigation'; diff --git a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx new file mode 100644 index 0000000000..c426eff8bc --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx @@ -0,0 +1,249 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useCallback, useEffect, useState } from 'react'; +import { Box, Container, Divider, Grid } from '@material-ui/core'; +import { Progress, useApi, featureFlagsApiRef } from '@backstage/core'; +import { default as MaterialAlert } from '@material-ui/lab/Alert'; +import { costInsightsApiRef } from '../../api'; +import AlertActionCardList from '../AlertActionCardList'; +import AlertInsights from '../AlertInsights'; +import CostInsightsLayout from '../CostInsightsLayout'; +import CopyUrlToClipboard from '../CopyUrlToClipboard'; +import CurrencySelect from '../CurrencySelect'; +import WhyCostsMatter from '../WhyCostsMatter'; +import CostInsightsHeader, { + CostInsightsHeaderNoGroups, +} from '../CostInsightsHeader'; +import CostInsightsNavigation from '../CostInsightsNavigation'; +import CostOverviewCard from '../CostOverviewCard'; +import ProductInsights from '../ProductInsights'; +import CostInsightsSupportButton from '../CostInsightsSupportButton'; +import { + useLoading, + useFilters, + useGroups, + useCurrency, + useConfig, +} from '../../hooks'; +import { Alert, Cost, intervalsOf, Maybe, Project } from '../../types'; +import { mapLoadingToProps } from './selector'; + +const CostInsightsPage = () => { + const flags = useApi(featureFlagsApiRef).getFlags(); + // There is not currently a UI to set feature flags + // flags.set('cost-insights-currencies', FeatureFlagState.On); + const client = useApi(costInsightsApiRef); + const { currencies } = useConfig(); + const groups = useGroups(); + const [currency, setCurrency] = useCurrency(); + const [projects, setProjects] = useState>(null); + const [dailyCost, setDailyCost] = useState>(null); + const [alerts, setAlerts] = useState>(null); + const [error, setError] = useState>(null); + + const { pageFilters } = useFilters(p => p); + const { + loadingActions, + loadingGroups, + loadingInitial, + dispatchInitial, + dispatchInsights, + dispatchNone, + } = useLoading(mapLoadingToProps); + + /* eslint-disable react-hooks/exhaustive-deps */ + // The dispatchLoading functions are derived from loading state using mapLoadingToProps, to + // provide nicer props for the component. These are re-derived whenever loading state changes, + // which causes an infinite loop as product panels load and re-trigger the useEffect below. + // Since the functions don't change, we can memoize - but we trigger the same loop if we satisfy + // exhaustive-deps by including the function itself in dependencies. + + const dispatchLoadingInitial = useCallback(dispatchInitial, []); + const dispatchLoadingInsights = useCallback(dispatchInsights, []); + const dispatchLoadingNone = useCallback(dispatchNone, []); + /* eslint-enable react-hooks/exhaustive-deps */ + + useEffect(() => { + async function getInsights() { + setError(null); + try { + if (pageFilters.group) { + dispatchLoadingInsights(true); + const [ + fetchedProjects, + fetchedCosts, + fetchedAlerts, + ] = await Promise.all([ + client.getGroupProjects(pageFilters.group), + pageFilters.project + ? client.getProjectDailyCost( + pageFilters.project, + pageFilters.metric, + intervalsOf(pageFilters.duration), + ) + : client.getGroupDailyCost( + pageFilters.group, + pageFilters.metric, + intervalsOf(pageFilters.duration), + ), + client.getAlerts(pageFilters.group), + ]); + setProjects(fetchedProjects); + setDailyCost(fetchedCosts); + setAlerts(fetchedAlerts); + } else { + dispatchLoadingNone(loadingActions); + } + } catch (e) { + setError(e); + dispatchLoadingNone(loadingActions); + } finally { + dispatchLoadingInitial(false); + dispatchLoadingInsights(false); + } + } + + // Wait for user groups to finish loading + if (!loadingGroups) { + getInsights(); + } + }, [ + client, + pageFilters, + loadingGroups, + dispatchLoadingInsights, + dispatchLoadingInitial, + dispatchLoadingNone, + loadingActions, + ]); + + if (loadingInitial) { + return ; + } + + if (error) { + return {error.message}; + } + + // Loaded but no groups found for the user + if (!pageFilters.group) { + return ( + + + + + + + + + + + + + + ); + } + + // These should be defined, alerts can be an empty array but that's truthy + if (!dailyCost || !alerts) { + return ( + {`Error: Could not fetch cost insights data for team ${pageFilters.group}`} + ); + } + + return ( + + + + + + + + + + {!!flags.get('cost-insights-currencies') && ( + + + + )} + + + + + + + + + {!!alerts.length && ( + <> + + + + + + + + )} + + + {!!dailyCost.aggregation.length && ( + + )} + + + + + {!!alerts?.length && ( + + + + )} + + {!alerts.length && } + + + + + + + + + + + ); +}; + +export default CostInsightsPage; diff --git a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPageRoot.tsx b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPageRoot.tsx new file mode 100644 index 0000000000..b2276481b2 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPageRoot.tsx @@ -0,0 +1,45 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import CostInsightsPage from './CostInsightsPage'; +import { FilterProvider } from '../../hooks/useFilters'; +import { LoadingProvider } from '../../hooks/useLoading'; +import { GroupsProvider } from '../../hooks/useGroups'; +import { CurrencyProvider } from '../../hooks/useCurrency'; +import { ScrollProvider } from '../../hooks/useScroll'; +import { ConfigProvider } from '../../hooks/useConfig'; +import { CostInsightsThemeProvider } from './CostInsightsThemeProvider'; + +const CostInsightsPageRoot = () => ( + + + + + + + + + + + + + + + +); + +export default CostInsightsPageRoot; diff --git a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsThemeProvider.tsx b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsThemeProvider.tsx new file mode 100644 index 0000000000..cecb076d35 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsThemeProvider.tsx @@ -0,0 +1,50 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { createMuiTheme, ThemeProvider } from '@material-ui/core'; +import { BackstageTheme } from '@backstage/theme'; +import { + costInsightsDarkTheme, + costInsightsLightTheme, +} from '../../utils/styles'; +import { CostInsightsTheme } from '../../types'; + +interface CostInsightsThemeProviderProps { + children?: React.ReactNode; +} + +export const CostInsightsThemeProvider = ({ + children, +}: CostInsightsThemeProviderProps) => { + return ( + + createMuiTheme({ + ...theme, + palette: { + ...theme.palette, + ...(theme.palette.type === 'dark' + ? costInsightsDarkTheme.palette + : costInsightsLightTheme.palette), + }, + }) as CostInsightsTheme + } + > + {children} + + ); +}; diff --git a/plugins/cost-insights/src/components/CostInsightsPage/index.ts b/plugins/cost-insights/src/components/CostInsightsPage/index.ts new file mode 100644 index 0000000000..67d5eb5daa --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsPage/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './CostInsightsPageRoot'; diff --git a/plugins/cost-insights/src/components/CostInsightsPage/selector.tsx b/plugins/cost-insights/src/components/CostInsightsPage/selector.tsx new file mode 100644 index 0000000000..51db47c082 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsPage/selector.tsx @@ -0,0 +1,42 @@ +/* + * 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 { MapLoadingToProps } from '../../hooks'; +import { getResetState, DefaultLoadingAction } from '../../types'; + +type CostInsightsPageLoadingProps = { + loadingActions: Array; + loadingGroups: boolean; + loadingInitial: boolean; + dispatchInitial: (isLoading: boolean) => void; + dispatchInsights: (isLoading: boolean) => void; + dispatchNone: (loadingActions: string[]) => void; +}; + +export const mapLoadingToProps: MapLoadingToProps = ({ + state, + actions, + dispatch, +}) => ({ + loadingActions: actions, + loadingGroups: state[DefaultLoadingAction.UserGroups], + loadingInitial: state[DefaultLoadingAction.CostInsightsInitial], + dispatchInitial: (isLoading: boolean) => + dispatch({ [DefaultLoadingAction.CostInsightsInitial]: isLoading }), + dispatchInsights: (isLoading: boolean) => + dispatch({ [DefaultLoadingAction.CostInsightsPage]: isLoading }), + dispatchNone: (loadingActions: string[]) => + dispatch(getResetState(loadingActions)), +}); diff --git a/plugins/cost-insights/src/components/CostInsightsSupportButton/CostInsightsSupportButton.tsx b/plugins/cost-insights/src/components/CostInsightsSupportButton/CostInsightsSupportButton.tsx new file mode 100644 index 0000000000..c8e7fdac97 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsSupportButton/CostInsightsSupportButton.tsx @@ -0,0 +1,28 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { SupportButton } from '@backstage/core'; + +const CostInsightsSupportButton = () => { + return ( + + Insights into cloud costs for your organization + + ); +}; + +export default CostInsightsSupportButton; diff --git a/plugins/cost-insights/src/components/CostInsightsSupportButton/index.ts b/plugins/cost-insights/src/components/CostInsightsSupportButton/index.ts new file mode 100644 index 0000000000..eab947c1be --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsSupportButton/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './CostInsightsSupportButton'; diff --git a/plugins/cost-insights/src/components/CostInsightsTabs/CostInsightsTabs.test.tsx b/plugins/cost-insights/src/components/CostInsightsTabs/CostInsightsTabs.test.tsx new file mode 100644 index 0000000000..0859ad18ce --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsTabs/CostInsightsTabs.test.tsx @@ -0,0 +1,104 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import CostInsightsTabs from './CostInsightsTabs'; +import UserEvent from '@testing-library/user-event'; +import { Group, defaultCurrencies } from '../../types'; +import { MockFilterProvider, MockConfigProvider } from '../../utils/tests'; +import { LoadingContext } from '../../hooks/useLoading'; +import { renderInTestApp } from '@backstage/test-utils'; +import { mockDefaultState } from '../../utils/mockData'; + +const mockSetPageFilters = jest.fn(); +const mockLoadingDispatch = jest.fn(); + +const mockGroups: Group[] = [ + { + id: 'test-group-1', + }, + { + id: 'test-group-2', + }, + { + id: 'test-group-3', + }, +]; + +describe('', () => { + const renderWrapped = (children: React.ReactNode) => + renderInTestApp( + + + + {children} + + + , + ); + + it('Does NOT display the tabs bar if owner belongs to less than two GROUPS', async () => { + const oneGroup: Group[] = [{ id: 'test-group-1' }]; + const rendered = await renderWrapped( + , + ); + expect( + rendered.container.querySelector('.cost-insights-tabs'), + ).not.toBeInTheDocument(); + }); + + it('Displays the correct number of groups in the groups tab', async () => { + const rendered = await renderWrapped( + , + ); + expect(rendered.getByText('3 teams')).toBeInTheDocument(); + }); + + it('Applies the correct group filter when selected', async () => { + const selectedGroup = expect.objectContaining({ group: 'test-group-1' }); + const rendered = await renderWrapped( + , + ); + UserEvent.click(rendered.getByTestId('cost-insights-groups-tab')); + UserEvent.click(rendered.getByTestId('test-group-1')); + expect(mockSetPageFilters).toHaveBeenCalledWith(selectedGroup); + }); + + it('Displays the correct group names in the menu', async () => { + const rendered = await renderWrapped( + , + ); + UserEvent.click(rendered.getByTestId('cost-insights-groups-tab')); + mockGroups.forEach(group => + expect(rendered.getByText(group.id)).toBeInTheDocument(), + ); + }); +}); diff --git a/plugins/cost-insights/src/components/CostInsightsTabs/CostInsightsTabs.tsx b/plugins/cost-insights/src/components/CostInsightsTabs/CostInsightsTabs.tsx new file mode 100644 index 0000000000..3b86aa79ef --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsTabs/CostInsightsTabs.tsx @@ -0,0 +1,111 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState } from 'react'; +import { Menu, MenuItem, Tab, Tabs, Typography } from '@material-ui/core'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import { mapLoadingToProps, mapFiltersToProps } from './selector'; +import { Group } from '../../types'; +import { useFilters, useLoading } from '../../hooks'; +import { useCostInsightsTabsStyles as useStyles } from '../../utils/styles'; + +export type CostInsightsTabsProps = { + groups: Group[]; +}; + +const CostInsightsTabs = ({ groups }: CostInsightsTabsProps) => { + const classes = useStyles(); + const [index] = useState(0); // index is fixed for now until other tabs are added + const [groupMenuEl, setGroupMenuEl] = useState(null); + const { group, setGroup } = useFilters(mapFiltersToProps); + const { loadingActions, dispatchReset } = useLoading(mapLoadingToProps); + + const openGroupMenu = (e: any) => setGroupMenuEl(e.currentTarget as Element); + + const closeGroupMenu = () => setGroupMenuEl(null); + + const updateGroupFilterAndCloseMenu = (g: Group) => () => { + dispatchReset(loadingActions); + closeGroupMenu(); + setGroup(g); + }; + + const renderTabLabel = () => ( +
+ + {`${groups.length} teams`} + + +
+ ); + + const hasAtLeastTwoGroups = groups.length >= 2; + + if (!hasAtLeastTwoGroups) return null; + + return ( + <> + + + + + {groups.map((g: Group) => ( + + {g.id} + + ))} + + + ); +}; + +export default CostInsightsTabs; diff --git a/plugins/cost-insights/src/components/CostInsightsTabs/index.ts b/plugins/cost-insights/src/components/CostInsightsTabs/index.ts new file mode 100644 index 0000000000..7055334c2d --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsTabs/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './CostInsightsTabs'; diff --git a/plugins/cost-insights/src/components/CostInsightsTabs/selector.ts b/plugins/cost-insights/src/components/CostInsightsTabs/selector.ts new file mode 100644 index 0000000000..3aa53cce21 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsTabs/selector.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { MapFiltersToProps } from '../../hooks/useFilters'; +import { MapLoadingToProps } from '../../hooks/useLoading'; +import { Group, PageFilters, getResetStateWithoutInitial } from '../../types'; + +type CostInsightsTabsFilterProps = PageFilters & { + setGroup: (group: Group) => void; +}; + +type CostInsightsTabsLoadingProps = { + loadingActions: Array; + dispatchReset: (loadingActions: string[]) => void; +}; + +export const mapFiltersToProps: MapFiltersToProps = ({ + pageFilters, + setPageFilters, +}) => ({ + ...pageFilters, + setGroup: (group: Group) => + setPageFilters({ + ...pageFilters, + group: group.id, + project: null, + }), +}); + +export const mapLoadingToProps: MapLoadingToProps = ({ + actions, + dispatch, +}) => ({ + loadingActions: actions, + dispatchReset: (loadingActions: string[]) => + dispatch(getResetStateWithoutInitial(loadingActions)), +}); diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx new file mode 100644 index 0000000000..ef174132a3 --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx @@ -0,0 +1,93 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Box, Card, CardContent, Divider } from '@material-ui/core'; +import CostOverviewChart from '../CostOverviewChart'; +import CostOverviewChartLegend from '../CostOverviewChartLegend'; +import CostOverviewHeader from './CostOverviewHeader'; +import CostOverviewFooter from './CostOverviewFooter'; +import MetricSelect from '../MetricSelect'; +import PeriodSelect from '../PeriodSelect'; +import ProjectSelect from '../ProjectSelect'; +import { useScroll, useFilters, useConfig } from '../../hooks'; +import { mapFiltersToProps } from './selector'; +import { DefaultNavigation } from '../../utils/navigation'; +import { + ChangeStatistic, + DateAggregation, + Project, + Trendline, + findAlways, +} from '../../types'; + +type CostOverviewCardProps = { + change: ChangeStatistic; + aggregation: Array; + trendline: Trendline; + projects: Array; +}; + +const CostOverviewCard = ({ + change, + aggregation, + trendline, + projects, +}: CostOverviewCardProps) => { + const { metrics } = useConfig(); + const { ScrollAnchor } = useScroll(DefaultNavigation.CostOverviewCard); + const { setDuration, setProject, metric, setMetric, ...filters } = useFilters( + mapFiltersToProps, + ); + + const { name } = findAlways(metrics, m => m.kind === metric); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export default CostOverviewCard; diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewFooter.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewFooter.tsx new file mode 100644 index 0000000000..03920887b8 --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewFooter.tsx @@ -0,0 +1,36 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { Box } from '@material-ui/core'; + +type CostOverviewFooterProps = { + children?: React.ReactNode; +}; + +const CostOverviewFooter = ({ children }: CostOverviewFooterProps) => ( + + {React.Children.map(children, child => ( + {child} + ))} + +); + +export default CostOverviewFooter; diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewHeader.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewHeader.tsx new file mode 100644 index 0000000000..5de90d155a --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewHeader.tsx @@ -0,0 +1,53 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { Box, Typography } from '@material-ui/core'; + +type CostOverviewHeaderProps = { + title: string; + subtitle?: string; + children?: React.ReactNode; +}; + +const CostOverviewHeader = ({ + title, + subtitle, + children, +}: CostOverviewHeaderProps) => ( + + + + {title} + + {!!subtitle && ( + + {subtitle} + + )} + + + {children} + + +); + +export default CostOverviewHeader; diff --git a/plugins/cost-insights/src/components/CostOverviewCard/index.ts b/plugins/cost-insights/src/components/CostOverviewCard/index.ts new file mode 100644 index 0000000000..c59fb5cc5b --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewCard/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './CostOverviewCard'; diff --git a/plugins/cost-insights/src/components/CostOverviewCard/selector.tsx b/plugins/cost-insights/src/components/CostOverviewCard/selector.tsx new file mode 100644 index 0000000000..ef65f38dbb --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewCard/selector.tsx @@ -0,0 +1,46 @@ +/* + * 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 { Duration, Maybe, PageFilters } from '../../types'; +import { MapFiltersToProps } from '../../hooks/useFilters'; + +type CostOverviewFilterProps = PageFilters & { + setDuration: (duration: Duration) => void; + setProject: (project: Maybe) => void; + setMetric: (metric: Maybe) => void; +}; + +export const mapFiltersToProps: MapFiltersToProps = ({ + pageFilters, + setPageFilters, +}) => ({ + ...pageFilters, + project: pageFilters.project || 'all', + setDuration: (duration: Duration) => + setPageFilters({ + ...pageFilters, + duration, + }), + setProject: (project: Maybe) => + setPageFilters({ + ...pageFilters, + project: project === 'all' ? null : project, + }), + setMetric: (metric: Maybe) => + setPageFilters({ + ...pageFilters, + metric: metric, + }), +}); diff --git a/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewChart.test.tsx b/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewChart.test.tsx new file mode 100644 index 0000000000..e6311d147a --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewChart.test.tsx @@ -0,0 +1,50 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import CostOverviewChart from './CostOverviewChart'; +import { renderInTestApp } from '@backstage/test-utils'; +import React from 'react'; +import { DateAggregation, Trendline } from '../../types'; +import { CostInsightsThemeProvider } from '../CostInsightsPage/CostInsightsThemeProvider'; + +const mockAggregation = [ + { date: '2020-04-01', amount: 100 }, + { date: '2020-04-02', amount: 101 }, + { date: '2020-04-03', amount: 102 }, + { date: '2020-04-04', amount: 103 }, +] as Array; + +const mockTrendline = { slope: 0.3, intercept: 101.5 } as Trendline; +const mockMetric = 'mock-metric'; + +describe('', () => { + it('Renders without exploding', async () => { + const rendered = await renderInTestApp( + + + , + ); + expect( + rendered.container.querySelector('.cost-overview-chart'), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewChart.tsx b/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewChart.tsx new file mode 100644 index 0000000000..2b40668409 --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewChart.tsx @@ -0,0 +1,120 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { + ComposedChart, + XAxis, + YAxis, + Tooltip, + CartesianGrid, + Area, + Line, + ResponsiveContainer, +} from 'recharts'; +import { + Maybe, + DateAggregation, + Trendline, + CostInsightsTheme, +} from '../../types'; +import { + overviewGraphTickFormatter, + formatGraphValue, +} from '../../utils/graphs'; +import CostOverviewTooltip from './CostOverviewTooltip'; +import { useTheme } from '@material-ui/core'; +import { useCostOverviewStyles as useStyles } from '../../utils/styles'; +import { NULL_METRIC } from '../../hooks/useConfig'; + +type CostOverviewChartProps = { + responsive: boolean; + aggregation: Array; + trendline?: Maybe; + metric: string | null; + tooltip: string; +}; + +const CostOverviewChart = ({ + responsive = true, + aggregation, + trendline, + metric, + tooltip, +}: CostOverviewChartProps) => { + const theme = useTheme(); + const styles = useStyles(theme); + + const id = metric ? metric : NULL_METRIC; + + const dailyCostData = aggregation.map((entry: DateAggregation) => ({ + date: Date.parse(entry.date), + [id]: entry.amount, + trend: trendline + ? trendline.slope * (Date.parse(entry.date) / 1000) + trendline.intercept + : null, + })); + + return ( + + + + + 0, 'dataMax']} + tick={{ fill: styles.axis.fill }} + tickFormatter={formatGraphValue} + width={styles.yAxis.width} + yAxisId={id} + /> + + + } + animationDuration={100} + /> + + + ); +}; + +export default CostOverviewChart; diff --git a/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewTooltip.tsx b/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewTooltip.tsx new file mode 100644 index 0000000000..17d466441d --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewTooltip.tsx @@ -0,0 +1,45 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import moment from 'moment'; +import { TooltipPayload, TooltipProps } from 'recharts'; +import Tooltip from '../../components/Tooltip'; +import { DEFAULT_DATE_FORMAT } from '../../types'; +import { formatGraphValue } from '../../utils/graphs'; + +type CostOverviewTooltipProps = TooltipProps & { + metric: string; + name: string; +}; + +const CostOverviewTooltip = ({ + label, + payload, + metric, + name, +}: CostOverviewTooltipProps) => { + const tooltipLabel = moment(label).format(DEFAULT_DATE_FORMAT); + const items = payload + ?.filter(data => data.name === metric) + .map((data: TooltipPayload) => ({ + label: name, + value: formatGraphValue(data.value as number), + fill: data.fill as string, + })); + return ; +}; + +export default CostOverviewTooltip; diff --git a/plugins/cost-insights/src/components/CostOverviewChart/index.ts b/plugins/cost-insights/src/components/CostOverviewChart/index.ts new file mode 100644 index 0000000000..21f345a282 --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewChart/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './CostOverviewChart'; diff --git a/plugins/cost-insights/src/components/CostOverviewChartLegend/CostOverviewChartLegend.test.tsx b/plugins/cost-insights/src/components/CostOverviewChartLegend/CostOverviewChartLegend.test.tsx new file mode 100644 index 0000000000..a40ff58ec1 --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewChartLegend/CostOverviewChartLegend.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { renderInTestApp } from '@backstage/test-utils'; +import CostOverviewChartLegend from './CostOverviewChartLegend'; +import React from 'react'; +import { ChangeStatistic } from '../../types'; + +describe('', () => { + it('Correctly displays text if change is not supplied', async () => { + const rendered = await renderInTestApp( + , + ); + expect(rendered.queryByText('Unclear')).toBeInTheDocument(); + }); + it('Correctly displays formatted change percentage', async () => { + const change = { + ratio: 0.3456, + amount: 40000, + } as ChangeStatistic; + const rendered = await renderInTestApp( + , + ); + expect(rendered.queryByText('Unclear')).not.toBeInTheDocument(); + expect(rendered.queryByText('35%')).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/CostOverviewChartLegend/CostOverviewChartLegend.tsx b/plugins/cost-insights/src/components/CostOverviewChartLegend/CostOverviewChartLegend.tsx new file mode 100644 index 0000000000..538cd2a63a --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewChartLegend/CostOverviewChartLegend.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { Box, useTheme } from '@material-ui/core'; +import LegendItem from '../LegendItem'; +import { formatPercent } from '../../utils/formatters'; +import { ChangeStatistic, CostInsightsTheme } from '../../types'; + +type CostOverviewChartLegendProps = { + change?: ChangeStatistic; + title: string; + tooltip?: string; +}; + +const CostOverviewChartLegend = ({ + change, + title, + tooltip, +}: CostOverviewChartLegendProps) => { + const theme = useTheme(); + + return ( + + + {change ? formatPercent(change.ratio) : 'Unclear'} + + + ); +}; + +export default CostOverviewChartLegend; diff --git a/plugins/cost-insights/src/components/CostOverviewChartLegend/index.ts b/plugins/cost-insights/src/components/CostOverviewChartLegend/index.ts new file mode 100644 index 0000000000..1b6c1fe975 --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewChartLegend/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './CostOverviewChartLegend'; diff --git a/plugins/cost-insights/src/components/CurrencySelect/CurrencySelect.tsx b/plugins/cost-insights/src/components/CurrencySelect/CurrencySelect.tsx new file mode 100644 index 0000000000..24063a664e --- /dev/null +++ b/plugins/cost-insights/src/components/CurrencySelect/CurrencySelect.tsx @@ -0,0 +1,75 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { MenuItem, Select, SelectProps } from '@material-ui/core'; +import { Currency, CurrencyType, findAlways } from '../../types'; +import { useSelectStyles as useStyles } from '../../utils/styles'; + +const NULL_VALUE = 'engineers'; + +type CurrencySelectProps = { + currency: Currency; + currencies: Currency[]; + onSelect: (currency: Currency) => void; +}; + +const CurrencySelect = ({ + currency, + currencies, + onSelect, +}: CurrencySelectProps) => { + const classes = useStyles(); + + const getOption = (value: unknown) => { + const kind = (value === NULL_VALUE ? null : value) as CurrencyType; + return findAlways(currencies, c => c.kind === kind); + }; + + const handleOnChange: SelectProps['onChange'] = e => { + const option = getOption(e.target.value); + onSelect(option); + }; + + const renderValue: SelectProps['renderValue'] = value => { + const option = getOption(value); + return {option.label}; + }; + + return ( + + ); +}; + +export default CurrencySelect; diff --git a/plugins/cost-insights/src/components/CurrencySelect/index.ts b/plugins/cost-insights/src/components/CurrencySelect/index.ts new file mode 100644 index 0000000000..322b67d4ce --- /dev/null +++ b/plugins/cost-insights/src/components/CurrencySelect/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './CurrencySelect'; diff --git a/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/LabelDataflowInstructionsPage.tsx b/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/LabelDataflowInstructionsPage.tsx new file mode 100644 index 0000000000..9783fadcb2 --- /dev/null +++ b/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/LabelDataflowInstructionsPage.tsx @@ -0,0 +1,96 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Box, Typography } from '@material-ui/core'; +import { CodeSnippet } from '@backstage/core'; +import AlertInstructionsLayout from '../AlertInstructionsLayout'; + +const LabelDataflowInstructionsPage = () => { + return ( + + Labeling Dataflow Jobs + + Labels in Google Cloud Platform are key-value pairs that can be added to + most types of cloud resources. Since these labels are also exported in + billing data, adding labels allows a granular breakdown of cloud cost by + software entity. + + + In Cloud Dataflow, labels can be added to a job either programmatically + or via the command-line when launching a job. Note that GCP has + + restrictions + {' '} + on the length and characters that can be used in labels. + + + Labels are not retroactive, so cost tracking is only possible from when + the labels are first added to a Dataflow job. + + + + DataflowPipelineOptions + + Dataflow jobs using Beam's{' '} + + DataflowPipelineOptions + {' '} + directly can use the setLabels function to add one or more + labels: + + + + Dataflow jobs using Scio can similarly set options on the ScioContext: + "my-dataflow-job").asJava)`} + /> + + + + + Command-line + + Dataflow jobs launched from the command-line can add labels as an + argument: + + + + For more information on specifying options, see the{' '} + + Dataflow documentation + {' '} + or{' '} + + Scio Scaladoc + + . + + + + ); +}; + +export default LabelDataflowInstructionsPage; diff --git a/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/index.ts b/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/index.ts new file mode 100644 index 0000000000..052ed379ec --- /dev/null +++ b/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './LabelDataflowInstructionsPage'; diff --git a/plugins/cost-insights/src/components/LegendItem/LegendItem.tsx b/plugins/cost-insights/src/components/LegendItem/LegendItem.tsx new file mode 100644 index 0000000000..dd87ecc8a1 --- /dev/null +++ b/plugins/cost-insights/src/components/LegendItem/LegendItem.tsx @@ -0,0 +1,78 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Box, Typography, Tooltip } from '@material-ui/core'; +import LensIcon from '@material-ui/icons/Lens'; +import HelpOutlineOutlinedIcon from '@material-ui/icons/HelpOutlineOutlined'; +import { useCostGrowthLegendStyles } from '../../utils/styles'; + +type LegendItemProps = { + title: string; + tooltipText?: string; + markerColor?: string; + children?: React.ReactNode; +}; + +const LegendItem = ({ + title, + tooltipText, + markerColor, + children, +}: LegendItemProps) => { + const classes = useCostGrowthLegendStyles(); + return ( + + + {markerColor && ( +
+ +
+ )} + + {title} + + {tooltipText && ( + + {tooltipText} + + } + placement="top-start" + > + + + + + )} +
+ + + {children} + + +
+ ); +}; + +export default LegendItem; diff --git a/plugins/cost-insights/src/components/LegendItem/index.ts b/plugins/cost-insights/src/components/LegendItem/index.ts new file mode 100644 index 0000000000..763b8fc4f4 --- /dev/null +++ b/plugins/cost-insights/src/components/LegendItem/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './LegendItem'; diff --git a/plugins/cost-insights/src/components/MetricSelect/MetricSelect.test.tsx b/plugins/cost-insights/src/components/MetricSelect/MetricSelect.test.tsx new file mode 100644 index 0000000000..67d1ec3ad3 --- /dev/null +++ b/plugins/cost-insights/src/components/MetricSelect/MetricSelect.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { waitFor } from '@testing-library/react'; +import UserEvent from '@testing-library/user-event'; +import MetricSelect, { MetricSelectProps } from './MetricSelect'; +import { renderInTestApp } from '@backstage/test-utils'; + +describe('', () => { + it('should display a metric', async () => { + const mockProps: MetricSelectProps = { + metric: 'test', + metrics: [{ kind: 'test', name: 'some-name' }], + onSelect: jest.fn(), + }; + const { getByText } = await renderInTestApp( + , + ); + expect(getByText(/some-name/)).toBeInTheDocument(); + }); + + it('should display a null metric', async () => { + const mockProps: MetricSelectProps = { + metric: null, + metrics: [{ kind: null, name: 'billie-nullish' }], + onSelect: jest.fn(), + }; + const { getByText } = await renderInTestApp( + , + ); + expect(getByText(/billie-nullish/)).toBeInTheDocument(); + }); + + it('should display all metrics', async () => { + const mockProps: MetricSelectProps = { + metric: null, + metrics: [ + { kind: null, name: 'billie-nullish' }, + { kind: 'MAU1M', name: 'Cost Per Million MAU' }, + { kind: 'my-cool-metric', name: 'metric-mcmetric-face' }, + ], + onSelect: jest.fn(), + }; + const { getAllByText, getByText, getByRole } = await renderInTestApp( + , + ); + const button = getByRole('button'); + + UserEvent.click(button); + + await waitFor(() => getAllByText(/billie-nullish/)); + + // The active metric should display in the popver list and in the input + expect(getAllByText(/billie-nullish/).length).toBe(2); + expect(getByText(/Cost Per Million MAU/)).toBeInTheDocument(); + expect(getByText(/metric-mcmetric-face/)).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/MetricSelect/MetricSelect.tsx b/plugins/cost-insights/src/components/MetricSelect/MetricSelect.tsx new file mode 100644 index 0000000000..1d3af911d4 --- /dev/null +++ b/plugins/cost-insights/src/components/MetricSelect/MetricSelect.tsx @@ -0,0 +1,67 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Select, MenuItem } from '@material-ui/core'; +import { Maybe, Metric, findAlways } from '../../types'; +import { useSelectStyles as useStyles } from '../../utils/styles'; +import { NULL_METRIC } from '../../hooks/useConfig'; + +export type MetricSelectProps = { + metric: Maybe; + metrics: Array; + onSelect: (metric: Maybe) => void; +}; + +const MetricSelect = ({ metric, metrics, onSelect }: MetricSelectProps) => { + const classes = useStyles(); + + const handleOnChange = (e: React.ChangeEvent<{ value: unknown }>) => { + if (e.target.value === NULL_METRIC) { + onSelect(null); + } else { + onSelect(e.target.value as string); + } + }; + + const renderValue = (value: unknown) => { + const kind = (value === NULL_METRIC ? null : value) as Maybe; + const { name } = findAlways(metrics, m => m.kind === kind); + return {name}; + }; + + return ( + + ); +}; + +export default MetricSelect; diff --git a/plugins/cost-insights/src/components/MetricSelect/index.ts b/plugins/cost-insights/src/components/MetricSelect/index.ts new file mode 100644 index 0000000000..f68e5031c1 --- /dev/null +++ b/plugins/cost-insights/src/components/MetricSelect/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './MetricSelect'; diff --git a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx new file mode 100644 index 0000000000..c632ebad34 --- /dev/null +++ b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx @@ -0,0 +1,84 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { getByRole, waitFor } from '@testing-library/react'; +import UserEvent from '@testing-library/user-event'; +import PeriodSelect, { DEFAULT_OPTIONS as options } from './PeriodSelect'; +import { Duration, getDefaultPageFilters, Group } from '../../types'; + +import { renderInTestApp } from '@backstage/test-utils'; + +const DefaultPageFilters = getDefaultPageFilters([{ id: 'tools' }] as Group[]); + +Date.now = jest.fn(() => new Date(Date.parse('2020-05-01')).valueOf()); + +describe('', () => { + it('Renders without exploding', async () => { + const rendered = await renderInTestApp( + , + ); + expect(rendered.getByTestId('period-select')).toBeInTheDocument(); + }); + + it('Should display all costGrowth period options', async () => { + const rendered = await renderInTestApp( + , + ); + const periodSelectContainer = rendered.getByTestId('period-select'); + const button = getByRole(periodSelectContainer, 'button'); + UserEvent.click(button); + await waitFor(() => rendered.getByText('Past 60 Days')); + options.forEach(option => + expect( + rendered.getByTestId(`period-select-option-${option.value}`), + ).toBeInTheDocument(), + ); + }); + + describe.each` + duration + ${Duration.P1M} + ${Duration.P3M} + ${Duration.P90D} + ${Duration.P30D} + `('Should select the correct duration', ({ duration }) => { + it(`Should select ${duration}`, async () => { + const mockOnSelect = jest.fn(); + const mockAggregation = + DefaultPageFilters.duration === duration + ? Duration.P1M + : DefaultPageFilters.duration; + + const rendered = await renderInTestApp( + , + ); + const periodSelect = rendered.getByTestId('period-select'); + const button = getByRole(periodSelect, 'button'); + + UserEvent.click(button); + await waitFor(() => rendered.getByText('Past 60 Days')); + UserEvent.click(rendered.getByTestId(`period-select-option-${duration}`)); + expect(mockOnSelect).toHaveBeenLastCalledWith(duration); + }); + }); +}); diff --git a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx new file mode 100644 index 0000000000..688e22452e --- /dev/null +++ b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx @@ -0,0 +1,100 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { MenuItem, Select, SelectProps } from '@material-ui/core'; +import { + formatLastTwoLookaheadQuarters, + formatLastTwoMonths, +} from '../../utils/formatters'; +import { Duration, findAlways } from '../../types'; +import { useSelectStyles as useStyles } from '../../utils/styles'; + +export type PeriodOption = { + value: Duration; + label: string; +}; + +const LAST_6_MONTHS = 'Past 6 Months'; +const LAST_60_DAYS = 'Past 60 Days'; +const LAST_2_COMPLETED_MONTHS = formatLastTwoMonths(); +const LAST_2_LOOKAHEAD_QUARTERS = formatLastTwoLookaheadQuarters(); + +export const DEFAULT_OPTIONS: PeriodOption[] = [ + { + value: Duration.P90D, + label: LAST_6_MONTHS, + }, + { + value: Duration.P30D, + label: LAST_60_DAYS, + }, + { + value: Duration.P1M, + label: LAST_2_COMPLETED_MONTHS, + }, + { + value: Duration.P3M, + label: LAST_2_LOOKAHEAD_QUARTERS, + }, +]; + +type PeriodSelectProps = { + duration: Duration; + onSelect: (duration: Duration) => void; + options?: PeriodOption[]; +}; + +const PeriodSelect = ({ + duration, + onSelect, + options = DEFAULT_OPTIONS, +}: PeriodSelectProps) => { + const classes = useStyles(); + + const handleOnChange: SelectProps['onChange'] = e => { + onSelect(e.target.value as Duration); + }; + + const renderValue: SelectProps['renderValue'] = value => { + const option = findAlways(DEFAULT_OPTIONS, o => o.value === value); + return {option.label}; + }; + + return ( + + ); +}; + +export default PeriodSelect; diff --git a/plugins/cost-insights/src/components/PeriodSelect/index.ts b/plugins/cost-insights/src/components/PeriodSelect/index.ts new file mode 100644 index 0000000000..7bd70b3358 --- /dev/null +++ b/plugins/cost-insights/src/components/PeriodSelect/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './PeriodSelect'; diff --git a/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx new file mode 100644 index 0000000000..cc52012543 --- /dev/null +++ b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Box, Typography, Grid } from '@material-ui/core'; +import ProductInsightsCard from '../ProductInsightsCard'; +import { useConfig } from '../../hooks'; + +const ProductInsights = ({}) => { + const { products } = useConfig(); + + return ( + <> + + + Your team's product usage + + + + {products.map(product => ( + + + + ))} + + + ); +}; + +export default ProductInsights; diff --git a/plugins/cost-insights/src/components/ProductInsights/index.ts b/plugins/cost-insights/src/components/ProductInsights/index.ts new file mode 100644 index 0000000000..0a20d3c322 --- /dev/null +++ b/plugins/cost-insights/src/components/ProductInsights/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './ProductInsights'; diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx new file mode 100644 index 0000000000..6d40fc276a --- /dev/null +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx @@ -0,0 +1,167 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import ProductInsightsCard from './ProductInsightsCard'; +import { + MockComputeEngine, + createMockEntity, + mockDefaultState, + createMockProductCost, +} from '../../utils/mockData'; +import { + IdentityApi, + ApiRegistry, + identityApiRef, + ApiProvider, +} from '@backstage/core'; +import { costInsightsApiRef, CostInsightsApi } from '../../api'; +import { renderInTestApp } from '@backstage/test-utils'; +import { GroupsContext } from '../../hooks/useGroups'; +import { LoadingContext } from '../../hooks/useLoading'; +import { + Product, + ProductCost, + defaultCurrencies, + findAlways, +} from '../../types'; +import { + MockConfigProvider, + MockFilterProvider, + MockCurrencyProvider, + MockScrollProvider, +} from '../../utils/tests'; + +const mockLoadingDispatch = jest.fn(); +const mockSetPageFilters = jest.fn(); +const mockSetProductFilters = jest.fn(); +const mockSetCurrency = jest.fn(); +const engineers = findAlways(defaultCurrencies, c => c.kind === null); + +const identityApi: Partial = { + getProfile: () => ({ + email: 'test-email@example.com', + displayName: 'User 1', + }), +}; + +const costInsightsApi = ( + productCost: ProductCost, +): Partial => ({ + getProductInsights: () => + Promise.resolve(productCost) as Promise, +}); + +const getApis = (productCost: ProductCost) => { + return ApiRegistry.from([ + [identityApiRef, identityApi], + [costInsightsApiRef, costInsightsApi(productCost)], + ]); +}; + +const mockProductCost = createMockProductCost(() => ({ + entities: [], + aggregation: [3000, 4000], + change: { + ratio: 0.23, + amount: 1000, + }, +})); + +const renderProductInsightsCardInTestApp = async ( + productCost: ProductCost, + product: Product, +) => + await renderInTestApp( + + + + + + + + + + + + + + + , + ); + +describe('', () => { + it('Renders the scroll anchors', async () => { + const rendered = await renderProductInsightsCardInTestApp( + mockProductCost, + MockComputeEngine, + ); + expect( + rendered.queryByTestId(`scroll-test-compute-engine`), + ).toBeInTheDocument(); + }); + + it('Should render the right subheader for products with cost data', async () => { + const productCost = { + ...mockProductCost, + entities: [...Array(1000)].map(createMockEntity), + }; + const rendered = await renderProductInsightsCardInTestApp( + productCost, + MockComputeEngine, + ); + const subheader = 'entities, sorted by cost'; + const subheaderRgx = new RegExp( + `${productCost.entities.length} ${subheader}`, + ); + expect(rendered.getByText(subheaderRgx)).toBeInTheDocument(); + }); + + it('Should render the right subheader if there is no cost data or change data', async () => { + const productCost = { entities: [], aggregation: [0, 0] } as ProductCost; + const subheader = `There are no ${MockComputeEngine.name} costs within this timeframe for your team's projects.`; + const rendered = await renderProductInsightsCardInTestApp( + productCost, + MockComputeEngine, + ); + const subheaderRgx = new RegExp(subheader); + expect(rendered.getByText(subheaderRgx)).toBeInTheDocument(); + expect( + rendered.queryByTestId('.resource-growth-chart-legend'), + ).not.toBeInTheDocument(); + expect( + rendered.queryByTestId('.insights-bar-chart'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx new file mode 100644 index 0000000000..1d24139c64 --- /dev/null +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx @@ -0,0 +1,147 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useCallback, useEffect, useState } from 'react'; +import { InfoCard, useApi } from '@backstage/core'; +import { Box } from '@material-ui/core'; +import Alert from '@material-ui/lab/Alert'; +import { costInsightsApiRef } from '../../api'; +import PeriodSelect from '../PeriodSelect'; +import ResourceGrowthBarChart from '../ResourceGrowthBarChart'; +import ResourceGrowthBarChartLegend from '../ResourceGrowthBarChartLegend'; +import { useFilters, useLoading, useScroll } from '../../hooks'; +import { useProductInsightsCardStyles as useStyles } from '../../utils/styles'; +import { mapFiltersToProps, mapLoadingToProps } from './selector'; +import { Duration, Maybe, Product, ProductCost } from '../../types'; +import { pluralOf } from '../../utils/grammar'; + +type ProductInsightsCardProps = { + product: Product; +}; + +const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => { + const client = useApi(costInsightsApiRef); + const classes = useStyles(); + const { ScrollAnchor } = useScroll(product.kind); + const [resource, setResource] = useState>(null); + const [error, setError] = useState>(null); + + const { group, product: productFilter, setProduct } = useFilters( + mapFiltersToProps(product.kind), + ); + const { loadingProduct, dispatchLoading } = useLoading( + mapLoadingToProps(product.kind), + ); + + // @see CostInsightsPage + // eslint-disable-next-line react-hooks/exhaustive-deps + const dispatchLoadingProduct = useCallback(dispatchLoading, [product.kind]); + + const amount = resource?.entities?.length || 0; + const hasCostsWithinTimeframe = resource?.change && amount; + + const subheader = amount + ? `${amount} ${pluralOf(amount, 'entity', 'entities')}, sorted by cost` + : `There are no ${product.name} costs within this timeframe for your team's projects.`; + + const costStart = resource?.aggregation[0] || 0; + const costEnd = resource?.aggregation[1] || 0; + + useEffect(() => { + async function load() { + if (loadingProduct) { + try { + const p: ProductCost = await client.getProductInsights( + product.kind, + group!, + productFilter!.duration, + ); + setResource(p); + } catch (e) { + setError(e); + } finally { + dispatchLoadingProduct(false); + } + } + } + load(); + }, [ + client, + product, + setResource, + loadingProduct, + dispatchLoadingProduct, + productFilter, + group, + product.kind, + ]); + + const onPeriodSelect = (duration: Duration) => { + dispatchLoadingProduct(true); + setProduct(duration); + }; + + const infoCardProps = { + headerProps: { + classes: classes, + action: ( + + ), + }, + }; + + if (error) { + return ( + + + {`Error: Could not fetch product insights for ${product.name}`} + + ); + } + + if (!resource) { + return null; + } + + return ( + + + {hasCostsWithinTimeframe && ( + <> + + + + + + + + )} + + ); +}; + +export default ProductInsightsCard; diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/index.ts b/plugins/cost-insights/src/components/ProductInsightsCard/index.ts new file mode 100644 index 0000000000..ef378004f0 --- /dev/null +++ b/plugins/cost-insights/src/components/ProductInsightsCard/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './ProductInsightsCard'; diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/selector.ts b/plugins/cost-insights/src/components/ProductInsightsCard/selector.ts new file mode 100644 index 0000000000..001ff717bd --- /dev/null +++ b/plugins/cost-insights/src/components/ProductInsightsCard/selector.ts @@ -0,0 +1,56 @@ +/* + * 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 { MapFiltersToProps } from '../../hooks/useFilters'; +import { MapLoadingToProps } from '../../hooks/useLoading'; +import { Duration, PageFilters, ProductPeriod, findAlways } from '../../types'; + +type ProductInsightsCardFilterProps = PageFilters & { + product: ProductPeriod; + setProduct: (duration: Duration) => void; +}; + +type ProductInsightsCardLoadingProps = { + loadingProduct: boolean; + dispatchLoading: (isLoading: boolean) => void; +}; + +export const mapFiltersToProps = ( + product: string, +): MapFiltersToProps => ({ + pageFilters, + productFilters, + setProductFilters, +}) => ({ + ...pageFilters, + product: findAlways(productFilters, p => p.productType === product), + setProduct: (duration: Duration) => + setProductFilters( + productFilters.map(period => + period.productType === product ? { ...period, duration } : period, + ), + ), +}); + +export const mapLoadingToProps = ( + product: string, +): MapLoadingToProps => ({ + state, + dispatch, +}) => ({ + loadingProduct: state[product], + dispatchLoading: (isLoading: boolean) => dispatch({ [product]: isLoading }), +}); diff --git a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx new file mode 100644 index 0000000000..5c3856cc9e --- /dev/null +++ b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx @@ -0,0 +1,85 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { renderInTestApp } from '@backstage/test-utils'; +import ProjectGrowthAlertCard from './ProjectGrowthAlertCard'; +import { createMockProjectGrowthAlert } from '../../utils/mockData'; +import { MockCurrencyProvider, MockConfigProvider } from '../../utils/tests'; +import { AlertCost, defaultCurrencies, findAlways } from '../../types'; + +const engineers = findAlways(defaultCurrencies, c => c.kind === null); + +const MockProject = 'test-project-1'; +const MockAlertCosts: AlertCost[] = [ + { id: 'test-id-1', aggregation: [150, 200] }, + { id: 'test-id-2', aggregation: [235, 400] }, +]; + +const MockProjectGrowthAlert = createMockProjectGrowthAlert(alert => ({ + ...alert, + project: MockProject, + products: MockAlertCosts, +})); + +describe('', () => { + it('renders the correct title and subheader for multiple services', async () => { + const subheader = new RegExp( + `${MockAlertCosts.length} products, sorted by cost`, + ); + const title = new RegExp(`Project growth for ${MockProject}`); + const rendered = await renderInTestApp( + + + , + + , + ); + expect(rendered.getByText(title)).toBeInTheDocument(); + expect(rendered.getByText(subheader)).toBeInTheDocument(); + }); + + it('renders the correct title and subheader for a single service', async () => { + const subheader = new RegExp('1 product'); + const title = new RegExp(`Project growth for ${MockProject}`); + const rendered = await renderInTestApp( + + + + + , + ); + expect(rendered.getByText(title)).toBeInTheDocument(); + expect(rendered.getByText(subheader)).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx new file mode 100644 index 0000000000..c27b201202 --- /dev/null +++ b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx @@ -0,0 +1,60 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Box } from '@material-ui/core'; +import { InfoCard } from '@backstage/core'; +import ResourceGrowthBarChart from '../ResourceGrowthBarChart'; +import ResourceGrowthBarChartLegend from '../ResourceGrowthBarChartLegend'; +import { Duration, ProjectGrowthAlert } from '../../types'; +import { pluralOf } from '../../utils/grammar'; + +type ProjectGrowthAlertProps = { + alert: ProjectGrowthAlert; +}; + +const ProjectGrowthAlertCard = ({ alert }: ProjectGrowthAlertProps) => { + const [costStart, costEnd] = alert.aggregation; + + const subheader = ` + ${alert.products.length} ${pluralOf(alert.products.length, 'product')}${ + alert.products.length > 1 ? ', sorted by cost' : '' + }`; + + return ( + + + + + + + + + ); +}; + +export default ProjectGrowthAlertCard; diff --git a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/index.ts b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/index.ts new file mode 100644 index 0000000000..ab608f5f4c --- /dev/null +++ b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './ProjectGrowthAlertCard'; diff --git a/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx b/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx new file mode 100644 index 0000000000..f8f4a90a9e --- /dev/null +++ b/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx @@ -0,0 +1,212 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Box, Typography } from '@material-ui/core'; +import { InfoCard } from '@backstage/core'; +import AlertInstructionsLayout from '../AlertInstructionsLayout'; +import ProjectGrowthAlertCard from '../ProjectGrowthAlertCard'; +import { + AlertType, + Duration, + Entity, + Product, + ProjectGrowthAlert, +} from '../../types'; +import ResourceGrowthBarChartLegend from '../ResourceGrowthBarChartLegend'; +import ResourceGrowthBarChart from '../ResourceGrowthBarChart'; + +const ProjectGrowthInstructionsPage = () => { + const projectGrowthAlert: ProjectGrowthAlert = { + id: AlertType.ProjectGrowth, + project: 'example-project', + periodStart: 'Q1 2020', + periodEnd: 'Q2 2020', + aggregation: [60000, 120000], + change: { + ratio: 1, + amount: 60000, + }, + products: [ + { + id: 'Compute Engine', + aggregation: [58000, 118000], + }, + { + id: 'Cloud Dataflow', + aggregation: [1200, 1500], + }, + { + id: 'Cloud Storage', + aggregation: [800, 500], + }, + ], + }; + + const product: Product = { + kind: 'ComputeEngine', + name: 'Compute Engine', + }; + + const entities: Entity[] = [ + { + id: 'service-one', + aggregation: [18200, 58500], + }, + { + id: 'service-two', + aggregation: [1200, 1300], + }, + { + id: 'service-three', + aggregation: [600, 200], + }, + ]; + + return ( + + Investigating cloud cost growth + + Cost Insights shows an alert when costs for a particular billing entity, + such as a GCP project, have grown at a rate faster than our alerting + threshold. The responsible team should follow this guide to decide + whether this warrants further investigation. + + + + Is the growth expected? + + The first question to ask is whether growth is expected. Perhaps a new + product has been deployed, or additional regions added for + reliability. + + + Many services increase cost linearly with load. Has the demand + increased? This may happen as you open new markets, or run marketing + offers. Costs should be compared against a business metric, such as + daily users, to normalize natural increases from business growth. + + + Seasonal variance may also cause cost growth; yearly campaigns, an + increase in demand during certain times of year. + + + Cloud costs will often go up before they go down, in the case of + migrations. Teams moving to new infrastructure may run in both the old + and new environment during the migration. + + + + + Is the growth significant? + + Next, evaluate whether the growth is significant. This helps avoid + premature optimization, where cost in engineering time is more than + would be saved from the optimization over a reasonable timeframe. + + + We recommend reframing the cost growth itself in terms of engineering + time. How much engineering time, for an average fully-loaded + engineer cost at the company, is being overspent each month? Compare + this to expected engineering time for optimization to decide whether + the optimization is worthwhile. + + + + + + Identifying which cloud product contributed most + + + For projects meeting the alert threshold, Cost Insights shows a cost + comparison of cloud products over the examined time period: + + + + + + This allows you to quickly see which cloud products contributed to the + growth in cloud costs. + + + + + + Identifying the responsible workload + + + After identifying the cloud product, use the corresponding product + panel in Cost Insights to find a particular workload (or entity + ) that has grown in cost: + + + {/* ProductInsightsCard without API query / PeriodSelect */} + + + + + + + + + + + + + From here, you can dig into commit history or deployment logs to find + probable causes of an unexpected spike in cost. + + + + + Optimizing the workload + + Workload optimization varies between cloud products, but there are a + few general optimization areas to consider: + + Retention + + Is the workload or storage necessary? Truly idle or unused resources + can be cleaned up for immediate cost savings. For storage, how long do + we need the data? Many cloud products support retention policies to + automatically delete data after a certain time period. + + Efficiency + + Is the workload using cloud resources efficiently? For compute + resources, do the utilization metrics look reasonable? Autoscaling + infrastructure, such as Kubernetes, can run workloads more efficiently + without comprimising reliability. + + Lifecycle + + Is the workload using an optimal pricing model? Some cloud products + offer better pricing for data that is accessed less frequently. + + + + ); +}; + +export default ProjectGrowthInstructionsPage; diff --git a/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/index.ts b/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/index.ts new file mode 100644 index 0000000000..c75240405c --- /dev/null +++ b/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './ProjectGrowthInstructionsPage'; diff --git a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx new file mode 100644 index 0000000000..92de819fe0 --- /dev/null +++ b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { getByRole, waitFor } from '@testing-library/react'; +import UserEvent from '@testing-library/user-event'; +import ProjectSelect from './ProjectSelect'; +import { MockFilterProvider } from '../../utils/tests'; +import { renderInTestApp } from '@backstage/test-utils'; + +const mockProjects = [ + { id: 'project1' }, + { id: 'project2' }, + { id: 'project3' }, +]; + +const mockSetPageFilters = jest.fn(); + +describe('', () => { + let Component: React.ReactNode; + beforeEach(() => { + Component = () => ( + + + + ); + }); + + it('Renders without exploding', async () => { + const rendered = await renderInTestApp(Component); + expect(rendered.getByText('All Projects')).toBeInTheDocument(); + }); + + it('shows all projects in the filter select', async () => { + const rendered = await renderInTestApp(Component); + const projectSelectContainer = rendered.getByTestId( + 'project-filter-select', + ); + const button = getByRole(projectSelectContainer, 'button'); + UserEvent.click(button); + await waitFor(() => rendered.getByTestId('option-all')); + mockProjects.forEach( + project => + project.id && + expect(rendered.getByText(project.id)).toBeInTheDocument(), + ); + }); +}); diff --git a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx new file mode 100644 index 0000000000..cbcd8ede4e --- /dev/null +++ b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx @@ -0,0 +1,70 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { MenuItem, Select } from '@material-ui/core'; +import { Maybe, Project } from '../../types'; +import { useSelectStyles as useStyles } from '../../utils/styles'; + +type ProjectSelectProps = { + project: Maybe; + projects: Array; + onSelect: (project: Maybe) => void; +}; + +const ProjectSelect = ({ project, projects, onSelect }: ProjectSelectProps) => { + const classes = useStyles(); + + const projectOptions = [{ id: 'all' } as Project, ...projects] + .filter(p => p.id) + .sort((a, b) => (a.id as string).localeCompare(b.id as string)); + + const handleOnChange = (e: React.ChangeEvent<{ value: unknown }>) => { + onSelect(e.target.value as string); + }; + + const renderValue = (value: unknown) => { + const proj = value as string; + return ( + + {proj === 'all' ? 'All Projects' : proj} + + ); + }; + + return ( + + ); +}; + +export default ProjectSelect; diff --git a/plugins/cost-insights/src/components/ProjectSelect/index.ts b/plugins/cost-insights/src/components/ProjectSelect/index.ts new file mode 100644 index 0000000000..47a2b9e22c --- /dev/null +++ b/plugins/cost-insights/src/components/ProjectSelect/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './ProjectSelect'; diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.test.tsx b/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.test.tsx new file mode 100644 index 0000000000..8355deab7d --- /dev/null +++ b/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.test.tsx @@ -0,0 +1,41 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import ResourceGrowthBarChart from './ResourceGrowthBarChart'; +import { Duration } from '../../types'; +import { renderInTestApp } from '@backstage/test-utils'; +import { createMockEntity } from '../../utils/mockData'; + +const MockResources = [...Array(10)].map((_, index) => + createMockEntity(() => ({ + id: `test-id-${index + 1}`, + // grow resource costs linearly for testing + aggregation: [index * 1000, (index + 1) * 1000], + })), +); + +describe('', () => { + it('Pre-renders without exploding', async () => { + const rendered = await renderInTestApp( + , + ); + expect(rendered.queryByTestId('bar-chart-wrapper')).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.tsx b/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.tsx new file mode 100644 index 0000000000..3ae35ba871 --- /dev/null +++ b/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.tsx @@ -0,0 +1,97 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { TooltipPayload } from 'recharts'; +import { + currencyFormatter, + dateRegex, + formatDuration, +} from '../../utils/formatters'; +import { + BarChartData, + CostInsightsTheme, + DataKey, + Duration, + Entity, + inclusiveEndDateOf, + inclusiveStartDateOf, + Maybe, + ResourceData, + AlertCost, +} from '../../types'; +import BarChart from '../BarChart'; +import { TooltipItemProps } from '../Tooltip'; +import { useTheme } from '@material-ui/core'; + +export type ResourceGrowthBarChartProps = { + duration: Duration; + resources: Array; +}; + +const ResourceGrowthBarChart = ({ + duration, + resources, +}: ResourceGrowthBarChartProps) => { + const theme = useTheme(); + const getTooltipItem = (payload: TooltipPayload): Maybe => { + const label = dateRegex.test(payload.name) + ? formatDuration(payload.name, duration) + : payload.name; + const value = + typeof payload.value === 'number' + ? currencyFormatter.format(payload.value) + : (payload.value as string); + const fill = payload.fill as string; + + switch (payload.dataKey) { + case DataKey.Current: + case DataKey.Previous: + return { + label: label, + value: value, + fill: fill, + }; + default: + return null; + } + }; + + const barChartData: BarChartData = { + previousFill: theme.palette.lightBlue, + currentFill: theme.palette.darkBlue, + previousName: inclusiveStartDateOf(duration), + currentName: inclusiveEndDateOf(duration), + }; + + const resourceData: ResourceData[] = resources.map(resource => { + return { + name: resource.id, + previous: resource.aggregation[0], + current: resource.aggregation[1], + }; + }); + + return ( + + ); +}; + +export default ResourceGrowthBarChart; diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChart/index.ts b/plugins/cost-insights/src/components/ResourceGrowthBarChart/index.ts new file mode 100644 index 0000000000..b1388143e4 --- /dev/null +++ b/plugins/cost-insights/src/components/ResourceGrowthBarChart/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './ResourceGrowthBarChart'; diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.test.tsx b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.test.tsx new file mode 100644 index 0000000000..1e432ff3bc --- /dev/null +++ b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.test.tsx @@ -0,0 +1,93 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ReactNode } from 'react'; +import { renderInTestApp } from '@backstage/test-utils'; +import ResourceGrowthBarChartLegend from './ResourceGrowthBarChartLegend'; +import { Currency, defaultCurrencies, Duration, findAlways } from '../../types'; +import { MockConfigProvider, MockCurrencyProvider } from '../../utils/tests'; + +const engineers = findAlways(defaultCurrencies, c => c.kind === null); + +const MockContext = ({ + children, + currency, +}: { + children: ReactNode; + currency: Currency; +}) => ( + + + {children} + + +); + +describe('', () => { + describe.each` + ratio | amount | costText | engineerTest + ${2.5} | ${300_000} | ${'Cost Growth'} | ${/\~6 engineers/} + ${-2.5} | ${-120_000} | ${'Cost Savings'} | ${/\~2 engineers/} + `( + 'Should display the cost text', + ({ ratio, amount, costText, engineerTest }) => { + it(`Should display the correct cost and engineer text for ${ratio} percent change`, async () => { + const rendered = await renderInTestApp( + + + , + ); + expect(rendered.getByText(costText)).toBeInTheDocument(); + expect(rendered.queryByText(engineerTest)).toBeInTheDocument(); + }); + }, + ); + + describe.each` + duration | periodStartText | periodEndText + ${Duration.P30D} | ${'First 30 Days'} | ${'Last 30 Days'} + ${Duration.P90D} | ${'First 90 Days'} | ${'Last 90 Days'} + `( + 'Should display the correct relative time', + ({ duration, periodStartText, periodEndText }) => { + it(`Should display the correct relative time for ${duration}`, async () => { + const rendered = await renderInTestApp( + + + , + ); + expect(rendered.getByText(periodStartText)).toBeInTheDocument(); + expect(rendered.getByText(periodEndText)).toBeInTheDocument(); + }); + }, + ); +}); diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.tsx b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.tsx new file mode 100644 index 0000000000..1466e18ee1 --- /dev/null +++ b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.tsx @@ -0,0 +1,72 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Box, useTheme } from '@material-ui/core'; +import LegendItem from '../LegendItem'; +import CostGrowth from '../CostGrowth'; +import { currencyFormatter, formatDuration } from '../../utils/formatters'; +import { + ChangeStatistic, + CostInsightsTheme, + Duration, + inclusiveEndDateOf, + inclusiveStartDateOf, +} from '../../types'; + +export type ResourceGrowthBarChartLegendProps = { + change: ChangeStatistic; + duration: Duration; + costStart: number; + costEnd: number; +}; + +const ResourceGrowthBarChartLegend = ({ + change, + duration, + costStart, + costEnd, +}: ResourceGrowthBarChartLegendProps) => { + const theme = useTheme(); + + const startOf = inclusiveStartDateOf(duration); + const endOf = inclusiveEndDateOf(duration); + const periodStartTitle = formatDuration(startOf, duration); + const periodEndTitle = formatDuration(endOf, duration); + + return ( + + + + {currencyFormatter.format(costStart)} + + + + + {currencyFormatter.format(costEnd)} + + + + + + + ); +}; + +export default ResourceGrowthBarChartLegend; diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/index.ts b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/index.ts new file mode 100644 index 0000000000..7071799805 --- /dev/null +++ b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './ResourceGrowthBarChartLegend'; diff --git a/plugins/cost-insights/src/components/Tooltip/Tooltip.test.tsx b/plugins/cost-insights/src/components/Tooltip/Tooltip.test.tsx new file mode 100644 index 0000000000..8c12b34ffb --- /dev/null +++ b/plugins/cost-insights/src/components/Tooltip/Tooltip.test.tsx @@ -0,0 +1,59 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { renderInTestApp } from '@backstage/test-utils'; +import Tooltip from './Tooltip'; +import { CostInsightsThemeProvider } from '../CostInsightsPage/CostInsightsThemeProvider'; + +const mockTooltipItems = [ + { + label: 'Cost', + value: '$1,000,000', + fill: '#FFF', + }, + { + label: 'Test Metric', + value: '100,000,000', + fill: '#FFF', + }, +]; + +describe('', () => { + it('renders without exploding', async () => { + const rendered = await renderInTestApp( + + + , + ); + expect( + rendered.container.querySelector('.tooltip-content'), + ).toBeInTheDocument(); + }); + + it('formats label and tooltip item text correctly', async () => { + const rendered = await renderInTestApp( + + + , + ); + expect(rendered.getByText('05/16/2020')).toBeInTheDocument(); + expect(rendered.getByText('Cost')).toBeInTheDocument(); + expect(rendered.getByText('Test Metric')).toBeInTheDocument(); + expect(rendered.getByText('$1,000,000')).toBeInTheDocument(); + expect(rendered.getByText('100,000,000')).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/Tooltip/Tooltip.tsx b/plugins/cost-insights/src/components/Tooltip/Tooltip.tsx new file mode 100644 index 0000000000..727b2476c8 --- /dev/null +++ b/plugins/cost-insights/src/components/Tooltip/Tooltip.tsx @@ -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 React from 'react'; +import { Box, Typography } from '@material-ui/core'; +import TooltipItem, { TooltipItemProps } from './TooltipItem'; +import { useTooltipStyles } from '../../utils/styles'; + +export type TooltipProps = { + label?: string; + items?: Array; +}; + +const Tooltip = ({ label, items }: TooltipProps) => { + const classes = useTooltipStyles(); + return ( + + {label && ( + + {label} + + )} + {items && + items.map((item, index) => ( + + ))} + + ); +}; + +export default Tooltip; diff --git a/plugins/cost-insights/src/components/Tooltip/TooltipItem.tsx b/plugins/cost-insights/src/components/Tooltip/TooltipItem.tsx new file mode 100644 index 0000000000..67d4eac15f --- /dev/null +++ b/plugins/cost-insights/src/components/Tooltip/TooltipItem.tsx @@ -0,0 +1,49 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Box, Typography } from '@material-ui/core'; +import LensIcon from '@material-ui/icons/Lens'; +import { useTooltipStyles as useStyles } from '../../utils/styles'; + +export type TooltipItemProps = { + value: string; + label: string; + fill: string; +}; + +const TooltipItem = ({ fill, label, value }: TooltipItemProps) => { + const classes = useStyles(); + const style = { fill: fill }; + return ( + + + + + + {label} + + {value} + + ); +}; + +export default TooltipItem; diff --git a/plugins/cost-insights/src/components/Tooltip/index.ts b/plugins/cost-insights/src/components/Tooltip/index.ts new file mode 100644 index 0000000000..ca20ab1242 --- /dev/null +++ b/plugins/cost-insights/src/components/Tooltip/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './Tooltip'; +export * from './TooltipItem'; diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.test.tsx b/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.test.tsx new file mode 100644 index 0000000000..6beb204b0d --- /dev/null +++ b/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.test.tsx @@ -0,0 +1,66 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import UnlabeledDataflowAlertCard from './UnlabeledDataflowAlertCard'; +import { + createMockUnlabeledDataflowAlert, + createMockUnlabeledDataflowAlertProject, +} from '../../utils/mockData'; +import { renderInTestApp } from '@backstage/test-utils'; + +const MockUnlabeledDataflowAlertMultipleProjects = createMockUnlabeledDataflowAlert( + alert => ({ + ...alert, + projects: [...Array(10)].map(() => + createMockUnlabeledDataflowAlertProject(), + ), + }), +); + +const MockUnlabeledDataflowAlertSingleProject = createMockUnlabeledDataflowAlert( + alert => ({ + ...alert, + projects: [...Array(1)].map(() => + createMockUnlabeledDataflowAlertProject(), + ), + }), +); + +describe('', () => { + it('renders the correct subheader for multiple projects', async () => { + const subheader = new RegExp( + `Showing costs from ${MockUnlabeledDataflowAlertMultipleProjects.projects.length} ` + + 'projects with unlabeled Dataflow jobs in the last 30 days.', + ); + const rendered = await renderInTestApp( + , + ); + expect(rendered.getByText(subheader)).toBeInTheDocument(); + }); + + it('renders the correct subheader for a single project', async () => { + const subheader = new RegExp('1 project'); + const rendered = await renderInTestApp( + , + ); + expect(rendered.getByText(subheader)).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.tsx b/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.tsx new file mode 100644 index 0000000000..8dff85e867 --- /dev/null +++ b/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Box } from '@material-ui/core'; +import { InfoCard } from '@backstage/core'; +import UnlabeledDataflowBarChart from '../UnlabeledDataflowBarChart'; +import UnlabeledDataflowBarChartLegend from '../UnlabeledDataflowBarChartLegend'; +import { UnlabeledDataflowAlert } from '../../types'; +import { pluralOf } from '../../utils/grammar'; + +type UnlabeledDataflowAlertProps = { + alert: UnlabeledDataflowAlert; +}; + +const UnlabeledDataflowAlertCard = ({ alert }: UnlabeledDataflowAlertProps) => { + const projects = pluralOf(alert.projects.length, 'project'); + const subheader = ` + Showing costs from ${alert.projects.length} ${projects} with unlabeled Dataflow jobs in the last 30 days. + `; + return ( + + + + + + + + + + + ); +}; + +export default UnlabeledDataflowAlertCard; diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/index.ts b/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/index.ts new file mode 100644 index 0000000000..6f22abc062 --- /dev/null +++ b/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './UnlabeledDataflowAlertCard'; diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowBarChart/UnlabeledDataflowBarChart.tsx b/plugins/cost-insights/src/components/UnlabeledDataflowBarChart/UnlabeledDataflowBarChart.tsx new file mode 100644 index 0000000000..7b1f4e31ec --- /dev/null +++ b/plugins/cost-insights/src/components/UnlabeledDataflowBarChart/UnlabeledDataflowBarChart.tsx @@ -0,0 +1,73 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { TooltipPayload } from 'recharts'; +import BarChart from '../BarChart'; +import { TooltipItemProps } from '../Tooltip'; +import { + BarChartData, + CostInsightsTheme, + ResourceData, + UnlabeledDataflowAlertProject, +} from '../../types'; +import { currencyFormatter } from '../../utils/formatters'; +import { useTheme } from '@material-ui/core'; + +type UnlabeledDataflowBarChartProps = { + projects: Array; +}; + +export const UnlabeledDataflowBarChart = ({ + projects, +}: UnlabeledDataflowBarChartProps) => { + const theme = useTheme(); + const barChartData: BarChartData = { + previousFill: theme.palette.lightBlue, + currentFill: theme.palette.darkBlue, + previousName: 'Unlabeled Cost', + currentName: 'Labeled Cost', + }; + + const getTooltipItem = (payload: TooltipPayload): TooltipItemProps => { + return { + label: payload.name, + value: + typeof payload.value === 'number' + ? currencyFormatter.format(payload.value) + : (payload.value as string), + fill: payload.fill as string, + }; + }; + + const resources: ResourceData[] = projects.map(project => { + return { + name: project.id, + previous: project.unlabeledCost || 0, + current: project.labeledCost || 0, + }; + }); + + return ( + + ); +}; + +export default UnlabeledDataflowBarChart; diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowBarChart/index.ts b/plugins/cost-insights/src/components/UnlabeledDataflowBarChart/index.ts new file mode 100644 index 0000000000..6f475d5a80 --- /dev/null +++ b/plugins/cost-insights/src/components/UnlabeledDataflowBarChart/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './UnlabeledDataflowBarChart'; diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/UnlabeledDataflowBarChartLegend.test.tsx b/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/UnlabeledDataflowBarChartLegend.test.tsx new file mode 100644 index 0000000000..9ab4e90001 --- /dev/null +++ b/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/UnlabeledDataflowBarChartLegend.test.tsx @@ -0,0 +1,34 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import UnlabeledDataflowBarChartLegend from './UnlabeledDataflowBarChartLegend'; +import { renderInTestApp } from '@backstage/test-utils'; + +describe('', () => { + it('Displays the correct text', async () => { + const rendered = await renderInTestApp( + , + ); + expect(rendered.getByText('Total Unlabeled Cost')).toBeInTheDocument(); + expect(rendered.getByText('Total Labeled Cost')).toBeInTheDocument(); + expect(rendered.getByText('$9,843')).toBeInTheDocument(); + expect(rendered.getByText('$2,309')).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/UnlabeledDataflowBarChartLegend.tsx b/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/UnlabeledDataflowBarChartLegend.tsx new file mode 100644 index 0000000000..68caaed6d0 --- /dev/null +++ b/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/UnlabeledDataflowBarChartLegend.tsx @@ -0,0 +1,59 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Box, useTheme } from '@material-ui/core'; +import LegendItem from '../LegendItem'; +import { currencyFormatter } from '../../utils/formatters'; +import { CostInsightsTheme } from '../../types'; + +type UnlabeledDataflowBarChartLegendProps = { + labeledCost: number; + unlabeledCost: number; +}; + +const UnlabeledDataflowBarChartLegend = ({ + unlabeledCost, + labeledCost, +}: UnlabeledDataflowBarChartLegendProps) => { + const theme = useTheme(); + return ( + + + + {currencyFormatter.format(unlabeledCost)} + + + + + {currencyFormatter.format(labeledCost)} + + + + ); +}; + +export default UnlabeledDataflowBarChartLegend; diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/index.ts b/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/index.ts new file mode 100644 index 0000000000..470722e60a --- /dev/null +++ b/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './UnlabeledDataflowBarChartLegend'; diff --git a/plugins/cost-insights/src/components/WhyCostsMatter/WhyCostsMatter.tsx b/plugins/cost-insights/src/components/WhyCostsMatter/WhyCostsMatter.tsx new file mode 100644 index 0000000000..69de854b00 --- /dev/null +++ b/plugins/cost-insights/src/components/WhyCostsMatter/WhyCostsMatter.tsx @@ -0,0 +1,77 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Typography, Box, Grid, Container, Divider } from '@material-ui/core'; + +const WhyCostsMatter = () => { + return ( + + + + + Why cloud costs matter + + + + + + Sustainability{' '} + + 🌎 + + + + Reducing cloud usage improves our carbon footprint. + + + + + + + + Revenue{' '} + + 💸 + + + + Keeping cloud costs well-tuned prevents infrastructure from eating + into revenue. + + + + + + + + Innovation{' '} + + 🥇 + + + + The more we save, the more we can reinvest in speed and + innovation. + + + + + + ); +}; + +export default WhyCostsMatter; diff --git a/plugins/cost-insights/src/components/WhyCostsMatter/index.ts b/plugins/cost-insights/src/components/WhyCostsMatter/index.ts new file mode 100644 index 0000000000..ed60207fde --- /dev/null +++ b/plugins/cost-insights/src/components/WhyCostsMatter/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './WhyCostsMatter'; diff --git a/plugins/cost-insights/src/hooks/index.ts b/plugins/cost-insights/src/hooks/index.ts new file mode 100644 index 0000000000..7065bcf91b --- /dev/null +++ b/plugins/cost-insights/src/hooks/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './useConfig'; +export * from './useCurrency'; +export * from './useFilters'; +export * from './useCurrency'; +export * from './useGroups'; +export * from './useLoading'; +export * from './useQueryParams'; +export * from './useScroll'; diff --git a/plugins/cost-insights/src/hooks/useConfig.tsx b/plugins/cost-insights/src/hooks/useConfig.tsx new file mode 100644 index 0000000000..aee6567056 --- /dev/null +++ b/plugins/cost-insights/src/hooks/useConfig.tsx @@ -0,0 +1,162 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { + ReactNode, + createContext, + useContext, + useEffect, + useState, +} from 'react'; +import { useApi, configApiRef } from '@backstage/core'; +import { Config as BackstageConfig } from '@backstage/config'; +import { Currency, defaultCurrencies, Product, Icon, Metric } from '../types'; +import { getIcon } from '../utils/navigation'; + +export const NULL_METRIC = 'dailyCost'; +export const NULL_METRIC_NAME = 'Daily Cost'; + +/* + * Config schema 2020-09-28 + * + * costInsights: + * engineerCost: 200000 + * products: + * productA: + * name: Product A + * icon: storage + * productB: + * name: Product B + * icon: data + * metrics: + * metricA: + * name: Metric A + * metricB: + * name: Metric B + */ + +export type ConfigContextProps = { + metrics: Metric[]; + products: Product[]; + icons: Icon[]; + engineerCost: number; + currencies: Currency[]; +}; + +export const ConfigContext = createContext( + undefined, +); + +const defaultState: ConfigContextProps = { + metrics: [{ kind: null, name: NULL_METRIC_NAME }], + products: [], + icons: [], + engineerCost: 0, + currencies: defaultCurrencies, +}; + +export const ConfigProvider = ({ children }: { children: ReactNode }) => { + const c: BackstageConfig = useApi(configApiRef); + const [config, setConfig] = useState(defaultState); + const [loading, setLoading] = useState(true); + + useEffect(() => { + function getProducts(): Product[] { + const products = c.getConfig('costInsights.products'); + return products.keys().map(key => ({ + kind: key, + name: products.getString(`${key}.name`), + aggregation: [0, 0], + })); + } + + function getMetrics(): Metric[] { + const metrics = c.getOptionalConfig('costInsights.metrics'); + if (metrics) { + return metrics.keys().map(key => ({ + kind: key === NULL_METRIC ? null : key, + name: metrics.getString(`${key}.name`), + })); + } + + return []; + } + + function getIcons(): Icon[] { + const products = c.getConfig('costInsights.products'); + const keys = products.keys(); + + return keys.map(k => ({ + kind: k, + component: getIcon(products.getOptionalString(`${k}.icon`)), + })); + } + + function getEngineerCost(): number { + return c.getNumber('costInsights.engineerCost'); + } + + function getConfig() { + const products = getProducts(); + const metrics = getMetrics(); + const engineerCost = getEngineerCost(); + const icons = getIcons(); + + if (metrics.find((m: Metric) => m.kind === null)) { + setConfig(prevState => ({ + ...prevState, + metrics, + products, + engineerCost, + icons, + })); + } else { + setConfig(prevState => ({ + ...prevState, + metrics: [...prevState.metrics, ...metrics], + products, + engineerCost, + icons, + })); + } + setLoading(false); + } + + getConfig(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + if (loading) { + return null; + } + + return ( + {children} + ); +}; + +export function useConfig(): ConfigContextProps { + const config = useContext(ConfigContext); + + if (!config) { + assertNever(); + } + + return config; +} + +function assertNever(): never { + throw new Error('Cannot use useConfig outside of ConfigProvider'); +} diff --git a/plugins/cost-insights/src/hooks/useCurrency.tsx b/plugins/cost-insights/src/hooks/useCurrency.tsx new file mode 100644 index 0000000000..f5d0df929a --- /dev/null +++ b/plugins/cost-insights/src/hooks/useCurrency.tsx @@ -0,0 +1,60 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { + Dispatch, + SetStateAction, + ReactNode, + useState, + useContext, +} from 'react'; +import { Currency, defaultCurrencies, findAlways } from '../types'; + +export type CurrencyContextProps = { + currency: Currency; + setCurrency: Dispatch>; +}; + +export type CurrencyProviderProps = { + children: ReactNode; +}; + +export const CurrencyContext = React.createContext< + CurrencyContextProps | undefined +>(undefined); + +export const CurrencyProvider = ({ children }: CurrencyProviderProps) => { + const engineers = findAlways(defaultCurrencies, c => c.kind === null); + const [currency, setCurrency] = useState(engineers); + return ( + + {children} + + ); +}; + +export function useCurrency(): [Currency, Dispatch>] { + const context = useContext(CurrencyContext); + + if (!context) { + assertNever(); + } + + return [context.currency, context.setCurrency]; +} + +function assertNever(): never { + throw Error('Cannot use useCurrency outside of CurrencyProvider'); +} diff --git a/plugins/cost-insights/src/hooks/useFilters.tsx b/plugins/cost-insights/src/hooks/useFilters.tsx new file mode 100644 index 0000000000..c2053d6b0f --- /dev/null +++ b/plugins/cost-insights/src/hooks/useFilters.tsx @@ -0,0 +1,148 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { + Dispatch, + ReactNode, + SetStateAction, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import { + getDefaultPageFilters, + PageFilters, + ProductFilters, + Duration, + Group, +} from '../types'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useQueryParams } from './useQueryParams'; +import { stringify } from '../utils/history'; +import { useGroups } from './useGroups'; +import { useConfig } from './useConfig'; + +const getInitialPageState = ( + groups: Group[], + queryParams?: Partial, +) => { + // The group is written initially to queryParams as null, since user groups are asynchronously + // loaded. We preserve nulls in queryParams for other parameters where null is meaningful; for + // group, avoid overwriting the default with null after groups are loaded. + const { group, ...otherParams } = queryParams || {}; + return { + ...getDefaultPageFilters(groups), + ...otherParams, + ...(group ? { group: group } : {}), + }; +}; + +export type FilterContextProps = { + pageFilters: PageFilters; + productFilters: ProductFilters; + setPageFilters: Dispatch>; + setProductFilters: Dispatch>; +}; + +export type MapFiltersToProps = (props: FilterContextProps) => T; + +export type FilterProviderProps = { + children: ReactNode; +}; + +export const FilterContext = React.createContext< + FilterContextProps | undefined +>(undefined); + +export const FilterProvider = ({ children }: FilterProviderProps) => { + const navigate = useNavigate(); + const location = useLocation(); + const queryParams = useQueryParams(); + const qsRef = useRef(''); + const groups = useGroups(); + const { products } = useConfig(); + + const defaultProductFilters = products.map(product => ({ + productType: product.kind, + duration: Duration.P1M, + })); + + const getInitialProductState = (productFilters?: ProductFilters) => { + if (!productFilters) return defaultProductFilters; + return defaultProductFilters.map(product => { + return ( + productFilters.find( + param => param.productType === product.productType, + ) || product + ); + }); + }; + + const [productFilters, setProductFilters] = useState( + getInitialProductState(queryParams.productFilters), + ); + const [pageFilters, setPageFilters] = useState( + getInitialPageState(groups, queryParams.pageFilters), + ); + + // TODO: Figure out why pageFilters doesn't get updated by the above when groups are loaded. + useEffect(() => { + setPageFilters(getInitialPageState(groups, queryParams.pageFilters)); + }, [groups]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + const queryString = stringify({ ...pageFilters, products: productFilters }); + if (queryString === qsRef.current) return; + qsRef.current = queryString; + // TODO Remove workaround once issue is resolved in react-router + // (https://github.com/ReactTraining/react-router/issues/7496) + // navigate({ ...location, search: queryString }); + navigate({ ...location, search: `?${queryString}` }); + }, [pageFilters, productFilters]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + + {children} + + ); +}; + +export function useFilters(mapFiltersToProps: MapFiltersToProps): T { + const context = useContext(FilterContext); + + if (!context) { + assertNever(); + } + + return mapFiltersToProps({ + pageFilters: context.pageFilters, + productFilters: context.productFilters, + setPageFilters: context.setPageFilters, + setProductFilters: context.setProductFilters, + }); +} + +function assertNever(): never { + throw Error('Cannot use useFilters outside of FilterProvider'); +} diff --git a/plugins/cost-insights/src/hooks/useGroups.tsx b/plugins/cost-insights/src/hooks/useGroups.tsx new file mode 100644 index 0000000000..02c32e7920 --- /dev/null +++ b/plugins/cost-insights/src/hooks/useGroups.tsx @@ -0,0 +1,84 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ReactNode, useContext, useEffect, useState } from 'react'; +import { useApi, identityApiRef } from '@backstage/core'; +import { costInsightsApiRef } from '../api'; +import { MapLoadingToProps, useLoading } from './useLoading'; +import { DefaultLoadingAction, Group } from '../types'; + +type GroupsProviderLoadingProps = { + dispatchLoadingGroups: (isLoading: boolean) => void; +}; + +export const mapLoadingToProps: MapLoadingToProps = ({ + dispatch, +}) => ({ + dispatchLoadingGroups: (isLoading: boolean) => + dispatch({ [DefaultLoadingAction.UserGroups]: isLoading }), +}); + +type GroupsContextProps = { + groups: Group[]; +}; + +export type GroupsProviderProps = { + children: ReactNode; +}; + +export const GroupsContext = React.createContext({ + groups: [], +}); + +export const GroupsProvider = ({ children }: GroupsProviderProps) => { + const userId = useApi(identityApiRef).getUserId(); + const client = useApi(costInsightsApiRef); + const { dispatchLoadingGroups } = useLoading(mapLoadingToProps); + + const [groups, setGroups] = useState([]); + + useEffect(() => { + dispatchLoadingGroups(true); + + async function getUserGroups() { + const g = await client.getUserGroups(userId); + setGroups(g); + dispatchLoadingGroups(false); + } + + getUserGroups(); + }, [userId, client]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + + {children} + + ); +}; + +export function useGroups(): Group[] { + const { groups } = useContext(GroupsContext); + + if (!groups) { + assertNever(); + } + + return groups; +} + +function assertNever(): never { + throw Error('Cannot use useGroups outside of GroupsProvider'); +} diff --git a/plugins/cost-insights/src/hooks/useLoading.tsx b/plugins/cost-insights/src/hooks/useLoading.tsx new file mode 100644 index 0000000000..bc0df4bbc7 --- /dev/null +++ b/plugins/cost-insights/src/hooks/useLoading.tsx @@ -0,0 +1,107 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { + Dispatch, + ReactNode, + SetStateAction, + createContext, + useContext, + useEffect, + useMemo, + useReducer, + useState, +} from 'react'; +import { Backdrop, CircularProgress } from '@material-ui/core'; +import { + Loading, + getDefaultState, + DefaultLoadingAction, + getLoadingActions, +} from '../types'; +import { useBackdropStyles as useStyles } from '../utils/styles'; +import { useConfig } from './useConfig'; + +export type LoadingContextProps = { + state: Loading; + dispatch: Dispatch>>; + actions: Array; +}; + +export type LoadingProviderProps = { + children: ReactNode; +}; + +export type MapLoadingToProps = (props: LoadingContextProps) => T; + +export const LoadingContext = createContext( + undefined, +); + +function reducer(prevState: Loading, action: Partial): Loading { + return { + ...prevState, + ...action, + } as Record; +} + +export const LoadingProvider = ({ children }: LoadingProviderProps) => { + const classes = useStyles(); + const { products } = useConfig(); + const actions = useMemo(() => getLoadingActions(products.map(p => p.kind)), [ + products, + ]); + const [state, dispatch] = useReducer(reducer, getDefaultState(actions)); + const [isBackdropVisible, setBackdropVisible] = useState(false); + + useEffect(() => { + function displayLoadingBackdrop() { + // Initial page loading is handled by progress bar + setBackdropVisible( + !state[DefaultLoadingAction.CostInsightsInitial] && + Object.values(state).some(l => l), + ); + } + displayLoadingBackdrop(); + }, [state, setBackdropVisible]); + + return ( + + {children} + + + + + ); +}; + +export function useLoading(mapLoadingToProps: MapLoadingToProps): T { + const context = useContext(LoadingContext); + + if (!context) { + assertNever(); + } + + return mapLoadingToProps({ + state: context.state, + actions: context.actions, + dispatch: context.dispatch, + }); +} + +function assertNever(): never { + throw Error('useLoading cannot be used outside of LoadingProvider'); +} diff --git a/plugins/cost-insights/src/hooks/useQueryParams.tsx b/plugins/cost-insights/src/hooks/useQueryParams.tsx new file mode 100644 index 0000000000..1e6ce8f0fd --- /dev/null +++ b/plugins/cost-insights/src/hooks/useQueryParams.tsx @@ -0,0 +1,35 @@ +/* + * 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 { useLocation } from 'react-router-dom'; +import { Location } from 'history'; +import { PageFilters, ProductFilters } from '../types'; +import { parse } from '../utils/history'; + +export type FilterParams = { + pageFilters?: Partial; + productFilters?: ProductFilters; +}; + +export function useQueryParams(): FilterParams { + const location: Location = useLocation(); + const { products: productFilters, ...pageFilters } = parse(location.search); + + return { + productFilters: productFilters, + pageFilters: pageFilters, + }; +} diff --git a/plugins/cost-insights/src/hooks/useScroll.tsx b/plugins/cost-insights/src/hooks/useScroll.tsx new file mode 100644 index 0000000000..480495a257 --- /dev/null +++ b/plugins/cost-insights/src/hooks/useScroll.tsx @@ -0,0 +1,126 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { + Dispatch, + ReactNode, + SetStateAction, + useState, + useContext, + useEffect, + useRef, +} from 'react'; +import { CSSProperties } from '@material-ui/styles'; +import { Maybe } from '../types'; + +export type ScrollTo = Maybe; + +export type ScrollContextProps = { + scrollTo: ScrollTo; + setScrollTo: Dispatch>; +}; + +export type ScrollProviderProps = { + children: ReactNode; +}; + +export type ScrollUtils = { + ScrollAnchor: (props: Omit) => JSX.Element; + scrollIntoView: () => void; +}; + +export interface ScrollAnchorProps extends ScrollIntoViewOptions { + id: ScrollTo; + top?: number; + left?: number; +} + +export const ScrollContext = React.createContext< + ScrollContextProps | undefined +>(undefined); + +export const ScrollAnchor = ({ + id, + top, + left, + behavior, + block, + inline, +}: ScrollAnchorProps) => { + const divRef = useRef(null); + const context = useContext(ScrollContext); + + if (!context) { + assertNever(); + } + + const { scrollTo, setScrollTo } = context; + + const styles: CSSProperties = { + position: 'absolute', + height: 0, + width: 0, + top: top || 0, + left: left || 0, + }; + + useEffect(() => { + function scrollIntoView() { + const options = { + behavior: behavior || 'auto', + block: block || 'start', + inline: inline || 'nearest', + }; + + if (divRef.current && scrollTo === id) { + divRef.current.scrollIntoView(options); + setScrollTo(null); + } + } + + scrollIntoView(); + }, [scrollTo, setScrollTo, id, behavior, block, inline]); + + return
; +}; + +export const ScrollProvider = ({ children }: ScrollProviderProps) => { + const [scrollTo, setScrollTo] = useState(null); + + return ( + + {children} + + ); +}; + +export function useScroll(id: ScrollTo): ScrollUtils { + const context = useContext(ScrollContext); + + if (!context) { + assertNever(); + } + + return { + ScrollAnchor: props => , + scrollIntoView: () => context.setScrollTo(id), + }; +} + +function assertNever(): never { + throw new Error( + `Cannot use useScroll or ScrollAnchor outside ScrollProvider`, + ); +} diff --git a/plugins/cost-insights/src/index.ts b/plugins/cost-insights/src/index.ts new file mode 100644 index 0000000000..cf7aa3ea8c --- /dev/null +++ b/plugins/cost-insights/src/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { plugin } from './plugin'; +export * from './api'; +export * from './types'; diff --git a/plugins/cost-insights/src/plugin.test.ts b/plugins/cost-insights/src/plugin.test.ts new file mode 100644 index 0000000000..6210e11d4c --- /dev/null +++ b/plugins/cost-insights/src/plugin.test.ts @@ -0,0 +1,23 @@ +/* + * 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 { plugin } from './plugin'; + +describe('cost-insights', () => { + it('should export plugin', () => { + expect(plugin).toBeDefined(); + }); +}); diff --git a/plugins/cost-insights/src/plugin.ts b/plugins/cost-insights/src/plugin.ts new file mode 100644 index 0000000000..d8d54ae71b --- /dev/null +++ b/plugins/cost-insights/src/plugin.ts @@ -0,0 +1,45 @@ +/* + * 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 { createPlugin, createRouteRef } from '@backstage/core'; +import CostInsightsPage from './components/CostInsightsPage'; +import ProjectGrowthInstructionsPage from './components/ProjectGrowthInstructionsPage'; +import LabelDataflowInstructionsPage from './components/LabelDataflowInstructionsPage'; + +export const rootRouteRef = createRouteRef({ + path: '/cost-insights', + title: 'Cost Insights', +}); + +export const projectGrowthAlertRef = createRouteRef({ + path: '/cost-insights/investigating-growth', + title: 'Investigating Growth', +}); + +export const unlabeledDataflowAlertRef = createRouteRef({ + path: '/cost-insights/labeling-jobs', + title: 'Labeling Dataflow Jobs', +}); + +export const plugin = createPlugin({ + id: 'cost-insights', + register({ router, featureFlags }) { + router.addRoute(rootRouteRef, CostInsightsPage); + router.addRoute(projectGrowthAlertRef, ProjectGrowthInstructionsPage); + router.addRoute(unlabeledDataflowAlertRef, LabelDataflowInstructionsPage); + featureFlags.register('cost-insights-currencies'); + }, +}); diff --git a/plugins/cost-insights/src/setupTests.ts b/plugins/cost-insights/src/setupTests.ts new file mode 100644 index 0000000000..8553642152 --- /dev/null +++ b/plugins/cost-insights/src/setupTests.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import '@testing-library/jest-dom'; + +require('jest-fetch-mock').enableMocks(); diff --git a/plugins/cost-insights/src/types/Alert.ts b/plugins/cost-insights/src/types/Alert.ts new file mode 100644 index 0000000000..8d560096e3 --- /dev/null +++ b/plugins/cost-insights/src/types/Alert.ts @@ -0,0 +1,77 @@ +/* + * 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 { ChangeStatistic } from './ChangeStatistic'; +import { Maybe } from './Maybe'; + +export type Alert = ProjectGrowthAlert | UnlabeledDataflowAlert; + +export interface AlertProps { + alert: Alert; +} + +export enum AlertType { + ProjectGrowth = 'projectGrowth', + UnlabeledDataflow = 'unlabeledDataflow', +} + +export interface AlertCost { + id: string; + aggregation: [number, number]; +} + +export interface ResourceData { + previous: number; + current: number; + name: Maybe; +} + +export interface BarChartData { + previousFill: string; + currentFill: string; + previousName: string; + currentName: string; +} + +export enum DataKey { + Previous = 'previous', + Current = 'current', + Name = 'name', +} + +export interface ProjectGrowthAlert { + id: AlertType.ProjectGrowth; + project: string; + periodStart: string; + periodEnd: string; + aggregation: [number, number]; + change: ChangeStatistic; + products: Array; +} + +export interface UnlabeledDataflowAlert { + id: AlertType.UnlabeledDataflow; + periodStart: string; + periodEnd: string; + projects: Array; + unlabeledCost: number; + labeledCost: number; +} + +export interface UnlabeledDataflowAlertProject { + id: string; + unlabeledCost: number; + labeledCost: number; +} diff --git a/plugins/cost-insights/src/types/ChangeStatistic.ts b/plugins/cost-insights/src/types/ChangeStatistic.ts new file mode 100644 index 0000000000..538692bb1d --- /dev/null +++ b/plugins/cost-insights/src/types/ChangeStatistic.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface ChangeStatistic { + // The ratio of change from one duration to another, expressed as: (newSum - oldSum) / oldSum + ratio: number; + // The actual USD change between time periods (can be negative if costs decreased) + amount: number; +} + +export const EngineerThreshold = 0.5; + +export enum ChangeThreshold { + upper = 0.05, + lower = -0.05, +} + +export enum Growth { + Negligible, + Savings, + Excess, +} + +// Used by for displaying status colors +export function growthOf(amount: number, ratio: number) { + if (amount >= EngineerThreshold && ratio >= ChangeThreshold.upper) { + return Growth.Excess; + } + + if (amount >= EngineerThreshold && ratio <= ChangeThreshold.lower) { + return Growth.Savings; + } + + return Growth.Negligible; +} diff --git a/plugins/cost-insights/src/types/Cost.ts b/plugins/cost-insights/src/types/Cost.ts new file mode 100644 index 0000000000..84a8bfae51 --- /dev/null +++ b/plugins/cost-insights/src/types/Cost.ts @@ -0,0 +1,26 @@ +/* + * 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 { DateAggregation } from './DateAggregation'; +import { ChangeStatistic } from './ChangeStatistic'; +import { Trendline } from './Trendline'; + +export interface Cost { + id: string; + aggregation: DateAggregation[]; + change: ChangeStatistic; + trendline: Trendline; +} diff --git a/plugins/cost-insights/src/types/Currency.ts b/plugins/cost-insights/src/types/Currency.ts new file mode 100644 index 0000000000..633e405893 --- /dev/null +++ b/plugins/cost-insights/src/types/Currency.ts @@ -0,0 +1,78 @@ +/* + * 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 { Duration } from './Duration'; +import { assertNever } from './Maybe'; + +export enum CurrencyType { + USD = 'USD', + CarbonOffsetTons = 'CARBON_OFFSET_TONS', + Beers = 'BEERS', + IceCream = 'PINTS_OF_ICE_CREAM', +} + +export interface Currency { + kind: string | null; + label: string; + unit: string; + prefix?: string; + rate?: number; +} + +export const rateOf = (cost: number, duration: Duration) => { + switch (duration) { + case Duration.P1M: + case Duration.P30D: + return cost / 12; + case Duration.P90D: + case Duration.P3M: + return cost / 4; + default: + return assertNever(duration); + } +}; + +export const defaultCurrencies: Currency[] = [ + { + kind: null, + label: 'Engineers 🛠', + unit: 'engineer', + }, + { + kind: CurrencyType.USD, + label: 'US Dollars 💵', + unit: 'dollar', + prefix: '$', + rate: 1, + }, + { + kind: CurrencyType.CarbonOffsetTons, + label: 'Carbon Offset Tons ♻️⚖️s', + unit: 'carbon offset ton', + rate: 3.5, + }, + { + kind: CurrencyType.Beers, + label: 'Beers 🍺', + unit: 'beer', + rate: 4.5, + }, + { + kind: CurrencyType.IceCream, + label: 'Pints of Ice Cream 🍦', + unit: 'ice cream pint', + rate: 5.5, + }, +]; diff --git a/plugins/cost-insights/src/types/DateAggregation.ts b/plugins/cost-insights/src/types/DateAggregation.ts new file mode 100644 index 0000000000..807387a265 --- /dev/null +++ b/plugins/cost-insights/src/types/DateAggregation.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type DateAggregation = { + date: string; // YYYY-MM-DD + amount: number; +}; diff --git a/plugins/cost-insights/src/types/Duration.test.ts b/plugins/cost-insights/src/types/Duration.test.ts new file mode 100644 index 0000000000..5b9f7b4a9b --- /dev/null +++ b/plugins/cost-insights/src/types/Duration.test.ts @@ -0,0 +1,32 @@ +/* + * 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 { Duration, inclusiveEndDateOf, inclusiveStartDateOf } from './Duration'; + +Date.now = jest.fn(() => new Date(Date.parse('2020-06-05')).valueOf()); + +describe.each` + duration | startDate | endDate + ${Duration.P30D} | ${'2020-04-06'} | ${'2020-06-05'} + ${Duration.P90D} | ${'2019-12-08'} | ${'2020-06-05'} + ${Duration.P1M} | ${'2020-04-01'} | ${'2020-05-31'} + ${Duration.P3M} | ${'2019-10-01'} | ${'2020-03-31'} +`('Calculates interval dates correctly', ({ duration, startDate, endDate }) => { + it(`Calculates dates correctly for ${duration}`, () => { + expect(inclusiveStartDateOf(duration)).toBe(startDate); + expect(inclusiveEndDateOf(duration)).toBe(endDate); + }); +}); diff --git a/plugins/cost-insights/src/types/Duration.ts b/plugins/cost-insights/src/types/Duration.ts new file mode 100644 index 0000000000..6e6c5b1100 --- /dev/null +++ b/plugins/cost-insights/src/types/Duration.ts @@ -0,0 +1,85 @@ +/* + * 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 moment from 'moment'; +import { assertNever } from './Maybe'; + +/** + * Time periods for cost comparison; slight abuse of ISO 8601 periods. We take P1M and P3M to mean + * 'last completed [month|quarter]', and P30D/P90D to be '[month|quarter] relative to today'. So if + * it's September 15, P1M represents costs for the month of August and P30D represents August 16 - + * September 15. + */ +export enum Duration { + P30D = 'P30D', + P90D = 'P90D', + P1M = 'P1M', + P3M = 'P3M', +} + +export const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD'; + +// Derive the start date of a given period, assuming two repeating intervals +export function inclusiveStartDateOf(duration: Duration): string { + switch (duration) { + case Duration.P30D: + case Duration.P90D: + return moment() + .utc() + .subtract(moment.duration(duration).add(moment.duration(duration))) + .format(DEFAULT_DATE_FORMAT); + case Duration.P1M: + return moment() + .utc() + .startOf('month') + .subtract(moment.duration(duration).add(moment.duration(duration))) + .format(DEFAULT_DATE_FORMAT); + case Duration.P3M: + return moment() + .utc() + .startOf('quarter') + .subtract(moment.duration(duration).add(moment.duration(duration))) + .format(DEFAULT_DATE_FORMAT); + default: + return assertNever(duration); + } +} + +export function exclusiveEndDateOf(duration: Duration): string { + switch (duration) { + case Duration.P30D: + case Duration.P90D: + return moment().utc().add(1, 'day').format(DEFAULT_DATE_FORMAT); + case Duration.P1M: + return moment().utc().startOf('month').format(DEFAULT_DATE_FORMAT); + case Duration.P3M: + return moment().utc().startOf('quarter').format(DEFAULT_DATE_FORMAT); + default: + return assertNever(duration); + } +} + +export function inclusiveEndDateOf(duration: Duration): string { + return moment(exclusiveEndDateOf(duration)) + .utc() + .subtract(1, 'day') + .format(DEFAULT_DATE_FORMAT); +} + +// https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals +export function intervalsOf(duration: Duration) { + return `R2/${duration}/${exclusiveEndDateOf(duration)}`; +} diff --git a/plugins/cost-insights/src/types/Entity.ts b/plugins/cost-insights/src/types/Entity.ts new file mode 100644 index 0000000000..f5ac620289 --- /dev/null +++ b/plugins/cost-insights/src/types/Entity.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ChangeStatistic } from './ChangeStatistic'; +import { Maybe } from './Maybe'; + +export interface Entity { + id: Maybe; + aggregation: [number, number]; + change?: Maybe; +} diff --git a/plugins/cost-insights/src/types/Filters.ts b/plugins/cost-insights/src/types/Filters.ts new file mode 100644 index 0000000000..def1d74d1a --- /dev/null +++ b/plugins/cost-insights/src/types/Filters.ts @@ -0,0 +1,44 @@ +/* + * 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 { Maybe } from './Maybe'; +import { Duration } from './Duration'; +import { Group } from './Group'; + +export interface PageFilters { + group: Maybe; + project: Maybe; + duration: Duration; + metric: Maybe; +} + +export type ProductFilters = Array; + +export type QueryParams = PageFilters & { products: ProductFilters }; + +export interface ProductPeriod { + duration: Duration; + productType: string; +} + +export function getDefaultPageFilters(groups: Group[]): PageFilters { + return { + group: groups.length ? groups[0].id : null, + project: null, + duration: Duration.P90D, + metric: null, + }; +} diff --git a/plugins/cost-insights/src/types/Group.ts b/plugins/cost-insights/src/types/Group.ts new file mode 100644 index 0000000000..3aef92e5b9 --- /dev/null +++ b/plugins/cost-insights/src/types/Group.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type Group = { + id: string; +}; diff --git a/plugins/cost-insights/src/types/Icon.ts b/plugins/cost-insights/src/types/Icon.ts new file mode 100644 index 0000000000..70f43a6813 --- /dev/null +++ b/plugins/cost-insights/src/types/Icon.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type Icon = { + kind: string; + component: JSX.Element; +}; + +export enum IconType { + Compute = 'compute', + Data = 'data', + Database = 'database', + Storage = 'storage', + Search = 'search', + ML = 'ml', +} diff --git a/plugins/cost-insights/src/types/Loading.ts b/plugins/cost-insights/src/types/Loading.ts new file mode 100644 index 0000000000..f5da1d2b07 --- /dev/null +++ b/plugins/cost-insights/src/types/Loading.ts @@ -0,0 +1,63 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type Loading = Record; + +export enum DefaultLoadingAction { + UserGroups = 'user-groups', + CostInsightsInitial = 'cost-insights-initial', + CostInsightsPage = 'cost-insights-page', +} + +export const INITIAL_LOADING_ACTIONS = [ + DefaultLoadingAction.UserGroups, + DefaultLoadingAction.CostInsightsInitial, +]; + +export const getDefaultState = (loadingActions: string[]): Loading => { + return loadingActions.reduce( + (defaultState, action) => ({ ...defaultState, [action]: true }), + {}, + ); +}; + +export const getResetState = (loadingActions: string[]): Loading => { + return loadingActions.reduce( + (defaultState, action) => ({ ...defaultState, [action]: false }), + {}, + ); +}; + +export const getResetStateWithoutInitial = ( + loadingActions: string[], +): Loading => { + return loadingActions.reduce((defaultState, action) => { + const loadingActionState = (INITIAL_LOADING_ACTIONS as string[]).includes( + action, + ) + ? false + : true; + return { ...defaultState, [action]: loadingActionState }; + }, {}); +}; + +export function getLoadingActions(products: string[]): string[] { + return ([ + DefaultLoadingAction.UserGroups, + DefaultLoadingAction.CostInsightsInitial, + DefaultLoadingAction.CostInsightsPage, + ] as string[]).concat(products); +} diff --git a/plugins/cost-insights/src/types/Maybe.ts b/plugins/cost-insights/src/types/Maybe.ts new file mode 100644 index 0000000000..d721f28d75 --- /dev/null +++ b/plugins/cost-insights/src/types/Maybe.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export type Maybe = T | null; + +export function notEmpty( + value: TValue | null | undefined, +): value is TValue { + return value !== null && value !== undefined; +} + +// Utility for exhaustiveness checking in switch statements +export function assertNever(x: never): never { + throw new Error(`Exhaustiveness check failed: ${x}`); +} + +export function assertAlways(argument: T | undefined): T { + if (argument === undefined) { + throw new TypeError( + 'Expected to always find a value but received undefined', + ); + } + return argument; +} + +// Utility for working with static lists; asserts a value will always be found or +// throws an error +export function findAlways( + collection: T[], + callback: (el: T) => boolean, +): T { + return assertAlways(collection.find(callback)); +} diff --git a/plugins/cost-insights/src/types/Metric.ts b/plugins/cost-insights/src/types/Metric.ts new file mode 100644 index 0000000000..5a440a8d1f --- /dev/null +++ b/plugins/cost-insights/src/types/Metric.ts @@ -0,0 +1,22 @@ +/* + * 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 { Maybe } from './Maybe'; + +export type Metric = { + kind: Maybe; + name: string; +}; diff --git a/plugins/cost-insights/src/types/Product.ts b/plugins/cost-insights/src/types/Product.ts new file mode 100644 index 0000000000..d2087644cb --- /dev/null +++ b/plugins/cost-insights/src/types/Product.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity } from './Entity'; +import { ChangeStatistic } from './ChangeStatistic'; +import { Maybe } from './Maybe'; + +export interface Product { + kind: string; + name: string; +} + +export interface ProductCost { + entities?: Array; + aggregation: [number, number]; + change?: Maybe; +} diff --git a/plugins/cost-insights/src/types/Project.ts b/plugins/cost-insights/src/types/Project.ts new file mode 100644 index 0000000000..24259f6910 --- /dev/null +++ b/plugins/cost-insights/src/types/Project.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface Project { + id: string; +} diff --git a/plugins/cost-insights/src/types/Theme.ts b/plugins/cost-insights/src/types/Theme.ts new file mode 100644 index 0000000000..9053283351 --- /dev/null +++ b/plugins/cost-insights/src/types/Theme.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { BackstagePalette, BackstageTheme } from '@backstage/theme'; +import { PaletteOptions } from '@material-ui/core/styles/createPalette'; + +type CostInsightsTooltipOptions = { + background: string; + color: string; +}; + +type CostInsightsPaletteAdditions = { + blue: string; + lightBlue: string; + darkBlue: string; + magenta: string; + yellow: string; + tooltip: CostInsightsTooltipOptions; + navigationText: string; + alertBackground: string; +}; + +export type CostInsightsPalette = BackstagePalette & + CostInsightsPaletteAdditions; + +export type CostInsightsPaletteOptions = PaletteOptions & + CostInsightsPaletteAdditions; + +export interface CostInsightsThemeOptions extends PaletteOptions { + palette: CostInsightsPaletteOptions; +} + +export interface CostInsightsTheme extends BackstageTheme { + palette: CostInsightsPalette; +} diff --git a/plugins/cost-insights/src/types/Trendline.ts b/plugins/cost-insights/src/types/Trendline.ts new file mode 100644 index 0000000000..aad7ba0bac --- /dev/null +++ b/plugins/cost-insights/src/types/Trendline.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export type Trendline = { + slope: number; + intercept: number; +}; diff --git a/plugins/cost-insights/src/types/index.ts b/plugins/cost-insights/src/types/index.ts new file mode 100644 index 0000000000..c3d9a87886 --- /dev/null +++ b/plugins/cost-insights/src/types/index.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './Alert'; +export * from './ChangeStatistic'; +export * from './Cost'; +export * from './DateAggregation'; +export * from './Duration'; +export * from './Currency'; +export * from './Entity'; +export * from './Icon'; +export * from './Filters'; +export * from './Group'; +export * from './Loading'; +export * from './Maybe'; +export * from './Metric'; +export * from './Product'; +export * from './Project'; +export * from './Theme'; +export * from './Trendline'; diff --git a/plugins/cost-insights/src/utils/alerts.tsx b/plugins/cost-insights/src/utils/alerts.tsx new file mode 100644 index 0000000000..07602be191 --- /dev/null +++ b/plugins/cost-insights/src/utils/alerts.tsx @@ -0,0 +1,82 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { Alert, AlertType, assertNever } from '../types'; +import ProjectGrowthAlertCard from '../components/ProjectGrowthAlertCard'; +import UnlabeledDataflowAlertCard from '../components/UnlabeledDataflowAlertCard'; + +export function getAlertText(alert: Alert) { + switch (alert.id) { + case AlertType.ProjectGrowth: + return { + title: `Investigate cost growth in project ${alert.project}`, + subtitle: + 'Cost growth outpacing business growth is unsustainable long-term.', + } as AlertText; + case AlertType.UnlabeledDataflow: + return { + title: 'Add labels to workflows', + subtitle: + 'Labels show in billing data, enabling cost insights for each workflow.', + }; + default: + return assertNever(alert); + } +} + +export function getAlertUrl(alert: Alert) { + switch (alert.id) { + case AlertType.ProjectGrowth: + return '/cost-insights/investigating-growth' as AlertUrl; + case AlertType.UnlabeledDataflow: + return '/cost-insights/labeling-jobs' as AlertUrl; + default: + return assertNever(alert); + } +} + +export function getAlertButtonText(alert: Alert) { + switch (alert.id) { + case AlertType.ProjectGrowth: + case AlertType.UnlabeledDataflow: + return 'View Instructions' as AlertButtonText; + default: + return assertNever(alert); + } +} + +export function getAlertNavigation(alert: Alert, number: number) { + return `${alert.id}-${number}`; +} + +export function renderAlert(alert: Alert) { + switch (alert.id) { + case AlertType.ProjectGrowth: + return ; + case AlertType.UnlabeledDataflow: + return ; + default: + return assertNever(alert); + } +} + +export type AlertUrl = string; +export type AlertButtonText = string; + +export interface AlertText { + title: string; + subtitle: string; +} diff --git a/plugins/cost-insights/src/utils/formatters.test.ts b/plugins/cost-insights/src/utils/formatters.test.ts new file mode 100644 index 0000000000..a76e838b53 --- /dev/null +++ b/plugins/cost-insights/src/utils/formatters.test.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { lengthyCurrencyFormatter, quarterOf } from './formatters'; + +Date.now = jest.fn(() => new Date(Date.parse('2019-12-07')).valueOf()); + +describe('date formatters', () => { + it('Formats quarters', () => { + expect(quarterOf('2020-01-01')).toBe('Q1 2020'); + expect(quarterOf('2020-02-29')).toBe('Q1 2020'); + expect(quarterOf('2020-05-11')).toBe('Q2 2020'); + expect(quarterOf('2020-06-30')).toBe('Q2 2020'); + expect(quarterOf('2020-07-01')).toBe('Q3 2020'); + expect(quarterOf('2020-10-04')).toBe('Q4 2020'); + }); + + it('Correctly formats values to two significant digits', () => { + const values = [ + 0.00000040925, + 0.21, + 0.0000004, + 0.4139877878, + 0.00000234566, + ]; + const formattedValues = values.map(val => + lengthyCurrencyFormatter.format(val), + ); + expect(formattedValues).toEqual([ + '$0.00000041', + '$0.21', + '$0.00000040', + '$0.41', + '$0.0000023', + ]); + }); +}); diff --git a/plugins/cost-insights/src/utils/formatters.ts b/plugins/cost-insights/src/utils/formatters.ts new file mode 100644 index 0000000000..f3790f315e --- /dev/null +++ b/plugins/cost-insights/src/utils/formatters.ts @@ -0,0 +1,126 @@ +/* + * 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 moment from 'moment'; +import { + Duration, + inclusiveEndDateOf, + inclusiveStartDateOf, +} from '../types/Duration'; +import { pluralOf } from '../utils/grammar'; + +export type Period = { + periodStart: string; + periodEnd: string; +}; + +export const currencyFormatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0, +}); + +export const lengthyCurrencyFormatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + minimumSignificantDigits: 2, + maximumSignificantDigits: 2, +}); + +export const numberFormatter = new Intl.NumberFormat('en-US', { + minimumFractionDigits: 0, + maximumFractionDigits: 0, +}); + +export const monthFormatter = new Intl.DateTimeFormat('en-US', { + timeZone: 'UTC', + month: 'long', + year: 'numeric', +}); + +export const dateFormatter = new Intl.DateTimeFormat('en-US', { + timeZone: 'UTC', + day: 'numeric', + month: 'short', +}); + +export const monthOf = (date: string): string => { + return monthFormatter.format(Date.parse(date)); +}; + +export const quarterOf = (date: string): string => { + // Supports formatting YYYY-MM-DD and YYYY-[Q]Q returned in alerts + const d = moment(date).isValid() ? moment(date) : moment(date, 'YYYY-[Q]Q'); + return d.format('[Q]Q YYYY'); +}; + +export function formatCurrency(amount: number, currency?: string): string { + const n = Math.round(amount); + const numString = numberFormatter.format(n); + + return currency ? `${numString} ${pluralOf(n, currency)}` : numString; +} + +export function formatPercent(n: number): string { + // Number.toFixed shows scientific notation for extreme numbers + if (isNaN(n) || Math.abs(n) < 0.01) { + return '0%'; + } + if (Math.abs(n) >= 1e19) { + return '∞%'; + } + return `${(n * 100).toFixed(0)}%`; +} + +export const formatLastTwoLookaheadQuarters = () => { + const start = moment(inclusiveStartDateOf(Duration.P3M)).format('[Q]Q YYYY'); + const end = moment(inclusiveEndDateOf(Duration.P3M)).format('[Q]Q YYYY'); + return `${start} vs ${end}`; +}; + +export const formatLastTwoMonths = () => { + const start = moment(inclusiveStartDateOf(Duration.P1M)).utc().format('MMMM'); + const end = moment(inclusiveEndDateOf(Duration.P1M)).utc().format('MMMM'); + return `${start} vs ${end}`; +}; + +export const dateRegex: RegExp = /^\d{4}-\d{2}-\d{2}$/; + +export const formatRelativeDuration = ( + date: string, + duration: Duration, +): string => { + const periodStart = inclusiveStartDateOf(duration); + const periodEnd = inclusiveEndDateOf(duration); + const days = moment.duration(duration).asDays(); + if (![periodStart, periodEnd].includes(date)) { + throw new Error(`Invalid relative date ${date} for duration ${duration}`); + } + return date === periodStart ? `First ${days} Days` : `Last ${days} Days`; +}; + +export function formatDuration(date: string, duration: Duration) { + switch (duration) { + case Duration.P1M: + return monthOf(date); + case Duration.P3M: + return quarterOf(date); + default: + return formatRelativeDuration(date, duration); + } +} diff --git a/plugins/cost-insights/src/utils/grammar.ts b/plugins/cost-insights/src/utils/grammar.ts new file mode 100644 index 0000000000..653c567d0a --- /dev/null +++ b/plugins/cost-insights/src/utils/grammar.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const vowels = { + a: 'A', + e: 'E', + i: 'I', + o: 'O', + u: 'U', +}; + +export const pluralOf = ( + n: number, + string: string, + plural?: string, +): string => { + if (n !== 1) { + if (plural) { + return plural; + } + return string.concat('s'); + } + return string; +}; + +export const indefiniteArticleOf = ( + articles: [string, string], + word: string, +) => { + const firstChar = word.charAt(0).toLowerCase(); + return firstChar in vowels + ? `${articles[1]} ${word}` + : `${articles[0]} ${word}`; +}; diff --git a/plugins/cost-insights/src/utils/graphs.tsx b/plugins/cost-insights/src/utils/graphs.tsx new file mode 100644 index 0000000000..5200b14b1f --- /dev/null +++ b/plugins/cost-insights/src/utils/graphs.tsx @@ -0,0 +1,31 @@ +/* + * 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 { + currencyFormatter, + dateFormatter, + lengthyCurrencyFormatter, +} from './formatters'; + +export function formatGraphValue(value: number) { + if (value < 1) { + return lengthyCurrencyFormatter.format(value); + } + return currencyFormatter.format(value); +} + +export const overviewGraphTickFormatter = (millis: string | number) => + typeof millis === 'number' ? dateFormatter.format(millis) : millis; diff --git a/plugins/cost-insights/src/utils/history.ts b/plugins/cost-insights/src/utils/history.ts new file mode 100644 index 0000000000..71e8e2c219 --- /dev/null +++ b/plugins/cost-insights/src/utils/history.ts @@ -0,0 +1,23 @@ +/* + * 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 qs from 'qs'; +import { QueryParams } from '../types'; + +export const stringify = (queryParams: Partial) => + qs.stringify(queryParams, { strictNullHandling: true }); +export const parse = (queryString: string): Partial => + qs.parse(queryString, { ignoreQueryPrefix: true, strictNullHandling: true }); diff --git a/plugins/cost-insights/src/utils/mockData.ts b/plugins/cost-insights/src/utils/mockData.ts new file mode 100644 index 0000000000..87dbcf8158 --- /dev/null +++ b/plugins/cost-insights/src/utils/mockData.ts @@ -0,0 +1,194 @@ +/* + * 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 { + AlertType, + Entity, + ProjectGrowthAlert, + Product, + UnlabeledDataflowAlert, + UnlabeledDataflowAlertProject, + getDefaultState, + DefaultLoadingAction, + Duration, + ProductCost, + findAlways, +} from '../types'; +import { Config } from '@backstage/config'; +import { ConfigApi } from '@backstage/core'; + +type mockAlertRenderer = (alert: T) => T; +type mockEntityRenderer = (entity: T) => T; + +export const createMockEntity = ( + callback?: mockEntityRenderer, +): Entity => { + const defaultEntity: Entity = { + id: 'test-entity', + aggregation: [100, 200], + }; + + if (typeof callback === 'function') { + return callback({ ...defaultEntity }); + } + return { ...defaultEntity }; +}; + +export const createMockProduct = ( + callback?: mockEntityRenderer, +): Product => { + const defaultProduct: Product = { + kind: 'compute-engine', + name: 'Compute Engine', + }; + if (typeof callback === 'function') { + return callback({ ...defaultProduct }); + } + return { ...defaultProduct }; +}; + +export const createMockProductCost = ( + callback?: mockEntityRenderer, +): ProductCost => { + const defaultProduct: ProductCost = { + entities: [], + aggregation: [0, 0], + }; + if (typeof callback === 'function') { + return callback({ ...defaultProduct }); + } + return { ...defaultProduct }; +}; + +export const createMockProjectGrowthAlert = ( + callback?: mockAlertRenderer, +): ProjectGrowthAlert => { + const defaultAlert: ProjectGrowthAlert = { + id: AlertType.ProjectGrowth, + project: 'test-project-growth-alert', + periodStart: '2019-10-01', + periodEnd: '2020-03-31', + aggregation: [670532.1, 970502.8], + change: { + ratio: 0.5, + amount: 180000, + }, + products: [], + }; + + if (typeof callback === 'function') { + return callback({ ...defaultAlert }); + } + + return { ...defaultAlert }; +}; + +export const createMockUnlabeledDataflowAlert = ( + callback?: mockAlertRenderer, +): UnlabeledDataflowAlert => { + const defaultAlert: UnlabeledDataflowAlert = { + id: AlertType.UnlabeledDataflow, + periodStart: '2020-05-01', + periodEnd: '2020-06-1', + projects: [], + unlabeledCost: 0, + labeledCost: 0, + }; + + if (typeof callback === 'function') { + return callback({ ...defaultAlert }); + } + + return { ...defaultAlert }; +}; + +export const createMockUnlabeledDataflowAlertProject = ( + callback?: mockEntityRenderer, +): UnlabeledDataflowAlertProject => { + const defaultProject: UnlabeledDataflowAlertProject = { + id: 'test-alert-project', + unlabeledCost: 2000.0, + labeledCost: 3200.0, + }; + if (typeof callback === 'function') { + return callback({ ...defaultProject }); + } + return { ...defaultProject }; +}; + +export const MockProductTypes: Record = { + 'compute-engine': 'Compute Engine', + 'cloud-dataflow': 'Cloud Dataflow', + 'cloud-storage': 'Cloud Storage', + 'big-query': 'Big Query', + 'big-table': 'BigTable', + 'cloud-pub-sub': 'Cloud Pub/Sub', +}; + +export const MockProductFilters = Object.keys( + MockProductTypes, +).map(productType => ({ duration: Duration.P1M, productType })); + +export const MockProducts: Product[] = Object.keys(MockProductTypes).map( + productType => + createMockProduct(() => ({ + kind: productType, + name: MockProductTypes[productType], + })), +); + +export const MockLoadingActions = ([ + DefaultLoadingAction.UserGroups, + DefaultLoadingAction.CostInsightsInitial, + DefaultLoadingAction.CostInsightsPage, +] as string[]).concat(MockProducts.map(product => product.kind)); + +export const mockDefaultState = getDefaultState(MockLoadingActions); + +export const MockComputeEngine = findAlways( + MockProducts, + p => p.kind === 'compute-engine', +); +export const MockCloudDataflow = findAlways( + MockProducts, + p => p.kind === 'cloud-dataflow', +); +export const MockCloudStorage = findAlways( + MockProducts, + p => p.kind === 'cloud-storage', +); +export const MockBigQuery = findAlways( + MockProducts, + p => p.kind === 'big-query', +); +export const MockBigtable = findAlways( + MockProducts, + p => p.kind === 'big-table', +); + +export const MockProductsConfig: Partial = { + keys: () => Object.keys(MockProductTypes), +}; + +export const MockMetricsConfig: Partial = { + getOptionalString: () => 'daily-cost', + keys: () => ['daily-cost'], +}; + +export const MockCostInsightsConfig: Partial = { + getConfig: () => MockProductsConfig as Config, + getOptionalConfig: () => MockMetricsConfig as Config, +}; diff --git a/plugins/cost-insights/src/utils/navigation.tsx b/plugins/cost-insights/src/utils/navigation.tsx new file mode 100644 index 0000000000..d2cb1d20bb --- /dev/null +++ b/plugins/cost-insights/src/utils/navigation.tsx @@ -0,0 +1,74 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import MoneyIcon from '@material-ui/icons/MonetizationOn'; +import ActionIcon from '@material-ui/icons/Whatshot'; +import Settings from '@material-ui/icons/Settings'; +import AccountTree from '@material-ui/icons/AccountTree'; +import Storage from '@material-ui/icons/Storage'; +import Search from '@material-ui/icons/Search'; +import CloudQueue from '@material-ui/icons/CloudQueue'; +import School from '@material-ui/icons/School'; +import ViewHeadline from '@material-ui/icons/ViewHeadline'; +import { IconType } from '../types'; + +export enum DefaultNavigation { + CostOverviewCard = 'cost-overview-card', + AlertInsightsHeader = 'alert-insights-header', +} + +export type NavigationItem = { + navigation: string; + icon: JSX.Element; + title: string; +}; + +export const getDefaultNavigationItems = (alerts: number): NavigationItem[] => { + const items = [ + { + navigation: DefaultNavigation.CostOverviewCard, + icon: , + title: 'Cost Overview', + }, + ]; + if (alerts > 0) { + items.push({ + navigation: DefaultNavigation.AlertInsightsHeader, + icon: , + title: 'Action Items', + }); + } + return items; +}; + +export function getIcon(icon?: string): JSX.Element { + switch (icon) { + case IconType.Compute: + return ; + case IconType.Data: + return ; + case IconType.Database: + return ; + case IconType.Storage: + return ; + case IconType.Search: + return ; + case IconType.ML: + return ; + default: + return ; + } +} diff --git a/plugins/cost-insights/src/utils/sort.test.ts b/plugins/cost-insights/src/utils/sort.test.ts new file mode 100644 index 0000000000..2f4ca87c7e --- /dev/null +++ b/plugins/cost-insights/src/utils/sort.test.ts @@ -0,0 +1,32 @@ +/* + * 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 { aggregationSort } from './sort'; + +describe('aggregationSort', () => { + const aggregation = [ + { date: '2020-07-05', amount: 0.250991 }, + { date: '2020-07-06', amount: 0.253284 }, + { date: '2020-07-01', amount: 0.260798 }, + ]; + + it('sorts in ascending order', () => { + const sortedAggregation = aggregation.sort(aggregationSort); + expect(sortedAggregation[0].date).toBe('2020-07-01'); + expect(sortedAggregation[1].date).toBe('2020-07-05'); + expect(sortedAggregation[2].date).toBe('2020-07-06'); + }); +}); diff --git a/plugins/cost-insights/src/utils/sort.ts b/plugins/cost-insights/src/utils/sort.ts new file mode 100644 index 0000000000..944f5fc806 --- /dev/null +++ b/plugins/cost-insights/src/utils/sort.ts @@ -0,0 +1,23 @@ +/* + * 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 { DateAggregation, ResourceData } from '../types'; + +export const aggregationSort = ( + a: DateAggregation, + b: DateAggregation, +): number => a.date.localeCompare(b.date); +export const resourceSort = (a: ResourceData, b: ResourceData) => + b.previous + b.current - (a.previous + a.current); diff --git a/plugins/cost-insights/src/utils/styles.ts b/plugins/cost-insights/src/utils/styles.ts new file mode 100644 index 0000000000..e6b38ee719 --- /dev/null +++ b/plugins/cost-insights/src/utils/styles.ts @@ -0,0 +1,455 @@ +/* + * 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 { + createStyles, + darken, + getLuminance, + lighten, + makeStyles, +} from '@material-ui/core'; +import { BackstageTheme } from '@backstage/theme'; +import { CostInsightsTheme, CostInsightsThemeOptions } from '../types'; + +export const costInsightsLightTheme = { + palette: { + blue: '#509AF5', + lightBlue: '#9BF0E1', + darkBlue: '#4101F5', + magenta: '#DC148C', + yellow: '#FFC864', + tooltip: { + background: '#171717', + color: '#DDD', + }, + navigationText: '#b5b5b5', + alertBackground: 'rgba(219, 219, 219, 0.13)', + }, +} as CostInsightsThemeOptions; + +// Higher tonal values from light theme (Google recommends 600 -> 200) +// https://material.io/design/color/the-color-system.html#tools-for-picking-colors +export const costInsightsDarkTheme = { + palette: { + blue: '#77b8f9', + lightBlue: '#d8f9f4', + darkBlue: '#b595fd', + magenta: '#ee93cd', + yellow: '#fff2da', + tooltip: { + background: '#EEE', + color: '#424242', + }, + navigationText: '#b5b5b5', + alertBackground: 'rgba(32, 32, 32, 0.13)', + }, +} as CostInsightsThemeOptions; + +// The opposite of MUI's emphasize function - darken darks, lighten lights +export function brighten(color: string, coefficient = 0.2) { + return getLuminance(color) > 0.5 + ? lighten(color, coefficient) + : darken(color, coefficient); +} + +export const useCostOverviewStyles = (theme: CostInsightsTheme) => ({ + axis: { + fill: theme.palette.text.primary, + }, + container: { + height: 450, + width: 1200, + }, + cartesianGrid: { + stroke: theme.palette.textVerySubtle, + }, + chart: { + margin: { + right: 30, + top: 16, + }, + }, + yAxis: { + width: 75, + }, +}); + +export const useBarChartStyles = (theme: CostInsightsTheme) => ({ + axis: { + fill: theme.palette.text.primary, + }, + barChart: { + margin: { + left: 16, + right: 16, + }, + }, + cartesianGrid: { + stroke: theme.palette.textVerySubtle, + }, + cursor: { + fill: theme.palette.textVerySubtle, + fillOpacity: 0.3, + }, + container: { + height: 400, + width: 1200, + }, + infoIcon: { + marginLeft: 2, + fontSize: '1.25em', + }, + xAxis: { + height: 50, + }, +}); + +export const useBarChartStepperButtonStyles = makeStyles( + (theme: CostInsightsTheme) => + createStyles({ + root: { + ...theme.typography.button, + boxSizing: 'border-box', + transition: theme.transitions.create( + ['background-color', 'box-shadow', 'border'], + { + duration: theme.transitions.duration.short, + }, + ), + borderRadius: '50%', + padding: 0, + width: 40, + height: 40, + boxShadow: theme.shadows[6], + '&:active': { + boxShadow: theme.shadows[12], + }, + color: theme.palette.text.primary, + backgroundColor: lighten(theme.palette.background.default, 0.1), + '&:hover': { + backgroundColor: lighten(theme.palette.background.default, 0.2), + textDecoration: 'none', + }, + }, + }), +); + +export const useBarChartLabelStyles = makeStyles(() => + createStyles({ + foreignObject: { + textAlign: 'center', + }, + label: { + display: 'block', + textDecoration: 'none', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + infoIcon: { + marginLeft: 2, + fontSize: '1.25em', + }, + }), +); + +export const useCostInsightsStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + h6Subtle: { + ...theme.typography.h6, + fontWeight: 'normal', + color: theme.palette.textSubtle, + }, + }), +); + +export const useCostInsightsTabsStyles = makeStyles( + (theme: BackstageTheme) => ({ + tabs: { + borderBottom: `1px solid ${theme.palette.textVerySubtle}`, + backgroundColor: brighten(theme.palette.background.default), + padding: theme.spacing(0, 4), + }, + tab: { + minHeight: 68, + minWidth: 180, + padding: theme.spacing(1, 4), + '&:hover': { + color: 'inherit', + backgroundColor: 'inherit', + }, + }, + indicator: { + backgroundColor: theme.palette.navigation.indicator, + height: 4, + }, + tabLabel: { + display: 'flex', + alignItems: 'center', + }, + tabLabelText: { + fontSize: theme.typography.fontSize, + fontWeight: theme.typography.fontWeightBold, + }, + menuItem: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + minWidth: 180, + padding: theme.spacing(2, 2), + }, + menuItemSelected: { + backgroundColor: lighten(theme.palette.background.default, 0.3), + }, + }), +); + +export const useAlertCardActionHeaderStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + root: { + paddingBottom: theme.spacing(2), + borderRadius: 'unset', + }, + title: { + fontSize: theme.typography.fontSize, + fontWeight: theme.typography.fontWeightBold, + lineHeight: 1.5, + }, + action: { + margin: 0, + }, + }), +); + +export const useCostGrowthStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + excess: { + color: theme.palette.status.error, + }, + savings: { + color: theme.palette.status.ok, + }, + }), +); + +export const useCostGrowthLegendStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + h5: { + ...theme.typography.h5, + fontWeight: 500, + padding: 0, + }, + marker: { + display: 'inherit', + marginRight: theme.spacing(1), + }, + helpIcon: { + display: 'inherit', + }, + title: { + ...theme.typography.overline, + fontWeight: 500, + lineHeight: 0, + marginRight: theme.spacing(1), + color: theme.palette.textSubtle, + }, + tooltip: { + display: 'block', + padding: theme.spacing(1), + backgroundColor: theme.palette.navigation.background, + }, + tooltipText: { + color: theme.palette.background.default, + fontSize: theme.typography.fontSize, + lineHeight: 1.5, + }, + }), +); + +export const useBarChartStepperStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + paper: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-around', + alignItems: 'center', + background: 'transparent', + padding: 8, + }, + step: { + backgroundColor: theme.palette.action.disabled, + borderRadius: '50%', + width: 9, + height: 9, + margin: '0 2px', + }, + stepActive: { + backgroundColor: theme.palette.primary.main, + }, + steps: { + display: 'flex', + flexDirection: 'row', + }, + backButton: { + position: 'absolute', + left: 0, + top: 'calc(50% - 60px)', + zIndex: 100, + }, + nextButton: { + position: 'absolute', + right: 0, + top: 'calc(50% - 60px)', + zIndex: 100, + }, + }), +); + +export const useNavigationStyles = makeStyles( + (theme: CostInsightsTheme) => + createStyles({ + menuList: { + borderRadius: theme.shape.borderRadius, + backgroundColor: theme.palette.navigation.background, + }, + menuItem: { + background: 'transparent', + border: 0, + textTransform: 'none', + width: '100%', + minHeight: theme.spacing(6), + margin: theme.spacing(0.5, 2, 0.5, 0), + }, + listItemIcon: { + minWidth: 40, + }, + navigationIcon: { + fill: theme.palette.navigationText, + }, + title: { + whiteSpace: 'nowrap', + lineHeight: 1, + color: theme.palette.navigationText, + fontWeight: theme.typography.fontWeightBold, + }, + }), +); + +export const useTooltipStyles = makeStyles( + (theme: CostInsightsTheme) => + createStyles({ + tooltip: { + padding: theme.spacing(1.5), + backgroundColor: theme.palette.tooltip.background, + borderRadius: theme.shape.borderRadius, + boxShadow: theme.shadows[1], + color: theme.palette.tooltip.color, + fontSize: theme.typography.fontSize, + }, + lensIcon: { + fontSize: `.75rem`, + }, + }), +); + +export const useAlertInsightsSectionStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + button: { + backgroundColor: theme.palette.textVerySubtle, + color: theme.palette.text.primary, + }, + }), +); + +export const useSelectStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + select: { + minWidth: 200, + textAlign: 'start', + }, + menuItem: { + minWidth: 200, + padding: theme.spacing(2), + '&.compact': { + padding: theme.spacing(1, 2), + }, + }, + }), +); + +export const useAlertActionCardStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + card: { + boxShadow: 'none', + }, + avatar: { + backgroundColor: theme.palette.textVerySubtle, + color: theme.palette.text.primary, + }, + }), +); + +export const useAlertActionCardHeader = makeStyles( + (theme: CostInsightsTheme) => + createStyles({ + root: { + paddingBottom: theme.spacing(2), + borderRadius: theme.shape.borderRadius, + cursor: 'pointer', + transition: theme.transitions.create('background', { + duration: theme.transitions.duration.short, + }), + '&:hover': { + background: theme.palette.alertBackground, + }, + }, + action: { + margin: 0, + }, + title: { + fontSize: theme.typography.fontSize, + fontWeight: theme.typography.fontWeightBold, + }, + }), +); + +export const useProductInsightsCardStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + root: { + padding: theme.spacing(2, 2, 2, 2.5), // restore backstage default padding + }, + action: { + margin: 0, // unset negative margins + }, + }), +); + +export const useBackdropStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + root: { + zIndex: theme.zIndex.modal, + }, + }), +); diff --git a/plugins/cost-insights/src/utils/tests.tsx b/plugins/cost-insights/src/utils/tests.tsx new file mode 100644 index 0000000000..807232555b --- /dev/null +++ b/plugins/cost-insights/src/utils/tests.tsx @@ -0,0 +1,86 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { Dispatch, ReactNode, SetStateAction } from 'react'; +import { + getDefaultPageFilters, + PageFilters, + ProductFilters, + Group, +} from '../types'; +import { FilterContext } from '../hooks/useFilters'; +import { ConfigContext, ConfigContextProps } from '../hooks/useConfig'; +import { CurrencyContext, CurrencyContextProps } from '../hooks/useCurrency'; +import { ScrollContext, ScrollProviderProps } from '../hooks/useScroll'; +import { MockProductFilters } from './mockData'; + +export const MockGroups: Group[] = [{ id: 'tech' }, { id: 'mock-group' }]; + +type MockFilterProviderProps = { + setPageFilters: Dispatch>; + setProductFilters: Dispatch>; + children: ReactNode; +}; + +export const MockFilterProvider = ({ + setPageFilters, + setProductFilters, + children, +}: MockFilterProviderProps) => { + const pageFilters = getDefaultPageFilters(MockGroups); + return ( + + {children} + + ); +}; + +export const MockConfigProvider = ({ + metrics, + products, + icons, + engineerCost, + currencies, + children, +}: ConfigContextProps & { children: React.ReactNode }) => ( + + {children} + +); + +export const MockCurrencyProvider = ({ + currency, + setCurrency, + children, +}: CurrencyContextProps & { children: React.ReactNode }) => ( + + {children} + +); + +export const MockScrollProvider = ({ children }: ScrollProviderProps) => ( + + {children} + +); diff --git a/plugins/explore/src/components/ExplorePluginPage.tsx b/plugins/explore/src/components/ExplorePluginPage.tsx index 4365157f62..83402d8062 100644 --- a/plugins/explore/src/components/ExplorePluginPage.tsx +++ b/plugins/explore/src/components/ExplorePluginPage.tsx @@ -79,6 +79,13 @@ const toolsCards = [ 'https://storage.googleapis.com/wf-blogs-engineering-media/2018/09/fe13bb32-wf-tech-radar-hero-1024x597.png', tags: ['standards', 'landscape'], }, + { + title: 'Cost Insights', + description: 'Insights into cloud costs for your organization.', + url: '/cost-insights', + image: 'https://cloud.google.com/images/press/logo-cloud.png', + tags: ['cloud', 'finops'], + }, { title: 'GraphiQL', description: diff --git a/yarn.lock b/yarn.lock index 804eb9dc10..c29734cc93 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3257,7 +3257,7 @@ react-transition-group "^4.0.0" rifm "^0.7.0" -"@material-ui/styles@^4.10.0": +"@material-ui/styles@^4.10.0", "@material-ui/styles@^4.9.6": version "4.10.0" resolved "https://registry.npmjs.org/@material-ui/styles/-/styles-4.10.0.tgz#2406dc23aa358217aa8cc772e6237bd7f0544071" integrity sha512-XPwiVTpd3rlnbfrgtEJ1eJJdFCXZkHxy8TrdieaTvwxNYj42VnnCyFzxYeNW9Lhj4V1oD8YtQ6S5Gie7bZDf7Q== @@ -4782,6 +4782,18 @@ resolved "https://registry.npmjs.org/@types/d3-force/-/d3-force-1.2.1.tgz#c28803ea36fe29788db69efa0ad6c2dc09544e83" integrity sha512-jqK+I36uz4kTBjyk39meed5y31Ab+tXYN/x1dn3nZEus9yOHCLc+VrcIYLc/aSQ0Y7tMPRlIhLetulME76EiiA== +"@types/d3-path@*": + version "1.0.8" + resolved "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.8.tgz#48e6945a8ff43ee0a1ce85c8cfa2337de85c7c79" + integrity sha512-AZGHWslq/oApTAHu9+yH/Bnk63y9oFOMROtqPAtxl5uB6qm1x2lueWdVEjsjjV3Qc2+QfuzKIwIR5MvVBakfzA== + +"@types/d3-shape@*": + version "1.3.2" + resolved "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.2.tgz#a41d9d6b10d02e221696b240caf0b5d0f5a588ec" + integrity sha512-LtD8EaNYCaBRzHzaAiIPrfcL3DdIysc81dkGlQvv7WQP3+YXV7b0JJTtR1U3bzeRieS603KF4wUo+ZkJVenh8w== + dependencies: + "@types/d3-path" "*" + "@types/diff@^4.0.2": version "4.0.2" resolved "https://registry.npmjs.org/@types/diff/-/diff-4.0.2.tgz#2e9bb89f9acc3ab0108f0f3dc4dbdcf2fff8a99c" @@ -5413,6 +5425,14 @@ dependencies: "@types/react" "*" +"@types/recharts@^1.8.14": + version "1.8.15" + resolved "https://registry.npmjs.org/@types/recharts/-/recharts-1.8.15.tgz#02bc06085c9a31a58c00194d15377b45cf506bbf" + integrity sha512-ApCfDT/R8RCbZTcl0iqLiKsxVdzE3GzoawTgJUHuQOz6ZXhsPVfp7CNSKI2s3zFwrRRsgmpv2AEcbcZceNHg4w== + dependencies: + "@types/d3-shape" "*" + "@types/react" "*" + "@types/recursive-readdir@^2.2.0": version "2.2.0" resolved "https://registry.npmjs.org/@types/recursive-readdir/-/recursive-readdir-2.2.0.tgz#b39cd5474fd58ea727fe434d5c68b7a20ba9121c" @@ -7213,6 +7233,11 @@ bail@^1.0.0: resolved "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz#b6fa133404a392cbc1f8c4bf63f5953351e7a776" integrity sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ== +balanced-match@^0.4.2: + version "0.4.2" + resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz#cb3f3e3c732dc0f01ee70b403f302e61d7709838" + integrity sha1-yz8+PHMtwPAe5wtAPzAuYddwmDg= + balanced-match@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" @@ -8763,7 +8788,7 @@ core-js-pure@^3.0.0, core-js-pure@^3.0.1: resolved "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.6.4.tgz#4bf1ba866e25814f149d4e9aaa08c36173506e3a" integrity sha512-epIhRLkXdgv32xIUFaaAry2wdxZYBi6bgM7cB136dzzXXa+dFyRLTZeLUJxnd8ShrmyVXBub63n2NHo2JAt8Cw== -core-js@^2.4.0, core-js@^2.4.1, core-js@^2.5.7, core-js@^2.6.11, core-js@^2.6.5: +core-js@^2.4.0, core-js@^2.4.1, core-js@^2.5.7, core-js@^2.6.10, core-js@^2.6.11, core-js@^2.6.5: version "2.6.11" resolved "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c" integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg== @@ -9281,6 +9306,21 @@ cypress@*, cypress@^4.2.0: url "^0.11.0" yauzl "^2.10.0" +d3-array@^1.2.0: + version "1.2.4" + resolved "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f" + integrity sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw== + +d3-collection@1: + version "1.0.7" + resolved "https://registry.npmjs.org/d3-collection/-/d3-collection-1.0.7.tgz#349bd2aa9977db071091c13144d5e4f16b5b310e" + integrity sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A== + +d3-color@1: + version "1.4.1" + resolved "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz#c52002bf8846ada4424d55d97982fef26eb3bc8a" + integrity sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q== + "d3-dispatch@1 - 2": version "2.0.0" resolved "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz#8a18e16f76dd3fcaef42163c97b926aa9b55e7cf" @@ -9295,11 +9335,59 @@ d3-force@^2.0.1: d3-quadtree "1 - 2" d3-timer "1 - 2" +d3-format@1: + version "1.4.5" + resolved "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz#374f2ba1320e3717eb74a9356c67daee17a7edb4" + integrity sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ== + +d3-interpolate@1, d3-interpolate@^1.3.0: + version "1.4.0" + resolved "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.4.0.tgz#526e79e2d80daa383f9e0c1c1c7dcc0f0583e987" + integrity sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA== + dependencies: + d3-color "1" + +d3-path@1: + version "1.0.9" + resolved "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf" + integrity sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg== + "d3-quadtree@1 - 2": version "2.0.0" resolved "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-2.0.0.tgz#edbad045cef88701f6fee3aee8e93fb332d30f9d" integrity sha512-b0Ed2t1UUalJpc3qXzKi+cPGxeXRr4KU9YSlocN74aTzp6R/Ud43t79yLLqxHRWZfsvWXmbDWPpoENK1K539xw== +d3-scale@^2.1.0: + version "2.2.2" + resolved "https://registry.npmjs.org/d3-scale/-/d3-scale-2.2.2.tgz#4e880e0b2745acaaddd3ede26a9e908a9e17b81f" + integrity sha512-LbeEvGgIb8UMcAa0EATLNX0lelKWGYDQiPdHj+gLblGVhGLyNbaCn3EvrJf0A3Y/uOOU5aD6MTh5ZFCdEwGiCw== + dependencies: + d3-array "^1.2.0" + d3-collection "1" + d3-format "1" + d3-interpolate "1" + d3-time "1" + d3-time-format "2" + +d3-shape@^1.2.0: + version "1.3.7" + resolved "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz#df63801be07bc986bc54f63789b4fe502992b5d7" + integrity sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw== + dependencies: + d3-path "1" + +d3-time-format@2: + version "2.3.0" + resolved "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.3.0.tgz#107bdc028667788a8924ba040faf1fbccd5a7850" + integrity sha512-guv6b2H37s2Uq/GefleCDtbe0XZAuy7Wa49VGkPVPMfLL9qObgBST3lEHJBMUp8S7NdLQAGIvr2KXk8Hc98iKQ== + dependencies: + d3-time "1" + +d3-time@1: + version "1.1.0" + resolved "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz#b1e19d307dae9c900b7e5b25ffc5dcc249a8a0f1" + integrity sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA== + "d3-timer@1 - 2": version "2.0.0" resolved "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz#055edb1d170cfe31ab2da8968deee940b56623e6" @@ -9426,6 +9514,11 @@ decamelize@^1.1.0, decamelize@^1.1.1, decamelize@^1.1.2, decamelize@^1.2.0: resolved "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= +decimal.js-light@^2.4.1: + version "2.5.0" + resolved "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.0.tgz#ca7faf504c799326df94b0ab920424fdfc125348" + integrity sha512-b3VJCbd2hwUpeRGG3Toob+CRo8W22xplipNhP3tN7TSVB/cyMX71P1vM2Xjc9H74uV6dS2hDDmo/rHq8L87Upg== + decimal.js@^10.2.0: version "10.2.0" resolved "https://registry.npmjs.org/decimal.js/-/decimal.js-10.2.0.tgz#39466113a9e036111d02f82489b5fd6b0b5ed231" @@ -9833,6 +9926,13 @@ dom-converter@^0.2: dependencies: utila "~0.4" +dom-helpers@^3.4.0: + version "3.4.0" + resolved "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8" + integrity sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA== + dependencies: + "@babel/runtime" "^7.1.2" + dom-helpers@^5.0.0, dom-helpers@^5.0.1: version "5.1.4" resolved "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.1.4.tgz#4609680ab5c79a45f2531441f1949b79d6587f4b" @@ -15096,7 +15196,7 @@ lodash.clonedeep@^4.5.0: resolved "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8= -lodash.debounce@^4: +lodash.debounce@^4, lodash.debounce@^4.0.8: version "4.0.8" resolved "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168= @@ -15191,6 +15291,11 @@ lodash.templatesettings@^4.0.0: dependencies: lodash._reinterpolate "^3.0.0" +lodash.throttle@^4.1.1: + version "4.1.1" + resolved "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4" + integrity sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ= + lodash.uniq@^4.5.0: version "4.5.0" resolved "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" @@ -15206,7 +15311,7 @@ lodash@4.17.15: resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== -lodash@^4.0.1, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.4, lodash@^4.2.1: +lodash@^4.0.1, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.1, lodash@~4.17.4: version "4.17.20" resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== @@ -15534,6 +15639,11 @@ material-table@1.68.0: react-beautiful-dnd "^13.0.0" react-double-scrollbar "0.0.15" +math-expression-evaluator@^1.2.14: + version "1.2.22" + resolved "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.2.22.tgz#c14dcb3d8b4d150e5dcea9c68c8dad80309b0d5e" + integrity sha512-L0j0tFVZBQQLeEjmWOvDLoRciIY8gQGWahvkztXUal8jH8R5Rlqo9GCvgqvXcy9LQhEWdQCVvzqAbxgYNt4blQ== + md5.js@^1.3.4: version "1.3.5" resolved "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" @@ -18612,7 +18722,7 @@ raf-schd@^4.0.2: resolved "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.2.tgz#bd44c708188f2e84c810bf55fcea9231bcaed8a0" integrity sha512-VhlMZmGy6A6hrkJWHLNTGl5gtgMUm+xfGza6wbwnE914yeQ5Ybm18vgM734RZhMgfw4tacUrWseGZlpUrrakEQ== -raf@^3.1.0, raf@^3.4.1: +raf@^3.1.0, raf@^3.4.0, raf@^3.4.1: version "3.4.1" resolved "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39" integrity sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA== @@ -19035,6 +19145,16 @@ react-redux@^7.1.1: prop-types "^15.7.2" react-is "^16.9.0" +react-resize-detector@^2.3.0: + version "2.3.0" + resolved "https://registry.npmjs.org/react-resize-detector/-/react-resize-detector-2.3.0.tgz#57bad1ae26a28a62a2ddb678ba6ffdf8fa2b599c" + integrity sha512-oCAddEWWeFWYH5FAcHdBYcZjAw9fMzRUK9sWSx6WvSSOPVRxcHd5zTIGy/mOus+AhN/u6T4TMiWxvq79PywnJQ== + dependencies: + lodash.debounce "^4.0.8" + lodash.throttle "^4.1.1" + prop-types "^15.6.0" + resize-observer-polyfill "^1.5.0" + react-router-dom@6.0.0-beta.0: version "6.0.0-beta.0" resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.0.0-beta.0.tgz#9dcc8555365f22f7fbd09f26b6b82543f3eb97d6" @@ -19094,6 +19214,16 @@ react-sizeme@^2.6.7: shallowequal "^1.1.0" throttle-debounce "^2.1.0" +react-smooth@^1.0.5: + version "1.0.5" + resolved "https://registry.npmjs.org/react-smooth/-/react-smooth-1.0.5.tgz#94ae161d7951cdd893ccb7099d031d342cb762ad" + integrity sha512-eW057HT0lFgCKh8ilr0y2JaH2YbNcuEdFpxyg7Gf/qDKk9hqGMyXryZJ8iMGJEuKH0+wxS0ccSsBBB3W8yCn8w== + dependencies: + lodash "~4.17.4" + prop-types "^15.6.0" + raf "^3.4.0" + react-transition-group "^2.5.0" + react-sparklines@^1.7.0: version "1.7.0" resolved "https://registry.npmjs.org/react-sparklines/-/react-sparklines-1.7.0.tgz#9b1d97e8c8610095eeb2ad658d2e1fcf91f91a60" @@ -19149,6 +19279,16 @@ react-textarea-autosize@^8.1.1: use-composed-ref "^1.0.0" use-latest "^1.0.0" +react-transition-group@^2.5.0: + version "2.9.0" + resolved "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz#df9cdb025796211151a436c69a8f3b97b5b07c8d" + integrity sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg== + dependencies: + dom-helpers "^3.4.0" + loose-envify "^1.4.0" + prop-types "^15.6.2" + react-lifecycles-compat "^3.0.4" + react-transition-group@^4.0.0, react-transition-group@^4.4.0: version "4.4.1" resolved "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz#63868f9325a38ea5ee9535d828327f85773345c9" @@ -19416,6 +19556,30 @@ recast@^0.14.7: private "~0.1.5" source-map "~0.6.1" +recharts-scale@^0.4.2: + version "0.4.3" + resolved "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.3.tgz#040b4f638ed687a530357292ecac880578384b59" + integrity sha512-t8p5sccG9Blm7c1JQK/ak9O8o95WGhNXD7TXg/BW5bYbVlr6eCeRBNpgyigD4p6pSSMehC5nSvBUPj6F68rbFA== + dependencies: + decimal.js-light "^2.4.1" + +recharts@^1.8.5: + version "1.8.5" + resolved "https://registry.npmjs.org/recharts/-/recharts-1.8.5.tgz#ca94a3395550946334a802e35004ceb2583fdb12" + integrity sha512-tM9mprJbXVEBxjM7zHsIy6Cc41oO/pVYqyAsOHLxlJrbNBuLs0PHB3iys2M+RqCF0//k8nJtZF6X6swSkWY3tg== + dependencies: + classnames "^2.2.5" + core-js "^2.6.10" + d3-interpolate "^1.3.0" + d3-scale "^2.1.0" + d3-shape "^1.2.0" + lodash "^4.17.5" + prop-types "^15.6.0" + react-resize-detector "^2.3.0" + react-smooth "^1.0.5" + recharts-scale "^0.4.2" + reduce-css-calc "^1.3.0" + rechoir@^0.6.2: version "0.6.2" resolved "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" @@ -19454,6 +19618,22 @@ redent@^3.0.0: indent-string "^4.0.0" strip-indent "^3.0.0" +reduce-css-calc@^1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-1.3.0.tgz#747c914e049614a4c9cfbba629871ad1d2927716" + integrity sha1-dHyRTgSWFKTJz7umKYca0dKSdxY= + dependencies: + balanced-match "^0.4.2" + math-expression-evaluator "^1.2.14" + reduce-function-call "^1.0.1" + +reduce-function-call@^1.0.1: + version "1.0.3" + resolved "https://registry.npmjs.org/reduce-function-call/-/reduce-function-call-1.0.3.tgz#60350f7fb252c0a67eb10fd4694d16909971300f" + integrity sha512-Hl/tuV2VDgWgCSEeWMLwxLZqX7OK59eU1guxXsRKTAyeYimivsKdtcV4fu3r710tpG5GmDKDhQ0HSZLExnNmyQ== + dependencies: + balanced-match "^1.0.0" + redux-immutable@3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/redux-immutable/-/redux-immutable-3.1.0.tgz#cafbd686e0711261119b9c28960935dc47a49d0a" @@ -19781,7 +19961,7 @@ reselect@^4.0.0: resolved "https://registry.npmjs.org/reselect/-/reselect-4.0.0.tgz#f2529830e5d3d0e021408b246a206ef4ea4437f7" integrity sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA== -resize-observer-polyfill@^1.5.1: +resize-observer-polyfill@^1.5.0, resize-observer-polyfill@^1.5.1: version "1.5.1" resolved "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== From fdbe6952bb8085b1a96e6c6979a8078cd63dfcb5 Mon Sep 17 00:00:00 2001 From: Brenda Sukh Date: Mon, 28 Sep 2020 16:14:37 -0400 Subject: [PATCH 90/94] Add cost insights to marketplace --- microsite/data/plugins/cost-insights.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 microsite/data/plugins/cost-insights.yaml diff --git a/microsite/data/plugins/cost-insights.yaml b/microsite/data/plugins/cost-insights.yaml new file mode 100644 index 0000000000..7950879833 --- /dev/null +++ b/microsite/data/plugins/cost-insights.yaml @@ -0,0 +1,11 @@ +--- +title: Cost Insights +author: Spotify +authorUrl: https://github.com/spotify +category: Discovery +description: Visualize, understand and optimize your team's cloud costs. +documentation: https://github.com/spotify/backstage/tree/master/plugins/cost-insights +iconUrl: https://www.materialui.co/materialIcons/editor/monetization_on_white_192x192.png +npmPackageName: '@backstage/plugin-cost-insights' +tags: + - web From 7aff112af811d3444922a8d81a789aac240167e9 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 28 Sep 2020 23:50:39 +0200 Subject: [PATCH 91/94] changesets: add entry for /api change to create-app --- .changeset/metal-fishes-learn.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/metal-fishes-learn.md diff --git a/.changeset/metal-fishes-learn.md b/.changeset/metal-fishes-learn.md new file mode 100644 index 0000000000..d22af83ca0 --- /dev/null +++ b/.changeset/metal-fishes-learn.md @@ -0,0 +1,5 @@ +--- +'@backstage/create-app': minor +--- + +The default mount point for backend plugins have been changed to /api. These changes are done in the backend package itself, so it is recommended that you sync up existing backend packages with this new pattern. From 4268959088d80fc29ce3e1fbf865620c64c8bee5 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Tue, 29 Sep 2020 09:41:27 +0200 Subject: [PATCH 92/94] docs: Update TechDocs status in FAQs --- docs/FAQ.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/FAQ.md b/docs/FAQ.md index d4d129a0d6..c59470e2ee 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -142,10 +142,9 @@ can browse and search for all available plugins. By far, our most-used plugin is our TechDocs plugin, which we use for creating technical documentation. Our philosophy at Spotify is to treat "docs like code", where you write documentation using the same workflow as you write your code. -This makes it easier to create, find, and update documentation. We hope to -release -[the open source version](https://github.com/spotify/backstage/issues/687) in -the future. (See also: +This makes it easier to create, find, and update documentation. +[TechDocs is now open source.](https://backstage.io/docs/features/techdocs/techdocs-overview) +(See also: "[Will Spotify's internal plugins be open sourced, too?](#will-spotifys-internal-plugins-be-open-sourced-too)" above) From 04f939e3708919b03b8746fe028555f87c746abc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20=C3=85lund?= Date: Tue, 29 Sep 2020 09:47:06 +0200 Subject: [PATCH 93/94] Update adr006-avoid-react-fc.md (#2649) * Update adr006-avoid-react-fc.md * Update adr006-avoid-react-fc.md --- docs/architecture-decisions/adr006-avoid-react-fc.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture-decisions/adr006-avoid-react-fc.md b/docs/architecture-decisions/adr006-avoid-react-fc.md index 4aafa307cf..96f594daf7 100644 --- a/docs/architecture-decisions/adr006-avoid-react-fc.md +++ b/docs/architecture-decisions/adr006-avoid-react-fc.md @@ -25,7 +25,7 @@ should be avoided in our codebase when adding new code. Here is an example: -```ts +```typescript /* Avoid this: */ type BadProps = { text: string }; const BadComponent: FC = ({ text, children }) => ( From 219a278c488d8f7a0470e08c5dd83c252cb719fe Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 29 Sep 2020 10:07:11 +0200 Subject: [PATCH 94/94] docs/tutorials: remove apis.ts secion from app quickstart --- docs/tutorials/quickstart-app-auth.md | 34 +-------------------------- 1 file changed, 1 insertion(+), 33 deletions(-) diff --git a/docs/tutorials/quickstart-app-auth.md b/docs/tutorials/quickstart-app-auth.md index 00494c7768..44de2a1310 100644 --- a/docs/tutorials/quickstart-app-auth.md +++ b/docs/tutorials/quickstart-app-auth.md @@ -155,39 +155,7 @@ const app = createApp({ }); ``` -6. Open and change _root > packages > app > src >_ `apis.ts` as follows - -```ts -// Add the following imports to the existing list from core -import { githubAuthApiRef, GithubAuth } from '@backstage/core'; -``` - -7. In the same file, change the builder block for oauthRequestApiRef as follows - -_from:_ - -```ts -builder.add(oauthRequestApiRef, new OAuthRequestManager()); -``` - -_to:_ - -```ts -const oauthRequestApi = builder.add( - oauthRequestApiRef, - new OAuthRequestManager(), -); - -builder.add( - githubAuthApiRef, - GithubAuth.create({ - discoveryApi, - oauthRequestApi, - }), -); -``` - -8. Start the backend and frontend as before +7. Start the backend and frontend as before When the browser loads, you should be presented with a login page for GitHub. Login as usual with your GitHub account. If this is your first time, you will be