Skip to content

[WIP][fga] WorkspaceService: watchWorkspaceImageBuildLogsm getHeadlessLog, WorkspaceService.sendHeartBeat #18538

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Aug 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions components/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,7 @@ import { HostContextProvider } from "./auth/host-context-provider";
import { CodeSyncService } from "./code-sync/code-sync-service";
import { increaseHttpRequestCounter, observeHttpRequestDuration, setGitpodVersion } from "./prometheus-metrics";
import { OAuthController } from "./oauth-server/oauth-controller";
import {
HeadlessLogController,
HEADLESS_LOGS_PATH_PREFIX,
HEADLESS_LOG_DOWNLOAD_PATH_PREFIX,
} from "./workspace/headless-log-controller";
import { HeadlessLogController } from "./workspace/headless-log-controller";
import { NewsletterSubscriptionController } from "./user/newsletter-subscription-controller";
import { Config } from "./config";
import { DebugApp } from "@gitpod/gitpod-protocol/lib/util/debug-app";
Expand All @@ -51,6 +47,7 @@ import { BitbucketServerApp } from "./prebuilds/bitbucket-server-app";
import { GitHubEnterpriseApp } from "./prebuilds/github-enterprise-app";
import { JobRunner } from "./jobs/runner";
import { RedisSubscriber } from "./messaging/redis-subscriber";
import { HEADLESS_LOGS_PATH_PREFIX, HEADLESS_LOG_DOWNLOAD_PATH_PREFIX } from "./workspace/headless-log-service";

@injectable()
export class Server {
Expand Down
137 changes: 10 additions & 127 deletions components/server/src/workspace/gitpod-server-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ import {
Workspace,
WorkspaceContext,
WorkspaceCreationResult,
WorkspaceImageBuild,
WorkspaceInfo,
WorkspaceInstance,
WorkspaceInstancePort,
Expand Down Expand Up @@ -97,7 +96,6 @@ import { WorkspaceManagerClientProvider } from "@gitpod/ws-manager/lib/client-pr
import {
AdmissionLevel,
ControlAdmissionRequest,
MarkActiveRequest,
StopWorkspacePolicy,
TakeSnapshotRequest,
} from "@gitpod/ws-manager/lib/core_pb";
Expand All @@ -120,7 +118,6 @@ import { ContextParser } from "./context-parser-service";
import { GitTokenScopeGuesser } from "./git-token-scope-guesser";
import { isClusterMaintenanceError } from "./workspace-starter";
import { HeadlessLogUrls } from "@gitpod/gitpod-protocol/lib/headless-workspace-log";
import { HeadlessLogService, HeadlessLogEndpoint } from "./headless-log-service";
import { ConfigProvider, InvalidGitpodYMLError } from "./config-provider";
import { ProjectsService } from "../projects/projects-service";
import { IDEOptions } from "@gitpod/gitpod-protocol/lib/ide-protocol";
Expand All @@ -140,7 +137,6 @@ import {
UserFeatureSettings,
WorkspaceTimeoutSetting,
} from "@gitpod/gitpod-protocol/lib/protocol";
import { Deferred } from "@gitpod/gitpod-protocol/lib/util/deferred";
import { ListUsageRequest, ListUsageResponse } from "@gitpod/gitpod-protocol/lib/usage";
import { VerificationService } from "../auth/verification-service";
import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode";
Expand Down Expand Up @@ -176,7 +172,7 @@ import { RedisSubscriber } from "../messaging/redis-subscriber";
import { UsageService } from "../orgs/usage-service";
import { UserService } from "../user/user-service";
import { SSHKeyService } from "../user/sshkey-service";
import { StartWorkspaceOptions, WorkspaceService, mapGrpcError } from "./workspace-service";
import { StartWorkspaceOptions, WorkspaceService } from "./workspace-service";
import { GitpodTokenService } from "../user/gitpod-token-service";
import { EnvVarService } from "../user/env-var-service";
import { ScmService } from "../projects/scm-service";
Expand Down Expand Up @@ -236,8 +232,6 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {

@inject(GitTokenScopeGuesser) private readonly gitTokenScopeGuesser: GitTokenScopeGuesser,

@inject(HeadlessLogService) private readonly headlessLogService: HeadlessLogService,

@inject(ProjectsService) private readonly projectsService: ProjectsService,
@inject(ScmService) private readonly scmService: ScmService,

Expand Down Expand Up @@ -1105,36 +1099,9 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {

const user = await this.checkAndBlockUser("sendHeartBeat", undefined, { instanceId });

try {
const wsi = await this.workspaceDb.trace(ctx).findInstanceById(instanceId);
if (!wsi) {
throw new ApplicationError(ErrorCodes.NOT_FOUND, "workspace does not exist");
}

const ws = await this.workspaceDb.trace(ctx).findById(wsi.workspaceId);
if (!ws) {
throw new ApplicationError(ErrorCodes.NOT_FOUND, "workspace does not exist");
}
await this.guardAccess({ kind: "workspaceInstance", subject: wsi, workspace: ws }, "update");

const wasClosed = !!(options && options.wasClosed);
await this.workspaceDb.trace(ctx).updateLastHeartbeat(instanceId, user.id, new Date(), wasClosed);

const req = new MarkActiveRequest();
req.setId(instanceId);
req.setClosed(wasClosed);

const client = await this.workspaceManagerClientProvider.get(wsi.region);
await client.markActive(ctx, req);
} catch (e) {
if (e.message && typeof e.message === "string" && (e.message as String).endsWith("does not exist")) {
// This is an old tab with open workspace: drop silently
return;
} else {
e = mapGrpcError(e);
throw e;
}
}
await this.workspaceService.sendHeartBeat(user.id, options, (instance, workspace) =>
this.guardAccess({ kind: "workspaceInstance", subject: instance, workspace }, "update"),
);
}

async getWorkspaceOwner(ctx: TraceContext, workspaceId: string): Promise<UserInfo | undefined> {
Expand Down Expand Up @@ -1872,6 +1839,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
return;
}

// TODO(gpl) Remove entirely after FGA rollout
const logCtx: LogContext = { userId: user.id, workspaceId };
// eslint-disable-next-line prefer-const
let { instance, workspace } = await this.internGetCurrentWorkspaceInstance(ctx, user, workspaceId);
Expand All @@ -1883,103 +1851,18 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
const teamMembers = await this.organizationService.listMembers(user.id, workspace.organizationId);
await this.guardAccess({ kind: "workspaceLog", subject: workspace, teamMembers }, "get");

// wait for up to 20s for imageBuildLogInfo to appear due to:
// - db-sync round-trip times
// - but also: wait until the image build actually started (image pull!), and log info is available!
for (let i = 0; i < 10; i++) {
if (instance.imageBuildInfo?.log) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 2000));

const wsi = await this.workspaceDb.trace(ctx).findInstanceById(instance.id);
if (!wsi || !["preparing", "building"].includes(wsi.status.phase)) {
log.debug(logCtx, `imagebuild logs: instance is not/no longer in 'building' state`, {
phase: wsi?.status.phase,
});
return;
}
instance = wsi as WorkspaceInstance; // help the compiler a bit
}

const logInfo = instance.imageBuildInfo?.log;
if (!logInfo) {
log.error(logCtx, "cannot watch imagebuild logs for workspaceId: no image build info available");
throw new ApplicationError(
ErrorCodes.HEADLESS_LOG_NOT_YET_AVAILABLE,
"cannot watch imagebuild logs for workspaceId",
);
}

const aborted = new Deferred<boolean>();
try {
const logEndpoint: HeadlessLogEndpoint = {
url: logInfo.url,
headers: logInfo.headers,
};
let lineCount = 0;
await this.headlessLogService.streamImageBuildLog(
logCtx,
logEndpoint,
async (chunk) => {
if (aborted.isResolved) {
return;
}

try {
chunk = chunk.replace("\n", WorkspaceImageBuild.LogLine.DELIMITER);
lineCount += chunk.split(WorkspaceImageBuild.LogLine.DELIMITER_REGEX).length;

client.onWorkspaceImageBuildLogs(undefined as any, {
text: chunk,
isDiff: true,
upToLine: lineCount,
});
} catch (err) {
log.error("error while streaming imagebuild logs", err);
aborted.resolve(true);
}
},
aborted,
);
} catch (err) {
// This error is most likely a temporary one (too early). We defer to the client whether they want to keep on trying or not.
log.debug(logCtx, "cannot watch imagebuild logs for workspaceId", err);
throw new ApplicationError(
ErrorCodes.HEADLESS_LOG_NOT_YET_AVAILABLE,
"cannot watch imagebuild logs for workspaceId",
);
} finally {
aborted.resolve(false);
}
await this.workspaceService.watchWorkspaceImageBuildLogs(user.id, workspaceId, client);
}

async getHeadlessLog(ctx: TraceContext, instanceId: string): Promise<HeadlessLogUrls> {
traceAPIParams(ctx, { instanceId });

const user = await this.checkAndBlockUser("getHeadlessLog", { instanceId });
const logCtx: LogContext = { instanceId };

const ws = await this.workspaceDb.trace(ctx).findByInstanceId(instanceId);
if (!ws) {
throw new ApplicationError(ErrorCodes.NOT_FOUND, `Workspace ${instanceId} not found`);
}

const wsiPromise = this.workspaceDb.trace(ctx).findInstanceById(instanceId);
const teamMembers = await this.organizationService.listMembers(user.id, ws.organizationId);

await this.guardAccess({ kind: "workspaceLog", subject: ws, teamMembers }, "get");

const wsi = await wsiPromise;
if (!wsi) {
throw new ApplicationError(ErrorCodes.NOT_FOUND, `Workspace instance for ${instanceId} not found`);
}

const urls = await this.headlessLogService.getHeadlessLogURLs(logCtx, wsi, ws.ownerId);
if (!urls || (typeof urls.streams === "object" && Object.keys(urls.streams).length === 0)) {
throw new ApplicationError(ErrorCodes.NOT_FOUND, `Headless logs for ${instanceId} not found`);
}
return urls;
return this.workspaceService.getHeadlessLog(user.id, instanceId, async (workspace) => {
const teamMembers = await this.organizationService.listMembers(user.id, workspace.organizationId);
await this.guardAccess({ kind: "workspaceLog", subject: workspace, teamMembers }, "get");
});
}

private async internGetCurrentWorkspaceInstance(
Expand Down
10 changes: 6 additions & 4 deletions components/server/src/workspace/headless-log-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,12 @@ import {
import { DBWithTracing, TracedWorkspaceDB } from "@gitpod/gitpod-db/lib/traced-db";
import { WorkspaceDB } from "@gitpod/gitpod-db/lib/workspace-db";
import { TeamDB } from "@gitpod/gitpod-db/lib/team-db";
import { HeadlessLogService, HeadlessLogEndpoint } from "./headless-log-service";
import {
HeadlessLogService,
HeadlessLogEndpoint,
HEADLESS_LOGS_PATH_PREFIX,
HEADLESS_LOG_DOWNLOAD_PATH_PREFIX,
} from "./headless-log-service";
import * as opentracing from "opentracing";
import { asyncHandler } from "../express-util";
import { Deferred } from "@gitpod/gitpod-protocol/lib/util/deferred";
Expand All @@ -38,9 +43,6 @@ import { HostContextProvider } from "../auth/host-context-provider";
import { TraceContext } from "@gitpod/gitpod-protocol/lib/util/tracing";
import { ApplicationError } from "@gitpod/gitpod-protocol/lib/messaging/error";

export const HEADLESS_LOGS_PATH_PREFIX = "/headless-logs";
export const HEADLESS_LOG_DOWNLOAD_PATH_PREFIX = "/headless-log-download";

@injectable()
export class HeadlessLogController {
@inject(TracedWorkspaceDB) protected readonly workspaceDb: DBWithTracing<WorkspaceDB>;
Expand Down
4 changes: 3 additions & 1 deletion components/server/src/workspace/headless-log-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ import {
LogDownloadURLRequest,
LogDownloadURLResponse,
} from "@gitpod/content-service/lib/headless-log_pb";
import { HEADLESS_LOG_DOWNLOAD_PATH_PREFIX } from "./headless-log-controller";
import { CachingHeadlessLogServiceClientProvider } from "../util/content-service-sugar";

export const HEADLESS_LOGS_PATH_PREFIX = "/headless-logs";
export const HEADLESS_LOG_DOWNLOAD_PATH_PREFIX = "/headless-log-download";

export type HeadlessLogEndpoint = {
url: string;
ownerToken?: string;
Expand Down
37 changes: 37 additions & 0 deletions components/server/src/workspace/workspace-service.spec.db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Project,
User,
WorkspaceConfig,
WorkspaceImageBuild,
WorkspaceInstancePort,
} from "@gitpod/gitpod-protocol";
import { Experiments } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server";
Expand Down Expand Up @@ -341,6 +342,42 @@ describe("WorkspaceService", async () => {
"should fail on non-running workspace",
);
});

it("should getHeadlessLog", async () => {
const svc = container.get(WorkspaceService);
await createTestWorkspace(svc, org, owner, project);

await expectError(
ErrorCodes.NOT_FOUND,
svc.getHeadlessLog(owner.id, "non-existing-instanceId"),
"should fail on non-running workspace",
);
});

it("should watchWorkspaceImageBuildLogs", async () => {
const svc = container.get(WorkspaceService);
const ws = await createTestWorkspace(svc, org, owner, project);

await svc.watchWorkspaceImageBuildLogs(owner.id, ws.id, {
onWorkspaceImageBuildLogs: (
info: WorkspaceImageBuild.StateInfo,
content: WorkspaceImageBuild.LogContent | undefined,
) => {},
}); // returns without error in case of non-running workspace
});

it("should sendHeartBeat", async () => {
const svc = container.get(WorkspaceService);
await createTestWorkspace(svc, org, owner, project);

await expectError(
ErrorCodes.NOT_FOUND,
svc.sendHeartBeat(owner.id, {
instanceId: "non-existing-instanceId",
}),
"should fail on non-running workspace",
);
});
});

async function createTestWorkspace(svc: WorkspaceService, org: Organization, owner: User, project: Project) {
Expand Down
Loading