diff --git a/components/gitpod-db/src/typeorm/entity/db-workspace.ts b/components/gitpod-db/src/typeorm/entity/db-workspace.ts index d9e162fd320dc0..1c57ea52a89c99 100644 --- a/components/gitpod-db/src/typeorm/entity/db-workspace.ts +++ b/components/gitpod-db/src/typeorm/entity/db-workspace.ts @@ -90,6 +90,13 @@ export class DBWorkspace implements Workspace { }) type: WorkspaceType; + @Column({ + default: "", + transformer: Transformer.MAP_EMPTY_STR_TO_UNDEFINED, + }) + @Index("ind_deletionEligibilityTime") + deletionEligibilityTime?: string; + @Column({ type: "varchar", default: "", diff --git a/components/gitpod-db/src/typeorm/migration/1717407555612-WorkspaceDeletionEligibilityTime.ts b/components/gitpod-db/src/typeorm/migration/1717407555612-WorkspaceDeletionEligibilityTime.ts new file mode 100644 index 00000000000000..d3e7b45ba68003 --- /dev/null +++ b/components/gitpod-db/src/typeorm/migration/1717407555612-WorkspaceDeletionEligibilityTime.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2024 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License.AGPL.txt in the project root for license information. + */ + +import { MigrationInterface, QueryRunner } from "typeorm"; +import { columnExists, indexExists } from "./helper/helper"; + +const TABLE_NAME = "d_b_workspace"; +const COLUMN_NAME = "deletionEligibilityTime"; +const INDEX_NAME = "ind_deletionEligibilityTime"; + +export class WorkspaceDeletionEligibilityTime1717407555612 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + if (!(await columnExists(queryRunner, TABLE_NAME, COLUMN_NAME))) { + await queryRunner.query( + `ALTER TABLE \`${TABLE_NAME}\` ADD COLUMN \`${COLUMN_NAME}\` varchar(30) NOT NULL DEFAULT '', ALGORITHM=INPLACE, LOCK=NONE`, + ); + } + if (!(await indexExists(queryRunner, TABLE_NAME, INDEX_NAME))) { + await queryRunner.query( + `ALTER TABLE \`${TABLE_NAME}\` ADD INDEX \`${INDEX_NAME}\` (${COLUMN_NAME}), ALGORITHM=INPLACE, LOCK=NONE`, + ); + } + } + + public async down(queryRunner: QueryRunner): Promise {} +} diff --git a/components/gitpod-protocol/src/protocol.ts b/components/gitpod-protocol/src/protocol.ts index 04d002dea02280..effd37c6e86e33 100644 --- a/components/gitpod-protocol/src/protocol.ts +++ b/components/gitpod-protocol/src/protocol.ts @@ -724,6 +724,12 @@ export interface Workspace { */ contentDeletedTime?: string; + /** + * The time when the workspace is eligible for soft deletion. This is the time when the workspace + * is marked as softDeleted earliest. + */ + deletionEligibilityTime?: string; + type: WorkspaceType; basedOnPrebuildId?: string; diff --git a/components/server/src/workspace/workspace-service.spec.db.ts b/components/server/src/workspace/workspace-service.spec.db.ts index 0692e5161a80b8..8258442d541af7 100644 --- a/components/server/src/workspace/workspace-service.spec.db.ts +++ b/components/server/src/workspace/workspace-service.spec.db.ts @@ -690,6 +690,107 @@ describe("WorkspaceService", async () => { "from must be before to", ); }); + + it("should update the deletion eligibility time of a workspace", async () => { + const svc = container.get(WorkspaceService); + const db = container.get(WorkspaceDB); + const today = new Date(); + const daysAgo = (days: number) => new Date(today.getTime() - 1000 * 60 * 60 * 24 * days); + + const ws = await createTestWorkspace(svc, org, owner, project); + await db.storeInstance({ + id: v4(), + workspaceId: ws.id, + creationTime: daysAgo(7).toISOString(), + status: { + conditions: {}, + phase: "stopped", + }, + region: "us-central1", + ideUrl: "", + configuration: { + ideImage: "", + }, + workspaceImage: "", + }); + + await svc.updateDeletionEligabilityTime(owner.id, ws.id); + + const workspace = await svc.getWorkspace(owner.id, ws.id); + expect(workspace).to.not.be.undefined; + expect(workspace.workspace.deletionEligibilityTime).to.not.be.undefined; + expect(workspace.workspace.deletionEligibilityTime).to.eq(daysAgo(7 - 14).toISOString()); + }); + + it("should update the deletion eligibility time of a workspace with git changes", async () => { + const svc = container.get(WorkspaceService); + const db = container.get(WorkspaceDB); + const today = new Date(); + const daysAgo = (days: number) => new Date(today.getTime() - 1000 * 60 * 60 * 24 * days); + + const ws = await createTestWorkspace(svc, org, owner, project); + await db.storeInstance({ + id: v4(), + workspaceId: ws.id, + creationTime: daysAgo(7).toISOString(), + status: { + conditions: {}, + phase: "stopped", + }, + region: "us-central1", + ideUrl: "", + gitStatus: { + totalUnpushedCommits: 2, + }, + configuration: { + ideImage: "", + }, + workspaceImage: "", + }); + + await svc.updateDeletionEligabilityTime(owner.id, ws.id); + + const workspace = await svc.getWorkspace(owner.id, ws.id); + expect(workspace).to.not.be.undefined; + expect(workspace.workspace.deletionEligibilityTime).to.not.be.undefined; + expect(workspace.workspace.deletionEligibilityTime).to.eq(daysAgo(7 - 14 * 2).toISOString()); + }); + + it("should update the deletion eligibility time of a prebuild", async () => { + const svc = container.get(WorkspaceService); + const db = container.get(WorkspaceDB); + const today = new Date(); + const daysAgo = (days: number) => new Date(today.getTime() - 1000 * 60 * 60 * 24 * days); + + const ws = await createTestWorkspace(svc, org, owner, project); + ws.type = "prebuild"; + await db.store(ws); + await db.storeInstance({ + id: v4(), + workspaceId: ws.id, + creationTime: daysAgo(7).toISOString(), + status: { + conditions: {}, + phase: "stopped", + }, + region: "us-central1", + ideUrl: "", + gitStatus: { + totalUnpushedCommits: 2, + }, + configuration: { + ideImage: "", + }, + workspaceImage: "", + }); + + await svc.updateDeletionEligabilityTime(owner.id, ws.id); + + const workspace = await svc.getWorkspace(owner.id, ws.id); + expect(workspace).to.not.be.undefined; + expect(workspace.workspace.deletionEligibilityTime).to.not.be.undefined; + expect(workspace.workspace.deletionEligibilityTime).to.eq(daysAgo(7 - 7).toISOString()); + }); }); async function createTestWorkspace(svc: WorkspaceService, org: Organization, owner: User, project: Project) { @@ -754,4 +855,6 @@ async function createTestWorkspaceWithInstances( workspaceImage: "", }); } + + return id; } diff --git a/components/server/src/workspace/workspace-service.ts b/components/server/src/workspace/workspace-service.ts index b85653ca1c5dc1..cbd8a50b4dd937 100644 --- a/components/server/src/workspace/workspace-service.ts +++ b/components/server/src/workspace/workspace-service.ts @@ -191,7 +191,7 @@ export class WorkspaceService { ); throw err; } - + await this.updateDeletionEligabilityTime(user.id, workspace.id); return workspace; } @@ -335,6 +335,7 @@ export class WorkspaceService { return; } await this.workspaceStarter.stopWorkspaceInstance({}, instance.id, instance.region, reason, policy); + this.updateDeletionEligabilityTime(userId, workspaceId); } public async stopRunningWorkspacesForUser( @@ -355,11 +356,57 @@ export class WorkspaceService { reason, policy, ); + await this.updateDeletionEligabilityTime(userId, info.workspace.id); }), ); return infos.map((instance) => instance.workspace); } + /** + * Sets the deletionEligibilityTime of the workspace, depening of the current state of the workspace and the configuration. + * + * @param userId sets the + * @param workspaceId + * @returns + */ + async updateDeletionEligabilityTime(userId: string, workspaceId: string): Promise { + try { + let daysToLive = this.config.workspaceGarbageCollection?.minAgeDays || 14; + const daysToLiveForPrebuilds = this.config.workspaceGarbageCollection?.minAgePrebuildDays || 7; + + const workspace = await this.doGetWorkspace(userId, workspaceId); + const instance = await this.db.findCurrentInstance(workspaceId); + const lastActive = + instance?.stoppingTime || instance?.startedTime || instance?.creationTime || workspace?.creationTime; + if (!lastActive) { + return; + } + const deletionEligibilityTime = new Date(lastActive); + if (workspace.type === "prebuild") { + // set to last active plus daysToLiveForPrebuilds as iso string + deletionEligibilityTime.setDate(deletionEligibilityTime.getDate() + daysToLiveForPrebuilds); + await this.db.updatePartial(workspaceId, { + deletionEligibilityTime: deletionEligibilityTime.toISOString(), + }); + return; + } + // workspaces with pending changes live twice as long + if ( + (instance?.gitStatus?.totalUncommitedFiles || 0) > 0 || + (instance?.gitStatus?.totalUnpushedCommits || 0) > 0 || + (instance?.gitStatus?.totalUnpushedCommits || 0) > 0 + ) { + daysToLive = daysToLive * 2; + } + deletionEligibilityTime.setDate(deletionEligibilityTime.getDate() + daysToLive); + await this.db.updatePartial(workspaceId, { + deletionEligibilityTime: deletionEligibilityTime.toISOString(), + }); + } catch (error) { + log.error({ userId, workspaceId }, "Failed to update deletion eligibility time", error); + } + } + /** * This method does nothing beyond marking the given workspace as 'softDeleted' with the given cause and sets the 'softDeletedTime' to now. * The actual deletion happens as part of the regular workspace garbage collection. @@ -610,6 +657,7 @@ export class WorkspaceService { // at this point we're about to actually start a new workspace const result = await this.workspaceStarter.startWorkspace(ctx, workspace, user, await projectPromise, options); + await this.updateDeletionEligabilityTime(user.id, workspaceId); return result; } @@ -739,6 +787,7 @@ export class WorkspaceService { const workspace = await this.doGetWorkspace(userId, workspaceId); instance = await this.db.updateInstancePartial(instance.id, { gitStatus }); + await this.updateDeletionEligabilityTime(userId, workspaceId); await this.publisher.publishInstanceUpdate({ instanceID: instance.id, ownerID: workspace.ownerId,