Skip to content

Commit 8d1fd9d

Browse files
committed
[server] WorkspaceService.(hard-)deleteWorkspace
1 parent 8337a78 commit 8d1fd9d

File tree

8 files changed

+72
-49
lines changed

8 files changed

+72
-49
lines changed

components/server/src/authorization/definitions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export type WorkspaceResourceType = "workspace";
7373

7474
export type WorkspaceRelation = "org" | "owner";
7575

76-
export type WorkspacePermission = "access" | "stop" | "read_info";
76+
export type WorkspacePermission = "access" | "stop" | "delete" | "read_info";
7777

7878
export const rel = {
7979
user(id: string) {

components/server/src/jobs/workspace-gc.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { TracedWorkspaceDB, DBWithTracing, WorkspaceDB } from "@gitpod/gitpod-db
1212
import { TraceContext } from "@gitpod/gitpod-protocol/lib/util/tracing";
1313
import { Config } from "../config";
1414
import { Job } from "./runner";
15+
import { WorkspaceService } from "../workspace/workspace-service";
1516

1617
/**
1718
* The WorkspaceGarbageCollector has two tasks:
@@ -21,6 +22,7 @@ import { Job } from "./runner";
2122
@injectable()
2223
export class WorkspaceGarbageCollector implements Job {
2324
@inject(WorkspaceDeletionService) protected readonly deletionService: WorkspaceDeletionService;
25+
@inject(WorkspaceService) protected readonly workspaceService: WorkspaceService;
2426
@inject(TracedWorkspaceDB) protected readonly workspaceDB: DBWithTracing<WorkspaceDB>;
2527
@inject(Config) protected readonly config: Config;
2628

@@ -70,7 +72,7 @@ export class WorkspaceGarbageCollector implements Job {
7072
);
7173
const afterSelect = new Date();
7274
const deletes = await Promise.all(
73-
workspaces.map((ws) => this.deletionService.softDeleteWorkspace({ span }, ws, "gc")),
75+
workspaces.map((ws) => this.workspaceService.deleteWorkspace(ws.ownerId, ws.id, "gc")), // TODO(gpl) This should be a system user/service account instead of ws owner
7476
);
7577
const afterDelete = new Date();
7678

@@ -125,7 +127,7 @@ export class WorkspaceGarbageCollector implements Job {
125127
now,
126128
);
127129
const deletes = await Promise.all(
128-
workspaces.map((ws) => this.deletionService.hardDeleteWorkspace({ span }, ws.id)),
130+
workspaces.map((ws) => this.workspaceService.hardDeleteWorkspace(ws.ownerId, ws.id)), // TODO(gpl) This should be a system user/service account instead of ws owner
129131
);
130132

131133
log.info(`workspace-gc: successfully purged ${deletes.length} workspaces`);

components/server/src/test/expect-utils.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@
77
import { ApplicationError, ErrorCode } from "@gitpod/gitpod-protocol/lib/messaging/error";
88
import { expect } from "chai";
99

10-
export async function expectError(errorCode: ErrorCode, code: Promise<any> | (() => Promise<any>)) {
10+
export async function expectError(errorCode: ErrorCode, code: Promise<any> | (() => Promise<any>), message?: string) {
11+
const msg = "expected error: " + errorCode + (message ? " - " + message : "");
1112
try {
1213
await (code instanceof Function ? code() : code);
13-
expect.fail("expected error: " + errorCode);
14+
expect.fail(msg);
1415
} catch (err) {
1516
if (!ApplicationError.hasErrorCode(err)) {
1617
throw err;
1718
}
18-
expect(err && ApplicationError.hasErrorCode(err) && err.code).to.equal(errorCode);
19+
expect(err && ApplicationError.hasErrorCode(err) && err.code, msg).to.equal(errorCode);
1920
}
2021
}

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

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,6 @@ import { UserDeletionService } from "../user/user-deletion-service";
130130
import { UserAuthentication } from "../user/user-authentication";
131131
import { ContextParser } from "./context-parser-service";
132132
import { GitTokenScopeGuesser } from "./git-token-scope-guesser";
133-
import { WorkspaceDeletionService } from "./workspace-deletion-service";
134133
import { WorkspaceStarter, isClusterMaintenanceError } from "./workspace-starter";
135134
import { HeadlessLogUrls } from "@gitpod/gitpod-protocol/lib/headless-workspace-log";
136135
import { HeadlessLogService, HeadlessLogEndpoint } from "./headless-log-service";
@@ -213,7 +212,6 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
213212
constructor(
214213
@inject(Config) private readonly config: Config,
215214
@inject(TracedWorkspaceDB) private readonly workspaceDb: DBWithTracing<WorkspaceDB>,
216-
@inject(WorkspaceDeletionService) private readonly workspaceDeletionService: WorkspaceDeletionService,
217215
@inject(ContextParser) private contextParser: ContextParser,
218216
@inject(HostContextProvider) private readonly hostContextProvider: HostContextProvider,
219217

@@ -1116,8 +1114,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
11161114
// for good measure, try and stop running instances
11171115
await this.internalStopWorkspace(ctx, user.id, ws, "deleted via API");
11181116

1119-
// actually delete the workspace
1120-
await this.workspaceDeletionService.softDeleteWorkspace(ctx, ws, "user");
1117+
await this.workspaceService.deleteWorkspace(user.id, workspaceId, "user");
11211118
}
11221119

11231120
public async setWorkspaceDescription(ctx: TraceContext, workspaceId: string, description: string): Promise<void> {
@@ -1460,7 +1457,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
14601457
try {
14611458
await this.guardAccess({ kind: "workspace", subject: workspace }, "create");
14621459
} catch (err) {
1463-
await this.workspaceDeletionService.hardDeleteWorkspace(ctx, workspace.id);
1460+
await this.workspaceService.hardDeleteWorkspace(user.id, workspace.id);
14641461
throw err;
14651462
}
14661463

components/server/src/workspace/workspace-deletion-service.ts

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
*/
66

77
import { inject, injectable } from "inversify";
8-
import { WorkspaceSoftDeletion } from "@gitpod/gitpod-protocol";
98
import {
109
WorkspaceDB,
1110
WorkspaceAndOwner,
@@ -17,7 +16,6 @@ import { StorageClient } from "../storage/storage-client";
1716
import { Config } from "../config";
1817
import { TraceContext } from "@gitpod/gitpod-protocol/lib/util/tracing";
1918
import { WorkspaceManagerClientProvider } from "@gitpod/ws-manager/lib/client-provider";
20-
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
2119

2220
@injectable()
2321
export class WorkspaceDeletionService {
@@ -27,36 +25,6 @@ export class WorkspaceDeletionService {
2725
@inject(WorkspaceManagerClientProvider)
2826
protected readonly workspaceManagerClientProvider: WorkspaceManagerClientProvider;
2927

30-
/**
31-
* This method does nothing beyond marking the given workspace as 'softDeleted' with the given cause and sets the 'softDeletedTime' to now.
32-
* The actual deletion happens as part of the regular workspace garbage collection.
33-
* @param ctx
34-
* @param ws
35-
* @param softDeleted
36-
*/
37-
public async softDeleteWorkspace(
38-
ctx: TraceContext,
39-
ws: WorkspaceAndOwner,
40-
softDeleted: WorkspaceSoftDeletion,
41-
): Promise<void> {
42-
await this.db.trace(ctx).updatePartial(ws.id, {
43-
softDeleted,
44-
softDeletedTime: new Date().toISOString(),
45-
});
46-
}
47-
48-
/**
49-
* This *hard deletes* the workspace entry and all corresponding workspace-instances, by triggering a periodic deleter mechanism that purges it from the DB.
50-
* Note: when this function returns that doesn't mean that the entries are actually gone yet, that might still take a short while until periodic deleter comes
51-
* around to deleting them.
52-
* @param ctx
53-
* @param workspaceId
54-
*/
55-
public async hardDeleteWorkspace(ctx: TraceContext, workspaceId: string): Promise<void> {
56-
await this.db.trace(ctx).hardDeleteWorkspace(workspaceId);
57-
log.info(`Purged Workspace ${workspaceId} and all WorkspaceInstances for this workspace`, { workspaceId });
58-
}
59-
6028
/**
6129
* This method garbageCollects a workspace. It deletes its contents and sets the workspaces 'contentDeletedTime'
6230
* @param ctx

components/server/src/workspace/workspace-service.spec.db.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,24 @@ describe("WorkspaceService", async () => {
107107
svc.stopWorkspace(stranger.id, ws.id, "test stranger stopping stopped workspace"),
108108
);
109109
});
110+
111+
it("should deleteWorkspace", async () => {
112+
const svc = container.get(WorkspaceService);
113+
const ws = await createTestWorkspace(svc, org, owner, project);
114+
115+
await expectError(
116+
ErrorCodes.NOT_FOUND,
117+
() => svc.deleteWorkspace(stranger.id, ws.id),
118+
"stranger can't delete workspace",
119+
);
120+
121+
await svc.deleteWorkspace(owner.id, ws.id);
122+
await expectError(
123+
ErrorCodes.NOT_FOUND,
124+
() => svc.getWorkspace(owner.id, ws.id),
125+
"getWorkspace should return NOT_FOUND after deletion",
126+
);
127+
});
110128
});
111129

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

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

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,20 @@
66

77
import { inject, injectable } from "inversify";
88
import { WorkspaceDB } from "@gitpod/gitpod-db/lib";
9-
import { Project, User, Workspace, WorkspaceContext } from "@gitpod/gitpod-protocol";
9+
import { Project, User, Workspace, WorkspaceContext, WorkspaceSoftDeletion } from "@gitpod/gitpod-protocol";
1010
import { ErrorCodes, ApplicationError } from "@gitpod/gitpod-protocol/lib/messaging/error";
1111
import { Authorizer } from "../authorization/authorizer";
1212
import { TraceContext } from "@gitpod/gitpod-protocol/lib/util/tracing";
1313
import { WorkspaceFactory } from "./workspace-factory";
14-
import { WorkspaceDeletionService } from "./workspace-deletion-service";
1514
import { StopWorkspacePolicy } from "@gitpod/ws-manager/lib";
1615
import { WorkspaceStarter } from "./workspace-starter";
16+
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
1717

1818
@injectable()
1919
export class WorkspaceService {
2020
constructor(
2121
@inject(WorkspaceFactory) private readonly factory: WorkspaceFactory,
2222
@inject(WorkspaceStarter) private readonly workspaceStarter: WorkspaceStarter,
23-
@inject(WorkspaceDeletionService) private readonly workspaceDeletionService: WorkspaceDeletionService,
2423
@inject(WorkspaceDB) private readonly db: WorkspaceDB,
2524
@inject(Authorizer) private readonly auth: Authorizer,
2625
) {}
@@ -50,7 +49,7 @@ export class WorkspaceService {
5049
try {
5150
await this.auth.createWorkspaceInOrg(organizationId, user.id, workspace.id);
5251
} catch (err) {
53-
await this.workspaceDeletionService.hardDeleteWorkspace(ctx, workspace.id);
52+
await this.hardDeleteWorkspace(user.id, workspace.id);
5453
throw err;
5554
}
5655

@@ -61,7 +60,7 @@ export class WorkspaceService {
6160
await this.auth.checkPermissionOnWorkspace(userId, "access", workspaceId);
6261

6362
const workspace = await this.db.findById(workspaceId);
64-
if (!workspace) {
63+
if (!workspace || !!workspace.softDeleted || workspace.deleted) {
6564
throw new ApplicationError(ErrorCodes.NOT_FOUND, "Workspace not found.");
6665
}
6766
return workspace;
@@ -83,4 +82,40 @@ export class WorkspaceService {
8382
}
8483
await this.workspaceStarter.stopWorkspaceInstance({}, instance.id, instance.region, reason, policy);
8584
}
85+
86+
/**
87+
* This method does nothing beyond marking the given workspace as 'softDeleted' with the given cause and sets the 'softDeletedTime' to now.
88+
* The actual deletion happens as part of the regular workspace garbage collection.
89+
* @param ctx
90+
* @param ws
91+
* @param softDeleted
92+
*/
93+
async deleteWorkspace(
94+
userId: string,
95+
workspaceId: string,
96+
softDeleted: WorkspaceSoftDeletion = "user",
97+
): Promise<void> {
98+
await this.auth.checkPermissionOnWorkspace(userId, "delete", workspaceId);
99+
100+
await this.stopWorkspace(userId, workspaceId, "deleted via WorkspaceService");
101+
await this.db.updatePartial(workspaceId, {
102+
softDeleted,
103+
softDeletedTime: new Date().toISOString(),
104+
});
105+
}
106+
107+
/**
108+
* This *hard deletes* the workspace entry and all corresponding workspace-instances, by triggering a periodic deleter mechanism that purges it from the DB.
109+
* Note: when this function returns that doesn't mean that the entries are actually gone yet, that might still take a short while until periodic deleter comes
110+
* around to deleting them.
111+
* @param ctx
112+
* @param userId
113+
* @param workspaceId
114+
*/
115+
public async hardDeleteWorkspace(userId: string, workspaceId: string): Promise<void> {
116+
await this.auth.checkPermissionOnWorkspace(userId, "delete", workspaceId);
117+
118+
await this.db.hardDeleteWorkspace(workspaceId);
119+
log.info(`Purged Workspace ${workspaceId} and all WorkspaceInstances for this workspace`, { workspaceId });
120+
}
86121
}

components/spicedb/schema/schema.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,10 @@ schema: |-
9292
//+ (hasAccessToRepository && isPrebuild) + everyoneIfIsShared
9393
permission access = owner
9494
95-
// This is modelled after current behavior
95+
// Note: All of this is modelled after current behavior.
96+
// There are a lot of improvements we can make here in the light of Organizations, but we explicitly do that as a separate step
9697
permission stop = owner + org->installation_admin
98+
permission delete = owner
9799
98100
// Whether a user can read basic info/metadata of a workspace
99101
//+ (hasAccessToRepository && isPrebuild) + everyoneIfIsShared

0 commit comments

Comments
 (0)