Skip to content

[fga] WorkspaceService: workspace timeout + classes + git status (misc I) #18535

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 17, 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
90 changes: 10 additions & 80 deletions components/server/src/workspace/gitpod-server-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
TracedWorkspaceDB,
EmailDomainFilterDB,
TeamDB,
RedisPublisher,
DBGitpodToken,
} from "@gitpod/gitpod-db/lib";
import { BlockedRepositoryDB } from "@gitpod/gitpod-db/lib/blocked-repository-db";
Expand Down Expand Up @@ -67,7 +66,6 @@ import {
UserSSHPublicKeyValue,
PrebuildEvent,
RoleOrPermission,
WORKSPACE_TIMEOUT_DEFAULT_SHORT,
WorkspaceInstanceRepoStatus,
} from "@gitpod/gitpod-protocol";
import { BlockedRepository } from "@gitpod/gitpod-protocol/lib/blocked-repositories-protocol";
Expand Down Expand Up @@ -99,9 +97,7 @@ import { WorkspaceManagerClientProvider } from "@gitpod/ws-manager/lib/client-pr
import {
AdmissionLevel,
ControlAdmissionRequest,
DescribeWorkspaceRequest,
MarkActiveRequest,
SetTimeoutRequest,
StopWorkspacePolicy,
TakeSnapshotRequest,
} from "@gitpod/ws-manager/lib/core_pb";
Expand Down Expand Up @@ -174,7 +170,6 @@ import {
} from "@gitpod/usage-api/lib/usage/v1/billing.pb";
import { ClientError } from "nice-grpc-common";
import { BillingModes } from "../billing/billing-mode";
import { goDurationToHumanReadable } from "@gitpod/gitpod-protocol/lib/util/timeutil";
import { Authorizer, SYSTEM_USER } from "../authorization/authorizer";
import { OrganizationService } from "../orgs/organization-service";
import { RedisSubscriber } from "../messaging/redis-subscriber";
Expand Down Expand Up @@ -260,8 +255,6 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
@inject(EmailDomainFilterDB) private emailDomainFilterdb: EmailDomainFilterDB,

@inject(RedisSubscriber) private readonly subscriber: RedisSubscriber,
@inject(RedisPublisher) private readonly publisher: RedisPublisher,
@inject(TracedWorkspaceDB) private readonly workspaceDB: DBWithTracing<WorkspaceDB>,
) {}

/** Id the uniquely identifies this server instance */
Expand Down Expand Up @@ -1767,36 +1760,9 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {

const user = await this.checkUser("setWorkspaceTimeout");

let validatedDuration;
try {
validatedDuration = WorkspaceTimeoutDuration.validate(duration);
} catch (err) {
throw new ApplicationError(ErrorCodes.INVALID_VALUE, "Invalid duration : " + err.message);
}

const workspace = await this.workspaceService.getWorkspace(user.id, workspaceId);
if (!(await this.entitlementService.maySetTimeout(user.id, workspace.organizationId))) {
throw new ApplicationError(ErrorCodes.PLAN_PROFESSIONAL_REQUIRED, "Plan upgrade is required");
}

const runningInstances = await this.workspaceDb.trace(ctx).findRegularRunningInstances(user.id);
const runningInstance = runningInstances.find((i) => i.workspaceId === workspaceId);
if (!runningInstance) {
throw new ApplicationError(ErrorCodes.NOT_FOUND, "Can only set keep-alive for running workspaces");
}
await this.guardAccess({ kind: "workspaceInstance", subject: runningInstance, workspace: workspace }, "update");

const client = await this.workspaceManagerClientProvider.get(runningInstance.region);

const req = new SetTimeoutRequest();
req.setId(runningInstance.id);
req.setDuration(validatedDuration);
await client.setTimeout(ctx, req);

return {
resetTimeoutOnWorkspaces: [workspace.id],
humanReadableDuration: goDurationToHumanReadable(validatedDuration),
};
return this.workspaceService.setWorkspaceTimeout(user.id, workspaceId, duration, (instance, workspace) =>
this.guardAccess({ kind: "workspaceInstance", subject: instance, workspace: workspace }, "update"),
);
}

public async getWorkspaceTimeout(ctx: TraceContext, workspaceId: string): Promise<GetWorkspaceTimeoutResult> {
Expand All @@ -1805,25 +1771,9 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {

const user = await this.checkUser("getWorkspaceTimeout");

const workspace = await this.workspaceService.getWorkspace(user.id, workspaceId);
const canChange = await this.entitlementService.maySetTimeout(user.id, workspace.organizationId);

const runningInstance = await this.workspaceDb.trace(ctx).findRunningInstance(workspaceId);
if (!runningInstance) {
log.warn({ userId: user.id, workspaceId }, "Can only get keep-alive for running workspaces");
const duration = WORKSPACE_TIMEOUT_DEFAULT_SHORT;
return { duration, canChange, humanReadableDuration: goDurationToHumanReadable(duration) };
}
await this.guardAccess({ kind: "workspaceInstance", subject: runningInstance, workspace: workspace }, "get");

const req = new DescribeWorkspaceRequest();
req.setId(runningInstance.id);

const client = await this.workspaceManagerClientProvider.get(runningInstance.region);
const desc = await client.describeWorkspace(ctx, req);
const duration = desc.getStatus()!.getSpec()!.getTimeout();

return { duration, canChange, humanReadableDuration: goDurationToHumanReadable(duration) };
return this.workspaceService.getWorkspaceTimeout(user.id, workspaceId, (instance, workspace) =>
this.guardAccess({ kind: "workspaceInstance", subject: instance, workspace }, "get"),
);
}

public async getOpenPorts(ctx: TraceContext, workspaceId: string): Promise<WorkspaceInstancePort[]> {
Expand Down Expand Up @@ -1854,23 +1804,11 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
const user = await this.checkAndBlockUser("updateGitStatus");

const workspace = await this.workspaceService.getWorkspace(user.id, workspaceId);
let instance = await this.workspaceDb.trace(ctx).findCurrentInstance(workspaceId);
if (!instance) {
throw new ApplicationError(ErrorCodes.NOT_FOUND, `workspace ${workspaceId} has no instance`);
}
const instance = await this.workspaceService.getCurrentInstance(user.id, workspaceId);
traceWI(ctx, { instanceId: instance.id });
await this.guardAccess({ kind: "workspaceInstance", subject: instance, workspace }, "update");

if (WorkspaceInstanceRepoStatus.equals(instance.gitStatus, gitStatus)) {
return;
}

instance = await this.workspaceDB.trace(ctx).updateInstancePartial(instance.id, { gitStatus });
await this.publisher.publishInstanceUpdate({
instanceID: instance.id,
ownerID: workspace.ownerId,
workspaceID: workspace.id,
});
await this.workspaceService.updateGitStatus(user.id, workspaceId, gitStatus);
}

public async openPort(
Expand Down Expand Up @@ -3385,16 +3323,8 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
}

async getSupportedWorkspaceClasses(ctx: TraceContext): Promise<SupportedWorkspaceClass[]> {
await this.checkAndBlockUser("getSupportedWorkspaceClasses");
const classes = this.config.workspaceClasses.map((c) => ({
id: c.id,
category: c.category,
displayName: c.displayName,
description: c.description,
powerups: c.powerups,
isDefault: c.isDefault,
}));
return classes;
const user = await this.checkAndBlockUser("getSupportedWorkspaceClasses");
return this.workspaceService.getSupportedWorkspaceClasses(user.id);
}

//#region gitpod.io concerns
Expand Down
39 changes: 39 additions & 0 deletions components/server/src/workspace/workspace-service.spec.db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,45 @@ describe("WorkspaceService", async () => {
"should fail on non-running workspace",
);
});

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

await expectError(
ErrorCodes.NOT_FOUND,
svc.updateGitStatus(owner.id, ws.id, {
branch: "main",
uncommitedFiles: ["new-unit.ts"],
latestCommit: "asdf",
totalUncommitedFiles: 1,
totalUntrackedFiles: 1,
unpushedCommits: [],
untrackedFiles: ["new-unit.ts"],
totalUnpushedCommits: 0,
}),
"should fail on non-running workspace",
);
});

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

const actual = await svc.getWorkspaceTimeout(owner.id, ws.id);
expect(actual, "even stopped workspace get a default response").to.not.be.undefined;
});

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

await expectError(
ErrorCodes.NOT_FOUND,
svc.setWorkspaceTimeout(owner.id, ws.id, "180m"),
"should fail on non-running workspace",
);
});
});

async function createTestWorkspace(svc: WorkspaceService, org: Organization, owner: User, project: Project) {
Expand Down
119 changes: 118 additions & 1 deletion components/server/src/workspace/workspace-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,24 @@

import { inject, injectable } from "inversify";
import * as grpc from "@grpc/grpc-js";
import { WorkspaceDB } from "@gitpod/gitpod-db/lib";
import { RedisPublisher, WorkspaceDB } from "@gitpod/gitpod-db/lib";
import {
GetWorkspaceTimeoutResult,
GitpodServer,
PortProtocol,
PortVisibility,
Project,
SetWorkspaceTimeoutResult,
StartWorkspaceResult,
User,
WORKSPACE_TIMEOUT_DEFAULT_SHORT,
Workspace,
WorkspaceContext,
WorkspaceInstance,
WorkspaceInstancePort,
WorkspaceInstanceRepoStatus,
WorkspaceSoftDeletion,
WorkspaceTimeoutDuration,
} from "@gitpod/gitpod-protocol";
import { ErrorCodes, ApplicationError } from "@gitpod/gitpod-protocol/lib/messaging/error";
import { Authorizer } from "../authorization/authorizer";
Expand All @@ -31,6 +36,7 @@ import {
PortProtocol as ProtoPortProtocol,
PortSpec,
ControlPortRequest,
SetTimeoutRequest,
} from "@gitpod/ws-manager/lib";
import { WorkspaceStarter } from "./workspace-starter";
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
Expand All @@ -42,6 +48,9 @@ import { RegionService } from "./region-service";
import { ProjectsService } from "../projects/projects-service";
import { EnvVarService } from "../user/env-var-service";
import { WorkspaceManagerClientProvider } from "@gitpod/ws-manager/lib/client-provider";
import { SupportedWorkspaceClass } from "@gitpod/gitpod-protocol/lib/workspace-class";
import { Config } from "../config";
import { goDurationToHumanReadable } from "@gitpod/gitpod-protocol/lib/util/timeutil";

export interface StartWorkspaceOptions extends GitpodServer.StartWorkspaceOptions {
/**
Expand All @@ -53,13 +62,15 @@ export interface StartWorkspaceOptions extends GitpodServer.StartWorkspaceOption
@injectable()
export class WorkspaceService {
constructor(
@inject(Config) private readonly config: Config,
@inject(WorkspaceFactory) private readonly factory: WorkspaceFactory,
@inject(WorkspaceStarter) private readonly workspaceStarter: WorkspaceStarter,
@inject(WorkspaceManagerClientProvider) private readonly clientProvider: WorkspaceManagerClientProvider,
@inject(WorkspaceDB) private readonly db: WorkspaceDB,
@inject(EntitlementService) private readonly entitlementService: EntitlementService,
@inject(EnvVarService) private readonly envVarService: EnvVarService,
@inject(ProjectsService) private readonly projectsService: ProjectsService,
@inject(RedisPublisher) private readonly publisher: RedisPublisher,
@inject(Authorizer) private readonly auth: Authorizer,
) {}

Expand Down Expand Up @@ -539,6 +550,112 @@ export class WorkspaceService {
await this.auth.checkPermissionOnWorkspace(userId, "access", workspaceId);
await this.db.updatePartial(workspaceId, { description });
}

public async updateGitStatus(
userId: string,
workspaceId: string,
gitStatus: Required<WorkspaceInstanceRepoStatus> | undefined,
) {
await this.auth.checkPermissionOnWorkspace(userId, "access", workspaceId);

let instance = await this.getCurrentInstance(userId, workspaceId);
if (WorkspaceInstanceRepoStatus.equals(instance.gitStatus, gitStatus)) {
return;
}

const workspace = await this.getWorkspace(userId, workspaceId);
instance = await this.db.updateInstancePartial(instance.id, { gitStatus });
await this.publisher.publishInstanceUpdate({
instanceID: instance.id,
ownerID: workspace.ownerId,
workspaceID: workspace.id,
});
}

public async getSupportedWorkspaceClasses(userId: string): Promise<SupportedWorkspaceClass[]> {
// No access check required, valid session/user is enough
const classes = this.config.workspaceClasses.map((c) => ({
id: c.id,
category: c.category,
displayName: c.displayName,
description: c.description,
powerups: c.powerups,
isDefault: c.isDefault,
}));
return classes;
}

/**
*
* @param userId
* @param workspaceId
* @param check TODO(gpl) Remove after FGA rollout
* @returns
*/
public async getWorkspaceTimeout(
userId: string,
workspaceId: string,
check: (instance: WorkspaceInstance, workspace: Workspace) => Promise<void> = async () => {},
): Promise<GetWorkspaceTimeoutResult> {
await this.auth.checkPermissionOnWorkspace(userId, "access", workspaceId);

const workspace = await this.getWorkspace(userId, workspaceId);
const canChange = await this.entitlementService.maySetTimeout(userId, workspace.organizationId);

const instance = await this.db.findCurrentInstance(workspaceId);
if (!instance || instance.status.phase !== "running") {
log.warn({ userId, workspaceId }, "Can only get keep-alive for running workspaces");
const duration = WORKSPACE_TIMEOUT_DEFAULT_SHORT;
return { duration, canChange, humanReadableDuration: goDurationToHumanReadable(duration) };
}
await check(instance, workspace);

const req = new DescribeWorkspaceRequest();
req.setId(instance.id);
const client = await this.clientProvider.get(instance.region);
const desc = await client.describeWorkspace({}, req);
const duration = desc.getStatus()!.getSpec()!.getTimeout();

return { duration, canChange, humanReadableDuration: goDurationToHumanReadable(duration) };
}

public async setWorkspaceTimeout(
userId: string,
workspaceId: string,
duration: WorkspaceTimeoutDuration,
check: (instance: WorkspaceInstance, workspace: Workspace) => Promise<void> = async () => {},
): Promise<SetWorkspaceTimeoutResult> {
await this.auth.checkPermissionOnWorkspace(userId, "access", workspaceId);

let validatedDuration;
try {
validatedDuration = WorkspaceTimeoutDuration.validate(duration);
} catch (err) {
throw new ApplicationError(ErrorCodes.INVALID_VALUE, "Invalid duration : " + err.message);
}

const workspace = await this.getWorkspace(userId, workspaceId);
if (!(await this.entitlementService.maySetTimeout(userId, workspace.organizationId))) {
throw new ApplicationError(ErrorCodes.PLAN_PROFESSIONAL_REQUIRED, "Plan upgrade is required");
}

const instance = await this.getCurrentInstance(userId, workspaceId);
if (instance.status.phase !== "running" || workspace.type !== "regular") {
throw new ApplicationError(ErrorCodes.NOT_FOUND, "Can only set keep-alive for regular, running workspaces");
}
await check(instance, workspace);

const client = await this.clientProvider.get(instance.region);
const req = new SetTimeoutRequest();
req.setId(instance.id);
req.setDuration(validatedDuration);
await client.setTimeout({}, req);

return {
resetTimeoutOnWorkspaces: [workspace.id],
humanReadableDuration: goDurationToHumanReadable(validatedDuration),
};
}
}

// TODO(gpl) Make private after FGA rollout
Expand Down