Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ export class JsonRpcWorkspaceClient implements PromiseClient<typeof WorkspaceSer
throw new ApplicationError(ErrorCodes.BAD_REQUEST, "workspaceId is required");
}
if (
request.spec?.timeout?.inactivity?.seconds ||
request.spec?.timeout?.inactivity !== undefined ||
(request.spec?.sshPublicKeys && request.spec?.sshPublicKeys.length > 0)
) {
throw new ApplicationError(ErrorCodes.UNIMPLEMENTED, "not implemented");
Expand Down
16 changes: 16 additions & 0 deletions components/gitpod-protocol/src/gitpod-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,22 @@ export namespace WorkspaceTimeoutDuration {
throw new Error(`Invalid timeout format: ${duration}. Use Go duration format (e.g., "30m", "1h30m", "2h")`);
}
}

/**
* Parses a Go-style duration string to milliseconds.
* Returns undefined if the duration is invalid.
*/
export function toMs(duration: string): number | undefined {
try {
const ms = parse(duration.toLowerCase());
if (ms === undefined || ms === null) {
return undefined;
}
return ms;
} catch {
return undefined;
}
}
}

export const WORKSPACE_TIMEOUT_DEFAULT_SHORT: WorkspaceTimeoutDuration = "30m";
Expand Down
20 changes: 20 additions & 0 deletions components/gitpod-protocol/src/timeout-validation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,26 @@ describe("WorkspaceTimeoutDuration", () => {
// Zero duration components are handled by the totalMinutes > 0 check
expect(() => WorkspaceTimeoutDuration.validate("0m")).to.throw("Invalid timeout value");
expect(() => WorkspaceTimeoutDuration.validate("0h")).to.throw("Invalid timeout value");
expect(() => WorkspaceTimeoutDuration.validate("0s")).to.throw("Invalid timeout value");
});
});

describe("toMs", () => {
it("should parse valid durations to milliseconds", () => {
expect(WorkspaceTimeoutDuration.toMs("30m")).to.equal(30 * 60 * 1000);
expect(WorkspaceTimeoutDuration.toMs("1h")).to.equal(60 * 60 * 1000);
expect(WorkspaceTimeoutDuration.toMs("1h30m")).to.equal(90 * 60 * 1000);
expect(WorkspaceTimeoutDuration.toMs("2h15m")).to.equal(135 * 60 * 1000);
});

it("should return undefined for invalid durations", () => {
expect(WorkspaceTimeoutDuration.toMs("invalid")).to.be.undefined;
expect(WorkspaceTimeoutDuration.toMs("")).to.be.undefined;
});

it("should handle zero durations", () => {
expect(WorkspaceTimeoutDuration.toMs("0s")).to.equal(0);
expect(WorkspaceTimeoutDuration.toMs("0m")).to.equal(0);
});
});
});
2 changes: 1 addition & 1 deletion components/server/src/api/workspace-service-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ export class WorkspaceServiceAPI implements ServiceImpl<typeof WorkspaceServiceI
if (!isWorkspaceId(req.workspaceId)) {
throw new ApplicationError(ErrorCodes.BAD_REQUEST, "a valid workspaceId is required");
}
if (req.spec?.timeout?.inactivity?.seconds || (req.spec?.sshPublicKeys && req.spec?.sshPublicKeys.length > 0)) {
if (req.spec?.timeout?.inactivity !== undefined || (req.spec?.sshPublicKeys && req.spec?.sshPublicKeys.length > 0)) {
throw new ApplicationError(ErrorCodes.UNIMPLEMENTED, "not implemented");
}
const userId = ctxUserId();
Expand Down
49 changes: 49 additions & 0 deletions components/server/src/workspace/workspace-service.spec.db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,55 @@ describe("WorkspaceService", async () => {
);
});

it("should reject setWorkspaceTimeout when denyUserTimeouts is set", async () => {
const svc = container.get(WorkspaceService);
const orgService = container.get(OrganizationService);
const ws = await createTestWorkspace(svc, org, owner, project);

await orgService.updateSettings(owner.id, org.id, {
timeoutSettings: { denyUserTimeouts: true },
});

await expectError(
ErrorCodes.PRECONDITION_FAILED,
svc.setWorkspaceTimeout(owner.id, ws.id, "60m"),
"should fail when user timeouts are denied",
);
});

it("should reject setWorkspaceTimeout exceeding org inactivity policy", async () => {
const svc = container.get(WorkspaceService);
const orgService = container.get(OrganizationService);
const ws = await createTestWorkspace(svc, org, owner, project);

await orgService.updateSettings(owner.id, org.id, {
timeoutSettings: { inactivity: "30m" },
});

await expectError(
ErrorCodes.PRECONDITION_FAILED,
svc.setWorkspaceTimeout(owner.id, ws.id, "60m"),
"should fail when timeout exceeds org inactivity policy",
);
});

it("should allow setWorkspaceTimeout within org inactivity policy", async () => {
const svc = container.get(WorkspaceService);
const orgService = container.get(OrganizationService);
const ws = await createTestWorkspace(svc, org, owner, project);

await orgService.updateSettings(owner.id, org.id, {
timeoutSettings: { inactivity: "60m" },
});

// This should pass the org policy check but fail on non-running workspace
await expectError(
ErrorCodes.NOT_FOUND,
svc.setWorkspaceTimeout(owner.id, ws.id, "30m"),
"should pass org policy check but fail on non-running workspace",
);
});

it("should getHeadlessLog", async () => {
const svc = container.get(WorkspaceService);
await createTestWorkspace(svc, org, owner, project);
Expand Down
12 changes: 12 additions & 0 deletions components/server/src/workspace/workspace-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1076,6 +1076,18 @@ export class WorkspaceService {
);
}

// Enforce org inactivity policy as a maximum: user-set timeout must not exceed it
if (orgSettings.timeoutSettings?.inactivity) {
const orgTimeoutMs = WorkspaceTimeoutDuration.toMs(orgSettings.timeoutSettings.inactivity);
const requestedMs = WorkspaceTimeoutDuration.toMs(validatedDuration);
if (orgTimeoutMs !== undefined && requestedMs !== undefined && requestedMs > orgTimeoutMs) {
throw new ApplicationError(
ErrorCodes.PRECONDITION_FAILED,
`Timeout exceeds the organization's inactivity limit of ${orgSettings.timeoutSettings.inactivity}`,
);
}
}

const instance = await this.getCurrentInstance(userId, workspaceId);
if (instance.status.phase !== "running" || workspace.type !== "regular") {
throw new ApplicationError(ErrorCodes.NOT_FOUND, "Can only set keep-alive for regular, running workspaces");
Expand Down
15 changes: 13 additions & 2 deletions components/server/src/workspace/workspace-starter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1673,11 +1673,22 @@ export class WorkspaceStarter {
} catch (err) {}
}

// Users can optionally override the organization-wide timeout default if the organization allows it
// Users can optionally override the organization-wide timeout default if the organization allows it,
// but the user timeout must not exceed the org's inactivity policy.
if (!organizationSettings.timeoutSettings?.denyUserTimeouts && user.additionalData?.workspaceTimeout) {
try {
const timeout = WorkspaceTimeoutDuration.validate(user.additionalData?.workspaceTimeout);
spec.setTimeout(timeout);
const orgTimeoutMs = organizationSettings.timeoutSettings?.inactivity
? WorkspaceTimeoutDuration.toMs(organizationSettings.timeoutSettings.inactivity)
: undefined;
const userTimeoutMs = WorkspaceTimeoutDuration.toMs(timeout);
if (
orgTimeoutMs === undefined ||
userTimeoutMs === undefined ||
userTimeoutMs <= orgTimeoutMs
) {
spec.setTimeout(timeout);
}
} catch (err) {}
}

Expand Down
Loading