Adds a new endpoint for consuming logs from the Scaffolder that uses long polling instead of Server Sent Events

Signed-off-by: Dominik Henneke <dominik.henneke@sda-se.com>
This commit is contained in:
Dominik Henneke
2021-10-25 11:27:28 +02:00
parent 95a48698bc
commit b45a34fb15
8 changed files with 404 additions and 15 deletions
+42
View File
@@ -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,
}),
}),
];
```
@@ -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);
});
});
});
@@ -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();
+2 -7
View File
@@ -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<string, any>): Promise<string>;
// (undocumented)
streamLogs({
taskId,
after,
}: {
taskId: string;
after?: number;
}): Observable<LogEvent>;
streamLogs(opts: { taskId: string; after?: number }): Observable<LogEvent>;
}
// Warning: (ae-missing-release-tag) "ScaffolderFieldExtensions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+1
View File
@@ -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",
+179
View File
@@ -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<void>(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<void>(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<void>(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!' },
});
});
});
});
});
+59 -7
View File
@@ -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<ScaffolderApi>({
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<LogEvent> {
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<LogEvent> {
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.
*/
+1
View File
@@ -15,6 +15,7 @@
*/
import '@testing-library/jest-dom';
import 'cross-fetch/polyfill';
const { EventSourcePolyfill } = jest.requireMock('event-source-polyfill');
global.EventSource = EventSourcePolyfill;