From e9206f437c8b4262d8c959546947fac046ca52ec Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Fri, 29 Oct 2021 13:39:35 -0600 Subject: [PATCH 01/13] Make theme banner warning color optional Signed-off-by: Tim Hansen --- .../src/components/DismissableBanner/DismissableBanner.tsx | 3 ++- packages/theme/src/types.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core-components/src/components/DismissableBanner/DismissableBanner.tsx b/packages/core-components/src/components/DismissableBanner/DismissableBanner.tsx index 40082925c6..92b449f705 100644 --- a/packages/core-components/src/components/DismissableBanner/DismissableBanner.tsx +++ b/packages/core-components/src/components/DismissableBanner/DismissableBanner.tsx @@ -80,7 +80,8 @@ const useStyles = makeStyles( backgroundColor: theme.palette.banner.error, }, warning: { - backgroundColor: theme.palette.banner.warning, + backgroundColor: + theme.palette.banner.warning ?? theme.palette.banner.error, }, }), { name: 'BackstageDismissableBanner' }, diff --git a/packages/theme/src/types.ts b/packages/theme/src/types.ts index 40f45ee739..e1acba3a01 100644 --- a/packages/theme/src/types.ts +++ b/packages/theme/src/types.ts @@ -76,7 +76,7 @@ export type BackstagePaletteAdditions = { error: string; text: string; link: string; - warning: string; + warning?: string; }; }; From c01e26327efc80555add4bc87171308006116d1a Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Fri, 29 Oct 2021 13:43:28 -0600 Subject: [PATCH 02/13] update changeset Signed-off-by: Tim Hansen --- .changeset/dry-spies-cover.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/dry-spies-cover.md b/.changeset/dry-spies-cover.md index 7006dee5c4..aae683faaf 100644 --- a/.changeset/dry-spies-cover.md +++ b/.changeset/dry-spies-cover.md @@ -3,4 +3,6 @@ '@backstage/theme': patch --- -Will Add warning variant to `DismissableBanner` component. +Added a warning variant to `DismissableBanner` component. If you are using a +custom theme, you will need to add the optional `palette.banner.warning` color, +otherwise this variant will fall back to the `palette.banner.error` color. From 78c512ce8f78b206001df6571be8f9a2bcf534bb Mon Sep 17 00:00:00 2001 From: hrithiksawhney Date: Sat, 30 Oct 2021 22:39:59 +0530 Subject: [PATCH 03/13] added 'Blessed' icons for core plugins Signed-off-by: hrithiksawhney --- .changeset/spicy-panthers-thank.md | 5 +++++ packages/core-app-api/src/app/icons.tsx | 9 +++++++++ 2 files changed, 14 insertions(+) create mode 100644 .changeset/spicy-panthers-thank.md diff --git a/.changeset/spicy-panthers-thank.md b/.changeset/spicy-panthers-thank.md new file mode 100644 index 0000000000..6c28b002f4 --- /dev/null +++ b/.changeset/spicy-panthers-thank.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': patch +--- + +I have added default icons for the catalog, scaffolder, techdocs, and search. diff --git a/packages/core-app-api/src/app/icons.tsx b/packages/core-app-api/src/app/icons.tsx index a102026b29..b234a292b1 100644 --- a/packages/core-app-api/src/app/icons.tsx +++ b/packages/core-app-api/src/app/icons.tsx @@ -18,6 +18,9 @@ import { IconComponent } from '@backstage/core-plugin-api'; import MuiApartmentIcon from '@material-ui/icons/Apartment'; import MuiBrokenImageIcon from '@material-ui/icons/BrokenImage'; import MuiCategoryIcon from '@material-ui/icons/Category'; +import MuiCreateNewFolderIcon from '@material-ui/icons/CreateNewFolder'; +import MuiSubjectIcon from '@material-ui/icons/Subject'; +import MuiSearchIcon from '@material-ui/icons/Search'; import MuiChatIcon from '@material-ui/icons/Chat'; import MuiDashboardIcon from '@material-ui/icons/Dashboard'; import MuiDocsIcon from '@material-ui/icons/Description'; @@ -35,6 +38,9 @@ import MuiWarningIcon from '@material-ui/icons/Warning'; type AppIconsKey = | 'brokenImage' | 'catalog' + | 'scaffolder' + | 'techdocs' + | 'search' | 'chat' | 'dashboard' | 'docs' @@ -58,6 +64,9 @@ export const defaultAppIcons: AppIcons = { brokenImage: MuiBrokenImageIcon, // To be confirmed: see https://github.com/backstage/backstage/issues/4970 catalog: MuiMenuBookIcon, + scaffolder: MuiCreateNewFolderIcon, + techdocs: MuiSubjectIcon, + search: MuiSearchIcon, chat: MuiChatIcon, dashboard: MuiDashboardIcon, docs: MuiDocsIcon, From 0f4619cb4db7f4ee4e50087521db7a1399dcb287 Mon Sep 17 00:00:00 2001 From: Hammar Johan Date: Mon, 1 Nov 2021 12:50:42 +0100 Subject: [PATCH 04/13] Add Volvofinans Signed-off-by: Hammar Johan --- ADOPTERS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ADOPTERS.md b/ADOPTERS.md index a2015086c3..64bd99e62f 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -62,3 +62,4 @@ | [Adevinta](https://www.adevinta.com) | [Ray Sinnema](https://github.com/RemonSinnema) | Showcase shared services to our internal customers. | | [Splunk](https://www.splunk.com) | [@tonytamsf](https://github.com/tonytamsf) | Developer portal as a centralized place to find people, services, documentation, escalation policies and give bravos. This portal is also being used as a centralized search engine for engineering specific documentation.| | [SoundCloud](https://www.soundcloud.com) | [Julio Zynger](https://github.com/julioz) | Developer portal as a [humane registry](https://martinfowler.com/bliki/HumaneRegistry.html) for the organization: catalog of people, services, documentation, feature toggles, escalation policies, etc.| +| [Volvofinans Bank](https://www.volvofinans.se) | [Johan Hammar](https://github.com/johanhammar) | Developer portal enabling engineers to manage and explore software and documentation. | \ No newline at end of file From 9587f6f7d26ad9cff89ed0f2fb9f310d86b8cf36 Mon Sep 17 00:00:00 2001 From: Dominik Henneke Date: Mon, 25 Oct 2021 11:26:13 +0200 Subject: [PATCH 05/13] Add tests for the scaffolder eventstream Signed-off-by: Dominik Henneke --- .../src/service/router.test.ts | 179 ++++++++++++++++-- .../scaffolder-backend/src/service/router.ts | 43 +++-- 2 files changed, 183 insertions(+), 39 deletions(-) diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index 1c5343d458..5157af4eaf 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -29,17 +29,17 @@ jest.doMock('fs-extra', () => ({ })); import { + DatabaseManager, + DockerContainerRunner, getVoidLogger, PluginDatabaseManager, - DatabaseManager, UrlReaders, - DockerContainerRunner, } from '@backstage/backend-common'; import { CatalogApi } from '@backstage/catalog-client'; +import { TemplateEntityV1beta2 } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; import express from 'express'; import request from 'supertest'; -import { TemplateEntityV1beta2 } from '@backstage/catalog-model'; /** * TODO: The following should import directly from the router file. * Due to a circular dependency between this plugin and the @@ -47,6 +47,12 @@ import { TemplateEntityV1beta2 } from '@backstage/catalog-model'; * TypeError: _pluginscaffolderbackend.createTemplateAction is not a function */ import { createRouter } from '../index'; +import { StorageTaskBroker } from '../scaffolder/tasks/StorageTaskBroker'; + +jest.mock('../scaffolder/tasks'); + +const MockStorageTaskBroker: jest.MockedClass = + StorageTaskBroker as any; const createCatalogClient = (templates: any[] = []) => ({ @@ -102,7 +108,7 @@ describe('createRouter', () => { }, }; - beforeAll(async () => { + beforeEach(async () => { const router = await createRouter({ logger: getVoidLogger(), config: new ConfigReader({}), @@ -114,7 +120,7 @@ describe('createRouter', () => { app = express().use(router); }); - beforeEach(() => { + afterEach(() => { jest.resetAllMocks(); }); @@ -142,6 +148,10 @@ describe('createRouter', () => { }); it('return the template id', async () => { + MockStorageTaskBroker.prototype.dispatch.mockResolvedValue({ + taskId: 'a-random-id', + }); + const response = await request(app) .post('/v2/tasks') .send({ @@ -151,28 +161,161 @@ describe('createRouter', () => { }, }); - expect(response.body.id).toBeDefined(); + expect(response.body.id).toBe('a-random-id'); expect(response.status).toEqual(201); }); }); describe('GET /v2/tasks/:taskId', () => { it('does not divulge secrets', async () => { - const postResponse = await request(app) - .post('/v2/tasks') - .set('Authorization', 'Bearer secret') - .send({ - templateName: 'create-react-app-template', - values: { - required: 'required-value', - }, - }); + MockStorageTaskBroker.prototype.get.mockResolvedValue({ + id: 'a-random-id', + spec: {} as any, + status: 'completed', + createdAt: '', + secrets: { token: 'secret' }, + }); - const response = await request(app) - .get(`/v2/tasks/${postResponse.body.id}`) - .send(); + const response = await request(app).get(`/v2/tasks/a-random-id`); expect(response.status).toEqual(200); + expect(response.body.status).toBe('completed'); expect(response.body.secrets).toBeUndefined(); }); }); + + describe('GET /v2/tasks/:taskId/eventstream', () => { + it('should return log messages', async () => { + const unsubscribe = jest.fn(); + MockStorageTaskBroker.prototype.observe.mockImplementation( + ({ taskId }, callback) => { + // emit after this function returned + setImmediate(() => { + callback(undefined, { + events: [ + { + id: 0, + taskId, + type: 'log', + createdAt: '', + body: { message: 'My log message' }, + }, + ], + }); + callback(undefined, { + events: [ + { + id: 1, + taskId, + type: 'completion', + createdAt: '', + body: { message: 'Finished!' }, + }, + ], + }); + }); + + return unsubscribe; + }, + ); + + let statusCode: any = undefined; + let headers: any = {}; + const responseDataFn = jest.fn(); + + const req = request(app) + .get('/v2/tasks/a-random-id/eventstream') + .set('accept', 'text/event-stream') + .parse((res, _) => { + ({ statusCode, headers } = res as any); + + res.on('data', chunk => { + responseDataFn(chunk.toString()); + + // the server expects the client to abort the request + if (chunk.includes('completion')) { + req.abort(); + } + }); + }); + + // wait for the request to finish + await req.catch(() => { + // ignore 'aborted' error + }); + + expect(statusCode).toBe(200); + expect(headers['content-type']).toBe('text/event-stream'); + expect(responseDataFn).toBeCalledTimes(2); + expect(responseDataFn).toBeCalledWith(`event: log +data: {"id":0,"taskId":"a-random-id","type":"log","createdAt":"","body":{"message":"My log message"}} + +`); + expect(responseDataFn).toBeCalledWith(`event: completion +data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{"message":"Finished!"}} + +`); + + expect(MockStorageTaskBroker.prototype.observe).toBeCalledTimes(1); + expect(MockStorageTaskBroker.prototype.observe).toBeCalledWith( + { taskId: 'a-random-id' }, + expect.any(Function), + ); + + expect(unsubscribe).toBeCalledTimes(1); + }); + + it('should return log messages with after query', async () => { + const unsubscribe = jest.fn(); + MockStorageTaskBroker.prototype.observe.mockImplementation( + ({ taskId }, callback) => { + setImmediate(() => { + callback(undefined, { + events: [ + { + id: 1, + taskId, + type: 'completion', + createdAt: '', + body: { message: 'Finished!' }, + }, + ], + }); + }); + return unsubscribe; + }, + ); + + let statusCode: any = undefined; + let headers: any = {}; + + const req = request(app) + .get('/v2/tasks/a-random-id/eventstream') + .query({ after: 10 }) + .set('accept', 'text/event-stream') + .parse((res, _) => { + ({ statusCode, headers } = res as any); + + res.on('data', () => { + // close immediately + req.abort(); + }); + }); + + // wait for the request to finish + await req.catch(() => { + // ignore 'aborted' error + }); + + expect(statusCode).toBe(200); + expect(headers['content-type']).toBe('text/event-stream'); + + expect(MockStorageTaskBroker.prototype.observe).toBeCalledTimes(1); + expect(MockStorageTaskBroker.prototype.observe).toBeCalledWith( + { taskId: 'a-random-id', after: 10 }, + expect.any(Function), + ); + + expect(unsubscribe).toBeCalledTimes(1); + }); + }); }); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index a78b04da78..fce3b885c6 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -14,33 +14,33 @@ * limitations under the License. */ -import { Config } from '@backstage/config'; -import express from 'express'; -import Router from 'express-promise-router'; -import { Logger } from 'winston'; -import { CatalogEntityClient } from '../lib/catalog'; -import { validate } from 'jsonschema'; -import { - DatabaseTaskStore, - TemplateActionRegistry, - TaskWorker, - TemplateAction, - createBuiltinActions, -} from '../scaffolder'; -import { StorageTaskBroker } from '../scaffolder/tasks/StorageTaskBroker'; -import { getEntityBaseUrl, getWorkingDirectory } from './helpers'; import { ContainerRunner, PluginDatabaseManager, UrlReader, } from '@backstage/backend-common'; -import { InputError, NotFoundError } from '@backstage/errors'; import { CatalogApi } from '@backstage/catalog-client'; -import { TemplateEntityV1beta2, Entity } from '@backstage/catalog-model'; -import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; - +import { Entity, TemplateEntityV1beta2 } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import { InputError, NotFoundError } from '@backstage/errors'; import { ScmIntegrations } from '@backstage/integration'; -import { TaskBroker, TaskSpec } from '../scaffolder/tasks/types'; +import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; +import express from 'express'; +import Router from 'express-promise-router'; +import { validate } from 'jsonschema'; +import { Logger } from 'winston'; +import { CatalogEntityClient } from '../lib/catalog'; +import { + createBuiltinActions, + DatabaseTaskStore, + TaskBroker, + TaskSpec, + TaskWorker, + TemplateAction, + TemplateActionRegistry, +} from '../scaffolder'; +import { StorageTaskBroker } from '../scaffolder/tasks/StorageTaskBroker'; +import { getEntityBaseUrl, getWorkingDirectory } from './helpers'; /** * RouterOptions @@ -278,7 +278,8 @@ export async function createRouter( // to automatically reconnect because it lost connection. } } - res.flush(); + // res.flush() is only available with the compression middleware + res.flush?.(); if (shouldUnsubscribe) unsubscribe(); }, ); From 95a48698bc5d64195076790d00baaebf07ee01e6 Mon Sep 17 00:00:00 2001 From: Dominik Henneke Date: Mon, 25 Oct 2021 14:17:37 +0200 Subject: [PATCH 06/13] Add tests for the scaffolder eventstream frontend client Signed-off-by: Dominik Henneke --- plugins/scaffolder/package.json | 1 + plugins/scaffolder/src/api.test.ts | 76 +++++++++++++++++++++++++--- plugins/scaffolder/src/setupTests.ts | 3 ++ 3 files changed, 74 insertions(+), 6 deletions(-) diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 0b517671ba..20b8e934f7 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -77,6 +77,7 @@ "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "cross-fetch": "^3.0.6", + "event-source-polyfill": "^1.0.25", "msw": "^0.35.0" }, "files": [ diff --git a/plugins/scaffolder/src/api.test.ts b/plugins/scaffolder/src/api.test.ts index cc4b70283c..f11b2cf06c 100644 --- a/plugins/scaffolder/src/api.test.ts +++ b/plugins/scaffolder/src/api.test.ts @@ -14,12 +14,18 @@ * limitations under the License. */ +import { ConfigReader } from '@backstage/core-app-api'; import { ScmIntegrations } from '@backstage/integration'; import { ScaffolderClient } from './api'; -import { ConfigReader } from '@backstage/core-app-api'; + +const MockedEventSource = global.EventSource as jest.MockedClass< + typeof EventSource +>; describe('api', () => { - const discoveryApi = {} as any; + const mockBaseUrl = 'http://backstage/api'; + + const discoveryApi = { getBaseUrl: async () => mockBaseUrl }; const identityApi = {} as any; const scmIntegrationsApi = ScmIntegrations.fromConfig( new ConfigReader({ @@ -32,10 +38,14 @@ describe('api', () => { }, }), ); - const apiClient = new ScaffolderClient({ - scmIntegrationsApi, - discoveryApi, - identityApi, + + let apiClient: ScaffolderClient; + beforeEach(() => { + apiClient = new ScaffolderClient({ + scmIntegrationsApi, + discoveryApi, + identityApi, + }); }); it('should return default and custom integrations', async () => { @@ -51,4 +61,58 @@ describe('api', () => { expect(allowedHosts).toContain(integration.host), ); }); + + describe('streamEvents', () => { + describe('eventsource', () => { + it('should work', async () => { + MockedEventSource.prototype.addEventListener.mockImplementation( + (type, fn) => { + if (typeof fn !== 'function') { + return; + } + + if (type === 'log') { + fn({ + data: '{"id":1,"taskId":"a-random-id","type":"log","createdAt":"","body":{"message":"My log message"}}', + } as any); + } else if (type === 'completion') { + fn({ + data: '{"id":2,"taskId":"a-random-id","type":"completion","createdAt":"","body":{"message":"Finished!"}}', + } as any); + } + }, + ); + + const next = jest.fn(); + + await new Promise(complete => { + apiClient + .streamLogs({ taskId: 'a-random-task-id' }) + .subscribe({ next, complete }); + }); + + expect(MockedEventSource).toBeCalledWith( + 'http://backstage/api/v2/tasks/a-random-task-id/eventstream', + { withCredentials: true }, + ); + expect(MockedEventSource.prototype.close).toBeCalled(); + + expect(next).toBeCalledTimes(2); + expect(next).toBeCalledWith({ + id: 1, + taskId: 'a-random-id', + type: 'log', + createdAt: '', + body: { message: 'My log message' }, + }); + expect(next).toBeCalledWith({ + id: 2, + taskId: 'a-random-id', + type: 'completion', + createdAt: '', + body: { message: 'Finished!' }, + }); + }); + }); + }); }); diff --git a/plugins/scaffolder/src/setupTests.ts b/plugins/scaffolder/src/setupTests.ts index 963c0f188b..84589060ec 100644 --- a/plugins/scaffolder/src/setupTests.ts +++ b/plugins/scaffolder/src/setupTests.ts @@ -15,3 +15,6 @@ */ import '@testing-library/jest-dom'; + +const { EventSourcePolyfill } = jest.requireMock('event-source-polyfill'); +global.EventSource = EventSourcePolyfill; From b45a34fb15e50593470afc73c14647d4d78a9ed2 Mon Sep 17 00:00:00 2001 From: Dominik Henneke Date: Mon, 25 Oct 2021 11:27:28 +0200 Subject: [PATCH 07/13] Adds a new endpoint for consuming logs from the Scaffolder that uses long polling instead of Server Sent Events Signed-off-by: Dominik Henneke --- .changeset/selfish-countries-prove.md | 42 ++++ .../src/service/router.test.ts | 80 ++++++++ .../scaffolder-backend/src/service/router.ts | 41 +++- plugins/scaffolder/api-report.md | 9 +- plugins/scaffolder/package.json | 1 + plugins/scaffolder/src/api.test.ts | 179 ++++++++++++++++++ plugins/scaffolder/src/api.ts | 66 ++++++- plugins/scaffolder/src/setupTests.ts | 1 + 8 files changed, 404 insertions(+), 15 deletions(-) create mode 100644 .changeset/selfish-countries-prove.md diff --git a/.changeset/selfish-countries-prove.md b/.changeset/selfish-countries-prove.md new file mode 100644 index 0000000000..03e36f6d80 --- /dev/null +++ b/.changeset/selfish-countries-prove.md @@ -0,0 +1,42 @@ +--- +'@backstage/plugin-scaffolder': patch +'@backstage/plugin-scaffolder-backend': patch +--- + +Adds a new endpoint for consuming logs from the Scaffolder that uses long polling instead of Server Sent Events. + +This is useful if Backstage is accessed from an environment that doesn't support SSE correctly, which happens in combination with certain enterprise HTTP Proxy servers. + +It is intended to switch the endpoint globally for the whole instance. +If you want to use it, you can provide a reconfigured API to the `scaffolderApiRef`: + +```tsx +// packages/app/src/apis.ts + +// ... +import { + scaffolderApiRef, + ScaffolderClient, +} from '@backstage/plugin-scaffolder'; + +export const apis: AnyApiFactory[] = [ + // ... + + createApiFactory({ + api: scaffolderApiRef, + deps: { + discoveryApi: discoveryApiRef, + identityApi: identityApiRef, + scmIntegrationsApi: scmIntegrationsApiRef, + }, + factory: ({ discoveryApi, identityApi, scmIntegrationsApi }) => + new ScaffolderClient({ + discoveryApi, + identityApi, + scmIntegrationsApi, + // use long polling instead of an eventsource + useLongPollingLogs: true, + }), + }), +]; +``` diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index 5157af4eaf..d6136fe872 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -318,4 +318,84 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ expect(unsubscribe).toBeCalledTimes(1); }); }); + + describe('GET /v2/tasks/:taskId/logs', () => { + it('should return log messages', async () => { + const unsubscribe = jest.fn(); + MockStorageTaskBroker.prototype.observe.mockImplementation( + ({ taskId }, callback) => { + callback(undefined, { + events: [ + { + id: 0, + taskId, + type: 'log', + createdAt: '', + body: { message: 'My log message' }, + }, + { + id: 1, + taskId, + type: 'completion', + createdAt: '', + body: { message: 'Finished!' }, + }, + ], + }); + return unsubscribe; + }, + ); + + const response = await request(app).get('/v2/tasks/a-random-id/logs'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + { + id: 0, + taskId: 'a-random-id', + type: 'log', + createdAt: '', + body: { message: 'My log message' }, + }, + { + id: 1, + taskId: 'a-random-id', + type: 'completion', + createdAt: '', + body: { message: 'Finished!' }, + }, + ]); + + expect(MockStorageTaskBroker.prototype.observe).toBeCalledTimes(1); + expect(MockStorageTaskBroker.prototype.observe).toBeCalledWith( + { taskId: 'a-random-id' }, + expect.any(Function), + ); + expect(unsubscribe).toBeCalledTimes(1); + }); + + it('should return log messages with after query', async () => { + const unsubscribe = jest.fn(); + MockStorageTaskBroker.prototype.observe.mockImplementation( + (_, callback) => { + callback(undefined, { events: [] }); + return unsubscribe; + }, + ); + + const response = await request(app) + .get('/v2/tasks/a-random-id/logs') + .query({ after: 10 }); + + expect(response.status).toEqual(200); + expect(response.body).toEqual([]); + + expect(MockStorageTaskBroker.prototype.observe).toBeCalledTimes(1); + expect(MockStorageTaskBroker.prototype.observe).toBeCalledWith( + { taskId: 'a-random-id', after: 10 }, + expect.any(Function), + ); + expect(unsubscribe).toBeCalledTimes(1); + }); + }); }); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index fce3b885c6..076a7b0036 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -247,7 +247,9 @@ export async function createRouter( }) .get('/v2/tasks/:taskId/eventstream', async (req, res) => { const { taskId } = req.params; - const after = Number(req.query.after) || undefined; + const after = + req.query.after !== undefined ? Number(req.query.after) : undefined; + logger.debug(`Event stream observing taskId '${taskId}' opened`); // Mandatory headers and http status to keep connection open @@ -289,6 +291,43 @@ export async function createRouter( unsubscribe(); logger.debug(`Event stream observing taskId '${taskId}' closed`); }); + }) + .get('/v2/tasks/:taskId/logs', async (req, res) => { + const { taskId } = req.params; + const after = Number(req.query.after) || undefined; + + let unsubscribe = () => {}; + + // cancel the request after 30 seconds. this aligns with the recommendations of RFC 6202. + const timeout = setTimeout(() => { + unsubscribe(); + res.json([]); + }, 30_000); + + // Get all known events after an id (always includes the completion event) and return the first callback + unsubscribe = taskBroker.observe( + { taskId, after }, + (error, { events }) => { + // stop the timeout + clearTimeout(timeout); + unsubscribe(); + + if (error) { + logger.error( + `Received error from log when observing taskId '${taskId}', ${error}`, + ); + } + + res.json(events); + }, + ); + + // When client closes connection we update the clients list + // avoiding the disconnected one + req.on('close', () => { + unsubscribe(); + clearTimeout(timeout); + }); }); const app = express(); diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index 554fccabb7..0ac6910264 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -178,6 +178,7 @@ export class ScaffolderClient implements ScaffolderApi { discoveryApi: DiscoveryApi; identityApi: IdentityApi; scmIntegrationsApi: ScmIntegrationRegistry; + useLongPollingLogs?: boolean; }); // (undocumented) getIntegrationsList(options: { allowedHosts: string[] }): Promise< @@ -199,13 +200,7 @@ export class ScaffolderClient implements ScaffolderApi { // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen scaffold(templateName: string, values: Record): Promise; // (undocumented) - streamLogs({ - taskId, - after, - }: { - taskId: string; - after?: number; - }): Observable; + streamLogs(opts: { taskId: string; after?: number }): Observable; } // Warning: (ae-missing-release-tag) "ScaffolderFieldExtensions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 20b8e934f7..58bee32201 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -55,6 +55,7 @@ "json-schema": "^0.3.0", "lodash": "^4.17.21", "luxon": "^2.0.2", + "qs": "^6.9.4", "react": "^16.13.1", "react-dom": "^16.13.1", "react-lazylog": "^4.5.2", diff --git a/plugins/scaffolder/src/api.test.ts b/plugins/scaffolder/src/api.test.ts index f11b2cf06c..8b53029cd7 100644 --- a/plugins/scaffolder/src/api.test.ts +++ b/plugins/scaffolder/src/api.test.ts @@ -16,13 +16,19 @@ import { ConfigReader } from '@backstage/core-app-api'; import { ScmIntegrations } from '@backstage/integration'; +import { setupRequestMockHandlers } from '@backstage/test-utils'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; import { ScaffolderClient } from './api'; const MockedEventSource = global.EventSource as jest.MockedClass< typeof EventSource >; +const server = setupServer(); + describe('api', () => { + setupRequestMockHandlers(server); const mockBaseUrl = 'http://backstage/api'; const discoveryApi = { getBaseUrl: async () => mockBaseUrl }; @@ -114,5 +120,178 @@ describe('api', () => { }); }); }); + + describe('longPolling', () => { + beforeEach(() => { + apiClient = new ScaffolderClient({ + scmIntegrationsApi, + discoveryApi, + identityApi, + useLongPollingLogs: true, + }); + }); + + it('should work', async () => { + server.use( + rest.get(`${mockBaseUrl}/v2/tasks/:taskId/logs`, (req, res, ctx) => { + const { taskId } = req.params; + const after = req.url.searchParams.get('after'); + + if (taskId === 'a-random-task-id') { + if (!after) { + return res( + ctx.json([ + { + id: 1, + taskId: 'a-random-id', + type: 'log', + createdAt: '', + body: { message: 'My log message' }, + }, + ]), + ); + } else if (after === '1') { + return res( + ctx.json([ + { + id: 2, + taskId: 'a-random-id', + type: 'completion', + createdAt: '', + body: { message: 'Finished!' }, + }, + ]), + ); + } + } + + return res(ctx.status(500)); + }), + ); + + const next = jest.fn(); + + await new Promise(complete => + apiClient + .streamLogs({ taskId: 'a-random-task-id' }) + .subscribe({ next, complete }), + ); + + expect(next).toBeCalledTimes(2); + expect(next).toBeCalledWith({ + id: 1, + taskId: 'a-random-id', + type: 'log', + createdAt: '', + body: { message: 'My log message' }, + }); + expect(next).toBeCalledWith({ + id: 2, + taskId: 'a-random-id', + type: 'completion', + createdAt: '', + body: { message: 'Finished!' }, + }); + }); + + it('should unsubscribe', async () => { + expect.assertions(3); + + server.use( + rest.get(`${mockBaseUrl}/v2/tasks/:taskId/logs`, (req, res, ctx) => { + const { taskId } = req.params; + + const after = req.url.searchParams.get('after'); + + // use assertion to make sure it is not called after unsubscribing + expect(after).toBe(null); + + if (taskId === 'a-random-task-id') { + return res( + ctx.json([ + { + id: 1, + taskId: 'a-random-id', + type: 'log', + createdAt: '', + body: { message: 'My log message' }, + }, + ]), + ); + } + + return res(ctx.status(500)); + }), + ); + + const next = jest.fn(); + + await new Promise(complete => { + const subscription = apiClient + .streamLogs({ taskId: 'a-random-task-id' }) + .subscribe({ + next: (...args) => { + next(...args); + subscription.unsubscribe(); + complete(); + }, + }); + }); + + expect(next).toBeCalledTimes(1); + expect(next).toBeCalledWith({ + id: 1, + taskId: 'a-random-id', + type: 'log', + createdAt: '', + body: { message: 'My log message' }, + }); + }); + + it('should continue after error', async () => { + const called = jest.fn(); + + server.use( + rest.get(`${mockBaseUrl}/v2/tasks/:taskId/logs`, (_req, res, ctx) => { + called(); + + if (called.mock.calls.length > 1) { + return res( + ctx.json([ + { + id: 2, + taskId: 'a-random-id', + type: 'completion', + createdAt: '', + body: { message: 'Finished!' }, + }, + ]), + ); + } + + return res(ctx.status(500)); + }), + ); + + const next = jest.fn(); + + await new Promise(complete => + apiClient + .streamLogs({ taskId: 'a-random-task-id' }) + .subscribe({ next, complete }), + ); + + expect(called).toBeCalledTimes(2); + + expect(next).toBeCalledTimes(1); + expect(next).toBeCalledWith({ + id: 2, + taskId: 'a-random-id', + type: 'completion', + createdAt: '', + body: { message: 'Finished!' }, + }); + }); + }); }); }); diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 1b531b652d..5f2874d653 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -15,17 +15,18 @@ */ import { EntityName } from '@backstage/catalog-model'; -import { JsonObject, JsonValue, Observable } from '@backstage/types'; -import { ResponseError } from '@backstage/errors'; -import { ScmIntegrationRegistry } from '@backstage/integration'; -import { Field, FieldValidation } from '@rjsf/core'; -import ObservableImpl from 'zen-observable'; -import { ListActionsResponse, ScaffolderTask, Status } from './types'; import { createApiRef, DiscoveryApi, IdentityApi, } from '@backstage/core-plugin-api'; +import { ResponseError } from '@backstage/errors'; +import { ScmIntegrationRegistry } from '@backstage/integration'; +import { JsonObject, JsonValue, Observable } from '@backstage/types'; +import { Field, FieldValidation } from '@rjsf/core'; +import qs from 'qs'; +import ObservableImpl from 'zen-observable'; +import { ListActionsResponse, ScaffolderTask, Status } from './types'; export const scaffolderApiRef = createApiRef({ id: 'plugin.scaffolder.service', @@ -94,15 +95,18 @@ export class ScaffolderClient implements ScaffolderApi { private readonly discoveryApi: DiscoveryApi; private readonly identityApi: IdentityApi; private readonly scmIntegrationsApi: ScmIntegrationRegistry; + private readonly useLongPollingLogs: boolean; constructor(options: { discoveryApi: DiscoveryApi; identityApi: IdentityApi; scmIntegrationsApi: ScmIntegrationRegistry; + useLongPollingLogs?: boolean; }) { this.discoveryApi = options.discoveryApi; this.identityApi = options.identityApi; this.scmIntegrationsApi = options.scmIntegrationsApi; + this.useLongPollingLogs = options.useLongPollingLogs ?? false; } async getIntegrationsList(options: { allowedHosts: string[] }) { @@ -189,7 +193,15 @@ export class ScaffolderClient implements ScaffolderApi { return await response.json(); } - streamLogs({ + streamLogs(opts: { taskId: string; after?: number }): Observable { + if (this.useLongPollingLogs) { + return this.streamLogsPolling(opts); + } + + return this.streamLogsEventStream(opts); + } + + private streamLogsEventStream({ taskId, after, }: { @@ -239,6 +251,46 @@ export class ScaffolderClient implements ScaffolderApi { }); } + private streamLogsPolling({ + taskId, + after: inputAfter, + }: { + taskId: string; + after?: number; + }): Observable { + let after = inputAfter; + + return new ObservableImpl(subscriber => { + this.discoveryApi.getBaseUrl('scaffolder').then(async baseUrl => { + while (!subscriber.closed) { + const url = `${baseUrl}/v2/tasks/${encodeURIComponent( + taskId, + )}/logs?${qs.stringify({ after })}`; + const response = await fetch(url); + + if (!response.ok) { + // wait for one second to not run into an + await new Promise(resolve => setTimeout(resolve, 1000)); + continue; + } + + const logs = (await response.json()) as LogEvent[]; + + for (const event of logs) { + after = Number(event.id); + + subscriber.next(event); + + if (event.type === 'completion') { + subscriber.complete(); + return; + } + } + } + }); + }); + } + /** * @returns ListActionsResponse containing all registered actions. */ diff --git a/plugins/scaffolder/src/setupTests.ts b/plugins/scaffolder/src/setupTests.ts index 84589060ec..10252413a7 100644 --- a/plugins/scaffolder/src/setupTests.ts +++ b/plugins/scaffolder/src/setupTests.ts @@ -15,6 +15,7 @@ */ import '@testing-library/jest-dom'; +import 'cross-fetch/polyfill'; const { EventSourcePolyfill } = jest.requireMock('event-source-polyfill'); global.EventSource = EventSourcePolyfill; From 471b9243dead0d1fa0fe027137307096f30d2e91 Mon Sep 17 00:00:00 2001 From: Dominik Henneke Date: Thu, 28 Oct 2021 15:46:16 +0200 Subject: [PATCH 08/13] Rename endpoint to /events Signed-off-by: Dominik Henneke --- .../src/service/router.test.ts | 6 +- .../scaffolder-backend/src/service/router.ts | 2 +- plugins/scaffolder/src/api.test.ts | 145 ++++++++++-------- plugins/scaffolder/src/api.ts | 2 +- 4 files changed, 82 insertions(+), 73 deletions(-) diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index d6136fe872..5b9b248097 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -319,7 +319,7 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ }); }); - describe('GET /v2/tasks/:taskId/logs', () => { + describe('GET /v2/tasks/:taskId/events', () => { it('should return log messages', async () => { const unsubscribe = jest.fn(); MockStorageTaskBroker.prototype.observe.mockImplementation( @@ -346,7 +346,7 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ }, ); - const response = await request(app).get('/v2/tasks/a-random-id/logs'); + const response = await request(app).get('/v2/tasks/a-random-id/events'); expect(response.status).toEqual(200); expect(response.body).toEqual([ @@ -384,7 +384,7 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ ); const response = await request(app) - .get('/v2/tasks/a-random-id/logs') + .get('/v2/tasks/a-random-id/events') .query({ after: 10 }); expect(response.status).toEqual(200); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 076a7b0036..35bedb3651 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -292,7 +292,7 @@ export async function createRouter( logger.debug(`Event stream observing taskId '${taskId}' closed`); }); }) - .get('/v2/tasks/:taskId/logs', async (req, res) => { + .get('/v2/tasks/:taskId/events', async (req, res) => { const { taskId } = req.params; const after = Number(req.query.after) || undefined; diff --git a/plugins/scaffolder/src/api.test.ts b/plugins/scaffolder/src/api.test.ts index 8b53029cd7..01463a6e1c 100644 --- a/plugins/scaffolder/src/api.test.ts +++ b/plugins/scaffolder/src/api.test.ts @@ -133,40 +133,43 @@ describe('api', () => { it('should work', async () => { server.use( - rest.get(`${mockBaseUrl}/v2/tasks/:taskId/logs`, (req, res, ctx) => { - const { taskId } = req.params; - const after = req.url.searchParams.get('after'); + rest.get( + `${mockBaseUrl}/v2/tasks/:taskId/events`, + (req, res, ctx) => { + const { taskId } = req.params; + const after = req.url.searchParams.get('after'); - if (taskId === 'a-random-task-id') { - if (!after) { - return res( - ctx.json([ - { - id: 1, - taskId: 'a-random-id', - type: 'log', - createdAt: '', - body: { message: 'My log message' }, - }, - ]), - ); - } else if (after === '1') { - return res( - ctx.json([ - { - id: 2, - taskId: 'a-random-id', - type: 'completion', - createdAt: '', - body: { message: 'Finished!' }, - }, - ]), - ); + if (taskId === 'a-random-task-id') { + if (!after) { + return res( + ctx.json([ + { + id: 1, + taskId: 'a-random-id', + type: 'log', + createdAt: '', + body: { message: 'My log message' }, + }, + ]), + ); + } else if (after === '1') { + return res( + ctx.json([ + { + id: 2, + taskId: 'a-random-id', + type: 'completion', + createdAt: '', + body: { message: 'Finished!' }, + }, + ]), + ); + } } - } - return res(ctx.status(500)); - }), + return res(ctx.status(500)); + }, + ), ); const next = jest.fn(); @@ -198,30 +201,33 @@ describe('api', () => { expect.assertions(3); server.use( - rest.get(`${mockBaseUrl}/v2/tasks/:taskId/logs`, (req, res, ctx) => { - const { taskId } = req.params; + rest.get( + `${mockBaseUrl}/v2/tasks/:taskId/events`, + (req, res, ctx) => { + const { taskId } = req.params; - const after = req.url.searchParams.get('after'); + const after = req.url.searchParams.get('after'); - // use assertion to make sure it is not called after unsubscribing - expect(after).toBe(null); + // use assertion to make sure it is not called after unsubscribing + expect(after).toBe(null); - if (taskId === 'a-random-task-id') { - return res( - ctx.json([ - { - id: 1, - taskId: 'a-random-id', - type: 'log', - createdAt: '', - body: { message: 'My log message' }, - }, - ]), - ); - } + if (taskId === 'a-random-task-id') { + return res( + ctx.json([ + { + id: 1, + taskId: 'a-random-id', + type: 'log', + createdAt: '', + body: { message: 'My log message' }, + }, + ]), + ); + } - return res(ctx.status(500)); - }), + return res(ctx.status(500)); + }, + ), ); const next = jest.fn(); @@ -252,25 +258,28 @@ describe('api', () => { const called = jest.fn(); server.use( - rest.get(`${mockBaseUrl}/v2/tasks/:taskId/logs`, (_req, res, ctx) => { - called(); + rest.get( + `${mockBaseUrl}/v2/tasks/:taskId/events`, + (_req, res, ctx) => { + called(); - if (called.mock.calls.length > 1) { - return res( - ctx.json([ - { - id: 2, - taskId: 'a-random-id', - type: 'completion', - createdAt: '', - body: { message: 'Finished!' }, - }, - ]), - ); - } + if (called.mock.calls.length > 1) { + return res( + ctx.json([ + { + id: 2, + taskId: 'a-random-id', + type: 'completion', + createdAt: '', + body: { message: 'Finished!' }, + }, + ]), + ); + } - return res(ctx.status(500)); - }), + return res(ctx.status(500)); + }, + ), ); const next = jest.fn(); diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 5f2874d653..a16ab59053 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -265,7 +265,7 @@ export class ScaffolderClient implements ScaffolderApi { while (!subscriber.closed) { const url = `${baseUrl}/v2/tasks/${encodeURIComponent( taskId, - )}/logs?${qs.stringify({ after })}`; + )}/events?${qs.stringify({ after })}`; const response = await fetch(url); if (!response.ok) { From 7c7b0b27cb3fcc3a8a7bfefaee9fb51d9d03babc Mon Sep 17 00:00:00 2001 From: Dominik Henneke Date: Tue, 2 Nov 2021 13:51:17 +0100 Subject: [PATCH 09/13] Adapt code to the refactored classes and types Signed-off-by: Dominik Henneke --- .../src/service/router.test.ts | 191 +++++++++--------- .../scaffolder-backend/src/service/router.ts | 4 +- 2 files changed, 102 insertions(+), 93 deletions(-) diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index 5b9b248097..d8ba15279f 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -46,14 +46,9 @@ import request from 'supertest'; * plugin-scaffolder-backend-module-cookiecutter plugin, it results in an error: * TypeError: _pluginscaffolderbackend.createTemplateAction is not a function */ -import { createRouter } from '../index'; +import { createRouter, DatabaseTaskStore, TaskBroker } from '../index'; import { StorageTaskBroker } from '../scaffolder/tasks/StorageTaskBroker'; -jest.mock('../scaffolder/tasks'); - -const MockStorageTaskBroker: jest.MockedClass = - StorageTaskBroker as any; - const createCatalogClient = (templates: any[] = []) => ({ getEntities: async () => ({ items: templates }), @@ -79,6 +74,7 @@ const mockUrlReader = UrlReaders.default({ describe('createRouter', () => { let app: express.Express; + let taskBroker: TaskBroker; const template: TemplateEntityV1beta2 = { apiVersion: 'backstage.io/v1beta2', kind: 'Template', @@ -109,6 +105,16 @@ describe('createRouter', () => { }; beforeEach(async () => { + const logger = getVoidLogger(); + const databaseTaskStore = await DatabaseTaskStore.create({ + database: await createDatabase().getClient(), + }); + taskBroker = new StorageTaskBroker(databaseTaskStore, logger); + + jest.spyOn(taskBroker, 'dispatch'); + jest.spyOn(taskBroker, 'get'); + jest.spyOn(taskBroker, 'observe'); + const router = await createRouter({ logger: getVoidLogger(), config: new ConfigReader({}), @@ -116,6 +122,7 @@ describe('createRouter', () => { catalogClient: createCatalogClient([template]), containerRunner: new DockerContainerRunner({} as any), reader: mockUrlReader, + taskBroker, }); app = express().use(router); }); @@ -148,7 +155,9 @@ describe('createRouter', () => { }); it('return the template id', async () => { - MockStorageTaskBroker.prototype.dispatch.mockResolvedValue({ + ( + taskBroker.dispatch as jest.Mocked['dispatch'] + ).mockResolvedValue({ taskId: 'a-random-id', }); @@ -168,7 +177,7 @@ describe('createRouter', () => { describe('GET /v2/tasks/:taskId', () => { it('does not divulge secrets', async () => { - MockStorageTaskBroker.prototype.get.mockResolvedValue({ + (taskBroker.get as jest.Mocked['get']).mockResolvedValue({ id: 'a-random-id', spec: {} as any, status: 'completed', @@ -186,37 +195,37 @@ describe('createRouter', () => { describe('GET /v2/tasks/:taskId/eventstream', () => { it('should return log messages', async () => { const unsubscribe = jest.fn(); - MockStorageTaskBroker.prototype.observe.mockImplementation( - ({ taskId }, callback) => { - // emit after this function returned - setImmediate(() => { - callback(undefined, { - events: [ - { - id: 0, - taskId, - type: 'log', - createdAt: '', - body: { message: 'My log message' }, - }, - ], - }); - callback(undefined, { - events: [ - { - id: 1, - taskId, - type: 'completion', - createdAt: '', - body: { message: 'Finished!' }, - }, - ], - }); + ( + taskBroker.observe as jest.Mocked['observe'] + ).mockImplementation(({ taskId }, callback) => { + // emit after this function returned + setImmediate(() => { + callback(undefined, { + events: [ + { + id: 0, + taskId, + type: 'log', + createdAt: '', + body: { message: 'My log message' }, + }, + ], }); + callback(undefined, { + events: [ + { + id: 1, + taskId, + type: 'completion', + createdAt: '', + body: { message: 'Finished!' }, + }, + ], + }); + }); - return unsubscribe; - }, - ); + return { unsubscribe }; + }); let statusCode: any = undefined; let headers: any = {}; @@ -255,8 +264,8 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ `); - expect(MockStorageTaskBroker.prototype.observe).toBeCalledTimes(1); - expect(MockStorageTaskBroker.prototype.observe).toBeCalledWith( + expect(taskBroker.observe).toBeCalledTimes(1); + expect(taskBroker.observe).toBeCalledWith( { taskId: 'a-random-id' }, expect.any(Function), ); @@ -266,24 +275,24 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ it('should return log messages with after query', async () => { const unsubscribe = jest.fn(); - MockStorageTaskBroker.prototype.observe.mockImplementation( - ({ taskId }, callback) => { - setImmediate(() => { - callback(undefined, { - events: [ - { - id: 1, - taskId, - type: 'completion', - createdAt: '', - body: { message: 'Finished!' }, - }, - ], - }); + ( + taskBroker.observe as jest.Mocked['observe'] + ).mockImplementation(({ taskId }, callback) => { + setImmediate(() => { + callback(undefined, { + events: [ + { + id: 1, + taskId, + type: 'completion', + createdAt: '', + body: { message: 'Finished!' }, + }, + ], }); - return unsubscribe; - }, - ); + }); + return { unsubscribe }; + }); let statusCode: any = undefined; let headers: any = {}; @@ -309,8 +318,8 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ expect(statusCode).toBe(200); expect(headers['content-type']).toBe('text/event-stream'); - expect(MockStorageTaskBroker.prototype.observe).toBeCalledTimes(1); - expect(MockStorageTaskBroker.prototype.observe).toBeCalledWith( + expect(taskBroker.observe).toBeCalledTimes(1); + expect(taskBroker.observe).toBeCalledWith( { taskId: 'a-random-id', after: 10 }, expect.any(Function), ); @@ -322,29 +331,29 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ describe('GET /v2/tasks/:taskId/events', () => { it('should return log messages', async () => { const unsubscribe = jest.fn(); - MockStorageTaskBroker.prototype.observe.mockImplementation( - ({ taskId }, callback) => { - callback(undefined, { - events: [ - { - id: 0, - taskId, - type: 'log', - createdAt: '', - body: { message: 'My log message' }, - }, - { - id: 1, - taskId, - type: 'completion', - createdAt: '', - body: { message: 'Finished!' }, - }, - ], - }); - return unsubscribe; - }, - ); + ( + taskBroker.observe as jest.Mocked['observe'] + ).mockImplementation(({ taskId }, callback) => { + callback(undefined, { + events: [ + { + id: 0, + taskId, + type: 'log', + createdAt: '', + body: { message: 'My log message' }, + }, + { + id: 1, + taskId, + type: 'completion', + createdAt: '', + body: { message: 'Finished!' }, + }, + ], + }); + return { unsubscribe }; + }); const response = await request(app).get('/v2/tasks/a-random-id/events'); @@ -366,8 +375,8 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ }, ]); - expect(MockStorageTaskBroker.prototype.observe).toBeCalledTimes(1); - expect(MockStorageTaskBroker.prototype.observe).toBeCalledWith( + expect(taskBroker.observe).toBeCalledTimes(1); + expect(taskBroker.observe).toBeCalledWith( { taskId: 'a-random-id' }, expect.any(Function), ); @@ -376,12 +385,12 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ it('should return log messages with after query', async () => { const unsubscribe = jest.fn(); - MockStorageTaskBroker.prototype.observe.mockImplementation( - (_, callback) => { - callback(undefined, { events: [] }); - return unsubscribe; - }, - ); + ( + taskBroker.observe as jest.Mocked['observe'] + ).mockImplementation((_, callback) => { + callback(undefined, { events: [] }); + return { unsubscribe }; + }); const response = await request(app) .get('/v2/tasks/a-random-id/events') @@ -390,8 +399,8 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ expect(response.status).toEqual(200); expect(response.body).toEqual([]); - expect(MockStorageTaskBroker.prototype.observe).toBeCalledTimes(1); - expect(MockStorageTaskBroker.prototype.observe).toBeCalledWith( + expect(taskBroker.observe).toBeCalledTimes(1); + expect(taskBroker.observe).toBeCalledWith( { taskId: 'a-random-id', after: 10 }, expect.any(Function), ); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 35bedb3651..2ae30593f2 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -305,7 +305,7 @@ export async function createRouter( }, 30_000); // Get all known events after an id (always includes the completion event) and return the first callback - unsubscribe = taskBroker.observe( + ({ unsubscribe } = taskBroker.observe( { taskId, after }, (error, { events }) => { // stop the timeout @@ -320,7 +320,7 @@ export async function createRouter( res.json(events); }, - ); + )); // When client closes connection we update the clients list // avoiding the disconnected one From 7d95e98682ded152061973a1a336048f4ce97977 Mon Sep 17 00:00:00 2001 From: Tim Jacomb <21194782+timja@users.noreply.github.com> Date: Wed, 3 Nov 2021 14:33:43 +0000 Subject: [PATCH 10/13] Add yarn command for installing jenkins-backend plugin Signed-off-by: Tim Jacomb --- plugins/jenkins-backend/README.md | 6 ++++++ plugins/jenkins/README.md | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/plugins/jenkins-backend/README.md b/plugins/jenkins-backend/README.md index bac56b6eaf..56a2a7faab 100644 --- a/plugins/jenkins-backend/README.md +++ b/plugins/jenkins-backend/README.md @@ -12,6 +12,12 @@ This is the backend half of the 2 Jenkins plugins and is responsible for: This plugin needs to be added to an existing backstage instance. +```bash +# From your Backstage root directory +cd packages/backend +yarn add @backstage/plugin-jenkins-backend +``` + Typically, this means creating a `src/plugins/jenkins.ts` file and adding a reference to it to `src/index.ts` ### jenkins.ts diff --git a/plugins/jenkins/README.md b/plugins/jenkins/README.md index 1af511b16f..2012555d93 100644 --- a/plugins/jenkins/README.md +++ b/plugins/jenkins/README.md @@ -16,7 +16,7 @@ cd packages/app yarn add @backstage/plugin-jenkins ``` -2. Add and configure the backend plugin according to it's instructions +2. Add and configure the [jenkins-backend](../jenkins-backend) plugin according to it's instructions 3. Add the `EntityJenkinsContent` extension to the `CI/CD` page and `EntityLatestJenkinsRunCard` to the `overview` page in the app (or wherever you'd prefer): From bc4cc0d2bf73e9cf869d00604a13f18c754a6e34 Mon Sep 17 00:00:00 2001 From: Tim Jacomb Date: Wed, 3 Nov 2021 14:56:18 +0000 Subject: [PATCH 11/13] Add index.ts changes Signed-off-by: Tim Jacomb --- plugins/jenkins-backend/README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/plugins/jenkins-backend/README.md b/plugins/jenkins-backend/README.md index 56a2a7faab..1413a45136 100644 --- a/plugins/jenkins-backend/README.md +++ b/plugins/jenkins-backend/README.md @@ -48,6 +48,29 @@ export default async function createPlugin({ } ``` +### src/index.ts + +```diff +diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts +index f2b14b2..2c64f47 100644 +--- a/packages/backend/src/index.ts ++++ b/packages/backend/src/index.ts +@@ -22,6 +22,7 @@ import { Config } from '@backstage/config'; + import app from './plugins/app'; ++import jenkins from './plugins/jenkins'; + import scaffolder from './plugins/scaffolder'; +@@ -56,6 +57,7 @@ async function main() { + const authEnv = useHotMemoize(module, () => createEnv('auth')); ++ const jenkinsEnv = useHotMemoize(module, () => createEnv('jenkins')); + const proxyEnv = useHotMemoize(module, () => createEnv('proxy')); +@@ -63,6 +65,7 @@ async function main() { + + const apiRouter = Router(); + apiRouter.use('/catalog', await catalog(catalogEnv)); ++ apiRouter.use('/jenkins', await jenkins(jenkinsEnv)); + apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv)); +``` + This plugin must be provided with a JenkinsInfoProvider, this is a strategy object for finding the Jenkins instance and job for an entity. There is a standard one provided, but the Integrator is free to build their own. From ade04904d5f66278e85095692b32baf8b03b4a38 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Thu, 4 Nov 2021 13:37:34 +0530 Subject: [PATCH 12/13] fix api reports Signed-off-by: Himanshu Mishra --- packages/theme/api-report.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/theme/api-report.md b/packages/theme/api-report.md index 30dcb7b040..2dc6ab7b55 100644 --- a/packages/theme/api-report.md +++ b/packages/theme/api-report.md @@ -64,7 +64,7 @@ export type BackstagePaletteAdditions = { error: string; text: string; link: string; - warning: string; + warning?: string; }; }; From 98b8ef555fbeb8d41141360307b21ef7546d6092 Mon Sep 17 00:00:00 2001 From: Matto Date: Fri, 5 Nov 2021 11:03:35 +1100 Subject: [PATCH 13/13] Re-ran prettier on all files Signed-off-by: Matto --- ADOPTERS.md | 128 ++++++++++++++++++++++++++-------------------------- 1 file changed, 64 insertions(+), 64 deletions(-) diff --git a/ADOPTERS.md b/ADOPTERS.md index a2015086c3..797d178270 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -1,64 +1,64 @@ -| Organization | Contact | Description of Use | -| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [Spotify](https://www.spotify.com) | [@leemills83](https://github.com/leemills83) | Main interface towards all of Spotify's infrastructure and technical documentation. | -| [bol.com](https://www.bol.com) | [@sagacity](https://github.com/sagacity) | Initial work being done to unify platform tooling. | -| [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. | -| [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up | -| [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. | -| [SDA SE](https://sda.se) | [@Fox32](https://github.com/Fox32) | Central place for developing and sharing services in our insurance ecosystem. | -| [H-E-B](https://www.heb.com) | [@german-j-rodriguez](https://github.com/german-j-rodriguez) | Initial work on Engineering Portal service platform. | -| [American Airlines](https://www.aa.com) | [@paulpach](https://github.com/paulpach) | Central place for developers to develop and maintain applications | -| [Kiwi.com](https://kiwi.com) | [@aexvir](https://github.com/aexvir) | Replacing the frontend of [The Zoo](https://github.com/kiwicom/the-zoo), their service registry. | -| [Voi](https://www.voiscooters.com/) | [@K-Phoen](https://github.com/K-Phoen) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. | -| [Talkdesk](https://www.talkdesk.com) | [@jaime-talkdesk](https://github.com/jaime-talkdesk) | Initial work for Engineering Portal and Self Provisioning to R&D | -| [Wealthsimple](https://www.wealthsimple.com) | [@andrewthauer](https://github.com/andrewthauer) | Developer portal, service catalog, documentation and tooling | -| [Grab](https://www.grab.com) | [@althafh](https://github.com/althafh) | Initial work as a unified interface for all of Grab's internal tooling | -| [Telenor Sweden](https://www.telenor.se) | [@O5ten](https://github.com/O5ten) | Building a developer portal for scaffolding projects towards our unified build environment and microservice stacks | -| [Fiverr](https://www.fiverr.com) | [@nirga](https://github.com/nirga) | Unifying separate tools that developers are using today (i.e. monitoring, dead letter queues management, etc.) into a single platform. | -| [Zalando SE](https://www.zalando.de) | [@leviferreira](https://github.com/leviferreira) | Building V2 of the Internal Development Portal. | -| [LegalZoom](https://legalzoom.com) | [@backjo](https://github.com/backjo) | Developer portal - hub for all engineering projects and metadata. | -| [Expedia Group](https://www.expediagroup.com) | [Mike Turner](mailto:miturner@expediagroup.com), [Sneha Kumar](mailto:snkumar@expediagroup.com), [@guillermomanzo](https://github.com/guillermomanzo), [Erik Lindgren](https://github.com/lindgren) | EG Common Developer Toolkit | -| [Paddle.com](https://paddle.com) | [Ioannis Georgoulas](https://github.com/geototti21) | Developer portal (Tech Docs, Service Catalog, Internal Tooling), we use vanilla Backstage FE and custom BE implementation in Go | -| [Acast.com](https://acast.com) | [Olle Lundberg](https://github.com/lndbrg) | Developer portal with tech docs, service catalog and a bunch of other internal tooling | -| [Lunar](https://lunar.app) | [Jacob Valdemar](https://github.com/JacobValdemar) | Internal developer portal for service overview and insights, API documentation, technical guides, onboarding guides and RFC's. | -| [Trendyol](https://trendyol.com) | [Gamze Senturk](https://github.com/gmzsenturk), [Mert Can Bilgic](https://github.com/mertcb) | The Developer Portal has been called `Pandora`. Provides an overview of Trendyol tech ecosystem. TechDocs, Catalog, Custom Plugins and Theme. | -| [Peloton](https://www.onepeloton.com/) | [Jim Haughwout](https://github.com/JimHaughwout) | Creating our first developer portal and tech-docs. Exploring Service Catalog, Tech Insights and Cost Insights as well. | -| [TELUS](https://telus.com) | [Seb Barre](https://github.com/sbarre) | The Go-to place to find answers about development and delivery at TELUS. | -| [Brex](https://www.brex.com/) | [Vamsi Chitters](https://github.com/vamsikc) | A centralized UI to understand how a service fits in the whole Brex architecture and manage a teamโ€™s engineering dependencies. | -| [Oriflame](https://www.oriflame.com/) | [Oriflame](https://github.com/oriflame) | Internal developer portal for services, single page apps and packages overview, API documentation, technical guides, tech-radar and more. | -| [Booz Allen Hamilton](https://www.boozallen.com/) | [Jason Miller](https://github.com/JasonMiller-BAH) | Developer portal for a full-stack software development ecosystem that accelerates consistent and repeatable Modern Software Development practices for internal innovation and investments. | -| [Netflix](https://www.netflix.com/) | [bleathem](https://github.com/bleathem) | Our Backstage implementation will be the front door to a unified experience connecting our internal platform products across important workflows with integrated knowledge and support. | -| [b.well](https://www.icanbwell.com/) | [Jacob Rosales](https://github.com/jrosales) | Foundation for our engineering portal and cloud insights. | -| [PagerDuty](https://www.pagerduty.com/) | [Mark Shaw](https://github.com/markshawtoronto) | Developer portal, initially focused on software templates and tech-docs. | -| [MoonShiner](https://moonshiner.at) | [Fabian Hippmann](https://github.com/FabianHippmann) | Developer portal - helps us keep track of our customer projects, onboard new developers & improve our development process ๐ŸŒ•๐Ÿš€๐Ÿง‘โ€๐Ÿš€ | -| [FundApps](https://www.fundapps.co/) | [Elliot Greenwood](https://github.com/egnwd) | Developer Portal - A place for us to keep track of our projects and documentation for all services and processes | -| [DAZN](https://dazn.com/) | [Lou Bichard](https://twitter.com/loujaybee), [Marco Crivellaro](https://github.com/crivetechie), [Alex Hollerith](mailto:alex.hollerith@dazn.com) | Ingesting all of DAZN's repos for the catalog, migrating our internal platform apps (pull request boards, release information, inner source marketplace etc) to Backstage plugins (where applicable). | -| [HelloFresh](https://www.hellofresh.de/) | [@iammuho](https://github.com/iammuho), [@ElenaForester](https://github.com/ElenaForester), [@diegomarangoni](https://github.com/diegomarangoni) | Our developer portal at HelloFresh - Spread across an organisation of 500+ engineers globally. | -| [FactSet](https://www.factset.com/) | [@kuangp](https://github.com/kuangp) | Developer portal to provide discoverability to all internal components, APIs, documentation, and scaffold templates with integrations to our internal infrastructure tools. | -| [Workrise](https://www.workrise.com/) | [Michael Rode](https://github.com/michaelrode) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. | -| [RedVentures](https://www.redventures.com/) | [Chris Diaz](https://github.com/codingdiaz) | Developer portal that brings everything an engineer needs to provide value into a single pane of glass. | -| [MavTek](https://www.mavtek.com/) | [@fgascon](https://github.com/fgascon) | Developer portal focused on standardizing practices, centralizing documentation and streamlining developer practices. | -| [QuintoAndar](https://www.quintoandar.com.br/) | [@quintoandar](https://github.com/quintoandar) | Developer portal, services catalog and centralization of service metrics. | -| [empathy.co](https://empathy.co/) | [@guillermotti](https://github.com/guillermotti) | Developer portal for tech docs, service catalog, plugin discovery and much more. | -| [creditas.com](https://creditas.com/) | [@aureliosaraiva](https://github.com/aureliosaraiva) [@Creditas](https://github.com/creditas) | Centralization of all services, standards, documentation, etc. We started the deployment process. | -| [Prisjakt](https://www.prisjakt.nu) / [PriceSpy](https://pricespy.co.uk) | [@kennylindahl](https://github.com/kennylindahl) | Internal developer portal - Documentation, scaffolding, software catalog, TechRadar, Gitlab org data integration | -| [Powerspike](https://powerspike.tv/) | [@trelore](https://github.com/trelore) | Developer portal for documentation of core libraries and repositories. | -| [2U](https://2u.com) | [Andrew Thal](https://github.com/athal7) | Development team home-base, promoting service discoverability, resource dependencies, and tech radar | -| [Taxfix](https://taxfix.de/) | [Sami Ur Rehman](https://github.com/samiurrehman92) | Developer's portal with software catalog at it's core. Hosts API Specs, Tech Docs, Tech Radar and some custom plugins. | -| [Busuu](https://busuu.com/) | [Adam Tester](https://github.com/adamtester) | Developer portal with service catalog, API docs, Event docs, service templating, and cost insights. | -| [Loadsmart](https://loadsmart.com/) | [Loadsmart](https://github.com/loadsmart) | Improve services visibility and operations for service owners and developers. | -| [Monzo](https://monzo.com/) | [@WillSewell](https://github.com/WillSewell), [@joechrisellis](https://github.com/joechrisellis) | Developer portal showing metadata and docs for over 2000 microservices. We have built a number of plugins such as a UI for our system to measure [software excellence](https://monzo.com/blog/2021/09/15/how-we-measure-software-excellence), and a UI to show deployment and config change events. | -| [Vaimo](https://www.vaimo.com) | [@vaimo-magnus](https://github.com/vaimo-magnus) | Developer Portal for our developers at Vaimo, currently docs and self-service towards our internal PaaS based on k8s. Plans to extend the catalog into Projects, Environments etc | -| [Wayfair](https://www.wayfair.com) | [@fransan](https://github.com/fransan), [@errskipower](https://github.com/errskipower), [@hrrs](https://github.com/hrrs) | Developer portal for service catalog, technical documentation, and APIs. | -| [CircleHD](https://www.circlehd.com) | [@circlehddev](https://github.com/circlehddev) | Developer Portal for internal dev team across the globe | -| [CastDesk](https://castdesk.com) | [@circlehddev](https://github.com/circlehddev) | Developer Portal for internal dev team across the globe | -| [Santagostino](https://santagostino.it) | [@santagostino](https://github.com/santagostino) | Developer portal, gateway to our infrastructure, documentation, service catalog and internal tooling. | -| [Peak](https://peak.ai) | [Luke Beamish](https://github.com/lukebeamish-peak) | Developer portal for all internal engineers to access documentation and tooling. | -| [Gelato](https://gelato.com/) | [Dmitry Makarenko](https://github.com/dmitry-makarenko-gelato) | Developer portal: documentation, service templates, org structure, service catalog, plugins for integration with internal and third-party systems๐Ÿš€. | -| [GoCardless](https://gocardless.com/) | [James Turley](https://github.com/tragiclifestories) | Developer portal: documentation, service templates, org structure, service catalog, plugins for integration with internal systems. | -| [Box](https://www.box.com) | [@kielosz](https://github.com/kielosz), [@jluk-box](https://github.com/jluk-box), [@ptychu](https://github.com/ptychu), [@alexrybch](https://github.com/alexrybch), [@szubster](https://github.com/szubster) | Developer portal for service catalog, integration with internal systems, new service onboarding. | -| [Bazaarvoice](https://www.bazaarvoice.com) | [@niallmccullagh](https://github.com/niallmccullagh) | Developer portal for service catalog and scaffolds, publishing Github docs and API documentation, visualising our internal tech radar and our product engineering org structure. | -| [Krateo PlatformOps](https://www.krateo.io) | [@projectkerberus](https://github.com/projectkerberus) | A multi-cloud control plane to create, manage and deploy any kind of resource easily and centrally via a Developer Portal that centralizes via a self-service catalog the templating and ownership of services, the available documentation, the overview of the components that compose an entire domain and all the data of the service lifecycle. | -| [Adevinta](https://www.adevinta.com) | [Ray Sinnema](https://github.com/RemonSinnema) | Showcase shared services to our internal customers. | -| [Splunk](https://www.splunk.com) | [@tonytamsf](https://github.com/tonytamsf) | Developer portal as a centralized place to find people, services, documentation, escalation policies and give bravos. This portal is also being used as a centralized search engine for engineering specific documentation.| -| [SoundCloud](https://www.soundcloud.com) | [Julio Zynger](https://github.com/julioz) | Developer portal as a [humane registry](https://martinfowler.com/bliki/HumaneRegistry.html) for the organization: catalog of people, services, documentation, feature toggles, escalation policies, etc.| +| Organization | Contact | Description of Use | +| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Spotify](https://www.spotify.com) | [@leemills83](https://github.com/leemills83) | Main interface towards all of Spotify's infrastructure and technical documentation. | +| [bol.com](https://www.bol.com) | [@sagacity](https://github.com/sagacity) | Initial work being done to unify platform tooling. | +| [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. | +| [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up | +| [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. | +| [SDA SE](https://sda.se) | [@Fox32](https://github.com/Fox32) | Central place for developing and sharing services in our insurance ecosystem. | +| [H-E-B](https://www.heb.com) | [@german-j-rodriguez](https://github.com/german-j-rodriguez) | Initial work on Engineering Portal service platform. | +| [American Airlines](https://www.aa.com) | [@paulpach](https://github.com/paulpach) | Central place for developers to develop and maintain applications | +| [Kiwi.com](https://kiwi.com) | [@aexvir](https://github.com/aexvir) | Replacing the frontend of [The Zoo](https://github.com/kiwicom/the-zoo), their service registry. | +| [Voi](https://www.voiscooters.com/) | [@K-Phoen](https://github.com/K-Phoen) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. | +| [Talkdesk](https://www.talkdesk.com) | [@jaime-talkdesk](https://github.com/jaime-talkdesk) | Initial work for Engineering Portal and Self Provisioning to R&D | +| [Wealthsimple](https://www.wealthsimple.com) | [@andrewthauer](https://github.com/andrewthauer) | Developer portal, service catalog, documentation and tooling | +| [Grab](https://www.grab.com) | [@althafh](https://github.com/althafh) | Initial work as a unified interface for all of Grab's internal tooling | +| [Telenor Sweden](https://www.telenor.se) | [@O5ten](https://github.com/O5ten) | Building a developer portal for scaffolding projects towards our unified build environment and microservice stacks | +| [Fiverr](https://www.fiverr.com) | [@nirga](https://github.com/nirga) | Unifying separate tools that developers are using today (i.e. monitoring, dead letter queues management, etc.) into a single platform. | +| [Zalando SE](https://www.zalando.de) | [@leviferreira](https://github.com/leviferreira) | Building V2 of the Internal Development Portal. | +| [LegalZoom](https://legalzoom.com) | [@backjo](https://github.com/backjo) | Developer portal - hub for all engineering projects and metadata. | +| [Expedia Group](https://www.expediagroup.com) | [Mike Turner](mailto:miturner@expediagroup.com), [Sneha Kumar](mailto:snkumar@expediagroup.com), [@guillermomanzo](https://github.com/guillermomanzo), [Erik Lindgren](https://github.com/lindgren) | EG Common Developer Toolkit | +| [Paddle.com](https://paddle.com) | [Ioannis Georgoulas](https://github.com/geototti21) | Developer portal (Tech Docs, Service Catalog, Internal Tooling), we use vanilla Backstage FE and custom BE implementation in Go | +| [Acast.com](https://acast.com) | [Olle Lundberg](https://github.com/lndbrg) | Developer portal with tech docs, service catalog and a bunch of other internal tooling | +| [Lunar](https://lunar.app) | [Jacob Valdemar](https://github.com/JacobValdemar) | Internal developer portal for service overview and insights, API documentation, technical guides, onboarding guides and RFC's. | +| [Trendyol](https://trendyol.com) | [Gamze Senturk](https://github.com/gmzsenturk), [Mert Can Bilgic](https://github.com/mertcb) | The Developer Portal has been called `Pandora`. Provides an overview of Trendyol tech ecosystem. TechDocs, Catalog, Custom Plugins and Theme. | +| [Peloton](https://www.onepeloton.com/) | [Jim Haughwout](https://github.com/JimHaughwout) | Creating our first developer portal and tech-docs. Exploring Service Catalog, Tech Insights and Cost Insights as well. | +| [TELUS](https://telus.com) | [Seb Barre](https://github.com/sbarre) | The Go-to place to find answers about development and delivery at TELUS. | +| [Brex](https://www.brex.com/) | [Vamsi Chitters](https://github.com/vamsikc) | A centralized UI to understand how a service fits in the whole Brex architecture and manage a teamโ€™s engineering dependencies. | +| [Oriflame](https://www.oriflame.com/) | [Oriflame](https://github.com/oriflame) | Internal developer portal for services, single page apps and packages overview, API documentation, technical guides, tech-radar and more. | +| [Booz Allen Hamilton](https://www.boozallen.com/) | [Jason Miller](https://github.com/JasonMiller-BAH) | Developer portal for a full-stack software development ecosystem that accelerates consistent and repeatable Modern Software Development practices for internal innovation and investments. | +| [Netflix](https://www.netflix.com/) | [bleathem](https://github.com/bleathem) | Our Backstage implementation will be the front door to a unified experience connecting our internal platform products across important workflows with integrated knowledge and support. | +| [b.well](https://www.icanbwell.com/) | [Jacob Rosales](https://github.com/jrosales) | Foundation for our engineering portal and cloud insights. | +| [PagerDuty](https://www.pagerduty.com/) | [Mark Shaw](https://github.com/markshawtoronto) | Developer portal, initially focused on software templates and tech-docs. | +| [MoonShiner](https://moonshiner.at) | [Fabian Hippmann](https://github.com/FabianHippmann) | Developer portal - helps us keep track of our customer projects, onboard new developers & improve our development process ๐ŸŒ•๐Ÿš€๐Ÿง‘โ€๐Ÿš€ | +| [FundApps](https://www.fundapps.co/) | [Elliot Greenwood](https://github.com/egnwd) | Developer Portal - A place for us to keep track of our projects and documentation for all services and processes | +| [DAZN](https://dazn.com/) | [Lou Bichard](https://twitter.com/loujaybee), [Marco Crivellaro](https://github.com/crivetechie), [Alex Hollerith](mailto:alex.hollerith@dazn.com) | Ingesting all of DAZN's repos for the catalog, migrating our internal platform apps (pull request boards, release information, inner source marketplace etc) to Backstage plugins (where applicable). | +| [HelloFresh](https://www.hellofresh.de/) | [@iammuho](https://github.com/iammuho), [@ElenaForester](https://github.com/ElenaForester), [@diegomarangoni](https://github.com/diegomarangoni) | Our developer portal at HelloFresh - Spread across an organisation of 500+ engineers globally. | +| [FactSet](https://www.factset.com/) | [@kuangp](https://github.com/kuangp) | Developer portal to provide discoverability to all internal components, APIs, documentation, and scaffold templates with integrations to our internal infrastructure tools. | +| [Workrise](https://www.workrise.com/) | [Michael Rode](https://github.com/michaelrode) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. | +| [RedVentures](https://www.redventures.com/) | [Chris Diaz](https://github.com/codingdiaz) | Developer portal that brings everything an engineer needs to provide value into a single pane of glass. | +| [MavTek](https://www.mavtek.com/) | [@fgascon](https://github.com/fgascon) | Developer portal focused on standardizing practices, centralizing documentation and streamlining developer practices. | +| [QuintoAndar](https://www.quintoandar.com.br/) | [@quintoandar](https://github.com/quintoandar) | Developer portal, services catalog and centralization of service metrics. | +| [empathy.co](https://empathy.co/) | [@guillermotti](https://github.com/guillermotti) | Developer portal for tech docs, service catalog, plugin discovery and much more. | +| [creditas.com](https://creditas.com/) | [@aureliosaraiva](https://github.com/aureliosaraiva) [@Creditas](https://github.com/creditas) | Centralization of all services, standards, documentation, etc. We started the deployment process. | +| [Prisjakt](https://www.prisjakt.nu) / [PriceSpy](https://pricespy.co.uk) | [@kennylindahl](https://github.com/kennylindahl) | Internal developer portal - Documentation, scaffolding, software catalog, TechRadar, Gitlab org data integration | +| [Powerspike](https://powerspike.tv/) | [@trelore](https://github.com/trelore) | Developer portal for documentation of core libraries and repositories. | +| [2U](https://2u.com) | [Andrew Thal](https://github.com/athal7) | Development team home-base, promoting service discoverability, resource dependencies, and tech radar | +| [Taxfix](https://taxfix.de/) | [Sami Ur Rehman](https://github.com/samiurrehman92) | Developer's portal with software catalog at it's core. Hosts API Specs, Tech Docs, Tech Radar and some custom plugins. | +| [Busuu](https://busuu.com/) | [Adam Tester](https://github.com/adamtester) | Developer portal with service catalog, API docs, Event docs, service templating, and cost insights. | +| [Loadsmart](https://loadsmart.com/) | [Loadsmart](https://github.com/loadsmart) | Improve services visibility and operations for service owners and developers. | +| [Monzo](https://monzo.com/) | [@WillSewell](https://github.com/WillSewell), [@joechrisellis](https://github.com/joechrisellis) | Developer portal showing metadata and docs for over 2000 microservices. We have built a number of plugins such as a UI for our system to measure [software excellence](https://monzo.com/blog/2021/09/15/how-we-measure-software-excellence), and a UI to show deployment and config change events. | +| [Vaimo](https://www.vaimo.com) | [@vaimo-magnus](https://github.com/vaimo-magnus) | Developer Portal for our developers at Vaimo, currently docs and self-service towards our internal PaaS based on k8s. Plans to extend the catalog into Projects, Environments etc | +| [Wayfair](https://www.wayfair.com) | [@fransan](https://github.com/fransan), [@errskipower](https://github.com/errskipower), [@hrrs](https://github.com/hrrs) | Developer portal for service catalog, technical documentation, and APIs. | +| [CircleHD](https://www.circlehd.com) | [@circlehddev](https://github.com/circlehddev) | Developer Portal for internal dev team across the globe | +| [CastDesk](https://castdesk.com) | [@circlehddev](https://github.com/circlehddev) | Developer Portal for internal dev team across the globe | +| [Santagostino](https://santagostino.it) | [@santagostino](https://github.com/santagostino) | Developer portal, gateway to our infrastructure, documentation, service catalog and internal tooling. | +| [Peak](https://peak.ai) | [Luke Beamish](https://github.com/lukebeamish-peak) | Developer portal for all internal engineers to access documentation and tooling. | +| [Gelato](https://gelato.com/) | [Dmitry Makarenko](https://github.com/dmitry-makarenko-gelato) | Developer portal: documentation, service templates, org structure, service catalog, plugins for integration with internal and third-party systems๐Ÿš€. | +| [GoCardless](https://gocardless.com/) | [James Turley](https://github.com/tragiclifestories) | Developer portal: documentation, service templates, org structure, service catalog, plugins for integration with internal systems. | +| [Box](https://www.box.com) | [@kielosz](https://github.com/kielosz), [@jluk-box](https://github.com/jluk-box), [@ptychu](https://github.com/ptychu), [@alexrybch](https://github.com/alexrybch), [@szubster](https://github.com/szubster) | Developer portal for service catalog, integration with internal systems, new service onboarding. | +| [Bazaarvoice](https://www.bazaarvoice.com) | [@niallmccullagh](https://github.com/niallmccullagh) | Developer portal for service catalog and scaffolds, publishing Github docs and API documentation, visualising our internal tech radar and our product engineering org structure. | +| [Krateo PlatformOps](https://www.krateo.io) | [@projectkerberus](https://github.com/projectkerberus) | A multi-cloud control plane to create, manage and deploy any kind of resource easily and centrally via a Developer Portal that centralizes via a self-service catalog the templating and ownership of services, the available documentation, the overview of the components that compose an entire domain and all the data of the service lifecycle. | +| [Adevinta](https://www.adevinta.com) | [Ray Sinnema](https://github.com/RemonSinnema) | Showcase shared services to our internal customers. | +| [Splunk](https://www.splunk.com) | [@tonytamsf](https://github.com/tonytamsf) | Developer portal as a centralized place to find people, services, documentation, escalation policies and give bravos. This portal is also being used as a centralized search engine for engineering specific documentation. | +| [SoundCloud](https://www.soundcloud.com) | [Julio Zynger](https://github.com/julioz) | Developer portal as a [humane registry](https://martinfowler.com/bliki/HumaneRegistry.html) for the organization: catalog of people, services, documentation, feature toggles, escalation policies, etc. |