Skip to content

Commit

Permalink
[server, cli] Allow flexible workspace timeouts
Browse files Browse the repository at this point in the history
  • Loading branch information
svenefftinge committed Jan 18, 2023
1 parent 2b17f0d commit c79ff26
Show file tree
Hide file tree
Showing 10 changed files with 105 additions and 142 deletions.
3 changes: 1 addition & 2 deletions components/gitpod-cli/cmd/timeout-extend.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,7 @@ var extendTimeoutCmd = &cobra.Command{
if err != nil {
fail(err.Error())
}
var tmp serverapi.WorkspaceTimeoutDuration = serverapi.WorkspaceTimeoutDuration180m
if _, err := client.SetWorkspaceTimeout(ctx, wsInfo.WorkspaceId, &tmp); err != nil {
if _, err := client.SetWorkspaceTimeout(ctx, wsInfo.WorkspaceId, time.Minute*180); err != nil {
if err, ok := err.(*jsonrpc2.Error); ok && err.Code == serverapi.PLAN_PROFESSIONAL_REQUIRED {
fail("Cannot extend workspace timeout for current plan, please upgrade your plan")
}
Expand Down
58 changes: 58 additions & 0 deletions components/gitpod-cli/cmd/timeout-set.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// 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.

package cmd

import (
"context"
"fmt"
"time"

gitpod "github.com/gitpod-io/gitpod/gitpod-cli/pkg/gitpod"
serverapi "github.com/gitpod-io/gitpod/gitpod-protocol"
"github.com/sourcegraph/jsonrpc2"
"github.com/spf13/cobra"
)

// setTimeoutCmd sets the timeout of current workspace
var setTimeoutCmd = &cobra.Command{
Use: "set <duration>",
Args: cobra.ExactArgs(1),
Short: "Set timeout of current workspace",
Long: `Set timeout of current workspace.
Duration must be in the format of <n>m (minutes), <n>h (hours), or <n>d (days).
For example, 30m, 1h, 2d, etc.`,
Example: `gitpod timeout set 1h`,
Run: func(cmd *cobra.Command, args []string) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
wsInfo, err := gitpod.GetWSInfo(ctx)
if err != nil {
fail(err.Error())
}
client, err := gitpod.ConnectToServer(ctx, wsInfo, []string{
"function:setWorkspaceTimeout",
"resource:workspace::" + wsInfo.WorkspaceId + "::get/update",
})
if err != nil {
fail(err.Error())
}
duration, err := time.ParseDuration(args[0])
if err != nil {
fail(err.Error())
}
if _, err := client.SetWorkspaceTimeout(ctx, wsInfo.WorkspaceId, duration); err != nil {
if err, ok := err.(*jsonrpc2.Error); ok && err.Code == serverapi.PLAN_PROFESSIONAL_REQUIRED {
fail("Cannot extend workspace timeout for current plan, please upgrade your plan")
}
fail(err.Error())
}
fmt.Printf("Workspace timeout has been set to %d minutes.\n", int(duration.Minutes()))
},
}

func init() {
timeoutCmd.AddCommand(setTimeoutCmd)
}
10 changes: 4 additions & 6 deletions components/gitpod-cli/cmd/timeout-show.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,11 @@ var showTimeoutCommand = &cobra.Command{
fail(err.Error())
}

// Try to use `DurationRaw` but fall back to `Duration` in case of
// old server component versions that don't expose it.
if res.DurationRaw != "" {
fmt.Println("Timeout for current workspace is", res.DurationRaw)
} else {
fmt.Println("Timeout for current workspace is", res.Duration)
duration, err := time.ParseDuration(res.Duration)
if err != nil {
fail(err.Error())
}
fmt.Printf("Workspace timeout is set to %d minutes.\n", int(duration.Minutes()))
},
}

Expand Down
24 changes: 6 additions & 18 deletions components/gitpod-protocol/go/gitpod-service.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"net/http"
"net/url"
"sync"
"time"

"github.com/sourcegraph/jsonrpc2"
"golang.org/x/xerrors"
Expand Down Expand Up @@ -57,7 +58,7 @@ type APIInterface interface {
SendHeartBeat(ctx context.Context, options *SendHeartBeatOptions) (err error)
WatchWorkspaceImageBuildLogs(ctx context.Context, workspaceID string) (err error)
IsPrebuildDone(ctx context.Context, pwsid string) (res bool, err error)
SetWorkspaceTimeout(ctx context.Context, workspaceID string, duration *WorkspaceTimeoutDuration) (res *SetWorkspaceTimeoutResult, err error)
SetWorkspaceTimeout(ctx context.Context, workspaceID string, duration time.Duration) (res *SetWorkspaceTimeoutResult, err error)
GetWorkspaceTimeout(ctx context.Context, workspaceID string) (res *GetWorkspaceTimeoutResult, err error)
GetOpenPorts(ctx context.Context, workspaceID string) (res []*WorkspaceInstancePort, err error)
OpenPort(ctx context.Context, workspaceID string, port *WorkspaceInstancePort) (res *WorkspaceInstancePort, err error)
Expand Down Expand Up @@ -952,15 +953,15 @@ func (gp *APIoverJSONRPC) IsPrebuildDone(ctx context.Context, pwsid string) (res
}

// SetWorkspaceTimeout calls setWorkspaceTimeout on the server
func (gp *APIoverJSONRPC) SetWorkspaceTimeout(ctx context.Context, workspaceID string, duration *WorkspaceTimeoutDuration) (res *SetWorkspaceTimeoutResult, err error) {
func (gp *APIoverJSONRPC) SetWorkspaceTimeout(ctx context.Context, workspaceID string, duration time.Duration) (res *SetWorkspaceTimeoutResult, err error) {
if gp == nil {
err = errNotConnected
return
}
var _params []interface{}

_params = append(_params, workspaceID)
_params = append(_params, duration)
_params = append(_params, fmt.Sprintf("%dm", int(duration.Minutes())))

var result SetWorkspaceTimeoutResult
err = gp.C.Call(ctx, "setWorkspaceTimeout", _params, &result)
Expand Down Expand Up @@ -1619,18 +1620,6 @@ const (
PinActionToggle PinAction = "toggle"
)

// WorkspaceTimeoutDuration is the durations one have set for the workspace timeout
type WorkspaceTimeoutDuration string

const (
// WorkspaceTimeoutDuration30m sets "30m" as timeout duration
WorkspaceTimeoutDuration30m = "30m"
// WorkspaceTimeoutDuration60m sets "60m" as timeout duration
WorkspaceTimeoutDuration60m = "60m"
// WorkspaceTimeoutDuration180m sets "180m" as timeout duration
WorkspaceTimeoutDuration180m = "180m"
)

// UserInfo is the UserInfo message type
type UserInfo struct {
Name string `json:"name,omitempty"`
Expand Down Expand Up @@ -1909,9 +1898,8 @@ type StartWorkspaceOptions struct {

// GetWorkspaceTimeoutResult is the GetWorkspaceTimeoutResult message type
type GetWorkspaceTimeoutResult struct {
CanChange bool `json:"canChange,omitempty"`
DurationRaw string `json:"durationRaw,omitempty"`
Duration string `json:"duration,omitempty"`
CanChange bool `json:"canChange,omitempty"`
Duration string `json:"duration,omitempty"`
}

// WorkspaceInstancePort is the WorkspaceInstancePort message type
Expand Down
5 changes: 3 additions & 2 deletions components/gitpod-protocol/go/mock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 18 additions & 14 deletions components/gitpod-protocol/src/gitpod-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,16 +353,24 @@ export interface ClientHeaderFields {
clientRegion?: string;
}

export const WORKSPACE_TIMEOUT_DEFAULT_SHORT = "short";
export const WORKSPACE_TIMEOUT_DEFAULT_LONG = "long";
export const WORKSPACE_TIMEOUT_EXTENDED = "extended";
export const WORKSPACE_TIMEOUT_EXTENDED_ALT = "180m"; // for backwards compatibility since the IDE uses this
export const WorkspaceTimeoutValues = [
WORKSPACE_TIMEOUT_DEFAULT_SHORT,
WORKSPACE_TIMEOUT_DEFAULT_LONG,
WORKSPACE_TIMEOUT_EXTENDED,
WORKSPACE_TIMEOUT_EXTENDED_ALT,
] as const;
export type WorkspaceTimeoutDuration = string;
export namespace WorkspaceTimeoutDuration {
export function validate(duration: string): WorkspaceTimeoutDuration {
const unit = duration.slice(-1);
if (!["m", "h", "d"].includes(unit)) {
throw new Error(`Invalid timeout unit: ${unit}`);
}
const value = parseInt(duration.slice(0, -1));
if (isNaN(value) || value <= 0) {
throw new Error(`Invalid timeout value: ${duration}`);
}
return duration;
}
}

export const WORKSPACE_TIMEOUT_DEFAULT_SHORT: WorkspaceTimeoutDuration = "30m";
export const WORKSPACE_TIMEOUT_DEFAULT_LONG: WorkspaceTimeoutDuration = "60m";
export const WORKSPACE_TIMEOUT_EXTENDED: WorkspaceTimeoutDuration = "180m";

export const createServiceMock = function <C extends GitpodClient, S extends GitpodServer>(
methods: Partial<JsonRpcProxy<S>>,
Expand All @@ -387,16 +395,12 @@ export const createServerMock = function <C extends GitpodClient, S extends Gitp
});
};

type WorkspaceTimeoutDurationTuple = typeof WorkspaceTimeoutValues;
export type WorkspaceTimeoutDuration = WorkspaceTimeoutDurationTuple[number];

export interface SetWorkspaceTimeoutResult {
resetTimeoutOnWorkspaces: string[];
}

export interface GetWorkspaceTimeoutResult {
duration: WorkspaceTimeoutDuration;
durationRaw: string;
canChange: boolean;
}

Expand Down
34 changes: 1 addition & 33 deletions components/server/ee/src/user/user-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,7 @@
*/

import { UserService, CheckSignUpParams, CheckTermsParams } from "../../../src/user/user-service";
import {
User,
WorkspaceTimeoutDuration,
WORKSPACE_TIMEOUT_EXTENDED,
WORKSPACE_TIMEOUT_EXTENDED_ALT,
WORKSPACE_TIMEOUT_DEFAULT_LONG,
WORKSPACE_TIMEOUT_DEFAULT_SHORT,
} from "@gitpod/gitpod-protocol";
import { User } from "@gitpod/gitpod-protocol";
import { inject } from "inversify";
import { LicenseEvaluator } from "@gitpod/licensor/lib";
import { AuthException } from "../../../src/auth/errors";
Expand All @@ -28,31 +21,6 @@ export class UserServiceEE extends UserService {
@inject(HostContextProvider) protected readonly hostContextProvider: HostContextProvider;
@inject(Config) protected readonly config: Config;

public workspaceTimeoutToDuration(timeout: WorkspaceTimeoutDuration): string {
switch (timeout) {
case WORKSPACE_TIMEOUT_DEFAULT_SHORT:
return "30m";
case WORKSPACE_TIMEOUT_DEFAULT_LONG:
return this.config.workspaceDefaults.timeoutDefault || "60m";
case WORKSPACE_TIMEOUT_EXTENDED:
case WORKSPACE_TIMEOUT_EXTENDED_ALT:
return this.config.workspaceDefaults.timeoutExtended || "180m";
}
}

public durationToWorkspaceTimeout(duration: string): WorkspaceTimeoutDuration {
switch (duration) {
case "30m":
return WORKSPACE_TIMEOUT_DEFAULT_SHORT;
case this.config.workspaceDefaults.timeoutDefault || "60m":
return WORKSPACE_TIMEOUT_DEFAULT_LONG;
case this.config.workspaceDefaults.timeoutExtended || "180m":
return WORKSPACE_TIMEOUT_EXTENDED_ALT;
default:
return WORKSPACE_TIMEOUT_DEFAULT_SHORT;
}
}

async checkSignUp(params: CheckSignUpParams) {
// todo@at: check if we need an optimization for SaaS here. used to be a no-op there.

Expand Down
41 changes: 12 additions & 29 deletions components/server/ee/src/workspace/gitpod-server-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import {
WorkspaceAndInstance,
GetWorkspaceTimeoutResult,
WorkspaceTimeoutDuration,
WorkspaceTimeoutValues,
SetWorkspaceTimeoutResult,
WorkspaceContext,
WorkspaceCreationResult,
Expand Down Expand Up @@ -374,14 +373,17 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
await this.requireEELicense(Feature.FeatureSetTimeout);
const user = this.checkUser("setWorkspaceTimeout");

if (!WorkspaceTimeoutValues.includes(duration)) {
throw new ResponseError(ErrorCodes.PERMISSION_DENIED, "Invalid duration");
}

if (!(await this.maySetTimeout(user))) {
throw new ResponseError(ErrorCodes.PLAN_PROFESSIONAL_REQUIRED, "Plan upgrade is required");
}

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

const workspace = await this.internalGetWorkspace(workspaceId, this.workspaceDb.trace(ctx));
const runningInstances = await this.workspaceDb.trace(ctx).findRegularRunningInstances(user.id);
const runningInstance = runningInstances.find((i) => i.workspaceId === workspaceId);
Expand All @@ -390,36 +392,18 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
}
await this.guardAccess({ kind: "workspaceInstance", subject: runningInstance, workspace: workspace }, "update");

// if any other running instance has a custom timeout other than the user's default, we'll reset that timeout
const client = await this.workspaceManagerClientProvider.get(
runningInstance.region,
this.config.installationShortname,
);
const defaultTimeout = await this.entitlementService.getDefaultWorkspaceTimeout(user, new Date());
const instancesWithReset = runningInstances.filter(
(i) => i.workspaceId !== workspaceId && i.status.timeout !== defaultTimeout && i.status.phase === "running",
);
await Promise.all(
instancesWithReset.map(async (i) => {
const req = new SetTimeoutRequest();
req.setId(i.id);
req.setDuration(this.userService.workspaceTimeoutToDuration(defaultTimeout));

const client = await this.workspaceManagerClientProvider.get(
i.region,
this.config.installationShortname,
);
return client.setTimeout(ctx, req);
}),
);

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

return {
resetTimeoutOnWorkspaces: instancesWithReset.map((i) => i.workspaceId),
resetTimeoutOnWorkspaces: [workspace.id],
};
}

Expand All @@ -439,7 +423,7 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
if (!runningInstance) {
log.warn({ userId: user.id, workspaceId }, "Can only get keep-alive for running workspaces");
const duration = WORKSPACE_TIMEOUT_DEFAULT_SHORT;
return { duration, durationRaw: this.userService.workspaceTimeoutToDuration(duration), canChange };
return { duration, canChange };
}
await this.guardAccess({ kind: "workspaceInstance", subject: runningInstance, workspace: workspace }, "get");

Expand All @@ -451,10 +435,9 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
this.config.installationShortname,
);
const desc = await client.describeWorkspace(ctx, req);
const duration = this.userService.durationToWorkspaceTimeout(desc.getStatus()!.getSpec()!.getTimeout());
const durationRaw = this.userService.workspaceTimeoutToDuration(duration);
const duration = desc.getStatus()!.getSpec()!.getTimeout();

return { duration, durationRaw, canChange };
return { duration, canChange };
}

public async isPrebuildDone(ctx: TraceContext, pwsId: string): Promise<boolean> {
Expand Down
Loading

0 comments on commit c79ff26

Please sign in to comment.