Skip to content

Stop running prebuilds for inactive projects (10+ weeks) #9219

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
Apr 12, 2022
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
4 changes: 3 additions & 1 deletion components/gitpod-db/src/project-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* See License.enterprise.txt in the project root folder.
*/

import { PartialProject, Project, ProjectEnvVar, ProjectEnvVarWithValue } from "@gitpod/gitpod-protocol";
import { PartialProject, Project, ProjectEnvVar, ProjectEnvVarWithValue, ProjectUsage } from "@gitpod/gitpod-protocol";

export const ProjectDB = Symbol("ProjectDB");
export interface ProjectDB {
Expand All @@ -30,4 +30,6 @@ export interface ProjectDB {
getProjectEnvironmentVariableValues(envVars: ProjectEnvVar[]): Promise<ProjectEnvVarWithValue[]>;
findCachedProjectOverview(projectId: string): Promise<Project.Overview | undefined>;
storeCachedProjectOverview(projectId: string, overview: Project.Overview): Promise<void>;
getProjectUsage(projectId: string): Promise<ProjectUsage | undefined>;
updateProjectUsage(projectId: string, usage: Partial<ProjectUsage>): Promise<void>;
}
6 changes: 6 additions & 0 deletions components/gitpod-db/src/tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,12 @@ export class GitpodTableDescriptionProvider implements TableDescriptionProvider
deletionColumn: "deleted",
timeColumn: "_lastModified",
},
{
name: "d_b_project_usage",
primaryKeys: ["projectId"],
deletionColumn: "deleted",
timeColumn: "_lastModified",
},
/**
* BEWARE
*
Expand Down
1 change: 1 addition & 0 deletions components/gitpod-db/src/typeorm/deleted-entry-gc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const tables: TableWithDeletion[] = [
{ deletionColumn: "deleted", name: "d_b_oss_allow_list" },
{ deletionColumn: "deleted", name: "d_b_project_env_var" },
{ deletionColumn: "deleted", name: "d_b_project_info" },
{ deletionColumn: "deleted", name: "d_b_project_usage" },
];

interface TableWithDeletion {
Expand Down
26 changes: 26 additions & 0 deletions components/gitpod-db/src/typeorm/entity/db-project-usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
* Licensed under the Gitpod Enterprise Source Code License,
* See License.enterprise.txt in the project root folder.
*/

import { Entity, Column, PrimaryColumn } from "typeorm";

import { TypeORM } from "../../typeorm/typeorm";

@Entity()
// on DB but not Typeorm: @Index("ind_dbsync", ["_lastModified"]) // DBSync
export class DBProjectUsage {
@PrimaryColumn(TypeORM.UUID_COLUMN_TYPE)
projectId: string;

@Column("varchar")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's historically the reason for using varchar to store timestamps? Is it possible to use a DATETIME?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question -- I don't have a strong opinion here, and was just following established practice 😊

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason I ask is that when prototyping an ORM for Golang, Varchar timestamps make it much more complicated - this is further coupled with the somewhat non-standard usage of ISO8601 in javascript to represent a timestamp, which isn't quite RFC3339 compatible.

See https://github.com/gitpod-io/gitpod/pull/8967/files#diff-64ef712fa293d2472e903c3b14ffbaceb31a8b65b79e0f7a31316dbdcbe0c702R18

Definitely not trying to block this PR on this, mostly looking to understand historical reasoning for the varchar choice. Feel free to ignore this comment to land the change.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason I ask is that when prototyping an ORM for Golang, Varchar timestamps make it much more complicated - this is further coupled with the somewhat non-standard usage of ISO8601 in javascript to represent a timestamp, which isn't quite RFC3339 compatible.

I think your concerns are valid, and there is definitely room for improvement in our DB types. 👍

Definitely not trying to block this PR on this, mostly looking to understand historical reasoning for the varchar choice. Feel free to ignore this comment to land the change.

Thanks! I would love to, but first I need a review. 😇

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still reviewing :)

Copy link
Contributor Author

@jankeromnes jankeromnes Apr 12, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's awesome, thanks a bunch 🙏

I'll try to practice our new review process by assigning this Pull Request to you then (but please feel free to un-assign yourself again if that didn't make sense!)

lastWebhookReceived: string;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this field strictly needed for our first version? We do set it, but we're not using it in read paths.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, it's not strictly needed here indeed. However, since I modified very related code, I thought this small drive-by change could be a good first step toward fixing #7010

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll leave it up to you.

For surfacing which webhook we've received, I'd expose it as a first class-table with webhooks (and webhook payloads), such that we can show what we've received in the webhook as well (would definitely be useful for our debugging).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea. I'll leave this as is, and we can decide later whether we want a full-fledged table of webhook events, or whether showing a simple timestamp of the last event is good enough. 🛹


@Column("varchar")
lastWorkspaceStart: string;

// This column triggers the db-sync deletion mechanism. It's not intended for public consumption.
@Column()
deleted: boolean;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Copyright (c) 2022 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";

export class ProjectUsage1649667202321 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"CREATE TABLE IF NOT EXISTS `d_b_project_usage` (`projectId` char(36) NOT NULL, `lastWebhookReceived` varchar(255) NOT NULL DEFAULT '', `lastWorkspaceStart` varchar(255) NOT NULL DEFAULT '', `deleted` tinyint(4) NOT NULL DEFAULT '0', `_lastModified` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`projectId`), KEY `ind_dbsync` (`_lastModified`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's we're setting a limit on projectId of 36 chars, should this also be reflected in the precision: 36 field on the Column of the ORM model?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not too familiar with the intricacies of our ORM model, so I wouldn't know the pros and cons of doing this.

Briefly looking through our code, it seems that we typically use precision for timestamps, but not for IDs. If you think this should be changed, could you please open an issue for this? 🙏

);
}

public async down(queryRunner: QueryRunner): Promise<void> {}
}
31 changes: 30 additions & 1 deletion components/gitpod-db/src/typeorm/project-db-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ import { inject, injectable } from "inversify";
import { TypeORM } from "./typeorm";
import { Repository } from "typeorm";
import { v4 as uuidv4 } from "uuid";
import { PartialProject, Project, ProjectEnvVar, ProjectEnvVarWithValue } from "@gitpod/gitpod-protocol";
import { PartialProject, Project, ProjectEnvVar, ProjectEnvVarWithValue, ProjectUsage } from "@gitpod/gitpod-protocol";
import { EncryptionService } from "@gitpod/gitpod-protocol/lib/encryption/encryption-service";
import { ProjectDB } from "../project-db";
import { DBProject } from "./entity/db-project";
import { DBProjectEnvVar } from "./entity/db-project-env-vars";
import { DBProjectInfo } from "./entity/db-project-info";
import { DBProjectUsage } from "./entity/db-project-usage";

function toProjectEnvVar(envVarWithValue: ProjectEnvVarWithValue): ProjectEnvVar {
const envVar = { ...envVarWithValue };
Expand Down Expand Up @@ -42,6 +43,10 @@ export class ProjectDBImpl implements ProjectDB {
return (await this.getEntityManager()).getRepository<DBProjectInfo>(DBProjectInfo);
}

protected async getProjectUsageRepo(): Promise<Repository<DBProjectUsage>> {
return (await this.getEntityManager()).getRepository<DBProjectUsage>(DBProjectUsage);
}

public async findProjectById(projectId: string): Promise<Project | undefined> {
const repo = await this.getRepo();
return repo.findOne({ id: projectId, markedDeleted: false });
Expand Down Expand Up @@ -146,6 +151,11 @@ export class ProjectDBImpl implements ProjectDB {
if (info) {
await projectInfoRepo.update(projectId, { deleted: true });
}
const projectUsageRepo = await this.getProjectUsageRepo();
const usage = await projectUsageRepo.findOne({ projectId, deleted: false });
if (usage) {
await projectUsageRepo.update(projectId, { deleted: true });
}
}

public async setProjectEnvironmentVariable(
Expand Down Expand Up @@ -229,4 +239,23 @@ export class ProjectDBImpl implements ProjectDB {
creationTime: new Date().toISOString(),
});
}

public async getProjectUsage(projectId: string): Promise<ProjectUsage | undefined> {
const projectUsageRepo = await this.getProjectUsageRepo();
const usage = await projectUsageRepo.findOne({ projectId });
if (usage) {
return {
lastWebhookReceived: usage.lastWebhookReceived,
lastWorkspaceStart: usage.lastWorkspaceStart,
};
}
}

public async updateProjectUsage(projectId: string, usage: Partial<ProjectUsage>): Promise<void> {
const projectUsageRepo = await this.getProjectUsageRepo();
await projectUsageRepo.save({
projectId,
...usage,
});
}
}
5 changes: 5 additions & 0 deletions components/gitpod-protocol/src/teams-projects-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ export namespace Project {

export type PartialProject = DeepPartial<Project> & Pick<Project, "id">;

export interface ProjectUsage {
lastWebhookReceived: string;
lastWorkspaceStart: string;
}

export interface PrebuildWithStatus {
info: PrebuildInfo;
status: PrebuiltWorkspaceState;
Expand Down
11 changes: 9 additions & 2 deletions components/server/ee/src/prebuilds/bitbucket-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,14 @@ export class BitbucketApp {
): Promise<StartPrebuildResult | undefined> {
const span = TraceContext.startSpan("Bitbucket.handlePushHook", ctx);
try {
const projectAndOwner = await this.findProjectAndOwner(data.gitCloneUrl, user);
if (projectAndOwner.project) {
/* tslint:disable-next-line */
/** no await */ this.projectDB.updateProjectUsage(projectAndOwner.project.id, {
lastWebhookReceived: new Date().toISOString(),
});
}

const contextURL = this.createContextUrl(data);
const context = (await this.contextParser.handle({ span }, user, contextURL)) as CommitContext;
span.setTag("contextURL", contextURL);
Expand All @@ -116,11 +124,10 @@ export class BitbucketApp {
data.commitHash,
);
}
const projectAndOwner = await this.findProjectAndOwner(data.gitCloneUrl, user);
// todo@alex: add branch and project args
const ws = await this.prebuildManager.startPrebuild(
{ span },
{ user, project: projectAndOwner?.project, context, commitInfo },
{ user, project: projectAndOwner.project, context, commitInfo },
);
return ws;
} finally {
Expand Down
14 changes: 13 additions & 1 deletion components/server/ee/src/prebuilds/github-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,8 +240,14 @@ export class GithubApp {
const installationId = ctx.payload.installation?.id;
const cloneURL = ctx.payload.repository.clone_url;
let { user, project } = await this.findOwnerAndProject(installationId, cloneURL);
const logCtx: LogContext = { userId: user.id };
if (project) {
/* tslint:disable-next-line */
/** no await */ this.projectDB.updateProjectUsage(project.id, {
lastWebhookReceived: new Date().toISOString(),
});
}

const logCtx: LogContext = { userId: user.id };
if (!!user.blocked) {
log.info(logCtx, `Blocked user tried to start prebuild`, { repo: ctx.payload.repository });
return;
Expand Down Expand Up @@ -347,6 +353,12 @@ export class GithubApp {
const pr = ctx.payload.pull_request;
const contextURL = pr.html_url;
let { user, project } = await this.findOwnerAndProject(installationId, cloneURL);
if (project) {
/* tslint:disable-next-line */
/** no await */ this.projectDB.updateProjectUsage(project.id, {
lastWebhookReceived: new Date().toISOString(),
});
}

const context = (await this.contextParser.handle({ span }, user, contextURL)) as CommitContext;
const config = await this.prebuildManager.fetchConfig({ span }, user, context);
Expand Down
13 changes: 10 additions & 3 deletions components/server/ee/src/prebuilds/github-enterprise-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,15 @@ export class GitHubEnterpriseApp {
): Promise<StartPrebuildResult | undefined> {
const span = TraceContext.startSpan("GitHubEnterpriseApp.handlePushHook", ctx);
try {
const cloneURL = payload.repository.clone_url;
const projectAndOwner = await this.findProjectAndOwner(cloneURL, user);
if (projectAndOwner.project) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the point of this call, how do we know that the project specified in the payload in fact belongs to the correct user? Is it possible to poison our data across projects here?

Mostly lacking context, so trying to understand better how the payload from the webhook is authhorized

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the point of this call, how do we know that the project specified in the payload in fact belongs to the correct user?

That's a good question. I don't know about webhook events from the GitHub App, but for GitLab, we look at the provided (user + project) token in order to make sure that the project specified in the payload actually belongs to the user who installed this webhook:

protected async findUser(ctx: TraceContext, context: GitLabPushHook, req: express.Request): Promise<User> {
const span = TraceContext.startSpan("GitLapApp.findUser", ctx);
try {
const secretToken = req.header("X-Gitlab-Token");
span.setTag("secret-token", secretToken);
if (!secretToken) {
throw new Error("No secretToken provided.");
}
const [userid, tokenValue] = secretToken.split("|");
const user = await this.userDB.findUserById(userid);
if (!user) {
throw new Error("No user found for " + secretToken + " found.");
} else if (!!user.blocked) {
throw new Error(`Blocked user ${user.id} tried to start prebuild.`);
}
const identity = user.identities.find((i) => i.authProviderId === TokenService.GITPOD_AUTH_PROVIDER_ID);
if (!identity) {
throw new Error(`User ${user.id} has no identity for '${TokenService.GITPOD_AUTH_PROVIDER_ID}'.`);
}
const tokens = await this.userDB.findTokensForIdentity(identity);
const token = tokens.find((t) => t.token.value === tokenValue);
if (!token) {
throw new Error(`User ${user.id} has no token with given value.`);
}
if (
token.token.scopes.indexOf(GitlabService.PREBUILD_TOKEN_SCOPE) === -1 ||
token.token.scopes.indexOf(context.repository.git_http_url) === -1
) {
throw new Error(
`The provided token is not valid for the repository ${context.repository.git_http_url}.`,
);
}
return user;
} finally {
span.finish();
}
}

We do something similar for GitHub Enterprise webhooks, where the secret token is not sent as part of the webhook payload, but is used to sign the webhook payload (which we can verify against the user's tokens):

// Verify the webhook signature
const signature = req.header("X-Hub-Signature-256");
const body = (req as any).rawBody;
const tokenEntries = (await this.userDB.findTokensForIdentity(gitpodIdentity)).filter((tokenEntry) => {
return tokenEntry.token.scopes.includes(GitHubService.PREBUILD_TOKEN_SCOPE);
});
const signingToken = tokenEntries.find((tokenEntry) => {
const sig =
"sha256=" +
createHmac("sha256", user.id + "|" + tokenEntry.token.value)
.update(body)
.digest("hex");
return timingSafeEqual(Buffer.from(sig), Buffer.from(signature ?? ""));
});
if (!signingToken) {
throw new Error(`User ${user.id} has no token matching the payload signature.`);
}
return user;

Is it possible to poison our data across projects here?

I don't think it is possible to send forged webhook events to Gitpod.

However, your question does remind me of an issue where Pull Requests can come from forks, but trigger a Prebuild for the main repository, which can be problematic in some cases: https://github.com/gitpod-io/security/issues/26

/* tslint:disable-next-line */
/** no await */ this.projectDB.updateProjectUsage(projectAndOwner.project.id, {
lastWebhookReceived: new Date().toISOString(),
});
}

const contextURL = this.createContextUrl(payload);
span.setTag("contextURL", contextURL);
const context = (await this.contextParser.handle({ span }, user, contextURL)) as CommitContext;
Expand All @@ -127,15 +136,13 @@ export class GitHubEnterpriseApp {

log.debug("GitHub Enterprise push event: Starting prebuild.", { contextURL });

const cloneURL = payload.repository.clone_url;
const projectAndOwner = await this.findProjectAndOwner(cloneURL, user);
const commitInfo = await this.getCommitInfo(user, payload.repository.url, payload.after);
const ws = await this.prebuildManager.startPrebuild(
{ span },
{
context,
user: projectAndOwner.user,
project: projectAndOwner?.project,
project: projectAndOwner.project,
commitInfo,
},
);
Expand Down
11 changes: 9 additions & 2 deletions components/server/ee/src/prebuilds/gitlab-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,13 @@ export class GitLabApp {
span.setTag("contextURL", contextURL);
const context = (await this.contextParser.handle({ span }, user, contextURL)) as CommitContext;
const projectAndOwner = await this.findProjectAndOwner(context.repository.cloneUrl, user);
if (projectAndOwner.project) {
/* tslint:disable-next-line */
/** no await */ this.projectDB.updateProjectUsage(projectAndOwner.project.id, {
lastWebhookReceived: new Date().toISOString(),
});
}

const config = await this.prebuildManager.fetchConfig({ span }, user, context);
if (!this.prebuildManager.shouldPrebuild(config)) {
log.debug({ userId: user.id }, "GitLab push hook: There is no prebuild config.", {
Expand All @@ -123,8 +130,8 @@ export class GitLabApp {
const ws = await this.prebuildManager.startPrebuild(
{ span },
{
user: projectAndOwner?.user || user,
project: projectAndOwner?.project,
user: projectAndOwner.user || user,
project: projectAndOwner.project,
context,
commitInfo,
},
Expand Down
55 changes: 32 additions & 23 deletions components/server/ee/src/prebuilds/prebuild-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,33 +159,11 @@ export class PrebuildManager {
prebuildContext,
context.normalizedContextURL!,
);
const prebuildPromise = this.workspaceDB.trace({ span }).findPrebuildByWorkspaceID(workspace.id)!;

span.setTag("starting", true);
const projectEnvVars = await projectEnvVarsPromise;
await this.workspaceStarter.startWorkspace({ span }, workspace, user, [], projectEnvVars, {
excludeFeatureFlags: ["full_workspace_backup"],
});
const prebuild = await prebuildPromise;
const prebuild = await this.workspaceDB.trace({ span }).findPrebuildByWorkspaceID(workspace.id)!;
if (!prebuild) {
throw new Error(`Failed to create a prebuild for: ${context.normalizedContextURL}`);
}

if (await this.shouldRateLimitPrebuild(span, cloneURL)) {
prebuild.state = "aborted";
prebuild.error =
"Prebuild is rate limited. Please contact Gitpod if you believe this happened in error.";

await this.workspaceDB.trace({ span }).storePrebuiltWorkspace(prebuild);
span.setTag("starting", false);
span.setTag("ratelimited", true);
return {
wsid: workspace.id,
prebuildId: prebuild.id,
done: false,
};
}

if (project) {
let aCommitInfo = commitInfo;
if (!aCommitInfo) {
Expand All @@ -205,6 +183,26 @@ export class PrebuildManager {
}
await this.storePrebuildInfo({ span }, project, prebuild, workspace, user, aCommitInfo);
}

if (await this.shouldRateLimitPrebuild(span, cloneURL)) {
prebuild.state = "aborted";
prebuild.error =
"Prebuild is rate limited. Please contact Gitpod if you believe this happened in error.";
await this.workspaceDB.trace({ span }).storePrebuiltWorkspace(prebuild);
span.setTag("ratelimited", true);
} else if (project && (await this.shouldSkipInactiveProject(project))) {
prebuild.state = "aborted";
prebuild.error =
"Project is inactive. Please start a new workspace for this project to re-enable prebuilds.";
await this.workspaceDB.trace({ span }).storePrebuiltWorkspace(prebuild);
} else {
span.setTag("starting", true);
const projectEnvVars = await projectEnvVarsPromise;
await this.workspaceStarter.startWorkspace({ span }, workspace, user, [], projectEnvVars, {
excludeFeatureFlags: ["full_workspace_backup"],
});
}

return { prebuildId: prebuild.id, wsid: workspace.id, done: false };
} catch (err) {
TraceContext.setError({ span }, err);
Expand Down Expand Up @@ -347,4 +345,15 @@ export class PrebuildManager {
// Last resort default
return PREBUILD_LIMITER_DEFAULT_LIMIT;
}

private async shouldSkipInactiveProject(project: Project): Promise<boolean> {
const usage = await this.projectService.getProjectUsage(project.id);
if (!usage?.lastWorkspaceStart) {
return false;
}
const now = Date.now();
const lastUse = new Date(usage.lastWorkspaceStart).getTime();
const inactiveProjectTime = 1000 * 60 * 60 * 24 * 7 * 10; // 10 weeks
return now - lastUse > inactiveProjectTime;
}
}
2 changes: 1 addition & 1 deletion components/server/src/gitlab/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export class GitLabApi {
log.error(`GitLab request error`, error);
throw error;
} finally {
log.info(`GitLab request took ${new Date().getTime() - before} ms`);
log.debug(`GitLab request took ${new Date().getTime() - before} ms`);
}
}

Expand Down
6 changes: 5 additions & 1 deletion components/server/src/projects/projects-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
import { HostContextProvider } from "../auth/host-context-provider";
import { RepoURL } from "../repohost";
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
import { PartialProject } from "@gitpod/gitpod-protocol/src/teams-projects-protocol";
import { PartialProject, ProjectUsage } from "@gitpod/gitpod-protocol/src/teams-projects-protocol";

@injectable()
export class ProjectsService {
Expand Down Expand Up @@ -248,4 +248,8 @@ export class ProjectsService {
async deleteProjectEnvironmentVariable(variableId: string): Promise<void> {
return this.projectDB.deleteProjectEnvironmentVariable(variableId);
}

async getProjectUsage(projectId: string): Promise<ProjectUsage | undefined> {
return this.projectDB.getProjectUsage(projectId);
}
}
2 changes: 1 addition & 1 deletion components/server/src/user/user-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,7 +580,7 @@ export class UserController {
await this.userService.updateUserEnvVarsOnLogin(user, envVars);
await this.userService.acceptCurrentTerms(user);

/* no await */ trackSignup(user, req, this.analytics).catch((err) =>
/** no await */ trackSignup(user, req, this.analytics).catch((err) =>
log.warn({ userId: user.id }, "trackSignup", err),
);

Expand Down
7 changes: 7 additions & 0 deletions components/server/src/workspace/workspace-starter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,13 @@ export class WorkspaceStarter {
const span = TraceContext.startSpan("WorkspaceStarter.startWorkspace", ctx);
span.setTag("workspaceId", workspace.id);

if (workspace.projectId && workspace.type === "regular") {
/* tslint:disable-next-line */
/** no await */ this.projectDB.updateProjectUsage(workspace.projectId, {
lastWorkspaceStart: new Date().toISOString(),
});
}

options = options || {};
try {
// Some workspaces do not have an image source.
Expand Down