Skip to content

Commit 3fd7b84

Browse files
committed
[server] Stream imagebuild logs from headless workspace directly
1 parent ad465b5 commit 3fd7b84

File tree

4 files changed

+118
-12
lines changed

4 files changed

+118
-12
lines changed

components/dashboard/src/start/StartWorkspace.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -418,11 +418,29 @@ function ImageBuildView(props: ImageBuildViewProps) {
418418
const logsEmitter = new EventEmitter();
419419

420420
useEffect(() => {
421-
const watchBuild = () => getGitpodService().server.watchWorkspaceImageBuildLogs(props.workspaceId);
421+
let registered = false;
422+
const watchBuild = () => {
423+
if (registered) {
424+
return;
425+
}
426+
427+
getGitpodService().server.watchWorkspaceImageBuildLogs(props.workspaceId)
428+
.then(() => registered = true)
429+
.catch(err => {
430+
431+
if (err?.code === ErrorCodes.HEADLESS_LOG_NOT_YET_AVAILABLE) {
432+
// wait, and then retry
433+
setTimeout(watchBuild, 5000);
434+
}
435+
})
436+
}
422437
watchBuild();
423438

424439
const toDispose = getGitpodService().registerClient({
425-
notifyDidOpenConnection: () => watchBuild(),
440+
notifyDidOpenConnection: () => {
441+
registered = false; // new connection, we're not registered anymore
442+
watchBuild();
443+
},
426444
onWorkspaceImageBuildLogs: (info: WorkspaceImageBuild.StateInfo, content?: WorkspaceImageBuild.LogContent) => {
427445
if (!content) {
428446
return;

components/gitpod-protocol/src/messaging/error.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,7 @@ export namespace ErrorCodes {
7878

7979
// 630 Snapshot Error
8080
export const SNAPSHOT_ERROR = 630;
81+
82+
// 640 Headless logs are not available (yet)
83+
export const HEADLESS_LOG_NOT_YET_AVAILABLE = 640;
8184
}

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

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ import { WorkspaceDeletionService } from './workspace-deletion-service';
4646
import { WorkspaceFactory } from './workspace-factory';
4747
import { WorkspaceStarter } from './workspace-starter';
4848
import { HeadlessLogUrls } from "@gitpod/gitpod-protocol/lib/headless-workspace-log";
49-
import { HeadlessLogService } from "./headless-log-service";
49+
import { HeadlessLogService, HeadlessLogEndpoint } from "./headless-log-service";
5050
import { InvalidGitpodYMLError } from "./config-provider";
5151
import { ProjectsService } from "../projects/projects-service";
5252
import { LocalMessageBroker } from "../messaging/local-message-broker";
@@ -58,6 +58,7 @@ import { ClientMetadata } from '../websocket/websocket-connection-manager';
5858
import { ConfigurationService } from '../config/configuration-service';
5959
import { ProjectEnvVar } from '@gitpod/gitpod-protocol/src/protocol';
6060
import { InstallationAdminSettings } from '@gitpod/gitpod-protocol';
61+
import { Deferred } from '@gitpod/gitpod-protocol/lib/util/deferred';
6162

6263
// shortcut
6364
export const traceWI = (ctx: TraceContext, wi: Omit<LogContext, "userId">) => TraceContext.setOWI(ctx, wi); // userId is already taken care of in WebsocketConnectionManager
@@ -1112,24 +1113,90 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
11121113
traceWI(ctx, { workspaceId });
11131114

11141115
const user = this.checkAndBlockUser("watchWorkspaceImageBuildLogs", undefined, { workspaceId });
1115-
const logCtx: LogContext = { userId: user.id, workspaceId };
1116-
1117-
const { instance, workspace } = await this.internGetCurrentWorkspaceInstance(ctx, workspaceId);
1118-
if (!this.client) {
1116+
const client = this.client;
1117+
if (!client) {
11191118
return;
11201119
}
1120+
1121+
const logCtx: LogContext = { userId: user.id, workspaceId };
1122+
let { instance, workspace } = await this.internGetCurrentWorkspaceInstance(ctx, workspaceId);
11211123
if (!instance) {
11221124
log.debug(logCtx, `No running instance for workspaceId.`);
11231125
return;
11241126
}
11251127
traceWI(ctx, { instanceId: instance.id });
1126-
if (!workspace.imageNameResolved) {
1127-
log.debug(logCtx, `No imageNameResolved set for workspaceId, cannot watch logs.`);
1128-
return;
1129-
}
11301128
const teamMembers = await this.getTeamMembersByProject(workspace.projectId);
11311129
await this.guardAccess({ kind: "workspaceInstance", subject: instance, workspace, teamMembers }, "get");
1132-
if (!this.client) {
1130+
1131+
// wait for up to 20s for imageBuildLogInfo to appear due to:
1132+
// - db-sync round-trip times
1133+
// - but also: wait until the image build actually started (image pull!), and log info is available!
1134+
for (let i = 0; i < 10; i++) {
1135+
if (!instance || instance.status.phase !== 'preparing') {
1136+
log.debug(logCtx, `imagebuild logs: instance is not/no longer in 'preparing' state`, { phase: instance?.status.phase });
1137+
return;
1138+
}
1139+
if (workspace.imageBuildInfo?.log) {
1140+
break;
1141+
}
1142+
await new Promise(resolve => setTimeout(resolve, 2000));
1143+
1144+
({ instance, workspace } = await this.internGetCurrentWorkspaceInstance(ctx, workspaceId));
1145+
if (!workspace) {
1146+
log.warn(logCtx, `no workspace for workspaceId.`);
1147+
return;
1148+
}
1149+
}
1150+
1151+
if (!workspace.imageBuildInfo?.log) {
1152+
// during roll-out this is our fall-back case.
1153+
// Afterwards we might want to do some spinning-lock and re-check for a certain period (30s?) to give db-sync
1154+
// a change to move the imageBuildLogInfo across the globe.
1155+
1156+
log.warn(logCtx, "imageBuild logs: fallback!");
1157+
ctx.span?.setTag("workspace.imageBuild.logs.fallback", true);
1158+
await this.deprecatedDoWatchWorkspaceImageBuildLogs(ctx, logCtx, workspace);
1159+
return;
1160+
}
1161+
const logInfo = workspace.imageBuildInfo.log;
1162+
1163+
const aborted = new Deferred<boolean>();
1164+
try {
1165+
const logEndpoint: HeadlessLogEndpoint = {
1166+
url: logInfo.url,
1167+
headers: logInfo.headers,
1168+
};
1169+
let lineCount = 0;
1170+
await this.headlessLogService.streamImageBuildLog(logCtx, logEndpoint, async (chunk) => {
1171+
if (aborted.isResolved) {
1172+
return;
1173+
}
1174+
1175+
try {
1176+
chunk = chunk.replace("\n", WorkspaceImageBuild.LogLine.DELIMITER);
1177+
lineCount += chunk.split(WorkspaceImageBuild.LogLine.DELIMITER_REGEX).length;
1178+
1179+
client.onWorkspaceImageBuildLogs(undefined as any, {
1180+
text: chunk,
1181+
isDiff: true,
1182+
upToLine: lineCount
1183+
});
1184+
} catch (err) {
1185+
log.error("error while streaming imagebuild logs", err);
1186+
aborted.resolve(true);
1187+
}
1188+
}, aborted);
1189+
} catch (err) {
1190+
log.error(logCtx, "cannot watch imagebuild logs for workspaceId", err);
1191+
throw new ResponseError(ErrorCodes.HEADLESS_LOG_NOT_YET_AVAILABLE, "cannot watch imagebuild logs for workspaceId");
1192+
} finally {
1193+
aborted.resolve(false);
1194+
}
1195+
}
1196+
1197+
protected async deprecatedDoWatchWorkspaceImageBuildLogs(ctx: TraceContext, logCtx: LogContext, workspace: Workspace) {
1198+
if (!workspace.imageNameResolved) {
1199+
log.debug(logCtx, `No imageNameResolved set for workspaceId, cannot watch logs.`);
11331200
return;
11341201
}
11351202

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,24 @@ export class HeadlessLogService {
261261
await this.retryOnError(doStream, "stream workspace logs", doContinue, aborted);
262262
}
263263

264+
/**
265+
* Streaming imagebuild logs is different to other headless workspaces (prebuilds) because we do not store them as "workspace" (incl. status, etc.), but have a special field "workspace.imageBuildInfo".
266+
* @param logCtx
267+
* @param logEndpoint
268+
* @param sink
269+
* @param aborted
270+
*/
271+
async streamImageBuildLog(logCtx: LogContext, logEndpoint: HeadlessLogEndpoint, sink: (chunk: string) => Promise<void>, aborted: Deferred<boolean>): Promise<void> {
272+
const tasks = await this.supervisorListTasks(logCtx, logEndpoint);
273+
if (tasks.length === 0) {
274+
throw new Error(`imagebuild logs: not tasks found for endpoint ${logEndpoint.url}!`);
275+
}
276+
277+
// we're just looking at the first stream; image builds just have one stream atm
278+
const task = tasks[0];
279+
await this.streamWorkspaceLog(logCtx, logEndpoint, task.getTerminal(), sink, () => Promise.resolve(true), aborted);
280+
}
281+
264282
/**
265283
* Retries op while the passed WorkspaceInstance is still starting. Retries are stopped if either:
266284
* - `op` calls `retry(false)` and an err is thrown, it is re-thrown by this method

0 commit comments

Comments
 (0)