Skip to content

Commit b385af4

Browse files
geroplroboquat
authored andcommitted
[server, protocl] Introduce 'waitForSnapshot'
1 parent b53fe37 commit b385af4

14 files changed

+239
-16
lines changed

components/gitpod-protocol/go/gitpod-service.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ type APIInterface interface {
7373
SendFeedback(ctx context.Context, feedback string) (res string, err error)
7474
RegisterGithubApp(ctx context.Context, installationID string) (err error)
7575
TakeSnapshot(ctx context.Context, options *TakeSnapshotOptions) (res string, err error)
76+
WaitForSnapshot(ctx context.Context, options *WaitForSnapshotOptions) (err error)
7677
GetSnapshots(ctx context.Context, workspaceID string) (res []*string, err error)
7778
StoreLayout(ctx context.Context, workspaceID string, layoutData string) (err error)
7879
GetLayout(ctx context.Context, workspaceID string) (res string, err error)
@@ -1277,6 +1278,21 @@ func (gp *APIoverJSONRPC) TakeSnapshot(ctx context.Context, options *TakeSnapsho
12771278
return
12781279
}
12791280

1281+
// WaitForSnapshot calls waitForSnapshot on the server
1282+
func (gp *APIoverJSONRPC) WaitForSnapshot(ctx context.Context, options *WaitForSnapshotOptions) (err error) {
1283+
if gp == nil {
1284+
err = errNotConnected
1285+
return
1286+
}
1287+
var _params []interface{}
1288+
1289+
_params = append(_params, options)
1290+
1291+
var result string
1292+
err = gp.C.Call(ctx, "waitForSnapshot", _params, &result)
1293+
return
1294+
}
1295+
12801296
// GetSnapshots calls getSnapshots on the server
12811297
func (gp *APIoverJSONRPC) GetSnapshots(ctx context.Context, workspaceID string) (res []*string, err error) {
12821298
if gp == nil {
@@ -1927,6 +1943,11 @@ type TakeSnapshotOptions struct {
19271943
WorkspaceID string `json:"workspaceId,omitempty"`
19281944
}
19291945

1946+
// WaitForSnapshotOptions is the WaitForSnapshotOptions message type
1947+
type WaitForSnapshotOptions struct {
1948+
SnapshotID string `json:"snapshotID,omitempty"`
1949+
}
1950+
19301951
// PreparePluginUploadParams is the PreparePluginUploadParams message type
19311952
type PreparePluginUploadParams struct {
19321953
FullPluginName string `json:"fullPluginName,omitempty"`

components/gitpod-protocol/go/mock.go

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

components/gitpod-protocol/src/gitpod-service.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,10 +153,15 @@ export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer,
153153
registerGithubApp(installationId: string): Promise<void>;
154154

155155
/**
156-
* Stores a new snapshot for the given workspace and bucketId
156+
* Stores a new snapshot for the given workspace and bucketId. Returns _before_ the actual snapshot is done. To wait for that, use `waitForSnapshot`.
157157
* @return the snapshot id
158158
*/
159159
takeSnapshot(options: GitpodServer.TakeSnapshotOptions): Promise<string>;
160+
/**
161+
*
162+
* @param snapshotId
163+
*/
164+
waitForSnapshot(snapshotId: string): Promise<void>;
160165

161166
/**
162167
* Returns the list of snapshots that exist for a workspace.

components/gitpod-protocol/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ export * from './email-protocol';
1717
export * from './headless-workspace-log';
1818
export * from './context-url';
1919
export * from './teams-projects-protocol';
20+
export * from './snapshot-url';

components/gitpod-protocol/src/messaging/error.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,6 @@ export namespace ErrorCodes {
7676
// 620 Team Subscription Assignment Failed
7777
export const TEAM_SUBSCRIPTION_ASSIGNMENT_FAILED = 620;
7878

79-
// 666 Not Implemented TODO IO-SPLIT remove
80-
export const NOT_IMPLEMENTED = 666;
79+
// 630 Snapshot Error
80+
export const SNAPSHOT_ERROR = 630;
8181
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* Copyright (c) 2020 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License-AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { suite, test } from "mocha-typescript"
8+
import * as chai from "chai"
9+
import { SnapshotUrl } from ".";
10+
11+
const expect = chai.expect
12+
13+
@suite class TestSnapshotUrlParser {
14+
15+
@test public testPositive() {
16+
const actual = SnapshotUrl.parse("workspaces/c362d434-6faa-4ce0-9ad4-91b4a87c4abe/3f0556f7-4afa-11e9-98d5-52f8983b9279.tar@gitpod-prodcopy-user-e1e28f18-0354-4a5d-b6b4-8879a2ff73fd");
17+
18+
expect(actual).to.deep.equal(<SnapshotUrl>{
19+
bucketId: "gitpod-prodcopy-user-e1e28f18-0354-4a5d-b6b4-8879a2ff73fd",
20+
filename: "3f0556f7-4afa-11e9-98d5-52f8983b9279.tar",
21+
fullPath: "workspaces/c362d434-6faa-4ce0-9ad4-91b4a87c4abe/3f0556f7-4afa-11e9-98d5-52f8983b9279.tar",
22+
});
23+
}
24+
}
25+
module.exports = new TestSnapshotUrlParser() // Only to circumvent no usage warning :-/
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* Copyright (c) 2021 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License-AGPL.txt in the project root for license information.
5+
*/
6+
7+
export interface SnapshotUrl {
8+
bucketId: string;
9+
fullPath: string;
10+
filename: string;
11+
}
12+
export namespace SnapshotUrl {
13+
export function parse(url: string): SnapshotUrl {
14+
const parts = url.split("@");
15+
if (parts.length !== 2) {
16+
throw new Error(`cannot parse snapshot URL: ${url}`);
17+
}
18+
const [fullPath, bucketId] = parts;
19+
20+
const pathParts = fullPath.split("/");
21+
if (pathParts.length < 1) {
22+
throw new Error(`cannot parse snapshot URL: ${url}`);
23+
}
24+
const filename = pathParts[pathParts.length - 1];
25+
return { bucketId, fullPath, filename };
26+
}
27+
}

components/server/ee/src/container-module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import { UserDeletionServiceEE } from "./user/user-deletion-service";
5151
import { GitHubAppSupport } from "./github/github-app-support";
5252
import { GitLabAppSupport } from "./gitlab/gitlab-app-support";
5353
import { Config } from "../../src/config";
54+
import { SnapshotService } from "./workspace/snapshot-service";
5455

5556
export const productionEEContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => {
5657
rebind(Server).to(ServerEE).inSingletonScope();
@@ -87,6 +88,7 @@ export const productionEEContainerModule = new ContainerModule((bind, unbind, is
8788
bind(EMailDomainService).to(EMailDomainServiceImpl).inSingletonScope();
8889
rebind(BlockedUserFilter).toService(EMailDomainService);
8990
rebind(UserController).to(UserControllerEE).inSingletonScope();
91+
bind(SnapshotService).toSelf().inSingletonScope();
9092

9193
bind(UserDeletionServiceEE).toSelf().inSingletonScope();
9294
rebind(UserDeletionService).to(UserDeletionServiceEE).inSingletonScope();

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

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import { injectable, inject } from "inversify";
88
import { GitpodServerImpl } from "../../../src/workspace/gitpod-server-impl";
99
import { TraceContext } from "@gitpod/gitpod-protocol/lib/util/tracing";
10-
import { GitpodServer, GitpodClient, AdminGetListRequest, User, AdminGetListResult, Permission, AdminBlockUserRequest, AdminModifyRoleOrPermissionRequest, RoleOrPermission, AdminModifyPermanentWorkspaceFeatureFlagRequest, UserFeatureSettings, AdminGetWorkspacesRequest, WorkspaceAndInstance, GetWorkspaceTimeoutResult, WorkspaceTimeoutDuration, WorkspaceTimeoutValues, SetWorkspaceTimeoutResult, WorkspaceContext, CreateWorkspaceMode, WorkspaceCreationResult, PrebuiltWorkspaceContext, CommitContext, PrebuiltWorkspace, PermissionName, WorkspaceInstance, EduEmailDomain, ProviderRepository, Queue, PrebuildWithStatus, CreateProjectParams, Project, StartPrebuildResult, ClientHeaderFields } from "@gitpod/gitpod-protocol";
10+
import { GitpodServer, GitpodClient, AdminGetListRequest, User, AdminGetListResult, Permission, AdminBlockUserRequest, AdminModifyRoleOrPermissionRequest, RoleOrPermission, AdminModifyPermanentWorkspaceFeatureFlagRequest, UserFeatureSettings, AdminGetWorkspacesRequest, WorkspaceAndInstance, GetWorkspaceTimeoutResult, WorkspaceTimeoutDuration, WorkspaceTimeoutValues, SetWorkspaceTimeoutResult, WorkspaceContext, CreateWorkspaceMode, WorkspaceCreationResult, PrebuiltWorkspaceContext, CommitContext, PrebuiltWorkspace, PermissionName, WorkspaceInstance, EduEmailDomain, ProviderRepository, Queue, PrebuildWithStatus, CreateProjectParams, Project, StartPrebuildResult, ClientHeaderFields, Workspace } from "@gitpod/gitpod-protocol";
1111
import { ResponseError } from "vscode-jsonrpc";
1212
import { TakeSnapshotRequest, AdmissionLevel, ControlAdmissionRequest, StopWorkspacePolicy, DescribeWorkspaceRequest, SetTimeoutRequest } from "@gitpod/ws-manager/lib";
1313
import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
@@ -41,6 +41,7 @@ import { Chargebee as chargebee } from '@gitpod/gitpod-payment-endpoint/lib/char
4141
import { GitHubAppSupport } from "../github/github-app-support";
4242
import { GitLabAppSupport } from "../gitlab/gitlab-app-support";
4343
import { Config } from "../../../src/config";
44+
import { SnapshotService } from "./snapshot-service";
4445

4546
@injectable()
4647
export class GitpodServerEEImpl extends GitpodServerImpl<GitpodClient, GitpodServer> {
@@ -73,6 +74,8 @@ export class GitpodServerEEImpl extends GitpodServerImpl<GitpodClient, GitpodSer
7374

7475
@inject(Config) protected readonly config: Config;
7576

77+
@inject(SnapshotService) protected readonly snapshotService: SnapshotService;
78+
7679
initialize(client: GitpodClient | undefined, user: User, accessGuard: ResourceAccessGuard, clientHeaderFields: ClientHeaderFields): void {
7780
super.initialize(client, user, accessGuard, clientHeaderFields);
7881
this.listenToCreditAlerts();
@@ -370,18 +373,13 @@ export class GitpodServerEEImpl extends GitpodServerImpl<GitpodClient, GitpodSer
370373
span.setTag("userId", user.id);
371374

372375
try {
373-
const workspace = await this.workspaceDb.trace({ span }).findById(workspaceId);
374-
if (!workspace || workspace.ownerId !== user.id) {
375-
throw new ResponseError(ErrorCodes.NOT_FOUND, `Workspace ${workspaceId} does not exist.`);
376-
}
376+
const workspace = await this.guardSnaphotAccess(span, user.id, workspaceId);
377377

378378
const instance = await this.workspaceDb.trace({ span }).findRunningInstance(workspaceId);
379379
if (!instance) {
380380
throw new ResponseError(ErrorCodes.NOT_FOUND, `Workspace ${workspaceId} has no running instance`);
381381
}
382-
383382
await this.guardAccess({ kind: "workspaceInstance", subject: instance, workspace}, "get");
384-
await this.guardAccess({ kind: "snapshot", subject: undefined, workspaceOwnerID: workspace.ownerId, workspaceID: workspace.id }, "create");
385383

386384
const client = await this.workspaceManagerClientProvider.get(instance.region);
387385
const request = new TakeSnapshotRequest();
@@ -392,18 +390,63 @@ export class GitpodServerEEImpl extends GitpodServerImpl<GitpodClient, GitpodSer
392390
const resp = await client.takeSnapshot({ span }, request);
393391

394392
const id = uuidv4()
395-
this.workspaceDb.trace({ span }).storeSnapshot({
393+
await this.workspaceDb.trace({ span }).storeSnapshot({
396394
id,
397395
creationTime: new Date().toISOString(),
396+
state: 'available',
398397
bucketId: resp.getUrl(),
399398
originalWorkspaceId: workspaceId,
400-
layoutData
399+
layoutData,
401400
});
402401

403402
return id;
404-
} catch (e) {
405-
TraceContext.logError({ span }, e);
406-
throw e;
403+
} catch (err) {
404+
TraceContext.logError({ span }, err);
405+
throw err;
406+
} finally {
407+
span.finish()
408+
}
409+
}
410+
411+
protected async guardSnaphotAccess(span: opentracing.Span, userId: string, workspaceId: string) : Promise<Workspace> {
412+
const workspace = await this.workspaceDb.trace({ span }).findById(workspaceId);
413+
if (!workspace || workspace.ownerId !== userId) {
414+
throw new ResponseError(ErrorCodes.NOT_FOUND, `Workspace ${workspaceId} does not exist.`);
415+
}
416+
await this.guardAccess({ kind: "snapshot", subject: undefined, workspaceOwnerID: workspace.ownerId, workspaceID: workspace.id }, "create");
417+
418+
return workspace;
419+
}
420+
421+
/**
422+
* @param snapshotId
423+
* @throws ResponseError with either NOT_FOUND or SNAPSHOT_ERROR in case the snapshot is not done yet.
424+
*/
425+
async waitForSnapshot(snapshotId: string): Promise<void> {
426+
this.requireEELicense(Feature.FeatureSnapshot);
427+
428+
const user = this.checkAndBlockUser("waitForSnapshot");
429+
430+
const span = opentracing.globalTracer().startSpan("waitForSnapshot");
431+
span.setTag("snapshotId", snapshotId);
432+
span.setTag("userId", user.id);
433+
434+
try {
435+
const snapshot = await this.workspaceDb.trace({ span }).findSnapshotById(snapshotId);
436+
if (!snapshot) {
437+
throw new ResponseError(ErrorCodes.NOT_FOUND, `No snapshot with id '${snapshotId}' found.`)
438+
}
439+
const snapshotWorkspace = await this.guardSnaphotAccess(span, user.id, snapshot.originalWorkspaceId);
440+
441+
try {
442+
await this.snapshotService.driveSnapshot(snapshotWorkspace.ownerId, snapshot);
443+
} catch (err) {
444+
// wrap in SNAPSHOT_ERROR to signal this call should not be retried.
445+
throw new ResponseError(ErrorCodes.SNAPSHOT_ERROR, err.toString());
446+
}
447+
} catch (err) {
448+
TraceContext.logError({ span }, err);
449+
throw err;
407450
} finally {
408451
span.finish()
409452
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* Copyright (c) 2021 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License-AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { WorkspaceDB } from "@gitpod/gitpod-db/lib";
8+
import { Snapshot } from "@gitpod/gitpod-protocol";
9+
import { inject, injectable } from "inversify";
10+
import { StorageClient } from "../../../src/storage/storage-client";
11+
12+
const SNAPSHOT_TIMEOUT_SECONDS = 60 * 30;
13+
14+
@injectable()
15+
export class SnapshotService {
16+
@inject(WorkspaceDB) protected readonly workspaceDb: WorkspaceDB;
17+
@inject(StorageClient) protected readonly storageClient: StorageClient;
18+
19+
public async driveSnapshot(snapshotWorkspaceOwner: string, _snapshot: Snapshot): Promise<void> {
20+
const { id: snapshotId, bucketId, originalWorkspaceId, creationTime } = _snapshot;
21+
const start = new Date(creationTime).getTime();
22+
while (start + SNAPSHOT_TIMEOUT_SECONDS < Date.now()) {
23+
await new Promise((resolve) => setTimeout(resolve, 3000));
24+
25+
// pending: check if we're done:
26+
const exists = await this.storageClient.workspaceSnapshotExists(snapshotWorkspaceOwner, originalWorkspaceId, bucketId);
27+
if (exists) {
28+
await this.workspaceDb.updateSnapshot({
29+
id: snapshotId,
30+
state: 'available',
31+
availableTime: new Date().toISOString(),
32+
});
33+
return
34+
}
35+
36+
const snapshot = await this.workspaceDb.findSnapshotById(snapshotId);
37+
if (!snapshot) {
38+
throw new Error(`no snapshot with id '${snapshotId}' found.`)
39+
}
40+
41+
if (snapshot.state === 'available') {
42+
return;
43+
}
44+
if (snapshot.state === 'error') {
45+
throw new Error(`snapshot error: ${snapshot.message}`);
46+
}
47+
}
48+
49+
// took too long
50+
const message = `snapshot timed out after taking longer than ${SNAPSHOT_TIMEOUT_SECONDS}s.`;
51+
await this.workspaceDb.updateSnapshot({
52+
id: snapshotId,
53+
state: 'error',
54+
message,
55+
});
56+
throw new Error(message);
57+
}
58+
}

components/server/src/auth/rate-limiter.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ function getConfig(config: RateLimiterConfig): RateLimiterConfig {
109109
"sendFeedback": { group: "default", points: 1 },
110110
"registerGithubApp": { group: "default", points: 1 },
111111
"takeSnapshot": { group: "default", points: 1 },
112+
"waitForSnapshot": { group: "default", points: 1 },
112113
"getSnapshots": { group: "default", points: 1 },
113114
"storeLayout": { group: "default", points: 1 },
114115
"getLayout": { group: "default", points: 1 },

components/server/src/storage/content-service-client.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import { DeleteUserContentRequest, DeleteUserContentResponse } from "@gitpod/con
99
import { IDEPluginServiceClient } from '@gitpod/content-service/lib/ideplugin_grpc_pb';
1010
import { PluginDownloadURLRequest, PluginDownloadURLResponse, PluginHashRequest, PluginHashResponse, PluginUploadURLRequest, PluginUploadURLResponse } from "@gitpod/content-service/lib/ideplugin_pb";
1111
import { WorkspaceServiceClient } from '@gitpod/content-service/lib/workspace_grpc_pb';
12-
import { DeleteWorkspaceRequest, DeleteWorkspaceResponse, WorkspaceDownloadURLRequest, WorkspaceDownloadURLResponse } from "@gitpod/content-service/lib/workspace_pb";
12+
import { DeleteWorkspaceRequest, DeleteWorkspaceResponse, WorkspaceDownloadURLRequest, WorkspaceDownloadURLResponse, WorkspaceSnapshotExistsRequest, WorkspaceSnapshotExistsResponse } from "@gitpod/content-service/lib/workspace_pb";
13+
import { SnapshotUrl } from '@gitpod/gitpod-protocol';
1314
import { inject, injectable } from "inversify";
1415
import { StorageClient } from "./storage-client";
1516

@@ -119,4 +120,22 @@ export class ContentServiceStorageClient implements StorageClient {
119120
});
120121
return response.toObject().hash;
121122
}
123+
124+
public async workspaceSnapshotExists(ownerId: string, workspaceId: string, snapshotUrl: string): Promise<boolean> {
125+
const { filename } = SnapshotUrl.parse(snapshotUrl);
126+
const response = await new Promise<WorkspaceSnapshotExistsResponse>((resolve, reject) => {
127+
const request = new WorkspaceSnapshotExistsRequest();
128+
request.setOwnerId(ownerId);
129+
request.setWorkspaceId(workspaceId);
130+
request.setFilename(filename);
131+
this.workspaceServiceClient.workspaceSnapshotExists(request, (err: any, resp: WorkspaceSnapshotExistsResponse) => {
132+
if (err) {
133+
reject(err);
134+
} else {
135+
resolve(resp);
136+
}
137+
});
138+
});
139+
return response.getExists();
140+
}
122141
}

components/server/src/storage/storage-client.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,7 @@ export interface StorageClient {
2121

2222
// getHash produces a hash of the of storage object
2323
getPluginHash(bucketName: string, objectPath: string): Promise<string>;
24+
25+
// checks whether the specified snashot exists or not
26+
workspaceSnapshotExists(ownerId: string, workspaceId: string, snapshotUrl: string): Promise<boolean>;
2427
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1254,6 +1254,10 @@ export class GitpodServerImpl<Client extends GitpodClient, Server extends Gitpod
12541254
throw new ResponseError(ErrorCodes.EE_FEATURE, `Snapshot support is implemented in Gitpod's Enterprise Edition`);
12551255
}
12561256

1257+
async waitForSnapshot(snapshotId: string): Promise<void> {
1258+
throw new ResponseError(ErrorCodes.EE_FEATURE, `Snapshot support is implemented in Gitpod's Enterprise Edition`);
1259+
}
1260+
12571261
async getSnapshots(workspaceId: string): Promise<string[]> {
12581262
// this is an EE feature. Throwing an exception here would break the dashboard though.
12591263
return [];

0 commit comments

Comments
 (0)