Skip to content

Commit ad465b5

Browse files
committed
[server] Generalize HeadlessLogService
1 parent 88b2d90 commit ad465b5

File tree

3 files changed

+102
-58
lines changed

3 files changed

+102
-58
lines changed

components/server/src/workspace/gitpod-server-impl.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1162,7 +1162,8 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
11621162
async getHeadlessLog(ctx: TraceContext, instanceId: string): Promise<HeadlessLogUrls> {
11631163
traceAPIParams(ctx, { instanceId });
11641164

1165-
const user = this.checkAndBlockUser('getHeadlessLog', { instanceId });
1165+
this.checkAndBlockUser('getHeadlessLog', { instanceId });
1166+
const logCtx: LogContext = { instanceId };
11661167

11671168
const ws = await this.workspaceDb.trace(ctx).findByInstanceId(instanceId);
11681169
if (!ws) {
@@ -1179,7 +1180,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
11791180
throw new ResponseError(ErrorCodes.NOT_FOUND, `Workspace instance for ${instanceId} not found`);
11801181
}
11811182

1182-
const urls = await this.headlessLogService.getHeadlessLogURLs(user.id, wsi, ws.ownerId);
1183+
const urls = await this.headlessLogService.getHeadlessLogURLs(logCtx, wsi, ws.ownerId);
11831184
if (!urls || (typeof urls.streams === "object" && Object.keys(urls.streams).length === 0)) {
11841185
throw new ResponseError(ErrorCodes.NOT_FOUND, `Headless logs for ${instanceId} not found`);
11851186
}

components/server/src/workspace/headless-log-controller.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { CompositeResourceAccessGuard, OwnerResourceGuard, TeamMemberResourceGua
1212
import { DBWithTracing, TracedWorkspaceDB } from "@gitpod/gitpod-db/lib/traced-db";
1313
import { WorkspaceDB } from "@gitpod/gitpod-db/lib/workspace-db";
1414
import { TeamDB } from "@gitpod/gitpod-db/lib/team-db";
15-
import { HeadlessLogService } from "./headless-log-service";
15+
import { HeadlessLogService, HeadlessLogEndpoint } from "./headless-log-service";
1616
import * as opentracing from 'opentracing';
1717
import { asyncHandler } from "../express-util";
1818
import { Deferred } from "@gitpod/gitpod-protocol/lib/util/deferred";
@@ -54,6 +54,7 @@ export class HeadlessLogController {
5454
const logCtx = { userId: user.id, instanceId, workspaceId: workspace!.id };
5555
log.debug(logCtx, HEADLESS_LOGS_PATH_PREFIX);
5656

57+
const aborted = new Deferred<boolean>();
5758
try {
5859
const head = {
5960
'Content-Type': 'text/html; charset=utf-8', // is text/plain, but with that node.js won't stream...
@@ -62,7 +63,6 @@ export class HeadlessLogController {
6263
};
6364
res.writeHead(200, head)
6465

65-
const aborted = new Deferred<boolean>();
6666
const abort = (err: any) => {
6767
aborted.resolve(true);
6868
log.debug(logCtx, "headless-log: aborted");
@@ -88,7 +88,8 @@ export class HeadlessLogController {
8888
process.nextTick(resolve);
8989
}
9090
}));
91-
await this.headlessLogService.streamWorkspaceLog(instance, params.terminalId, writeToResponse, aborted);
91+
const logEndpoint = HeadlessLogEndpoint.fromWithOwnerToken(instance);
92+
await this.headlessLogService.streamWorkspaceLogWhileRunning(logCtx, logEndpoint, instanceId, params.terminalId, writeToResponse, aborted);
9293

9394
// In an ideal world, we'd use res.addTrailers()/response.trailer here. But despite being introduced with HTTP/1.1 in 1999, trailers are not supported by popular proxies (nginx, for example).
9495
// So we resort to this hand-written solution
@@ -100,6 +101,8 @@ export class HeadlessLogController {
100101

101102
res.write(`\n${HEADLESS_LOG_STREAM_STATUS_CODE}: 500`);
102103
res.end();
104+
} finally {
105+
aborted.resolve(true); // ensure that the promise gets resolved eventually!
103106
}
104107
})]);
105108
router.get("/", malformedRequestHandler);

components/server/src/workspace/headless-log-service.ts

Lines changed: 93 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,41 @@ import { WorkspaceInstance } from "@gitpod/gitpod-protocol";
1616
import * as grpc from '@grpc/grpc-js';
1717
import { Config } from "../config";
1818
import * as browserHeaders from "browser-headers";
19-
import { log } from '@gitpod/gitpod-protocol/lib/util/logging';
19+
import { log, LogContext } from '@gitpod/gitpod-protocol/lib/util/logging';
2020
import { TextDecoder } from "util";
2121
import { WebsocketTransport } from "../util/grpc-web-ws-transport";
2222
import { Deferred } from "@gitpod/gitpod-protocol/lib/util/deferred";
2323
import { ListLogsRequest, ListLogsResponse, LogDownloadURLRequest, LogDownloadURLResponse } from '@gitpod/content-service/lib/headless-log_pb';
2424
import { HEADLESS_LOG_DOWNLOAD_PATH_PREFIX } from "./headless-log-controller";
2525
import { CachingHeadlessLogServiceClientProvider } from "@gitpod/content-service/lib/sugar";
2626

27+
export type HeadlessLogEndpoint = {
28+
url: string,
29+
ownerToken?: string,
30+
headers?: { [key: string]: string },
31+
};
32+
export namespace HeadlessLogEndpoint {
33+
export function authHeaders(logCtx: LogContext, logEndpoint: HeadlessLogEndpoint): browserHeaders.BrowserHeaders | undefined {
34+
const headers = new browserHeaders.BrowserHeaders(logEndpoint.headers);
35+
if (logEndpoint.ownerToken) {
36+
headers.set("x-gitpod-owner-token", logEndpoint.ownerToken);
37+
}
38+
39+
if (Object.keys(headers.headersMap).length === 0) {
40+
log.warn(logCtx, "workspace logs: no ownerToken nor headers!");
41+
return undefined;
42+
}
43+
44+
return headers;
45+
}
46+
export function fromWithOwnerToken(wsi: WorkspaceInstance): HeadlessLogEndpoint {
47+
return {
48+
url: wsi.ideUrl,
49+
ownerToken: wsi.status.ownerToken,
50+
}
51+
}
52+
}
53+
2754
@injectable()
2855
export class HeadlessLogService {
2956
static readonly SUPERVISOR_API_PATH = "/_supervisor/v1";
@@ -32,21 +59,22 @@ export class HeadlessLogService {
3259
@inject(Config) protected readonly config: Config;
3360
@inject(CachingHeadlessLogServiceClientProvider) protected readonly headlessLogClientProvider: CachingHeadlessLogServiceClientProvider;
3461

35-
public async getHeadlessLogURLs(userId: string, wsi: WorkspaceInstance, ownerId: string, maxTimeoutSecs: number = 30): Promise<HeadlessLogUrls | undefined> {
62+
public async getHeadlessLogURLs(logCtx: LogContext, wsi: WorkspaceInstance, ownerId: string, maxTimeoutSecs: number = 30): Promise<HeadlessLogUrls | undefined> {
3663
if (isSupervisorAvailableSoon(wsi)) {
64+
const logEndpoint = HeadlessLogEndpoint.fromWithOwnerToken(wsi);
3765
const aborted = new Deferred<boolean>();
3866
setTimeout(() => aborted.resolve(true), maxTimeoutSecs * 1000);
39-
const streamIds = await this.retryWhileInstanceIsRunning(wsi, () => this.supervisorListHeadlessLogs(wsi), "list headless log streams", aborted);
67+
const streamIds = await this.retryOnError(() => this.supervisorListHeadlessLogs(logCtx, wsi.id, logEndpoint), "list headless log streams", this.continueWhileRunning(wsi.id), aborted);
4068
if (streamIds !== undefined) {
4169
return streamIds;
4270
}
4371
}
4472

4573
// we were unable to get a repsonse from supervisor - let's try content service next
46-
return await this.contentServiceListLogs(userId, wsi, ownerId);
74+
return await this.contentServiceListLogs(wsi, ownerId);
4775
}
4876

49-
protected async contentServiceListLogs(userId: string, wsi: WorkspaceInstance, ownerId: string): Promise<HeadlessLogUrls | undefined> {
77+
protected async contentServiceListLogs(wsi: WorkspaceInstance, ownerId: string): Promise<HeadlessLogUrls | undefined> {
5078
const req = new ListLogsRequest();
5179
req.setOwnerId(ownerId);
5280
req.setWorkspaceId(wsi.workspaceId);
@@ -74,19 +102,24 @@ export class HeadlessLogService {
74102
};
75103
}
76104

77-
protected async supervisorListHeadlessLogs(wsi: WorkspaceInstance): Promise<HeadlessLogUrls> {
78-
if (wsi.ideUrl === "") {
105+
protected async supervisorListHeadlessLogs(logCtx: LogContext, instanceId: string, logEndpoint: HeadlessLogEndpoint): Promise<HeadlessLogUrls | undefined> {
106+
const tasks = await this.supervisorListTasks(logCtx, logEndpoint);
107+
return this.renderTasksHeadlessLogUrls(logCtx, instanceId, tasks);
108+
}
109+
110+
protected async supervisorListTasks(logCtx: LogContext, logEndpoint: HeadlessLogEndpoint): Promise<TaskStatus[]> {
111+
if (logEndpoint.url === "") {
79112
// if ideUrl is not yet set we're too early and we deem the workspace not ready yet: retry later!
80-
throw new Error(`instance's ${wsi.id} has no ideUrl, yet`);
113+
throw new Error(`instance's ${logCtx.instanceId} has no ideUrl, yet`);
81114
}
82115

83116
const tasks = await new Promise<TaskStatus[]>((resolve, reject) => {
84-
const client = new StatusServiceClient(toSupervisorURL(wsi.ideUrl), {
117+
const client = new StatusServiceClient(toSupervisorURL(logEndpoint.url), {
85118
transport: WebsocketTransport(),
86119
});
87120

88121
const req = new TasksStatusRequest(); // Note: Don't set observe here at all, else it won't work!
89-
const stream = client.tasksStatus(req, authHeaders(wsi));
122+
const stream = client.tasksStatus(req, HeadlessLogEndpoint.authHeaders(logCtx, logEndpoint));
90123
stream.on('data', (resp: TasksStatusResponse) => {
91124
resolve(resp.getTasksList());
92125
stream.cancel();
@@ -99,7 +132,10 @@ export class HeadlessLogService {
99132
}
100133
});
101134
});
135+
return tasks;
136+
}
102137

138+
protected renderTasksHeadlessLogUrls(logCtx: LogContext, instanceId: string, tasks: TaskStatus[]): HeadlessLogUrls {
103139
// render URLs that point to server's /headless-logs/ endpoint which forwards calls to the running workspaces's supervisor
104140
const streams: { [id: string]: string } = {};
105141
for (const task of tasks) {
@@ -109,14 +145,14 @@ export class HeadlessLogService {
109145
// this might be the case when there is no terminal for this task, yet.
110146
// if we find any such case, we deem the workspace not ready yet, and try to reconnect later,
111147
// to be sure to get hold of all terminals created.
112-
throw new Error(`instance's ${wsi.id} task ${task.getId()} has no terminal yet`);
148+
throw new Error(`instance's ${instanceId} task ${task.getId()} has no terminal yet`);
113149
}
114150
if (task.getState() === TaskState.CLOSED) {
115151
// if a task has already been closed we can no longer access it's terminal, and have to skip it.
116152
continue;
117153
}
118154
streams[taskId] = this.config.hostUrl.with({
119-
pathname: `/headless-logs/${wsi.id}/${terminalId}`,
155+
pathname: `/headless-logs/${instanceId}/${terminalId}`,
120156
}).toString();
121157
}
122158
return {
@@ -158,12 +194,29 @@ export class HeadlessLogService {
158194

159195
/**
160196
* For now, simply stream the supervisor data
161-
*
162-
* @param workspace
197+
* @param logCtx
198+
* @param logEndpoint
199+
* @param instanceId
200+
* @param terminalID
201+
* @param sink
202+
* @param doContinue
203+
* @param aborted
204+
*/
205+
async streamWorkspaceLogWhileRunning(logCtx: LogContext, logEndpoint: HeadlessLogEndpoint, instanceId: string, terminalID: string, sink: (chunk: string) => Promise<void>, aborted: Deferred<boolean>): Promise<void> {
206+
await this.streamWorkspaceLog(logCtx, logEndpoint, terminalID, sink, this.continueWhileRunning(instanceId), aborted);
207+
}
208+
209+
/**
210+
* For now, simply stream the supervisor data
211+
* @param logCtx
212+
* @param logEndpoint
163213
* @param terminalID
214+
* @param sink
215+
* @param doContinue
216+
* @param aborted
164217
*/
165-
async streamWorkspaceLog(wsi: WorkspaceInstance, terminalID: string, sink: (chunk: string) => Promise<void>, aborted: Deferred<boolean>): Promise<void> {
166-
const client = new TerminalServiceClient(toSupervisorURL(wsi.ideUrl), {
218+
protected async streamWorkspaceLog(logCtx: LogContext, logEndpoint: HeadlessLogEndpoint, terminalID: string, sink: (chunk: string) => Promise<void>, doContinue: () => Promise<boolean>, aborted: Deferred<boolean>): Promise<void> {
219+
const client = new TerminalServiceClient(toSupervisorURL(logEndpoint.url), {
167220
transport: WebsocketTransport(), // necessary because HTTPTransport causes caching issues
168221
});
169222
const req = new ListenTerminalRequest();
@@ -172,10 +225,10 @@ export class HeadlessLogService {
172225
let receivedDataYet = false;
173226
let stream: ResponseStream<ListenTerminalResponse> | undefined = undefined;
174227
aborted.promise.then(() => stream?.cancel());
175-
const doStream = (cancelRetry: () => void) => new Promise<void>((resolve, reject) => {
228+
const doStream = (retry: (doRetry?: boolean) => void) => new Promise<void>((resolve, reject) => {
176229
// [gpl] this is the very reason we cannot redirect the frontend to the supervisor URL: currently we only have ownerTokens for authentication
177230
const decoder = new TextDecoder('utf-8')
178-
stream = client.listen(req, authHeaders(wsi));
231+
stream = client.listen(req, HeadlessLogEndpoint.authHeaders(logCtx, logEndpoint));
179232
stream.on('data', (resp: ListenTerminalResponse) => {
180233
receivedDataYet = true;
181234

@@ -184,7 +237,7 @@ export class HeadlessLogService {
184237
sink(data)
185238
.catch((err) => {
186239
stream?.cancel(); // If downstream reports an error: cancel connection to upstream
187-
log.debug({ instanceId: wsi.id }, "stream cancelled", err);
240+
log.debug(logCtx, "stream cancelled", err);
188241
});
189242
});
190243
stream.on('end', (status?: Status) => {
@@ -201,58 +254,56 @@ export class HeadlessLogService {
201254
return;
202255
}
203256

204-
cancelRetry();
257+
retry(false);
205258
reject(err);
206259
});
207260
});
208-
await this.retryWhileInstanceIsRunning(wsi, doStream, "stream workspace logs", aborted);
261+
await this.retryOnError(doStream, "stream workspace logs", doContinue, aborted);
209262
}
210263

211264
/**
212265
* Retries op while the passed WorkspaceInstance is still starting. Retries are stopped if either:
213-
* - `op` calls `cancel()` and an err is thrown, it is re-thrown by this method
266+
* - `op` calls `retry(false)` and an err is thrown, it is re-thrown by this method
214267
* - `aborted` resolves to `true`: `undefined` is returned
215-
* - if the instance enters the either STOPPING/STOPPED phases, we stop retrying, and return `undefined`
216-
* @param wsi
268+
* - `(await while()) === true`: `undefined` is returned
217269
* @param op
218270
* @param description
271+
* @param doContinue
219272
* @param aborted
220273
* @returns
221274
*/
222-
protected async retryWhileInstanceIsRunning<T>(wsi: WorkspaceInstance, op: (cancel: () => void) => Promise<T>, description: string, aborted: Deferred<boolean>): Promise<T | undefined> {
223-
let cancelled = false;
224-
const cancel = () => { cancelled = true; };
275+
protected async retryOnError<T>(op: (cancel: () => void) => Promise<T>, description: string, doContinue: () => Promise<boolean>, aborted: Deferred<boolean>): Promise<T | undefined> {
276+
let retry = true;
277+
const retryFunction = (doRetry: boolean = true) => { retry = doRetry };
225278

226-
let instance = wsi;
227-
while (!cancelled && !(aborted.isResolved && (await aborted.promise)) ) {
279+
while (retry && !(aborted.isResolved && (await aborted.promise)) ) {
228280
try {
229-
return await op(cancel);
281+
return await op(retryFunction);
230282
} catch (err) {
231-
if (cancelled) {
283+
if (!retry) {
232284
throw err;
233285
}
234286

235-
log.debug(`unable to ${description}`, err);
236-
const maybeInstance = await this.db.findInstanceById(instance.id);
237-
if (!maybeInstance) {
287+
const shouldContinue = await doContinue();
288+
if (!shouldContinue) {
238289
return undefined;
239290
}
240-
instance = maybeInstance;
241291

242-
if (!this.shouldRetry(instance)) {
243-
return undefined;
244-
}
245-
log.debug(`re-trying ${description}...`);
292+
log.debug(`unable to ${description}, retrying...`, err);
246293
await new Promise((resolve) => setTimeout(resolve, 2000));
247294
continue;
248295
}
249296
}
250297
return undefined;
251298
}
252299

253-
protected shouldRetry(wsi: WorkspaceInstance): boolean {
254-
return isSupervisorAvailableSoon(wsi);
255-
}
300+
protected continueWhileRunning(instanceId: string): () => Promise<boolean> {
301+
const db = this.db;
302+
return async () => {
303+
const maybeInstance = await db.findInstanceById(instanceId);
304+
return !!maybeInstance && isSupervisorAvailableSoon(maybeInstance);
305+
}
306+
};
256307
}
257308

258309
function isSupervisorAvailableSoon(wsi: WorkspaceInstance): boolean {
@@ -273,14 +324,3 @@ function toSupervisorURL(ideUrl: string): string {
273324
u.pathname = HeadlessLogService.SUPERVISOR_API_PATH;
274325
return u.toString();
275326
}
276-
277-
function authHeaders(wsi: WorkspaceInstance): browserHeaders.BrowserHeaders | undefined {
278-
const ownerToken = wsi.status.ownerToken;
279-
if (!ownerToken) {
280-
log.warn({ instanceId: wsi.id }, "workspace logs: owner token not found");
281-
return undefined;
282-
}
283-
const headers = new browserHeaders.BrowserHeaders();
284-
headers.set("x-gitpod-owner-token", ownerToken);
285-
return headers;
286-
}

0 commit comments

Comments
 (0)