From 70517fbd5a8f69b35824f31cce9458683591ffce Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Wed, 22 Nov 2023 16:49:59 +0100 Subject: [PATCH] [dashboard/server] app error conversion based on error details (#19103) --- .../src/components/UsageLimitReachedModal.tsx | 2 +- components/dashboard/src/start/StartPage.tsx | 4 +- .../src/workspaces/CreateWorkspacePage.tsx | 2 +- .../gitpod-protocol/src/messaging/error.ts | 27 +- .../src/public-api-converter.spec.ts | 350 +++++++++++++++++- .../src/public-api-converter.ts | 270 ++++++++++++-- components/server/src/auth/errors.ts | 13 - components/server/src/errors/index.ts | 64 +--- .../server/src/prebuilds/prebuild-manager.ts | 42 --- .../server/src/workspace/config-provider.ts | 23 +- .../src/workspace/context-parser-service.ts | 13 +- .../src/workspace/gitpod-server-impl.ts | 41 +- 12 files changed, 660 insertions(+), 191 deletions(-) diff --git a/components/dashboard/src/components/UsageLimitReachedModal.tsx b/components/dashboard/src/components/UsageLimitReachedModal.tsx index 35d870a4fee429..5416c48d9a419e 100644 --- a/components/dashboard/src/components/UsageLimitReachedModal.tsx +++ b/components/dashboard/src/components/UsageLimitReachedModal.tsx @@ -10,7 +10,7 @@ import Alert from "./Alert"; import Modal from "./Modal"; import { Heading2 } from "./typography/headings"; -export function UsageLimitReachedModal(p: { hints: any; onClose?: () => void }) { +export function UsageLimitReachedModal(p: { onClose?: () => void }) { const currentOrg = useCurrentOrg(); const orgName = currentOrg.data?.name; diff --git a/components/dashboard/src/start/StartPage.tsx b/components/dashboard/src/start/StartPage.tsx index eaaf8eba6b3f6c..8002c2bf024475 100644 --- a/components/dashboard/src/start/StartPage.tsx +++ b/components/dashboard/src/start/StartPage.tsx @@ -111,9 +111,7 @@ export function StartPage(props: StartPageProps) { )} {error && error.code === ErrorCodes.NEEDS_VERIFICATION && } - {error && error.code === ErrorCodes.PAYMENT_SPENDING_LIMIT_REACHED && ( - - )} + {error && error.code === ErrorCodes.PAYMENT_SPENDING_LIMIT_REACHED && } {error && } {props.children} = ({ error, reset, setS case ErrorCodes.INVALID_COST_CENTER: return ; case ErrorCodes.PAYMENT_SPENDING_LIMIT_REACHED: - return ; + return ; case ErrorCodes.NEEDS_VERIFICATION: return ; default: diff --git a/components/gitpod-protocol/src/messaging/error.ts b/components/gitpod-protocol/src/messaging/error.ts index cd81832898971e..863ffc1cb0e2a9 100644 --- a/components/gitpod-protocol/src/messaging/error.ts +++ b/components/gitpod-protocol/src/messaging/error.ts @@ -5,9 +5,15 @@ */ import { scrubber } from "../util/scrubbing"; +import { PlainMessage } from "@bufbuild/protobuf"; +import { + InvalidGitpodYMLError as InvalidGitpodYMLErrorData, + RepositoryNotFoundError as RepositoryNotFoundErrorData, + RepositoryUnauthorizedError as RepositoryUnauthorizedErrorData, +} from "@gitpod/public-api/lib/gitpod/v1/error_pb"; export class ApplicationError extends Error { - constructor(public readonly code: ErrorCode, message: string, public readonly data?: any) { + constructor(readonly code: ErrorCode, readonly message: string, readonly data?: any) { super(message); this.data = scrubber.scrub(this.data, true); } @@ -21,6 +27,25 @@ export class ApplicationError extends Error { } } +export class RepositoryNotFoundError extends ApplicationError { + constructor(readonly info: PlainMessage) { + // on gRPC we remap to PRECONDITION_FAILED, all error code for backwards compatibility with the dashboard + super(ErrorCodes.NOT_FOUND, "Repository not found.", info); + } +} +export class UnauthorizedRepositoryAccessError extends ApplicationError { + constructor(readonly info: PlainMessage) { + // on gRPC we remap to PRECONDITION_FAILED, all error code for backwards compatibility with the dashboard + super(ErrorCodes.NOT_AUTHENTICATED, "Repository unauthorized.", info); + } +} +export class InvalidGitpodYMLError extends ApplicationError { + constructor(readonly info: PlainMessage) { + // on gRPC we remap to PRECONDITION_FAILED, all error code for backwards compatibility with the dashboard + super(ErrorCodes.INVALID_GITPOD_YML, "Invalid gitpod.yml: " + info.violations.join(","), info); + } +} + export namespace ApplicationError { export function hasErrorCode(e: any): e is Error & { code: ErrorCode; data?: any } { return ErrorCode.is(e["code"]); diff --git a/components/gitpod-protocol/src/public-api-converter.spec.ts b/components/gitpod-protocol/src/public-api-converter.spec.ts index 06e983411f99f1..087b610f74c72b 100644 --- a/components/gitpod-protocol/src/public-api-converter.spec.ts +++ b/components/gitpod-protocol/src/public-api-converter.spec.ts @@ -5,7 +5,7 @@ * See License.AGPL.txt in the project root for license information. */ -import { Timestamp } from "@bufbuild/protobuf"; +import { Timestamp, toPlainMessage } from "@bufbuild/protobuf"; import { AdmissionLevel, WorkspaceEnvironmentVariable, @@ -41,6 +41,27 @@ import { EnvironmentVariableAdmission, UserEnvironmentVariable, } from "@gitpod/public-api/lib/gitpod/v1/envvar_pb"; +import { + ApplicationError, + ErrorCodes, + InvalidGitpodYMLError, + RepositoryNotFoundError, + UnauthorizedRepositoryAccessError, +} from "./messaging/error"; +import { Code, ConnectError } from "@connectrpc/connect"; +import { + FailedPreconditionDetails, + NeedsVerificationError, + PermissionDeniedDetails, + UserBlockedError, + InvalidGitpodYMLError as InvalidGitpodYMLErrorData, + RepositoryNotFoundError as RepositoryNotFoundErrorData, + RepositoryUnauthorizedError as RepositoryUnauthorizedErrorData, + PaymentSpendingLimitReachedError, + InvalidCostCenterError, + ImageBuildLogsNotYetAvailableError, + TooManyRunningWorkspacesError, +} from "@gitpod/public-api/lib/gitpod/v1/error_pb"; describe("PublicAPIConverter", () => { const converter = new PublicAPIConverter(); @@ -998,4 +1019,331 @@ describe("PublicAPIConverter", () => { }); }); }); + + describe("errors", () => { + it("USER_BLOCKED", () => { + const connectError = converter.toError(new ApplicationError(ErrorCodes.USER_BLOCKED, "user blocked")); + expect(connectError.code).to.equal(Code.PermissionDenied); + expect(connectError.rawMessage).to.equal("user blocked"); + + const details = connectError.findDetails(PermissionDeniedDetails)[0]; + expect(details).to.not.be.undefined; + expect(details?.reason?.case).to.equal("userBlocked"); + expect(details?.reason?.value).to.be.instanceOf(UserBlockedError); + + const appError = converter.fromError(connectError); + expect(appError.code).to.equal(ErrorCodes.USER_BLOCKED); + expect(appError.message).to.equal("user blocked"); + }); + + it("NEEDS_VERIFICATION", () => { + const connectError = converter.toError( + new ApplicationError(ErrorCodes.NEEDS_VERIFICATION, "needs verification"), + ); + expect(connectError.code).to.equal(Code.PermissionDenied); + expect(connectError.rawMessage).to.equal("needs verification"); + + const details = connectError.findDetails(PermissionDeniedDetails)[0]; + expect(details).to.not.be.undefined; + expect(details?.reason?.case).to.equal("needsVerification"); + expect(details?.reason?.value).to.be.instanceOf(NeedsVerificationError); + + const appError = converter.fromError(connectError); + expect(appError.code).to.equal(ErrorCodes.NEEDS_VERIFICATION); + expect(appError.message).to.equal("needs verification"); + }); + + it("INVALID_GITPOD_YML", () => { + const connectError = converter.toError( + new InvalidGitpodYMLError({ + violations: ['Invalid value: "": must not be empty'], + }), + ); + expect(connectError.code).to.equal(Code.FailedPrecondition); + expect(connectError.rawMessage).to.equal('Invalid gitpod.yml: Invalid value: "": must not be empty'); + + const details = connectError.findDetails(FailedPreconditionDetails)[0]; + expect(details).to.not.be.undefined; + expect(details?.reason?.case).to.equal("invalidGitpodYml"); + expect(details?.reason?.value).to.be.instanceOf(InvalidGitpodYMLErrorData); + + let violations = (details?.reason?.value as InvalidGitpodYMLErrorData).violations; + expect(violations).to.deep.equal(['Invalid value: "": must not be empty']); + + const appError = converter.fromError(connectError); + expect(appError).to.be.instanceOf(InvalidGitpodYMLError); + expect(appError.code).to.equal(ErrorCodes.INVALID_GITPOD_YML); + expect(appError.message).to.equal('Invalid gitpod.yml: Invalid value: "": must not be empty'); + + violations = (appError as InvalidGitpodYMLError).info.violations; + expect(violations).to.deep.equal(['Invalid value: "": must not be empty']); + }); + + it("RepositoryNotFoundError", () => { + const connectError = converter.toError( + new RepositoryNotFoundError({ + host: "github.com", + lastUpdate: "2021-06-28T10:48:28Z", + owner: "akosyakov", + userIsOwner: true, + userScopes: ["repo"], + }), + ); + expect(connectError.code).to.equal(Code.FailedPrecondition); + expect(connectError.rawMessage).to.equal("Repository not found."); + + const details = connectError.findDetails(FailedPreconditionDetails)[0]; + expect(details).to.not.be.undefined; + expect(details?.reason?.case).to.equal("repositoryNotFound"); + expect(details?.reason?.value).to.be.instanceOf(RepositoryNotFoundErrorData); + + let data = toPlainMessage(details?.reason?.value as RepositoryNotFoundErrorData); + expect(data.host).to.equal("github.com"); + expect(data.lastUpdate).to.equal("2021-06-28T10:48:28Z"); + expect(data.owner).to.equal("akosyakov"); + expect(data.userIsOwner).to.equal(true); + expect(data.userScopes).to.deep.equal(["repo"]); + + const appError = converter.fromError(connectError); + expect(appError).to.be.instanceOf(RepositoryNotFoundError); + expect(appError.code).to.equal(ErrorCodes.NOT_FOUND); + expect(appError.message).to.equal("Repository not found."); + + data = (appError as RepositoryNotFoundError).info; + expect(data.host).to.equal("github.com"); + expect(data.lastUpdate).to.equal("2021-06-28T10:48:28Z"); + expect(data.owner).to.equal("akosyakov"); + expect(data.userIsOwner).to.equal(true); + expect(data.userScopes).to.deep.equal(["repo"]); + }); + + it("UnauthorizedRepositoryAccessError", () => { + const connectError = converter.toError( + new UnauthorizedRepositoryAccessError({ + host: "github.com", + scopes: ["repo"], + }), + ); + expect(connectError.code).to.equal(Code.FailedPrecondition); + expect(connectError.rawMessage).to.equal("Repository unauthorized."); + + const details = connectError.findDetails(FailedPreconditionDetails)[0]; + expect(details).to.not.be.undefined; + expect(details?.reason?.case).to.equal("repositoryUnauthorized"); + expect(details?.reason?.value).to.be.instanceOf(RepositoryUnauthorizedErrorData); + + let data = toPlainMessage(details?.reason?.value as RepositoryUnauthorizedErrorData); + expect(data.host).to.equal("github.com"); + expect(data.scopes).to.deep.equal(["repo"]); + + const appError = converter.fromError(connectError); + expect(appError).to.be.instanceOf(UnauthorizedRepositoryAccessError); + expect(appError.code).to.equal(ErrorCodes.NOT_AUTHENTICATED); + expect(appError.message).to.equal("Repository unauthorized."); + + data = (appError as UnauthorizedRepositoryAccessError).info; + expect(data.host).to.equal("github.com"); + expect(data.scopes).to.deep.equal(["repo"]); + }); + + it("PAYMENT_SPENDING_LIMIT_REACHED", () => { + const connectError = converter.toError( + new ApplicationError(ErrorCodes.PAYMENT_SPENDING_LIMIT_REACHED, "payment spending limit reached"), + ); + expect(connectError.code).to.equal(Code.FailedPrecondition); + expect(connectError.rawMessage).to.equal("payment spending limit reached"); + + const details = connectError.findDetails(FailedPreconditionDetails)[0]; + expect(details).to.not.be.undefined; + expect(details?.reason?.case).to.equal("paymentSpendingLimitReached"); + expect(details?.reason?.value).to.be.instanceOf(PaymentSpendingLimitReachedError); + + const appError = converter.fromError(connectError); + expect(appError.code).to.equal(ErrorCodes.PAYMENT_SPENDING_LIMIT_REACHED); + expect(appError.message).to.equal("payment spending limit reached"); + }); + + it("INVALID_COST_CENTER", () => { + const connectError = converter.toError( + new ApplicationError(ErrorCodes.INVALID_COST_CENTER, "invalid cost center", { + attributionId: 12345, + }), + ); + expect(connectError.code).to.equal(Code.FailedPrecondition); + expect(connectError.rawMessage).to.equal("invalid cost center"); + + const details = connectError.findDetails(FailedPreconditionDetails)[0]; + expect(details).to.not.be.undefined; + expect(details?.reason?.case).to.equal("invalidCostCenter"); + expect(details?.reason?.value).to.be.instanceOf(InvalidCostCenterError); + + let data = toPlainMessage(details?.reason?.value as InvalidCostCenterError); + expect(data.attributionId).to.equal(12345); + + const appError = converter.fromError(connectError); + expect(appError.code).to.equal(ErrorCodes.INVALID_COST_CENTER); + expect(appError.message).to.equal("invalid cost center"); + + data = appError.data; + expect(data.attributionId).to.equal(12345); + }); + + it("HEADLESS_LOG_NOT_YET_AVAILABLE", () => { + const connectError = converter.toError( + new ApplicationError(ErrorCodes.HEADLESS_LOG_NOT_YET_AVAILABLE, "image build log not yet available"), + ); + expect(connectError.code).to.equal(Code.FailedPrecondition); + expect(connectError.rawMessage).to.equal("image build log not yet available"); + + const details = connectError.findDetails(FailedPreconditionDetails)[0]; + expect(details).to.not.be.undefined; + expect(details?.reason?.case).to.equal("imageBuildLogsNotYetAvailable"); + expect(details?.reason?.value).to.be.instanceOf(ImageBuildLogsNotYetAvailableError); + + const appError = converter.fromError(connectError); + expect(appError.code).to.equal(ErrorCodes.HEADLESS_LOG_NOT_YET_AVAILABLE); + expect(appError.message).to.equal("image build log not yet available"); + }); + + it("TOO_MANY_RUNNING_WORKSPACES", () => { + const connectError = converter.toError( + new ApplicationError(ErrorCodes.TOO_MANY_RUNNING_WORKSPACES, "too many running workspaces"), + ); + expect(connectError.code).to.equal(Code.FailedPrecondition); + expect(connectError.rawMessage).to.equal("too many running workspaces"); + + const details = connectError.findDetails(FailedPreconditionDetails)[0]; + expect(details).to.not.be.undefined; + expect(details?.reason?.case).to.equal("tooManyRunningWorkspaces"); + expect(details?.reason?.value).to.be.instanceOf(TooManyRunningWorkspacesError); + + const appError = converter.fromError(connectError); + expect(appError.code).to.equal(ErrorCodes.TOO_MANY_RUNNING_WORKSPACES); + expect(appError.message).to.equal("too many running workspaces"); + }); + + it("NOT_FOUND", () => { + const connectError = converter.toError(new ApplicationError(ErrorCodes.NOT_FOUND, "not found")); + expect(connectError.code).to.equal(Code.NotFound); + expect(connectError.rawMessage).to.equal("not found"); + + const appError = converter.fromError(connectError); + expect(appError.code).to.equal(ErrorCodes.NOT_FOUND); + expect(appError.message).to.equal("not found"); + }); + + it("NOT_AUTHENTICATED", () => { + const connectError = converter.toError( + new ApplicationError(ErrorCodes.NOT_AUTHENTICATED, "not authenticated"), + ); + expect(connectError.code).to.equal(Code.Unauthenticated); + expect(connectError.rawMessage).to.equal("not authenticated"); + + const appError = converter.fromError(connectError); + expect(appError.code).to.equal(ErrorCodes.NOT_AUTHENTICATED); + expect(appError.message).to.equal("not authenticated"); + }); + + it("PERMISSION_DENIED", () => { + const connectError = converter.toError( + new ApplicationError(ErrorCodes.PERMISSION_DENIED, "permission denied"), + ); + expect(connectError.code).to.equal(Code.PermissionDenied); + expect(connectError.rawMessage).to.equal("permission denied"); + + const appError = converter.fromError(connectError); + expect(appError.code).to.equal(ErrorCodes.PERMISSION_DENIED); + expect(appError.message).to.equal("permission denied"); + }); + + it("CONFLICT", () => { + const connectError = converter.toError(new ApplicationError(ErrorCodes.CONFLICT, "conflict")); + expect(connectError.code).to.equal(Code.AlreadyExists); + expect(connectError.rawMessage).to.equal("conflict"); + + const appError = converter.fromError(connectError); + expect(appError.code).to.equal(ErrorCodes.CONFLICT); + expect(appError.message).to.equal("conflict"); + }); + + it("PRECONDITION_FAILED", () => { + const connectError = converter.toError( + new ApplicationError(ErrorCodes.PRECONDITION_FAILED, "precondition failed"), + ); + expect(connectError.code).to.equal(Code.FailedPrecondition); + expect(connectError.rawMessage).to.equal("precondition failed"); + + const appError = converter.fromError(connectError); + expect(appError.code).to.equal(ErrorCodes.PRECONDITION_FAILED); + expect(appError.message).to.equal("precondition failed"); + }); + + it("TOO_MANY_REQUESTS", () => { + const connectError = converter.toError( + new ApplicationError(ErrorCodes.TOO_MANY_REQUESTS, "too many requests"), + ); + expect(connectError.code).to.equal(Code.ResourceExhausted); + expect(connectError.rawMessage).to.equal("too many requests"); + + const appError = converter.fromError(connectError); + expect(appError.code).to.equal(ErrorCodes.TOO_MANY_REQUESTS); + expect(appError.message).to.equal("too many requests"); + }); + + it("CANCELLED", () => { + const connectError = converter.toError(new ApplicationError(ErrorCodes.CANCELLED, "cancelled")); + expect(connectError.code).to.equal(Code.Canceled); + expect(connectError.rawMessage).to.equal("cancelled"); + + const appError = converter.fromError(connectError); + expect(appError.code).to.equal(ErrorCodes.CANCELLED); + expect(appError.message).to.equal("cancelled"); + }); + + it("INTERNAL_SERVER_ERROR", () => { + const connectError = converter.toError( + new ApplicationError(ErrorCodes.INTERNAL_SERVER_ERROR, "internal server error"), + ); + expect(connectError.code).to.equal(Code.Internal); + expect(connectError.rawMessage).to.equal("internal server error"); + + const appError = converter.fromError(connectError); + expect(appError.code).to.equal(ErrorCodes.INTERNAL_SERVER_ERROR); + expect(appError.message).to.equal("internal server error"); + }); + + it("UNKNOWN", () => { + // some errors are not really used by clients we turn them to unknown on API interface + // and then to internal on the dashboard + // we monitor such occurences via tests and observability and replace them with stanard codes or get rid of them + const connectError = converter.toError(new ApplicationError(ErrorCodes.EE_FEATURE, "unknown")); + expect(connectError.code).to.equal(Code.Unknown); + expect(connectError.rawMessage).to.equal("unknown"); + + const appError = converter.fromError(connectError); + expect(appError.code).to.equal(ErrorCodes.INTERNAL_SERVER_ERROR); + expect(appError.message).to.equal("unknown"); + }); + + it("ConnectError", () => { + const connectError = new ConnectError("already exists", Code.AlreadyExists); + const error = converter.toError(connectError); + expect(error).to.equal(connectError, "preserved on API"); + + const appError = converter.fromError(connectError); + expect(appError.code).to.equal(ErrorCodes.CONFLICT, "app error on dashboard"); + expect(appError.message).to.equal("already exists"); + }); + + it("Any other error is internal", () => { + const error = new Error("unknown"); + const connectError = converter.toError(error); + expect(connectError.code).to.equal(Code.Internal); + expect(connectError.rawMessage).to.equal("unknown"); + + const appError = converter.fromError(connectError); + expect(appError.code).to.equal(ErrorCodes.INTERNAL_SERVER_ERROR); + expect(appError.message).to.equal("unknown"); + }); + }); }); diff --git a/components/gitpod-protocol/src/public-api-converter.ts b/components/gitpod-protocol/src/public-api-converter.ts index b36c0bc0e6319c..b27b7730ad43a7 100644 --- a/components/gitpod-protocol/src/public-api-converter.ts +++ b/components/gitpod-protocol/src/public-api-converter.ts @@ -4,8 +4,21 @@ * See License.AGPL.txt in the project root for license information. */ -import { Timestamp } from "@bufbuild/protobuf"; +import { Timestamp, toPlainMessage } from "@bufbuild/protobuf"; import { Code, ConnectError } from "@connectrpc/connect"; +import { + FailedPreconditionDetails, + ImageBuildLogsNotYetAvailableError, + InvalidCostCenterError as InvalidCostCenterErrorData, + InvalidGitpodYMLError as InvalidGitpodYMLErrorData, + NeedsVerificationError, + PaymentSpendingLimitReachedError, + PermissionDeniedDetails, + RepositoryNotFoundError as RepositoryNotFoundErrorData, + RepositoryUnauthorizedError as RepositoryUnauthorizedErrorData, + TooManyRunningWorkspacesError, + UserBlockedError, +} from "@gitpod/public-api/lib/gitpod/v1/error_pb"; import { AuthProvider, AuthProviderDescription, @@ -51,7 +64,13 @@ import { PrebuildPhase, PrebuildPhase_Phase, } from "@gitpod/public-api/lib/gitpod/v1/prebuild_pb"; -import { ApplicationError, ErrorCode, ErrorCodes } from "./messaging/error"; +import { + ApplicationError, + ErrorCodes, + InvalidGitpodYMLError, + RepositoryNotFoundError, + UnauthorizedRepositoryAccessError, +} from "./messaging/error"; import { AuthProviderEntry as AuthProviderProtocol, AuthProviderInfo, @@ -79,7 +98,6 @@ import { Project, Organization as ProtocolOrganization, } from "./teams-projects-protocol"; -import { TrustedValue } from "./util/scrubbing"; import { ConfigurationIdeConfig, PortProtocol, @@ -92,9 +110,6 @@ import type { DeepPartial } from "./util/deep-partial"; export type PartialConfiguration = DeepPartial & Pick; -const applicationErrorCode = "application-error-code"; -const applicationErrorData = "application-error-data"; - /** * Converter between gRPC and JSON-RPC types. * @@ -199,57 +214,240 @@ export class PublicAPIConverter { return reason; } if (reason instanceof ApplicationError) { - const metadata: HeadersInit = {}; - metadata[applicationErrorCode] = String(reason.code); - if (reason.data) { - metadata[applicationErrorData] = JSON.stringify(reason.data); + if (reason.code === ErrorCodes.USER_BLOCKED) { + return new ConnectError( + reason.message, + Code.PermissionDenied, + undefined, + [ + new PermissionDeniedDetails({ + reason: { + case: "userBlocked", + value: new UserBlockedError(), + }, + }), + ], + reason, + ); + } + if (reason.code === ErrorCodes.NEEDS_VERIFICATION) { + return new ConnectError( + reason.message, + Code.PermissionDenied, + undefined, + [ + new PermissionDeniedDetails({ + reason: { + case: "needsVerification", + value: new NeedsVerificationError(), + }, + }), + ], + reason, + ); + } + if (reason instanceof InvalidGitpodYMLError) { + return new ConnectError( + reason.message, + Code.FailedPrecondition, + undefined, + [ + new FailedPreconditionDetails({ + reason: { + case: "invalidGitpodYml", + value: new InvalidGitpodYMLErrorData(reason.info), + }, + }), + ], + reason, + ); + } + if (reason instanceof RepositoryNotFoundError) { + return new ConnectError( + reason.message, + Code.FailedPrecondition, + undefined, + [ + new FailedPreconditionDetails({ + reason: { + case: "repositoryNotFound", + value: new RepositoryNotFoundErrorData(reason.info), + }, + }), + ], + reason, + ); + } + if (reason instanceof UnauthorizedRepositoryAccessError) { + return new ConnectError( + reason.message, + Code.FailedPrecondition, + undefined, + [ + new FailedPreconditionDetails({ + reason: { + case: "repositoryUnauthorized", + value: new RepositoryUnauthorizedErrorData(reason.info), + }, + }), + ], + reason, + ); + } + if (reason.code === ErrorCodes.PAYMENT_SPENDING_LIMIT_REACHED) { + return new ConnectError( + reason.message, + Code.FailedPrecondition, + undefined, + [ + new FailedPreconditionDetails({ + reason: { + case: "paymentSpendingLimitReached", + value: new PaymentSpendingLimitReachedError(), + }, + }), + ], + reason, + ); + } + if (reason.code === ErrorCodes.INVALID_COST_CENTER) { + return new ConnectError( + reason.message, + Code.FailedPrecondition, + undefined, + [ + new FailedPreconditionDetails({ + reason: { + case: "invalidCostCenter", + value: new InvalidCostCenterErrorData({ + attributionId: reason.data.attributionId, + }), + }, + }), + ], + reason, + ); + } + if (reason.code === ErrorCodes.HEADLESS_LOG_NOT_YET_AVAILABLE) { + return new ConnectError( + reason.message, + Code.FailedPrecondition, + undefined, + [ + new FailedPreconditionDetails({ + reason: { + case: "imageBuildLogsNotYetAvailable", + value: new ImageBuildLogsNotYetAvailableError(), + }, + }), + ], + reason, + ); + } + if (reason.code === ErrorCodes.TOO_MANY_RUNNING_WORKSPACES) { + return new ConnectError( + reason.message, + Code.FailedPrecondition, + undefined, + [ + new FailedPreconditionDetails({ + reason: { + case: "tooManyRunningWorkspaces", + value: new TooManyRunningWorkspacesError(), + }, + }), + ], + reason, + ); } if (reason.code === ErrorCodes.NOT_FOUND) { - return new ConnectError(reason.message, Code.NotFound, metadata, undefined, reason); + return new ConnectError(reason.message, Code.NotFound, undefined, undefined, reason); } if (reason.code === ErrorCodes.NOT_AUTHENTICATED) { - return new ConnectError(reason.message, Code.Unauthenticated, metadata, undefined, reason); + return new ConnectError(reason.message, Code.Unauthenticated, undefined, undefined, reason); } - if (reason.code === ErrorCodes.PERMISSION_DENIED || reason.code === ErrorCodes.USER_BLOCKED) { - return new ConnectError(reason.message, Code.PermissionDenied, metadata, undefined, reason); + if (reason.code === ErrorCodes.PERMISSION_DENIED) { + return new ConnectError(reason.message, Code.PermissionDenied, undefined, undefined, reason); } if (reason.code === ErrorCodes.CONFLICT) { - return new ConnectError(reason.message, Code.AlreadyExists, metadata, undefined, reason); + return new ConnectError(reason.message, Code.AlreadyExists, undefined, undefined, reason); } if (reason.code === ErrorCodes.PRECONDITION_FAILED) { - return new ConnectError(reason.message, Code.FailedPrecondition, metadata, undefined, reason); + return new ConnectError(reason.message, Code.FailedPrecondition, undefined, undefined, reason); } if (reason.code === ErrorCodes.TOO_MANY_REQUESTS) { - return new ConnectError(reason.message, Code.ResourceExhausted, metadata, undefined, reason); - } - if (reason.code === ErrorCodes.INTERNAL_SERVER_ERROR) { - return new ConnectError(reason.message, Code.Internal, metadata, undefined, reason); + return new ConnectError(reason.message, Code.ResourceExhausted, undefined, undefined, reason); } if (reason.code === ErrorCodes.CANCELLED) { - return new ConnectError(reason.message, Code.DeadlineExceeded, metadata, undefined, reason); + return new ConnectError(reason.message, Code.Canceled, undefined, undefined, reason); } - return new ConnectError(reason.message, Code.InvalidArgument, metadata, undefined, reason); + if (reason.code === ErrorCodes.INTERNAL_SERVER_ERROR) { + return new ConnectError(reason.message, Code.Internal, undefined, undefined, reason); + } + return new ConnectError(reason.message, Code.Unknown, undefined, undefined, reason); } return ConnectError.from(reason, Code.Internal); } - fromError(reason: ConnectError): Error { - const codeMetadata = reason.metadata?.get(applicationErrorCode); - if (!codeMetadata) { - return reason; + fromError(reason: ConnectError): ApplicationError { + if (reason.code === Code.NotFound) { + return new ApplicationError(ErrorCodes.NOT_FOUND, reason.rawMessage); } - const code = Number(codeMetadata) as ErrorCode; - const dataMetadata = reason.metadata?.get(applicationErrorData); - let data = undefined; - if (dataMetadata) { - try { - data = JSON.parse(dataMetadata); - } catch (e) { - console.error("failed to parse application error data", e); + if (reason.code === Code.Unauthenticated) { + return new ApplicationError(ErrorCodes.NOT_AUTHENTICATED, reason.rawMessage); + } + if (reason.code === Code.PermissionDenied) { + const details = reason.findDetails(PermissionDeniedDetails)[0]; + switch (details?.reason?.case) { + case "userBlocked": + return new ApplicationError(ErrorCodes.USER_BLOCKED, reason.rawMessage); + case "needsVerification": + return new ApplicationError(ErrorCodes.NEEDS_VERIFICATION, reason.rawMessage); + } + return new ApplicationError(ErrorCodes.PERMISSION_DENIED, reason.rawMessage); + } + if (reason.code === Code.AlreadyExists) { + return new ApplicationError(ErrorCodes.CONFLICT, reason.rawMessage); + } + if (reason.code === Code.FailedPrecondition) { + const details = reason.findDetails(FailedPreconditionDetails)[0]; + switch (details?.reason?.case) { + case "invalidGitpodYml": + const invalidGitpodYmlInfo = toPlainMessage(details.reason.value); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + return new InvalidGitpodYMLError(invalidGitpodYmlInfo); + case "repositoryNotFound": + const repositoryNotFoundInfo = toPlainMessage(details.reason.value); + return new RepositoryNotFoundError(repositoryNotFoundInfo); + case "repositoryUnauthorized": + const repositoryUnauthorizedInfo = toPlainMessage(details.reason.value); + return new UnauthorizedRepositoryAccessError(repositoryUnauthorizedInfo); + case "paymentSpendingLimitReached": + return new ApplicationError(ErrorCodes.PAYMENT_SPENDING_LIMIT_REACHED, reason.rawMessage); + case "invalidCostCenter": + const invalidCostCenterInfo = toPlainMessage(details.reason.value); + return new ApplicationError( + ErrorCodes.INVALID_COST_CENTER, + reason.rawMessage, + invalidCostCenterInfo, + ); + case "imageBuildLogsNotYetAvailable": + return new ApplicationError(ErrorCodes.HEADLESS_LOG_NOT_YET_AVAILABLE, reason.rawMessage); + case "tooManyRunningWorkspaces": + return new ApplicationError(ErrorCodes.TOO_MANY_RUNNING_WORKSPACES, reason.rawMessage); } + return new ApplicationError(ErrorCodes.PRECONDITION_FAILED, reason.rawMessage); + } + if (reason.code === Code.ResourceExhausted) { + return new ApplicationError(ErrorCodes.TOO_MANY_REQUESTS, reason.rawMessage); + } + if (reason.code === Code.Canceled) { + return new ApplicationError(ErrorCodes.CANCELLED, reason.rawMessage); + } + if (reason.code === Code.Internal) { + return new ApplicationError(ErrorCodes.INTERNAL_SERVER_ERROR, reason.rawMessage); } - // data is trusted here, since it was scrubbed before on the server - return new ApplicationError(code, reason.message, new TrustedValue(data)); + return new ApplicationError(ErrorCodes.INTERNAL_SERVER_ERROR, reason.rawMessage); } toWorkspaceEnvironmentVariables(context: WorkspaceContext): WorkspaceEnvironmentVariable[] { diff --git a/components/server/src/auth/errors.ts b/components/server/src/auth/errors.ts index f517350e03330a..8017609e35dedf 100644 --- a/components/server/src/auth/errors.ts +++ b/components/server/src/auth/errors.ts @@ -4,21 +4,8 @@ * See License.AGPL.txt in the project root for license information. */ -import { Identity } from "@gitpod/gitpod-protocol"; import { SelectAccountPayload } from "@gitpod/gitpod-protocol/lib/auth"; -export interface TosNotAcceptedYetException extends Error { - readonly identity: Identity; -} -export namespace TosNotAcceptedYetException { - export function create(identity: Identity) { - return Object.assign(new Error("TosNotAcceptedYetException"), { identity }); - } - export function is(error: any): error is TosNotAcceptedYetException { - return !!error && error.message === "TosNotAcceptedYetException"; - } -} - export interface AuthException extends Error { readonly payload: any; readonly authException: string; diff --git a/components/server/src/errors/index.ts b/components/server/src/errors/index.ts index 793a15c3a6787b..9d85b7d1edbccc 100644 --- a/components/server/src/errors/index.ts +++ b/components/server/src/errors/index.ts @@ -5,62 +5,38 @@ */ import { User, Token } from "@gitpod/gitpod-protocol"; +import { + RepositoryNotFoundError, + UnauthorizedRepositoryAccessError, +} from "@gitpod/gitpod-protocol/lib/messaging/error"; -export interface NotFoundError extends Error { - readonly data: NotFoundError.Data; -} export namespace NotFoundError { - export interface Data { - readonly host: string; - readonly owner: string; - readonly repoName: string; - readonly userScopes?: string[]; - readonly lastUpdate?: string; - } export async function create(token: Token | undefined, user: User, host: string, owner: string, repoName: string) { - const lastUpdate = token && token.updateDate; + const lastUpdate = (token && token.updateDate) || ""; const userScopes = token ? [...token.scopes] : []; const userIsOwner = owner == user.name; // TODO: shouldn't this be a comparison with `identity.authName`? - const data = { host, owner, repoName, userIsOwner, userScopes, lastUpdate }; - const error = Object.assign(new Error("NotFoundError"), { data }); - return error; + return new RepositoryNotFoundError({ + host, + owner, + userIsOwner, + userScopes, + lastUpdate, + }); } - export function is(error: any): error is NotFoundError { - return ( - !!error && - !!error.data && - !!error.data.host && - !!error.data.owner && - !!error.data.repoName && - error.message === "NotFoundError" - ); + export function is(error: any): error is RepositoryNotFoundError { + return error instanceof RepositoryNotFoundError; } } -export interface UnauthorizedError extends Error { - readonly data: UnauthorizedError.Data; -} export namespace UnauthorizedError { - export interface Data { - readonly host: string; - readonly scopes: string[]; - readonly messageHint: string; - } - const message = "UnauthorizedError"; export function create(host: string, scopes: string[], messageHint?: string) { - const data = { host, scopes, messageHint: messageHint || "unauthorized" }; - const error = Object.assign(new Error(message), { data }); - return error; + return new UnauthorizedRepositoryAccessError({ + host, + scopes, + }); } - export function is(error: any): error is UnauthorizedError { - return ( - !!error && - !!error.data && - !!error.data.host && - !!error.data.scopes && - !!error.data.messageHint && - error.message === message - ); + export function is(error: any): error is UnauthorizedRepositoryAccessError { + return error instanceof UnauthorizedRepositoryAccessError; } } diff --git a/components/server/src/prebuilds/prebuild-manager.ts b/components/server/src/prebuilds/prebuild-manager.ts index 4bf3de5402e465..563a0d1ad6a497 100644 --- a/components/server/src/prebuilds/prebuild-manager.ts +++ b/components/server/src/prebuilds/prebuild-manager.ts @@ -17,7 +17,6 @@ import { User, Workspace, WorkspaceConfig, - WorkspaceInstance, } from "@gitpod/gitpod-protocol"; import { log } from "@gitpod/gitpod-protocol/lib/util/logging"; import { TraceContext } from "@gitpod/gitpod-protocol/lib/util/tracing"; @@ -42,12 +41,6 @@ import { generateAsyncGenerator } from "@gitpod/gitpod-protocol/lib/generate-asy import { RedisSubscriber } from "../messaging/redis-subscriber"; import { ctxSignal } from "../util/request-context"; -export class WorkspaceRunningError extends Error { - constructor(msg: string, public instance: WorkspaceInstance) { - super(msg); - } -} - export interface StartPrebuildParams { user: User; context: CommitContext; @@ -397,41 +390,6 @@ export class PrebuildManager { } } - async retriggerPrebuild( - ctx: TraceContext, - user: User, - project: Project | undefined, - workspaceId: string, - ): Promise { - const span = TraceContext.startSpan("retriggerPrebuild", ctx); - span.setTag("workspaceId", workspaceId); - try { - const workspacePromise = this.workspaceDB.trace({ span }).findById(workspaceId); - const prebuildPromise = this.workspaceDB.trace({ span }).findPrebuildByWorkspaceID(workspaceId); - const runningInstance = await this.workspaceDB.trace({ span }).findRunningInstance(workspaceId); - if (runningInstance !== undefined) { - throw new WorkspaceRunningError("Workspace is still runnning", runningInstance); - } - span.setTag("starting", true); - const workspace = await workspacePromise; - if (!workspace) { - console.error("Unknown workspace id.", { workspaceId }); - throw new Error("Unknown workspace " + workspaceId); - } - const prebuild = await prebuildPromise; - if (!prebuild) { - throw new Error("No prebuild found for workspace " + workspaceId); - } - await this.workspaceService.startWorkspace({ span }, user, workspaceId, {}, false); - return { prebuildId: prebuild.id, wsid: workspace.id, done: false }; - } catch (err) { - TraceContext.setError({ span }, err); - throw err; - } finally { - span.finish(); - } - } - checkPrebuildPrecondition(params: { config: WorkspaceConfig; project: Project; context: CommitContext }): { shouldRun: boolean; reason: string; diff --git a/components/server/src/workspace/config-provider.ts b/components/server/src/workspace/config-provider.ts index abca87436630fd..141288ec528189 100644 --- a/components/server/src/workspace/config-provider.ts +++ b/components/server/src/workspace/config-provider.ts @@ -32,6 +32,7 @@ import { TraceContext } from "@gitpod/gitpod-protocol/lib/util/tracing"; import { Config } from "../config"; import { EntitlementService } from "../billing/entitlement-service"; import { TeamDB } from "@gitpod/gitpod-db/lib"; +import { InvalidGitpodYMLError } from "@gitpod/gitpod-protocol/lib/messaging/error"; const POD_PATH_WORKSPACE_BASE = "/workspace"; @@ -149,7 +150,9 @@ export class ConfigProvider { customConfig = parseResult.config; customConfig._origin = "additional-content"; if (parseResult.validationErrors) { - const err = new InvalidGitpodYMLError(parseResult.validationErrors); + const err = new InvalidGitpodYMLError({ + violations: parseResult.validationErrors, + }); // this is not a system error but a user misconfiguration log.info(logContext, err.message, { repoCloneUrl: commit.repository.cloneUrl, @@ -186,7 +189,9 @@ export class ConfigProvider { const parseResult = this.gitpodParser.parse(customConfigString); customConfig = parseResult.config; if (parseResult.validationErrors) { - const err = new InvalidGitpodYMLError(parseResult.validationErrors); + const err = new InvalidGitpodYMLError({ + violations: parseResult.validationErrors, + }); // this is not a system error but a user misconfiguration log.info(logContext, err.message, { repoCloneUrl: commit.repository.cloneUrl, @@ -302,17 +307,3 @@ export class ConfigProvider { return normalizedPath.includes("..") || pathSegments.slice(0, 2).join("/") != POD_PATH_WORKSPACE_BASE; } } - -export class InvalidGitpodYMLError extends Error { - public readonly errorType = "invalidGitpodYML"; - - constructor(public readonly validationErrors: string[]) { - super("Invalid gitpod.yml: " + validationErrors.join(",")); - } -} - -export namespace InvalidGitpodYMLError { - export function is(obj: any): obj is InvalidGitpodYMLError { - return "errorType" in obj && (obj as any).errorType === "invalidGitpodYML" && "validationErrors" in obj; - } -} diff --git a/components/server/src/workspace/context-parser-service.ts b/components/server/src/workspace/context-parser-service.ts index 8f51c4945643ad..c44aba962094ba 100644 --- a/components/server/src/workspace/context-parser-service.ts +++ b/components/server/src/workspace/context-parser-service.ts @@ -9,7 +9,8 @@ import { injectable, multiInject, inject } from "inversify"; import { HostContextProvider } from "../auth/host-context-provider"; import { IPrefixContextParser, IContextParser } from "./context-parser"; import { TraceContext } from "@gitpod/gitpod-protocol/lib/util/tracing"; -import { ConfigProvider, InvalidGitpodYMLError } from "./config-provider"; +import { ConfigProvider } from "./config-provider"; +import { InvalidGitpodYMLError } from "@gitpod/gitpod-protocol/lib/messaging/error"; @injectable() export class ContextParser { @@ -128,9 +129,9 @@ export class ContextParser { config.config.mainConfiguration, ); if (!CommitContext.is(mainRepoContext)) { - throw new InvalidGitpodYMLError([ - `Cannot find main repository '${config.config.mainConfiguration}'.`, - ]); + throw new InvalidGitpodYMLError({ + violations: [`Cannot find main repository '${config.config.mainConfiguration}'.`], + }); } // Note: we only care about repo related stuff in this function. // Fields like `config.image` will not be exposed, so we don't pass organizationId here @@ -146,7 +147,9 @@ export class ContextParser { subRepo.url, )) as CommitContext; if (!CommitContext.is(subContext)) { - throw new InvalidGitpodYMLError([`Cannot find sub-repository '${subRepo.url}'.`]); + throw new InvalidGitpodYMLError({ + violations: [`Cannot find sub-repository '${subRepo.url}'.`], + }); } if (context.repository.cloneUrl === subContext.repository.cloneUrl) { // if it's the repo from the original context we want to use that commit. diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index 7d129f7f057a43..72eaa6640e7eb3 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -78,7 +78,11 @@ import { AdminModifyRoleOrPermissionRequest, WorkspaceAndInstance, } from "@gitpod/gitpod-protocol/lib/admin-protocol"; -import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; +import { + ApplicationError, + ErrorCodes, + UnauthorizedRepositoryAccessError, +} from "@gitpod/gitpod-protocol/lib/messaging/error"; import { log, LogContext } from "@gitpod/gitpod-protocol/lib/util/logging"; import { InterfaceWithTraceContext, @@ -101,13 +105,12 @@ import { IAnalyticsWriter } from "@gitpod/gitpod-protocol/lib/analytics"; import { AuthProviderService } from "../auth/auth-provider-service"; import { GuardedResource, ResourceAccessGuard, ResourceAccessOp } from "../auth/resource-access"; import { Config } from "../config"; -import { NotFoundError, UnauthorizedError } from "../errors"; import { AuthorizationService } from "../user/authorization-service"; import { UserAuthentication } from "../user/user-authentication"; import { ContextParser } from "./context-parser-service"; import { isClusterMaintenanceError } from "./workspace-starter"; import { HeadlessLogUrls } from "@gitpod/gitpod-protocol/lib/headless-workspace-log"; -import { ConfigProvider, InvalidGitpodYMLError } from "./config-provider"; +import { ConfigProvider } from "./config-provider"; import { ProjectsService } from "../projects/projects-service"; import { IDEOptions } from "@gitpod/gitpod-protocol/lib/ide-protocol"; import { @@ -1089,7 +1092,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { event: "snapshot_access_denied", properties: { snapshot_id: context.snapshotId, error: String(error) }, }).catch((err) => log.error("cannot track event", err)); - if (UnauthorizedError.is(error)) { + if (error instanceof UnauthorizedRepositoryAccessError) { throw error; } throw new ApplicationError( @@ -1197,20 +1200,12 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { } private handleError(error: any, logContext: LogContext, normalizedContextUrl: string) { - if (NotFoundError.is(error)) { - throw new ApplicationError(ErrorCodes.NOT_FOUND, "Repository not found.", error.data); - } - if (UnauthorizedError.is(error)) { - throw new ApplicationError(ErrorCodes.NOT_AUTHENTICATED, "Unauthorized", error.data); - } - if (InvalidGitpodYMLError.is(error)) { - throw new ApplicationError(ErrorCodes.INVALID_GITPOD_YML, error.message); - } - if (ApplicationError.hasErrorCode(error)) { // specific errors will be handled in create-workspace.tsx throw error; } + // TODO(ak) not sure about it we shovel all errors in context parsing error + // we should rather do internal errors, and categorize at sources log.debug(logContext, error); throw new ApplicationError( ErrorCodes.CONTEXT_PARSE_ERROR, @@ -1955,21 +1950,11 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { const user = await this.checkAndBlockUser("getProjectOverview"); await this.guardProjectOperation(user, projectId, "get"); - try { - const result = await this.projectsService.getProjectOverview(user, projectId); - if (result) { - result.isConsideredInactive = await this.projectsService.isProjectConsideredInactive( - user.id, - projectId, - ); - } - return result; - } catch (error) { - if (UnauthorizedError.is(error)) { - throw new ApplicationError(ErrorCodes.NOT_AUTHENTICATED, "Unauthorized", error.data); - } - throw error; + const result = await this.projectsService.getProjectOverview(user, projectId); + if (result) { + result.isConsideredInactive = await this.projectsService.isProjectConsideredInactive(user.id, projectId); } + return result; } async adminFindPrebuilds(ctx: TraceContext, params: FindPrebuildsParams): Promise {