diff --git a/extensions/vscode/src/utils/errorTypes.test.ts b/extensions/vscode/src/utils/errorTypes.test.ts index 71ea9bfe9..12f74c445 100644 --- a/extensions/vscode/src/utils/errorTypes.test.ts +++ b/extensions/vscode/src/utils/errorTypes.test.ts @@ -16,6 +16,12 @@ import { errUnknownTOMLKeyMessage, isErrInvalidConfigFile, resolveAgentJsonErrorMsg, + ErrTomlUnknownError, + isErrTomlUnknownError, + errTomlUnknownErrorMessage, + ErrTOMLValidationError, + isErrTOMLValidationError, + errTOMLValidationErrorMessage, } from "./errorTypes"; const mkAxiosJsonErr = (data: Record) => { @@ -158,6 +164,115 @@ describe("ErrUnknownTOMLKey", () => { }); }); +describe("ErrTOMLValidationError", () => { + test("isErrUnknownTOMLKey", () => { + let result = isErrUnknownTOMLKey( + mkAxiosJsonErr({ + code: "unknownTOMLKey", + }), + ); + + expect(result).toBe(true); + + result = isErrUnknownTOMLKey( + mkAxiosJsonErr({ + code: "bricks_raining", + }), + ); + + expect(result).toBe(false); + }); + + test("errUnknownTOMLKeyMessage", () => { + const err = mkAxiosJsonErr({ + code: "unknownTOMLKey", + details: { + filename: "/directory/configuration-lkdg.toml", + line: 7, + column: 1, + key: "shortcut_key", + }, + }); + + const msg = errUnknownTOMLKeyMessage( + err as axiosErrorWithJson, + ); + expect(msg).toBe(`The Configuration has a schema error on line 7`); + }); +}); + +describe("ErrTomlUnknownError", () => { + test("isErrTomlUnknownError", () => { + let result = isErrTomlUnknownError( + mkAxiosJsonErr({ + code: "tomlUnknownError", + }), + ); + + expect(result).toBe(true); + + result = isErrTomlUnknownError( + mkAxiosJsonErr({ + code: "bricks_raining", + }), + ); + + expect(result).toBe(false); + }); + + test("errTomlUnknownErrorMessage", () => { + const err = mkAxiosJsonErr({ + code: "tomlUnknownError", + details: { + filename: "config.toml", + problem: "problems...", + }, + }); + + const msg = errTomlUnknownErrorMessage( + err as axiosErrorWithJson, + ); + expect(msg).toBe(`The Configuration has a schema error`); + }); +}); + +describe("ErrTOMLValidationError", () => { + test("isErrTOMLValidationError", () => { + let result = isErrTOMLValidationError( + mkAxiosJsonErr({ + code: "tomlValidationError", + }), + ); + + expect(result).toBe(true); + + result = isErrTOMLValidationError( + mkAxiosJsonErr({ + code: "bricks_raining", + }), + ); + + expect(result).toBe(false); + }); + + test("errTOMLValidationErrorMessage", () => { + const err = mkAxiosJsonErr({ + code: "tomlValidationError", + details: { + filename: "/directory/configuration-lkdg.toml", + line: 7, + column: 1, + key: "shortcut_key", + }, + }); + + const msg = errTOMLValidationErrorMessage( + err as axiosErrorWithJson, + ); + expect(msg).toBe(`The Configuration has a schema error`); + }); +}); + describe("ErrInvalidConfigFiles", () => { test("isErrInvalidConfigFile", () => { let result = isErrInvalidConfigFile( diff --git a/extensions/vscode/src/utils/errorTypes.ts b/extensions/vscode/src/utils/errorTypes.ts index fac60e7d5..313c8e6ad 100644 --- a/extensions/vscode/src/utils/errorTypes.ts +++ b/extensions/vscode/src/utils/errorTypes.ts @@ -12,7 +12,9 @@ export type ErrorCode = | "deployFailed" | "renvlockPackagesReadingError" | "requirementsFileReadingError" - | "deployedContentNotRunning"; + | "deployedContentNotRunning" + | "tomlValidationError" + | "tomlUnknownError"; export type axiosErrorWithJson = AxiosError & { @@ -69,6 +71,25 @@ export type ErrResourceNotFound = MkErrorDataType< export const isErrResourceNotFound = mkErrorTypeGuard("resourceNotFound"); +// Invalid TOML file(s) +export type ErrTOMLValidationError = MkErrorDataType< + "tomlValidationError", + { + filename: string; + message: string; + key: string; + problem: string; + schemaReference: string; + } +>; +export const isErrTOMLValidationError = + mkErrorTypeGuard("tomlValidationError"); +export const errTOMLValidationErrorMessage = ( + _: axiosErrorWithJson, +) => { + return `The Configuration has a schema error`; +}; + // Invalid TOML file(s) export type ErrInvalidTOMLFile = MkErrorDataType< "invalidTOML", @@ -104,6 +125,22 @@ export const errUnknownTOMLKeyMessage = ( return `The Configuration has a schema error on line ${err.response.data.details.line}`; }; +// Unknown error within a TOML file +export type ErrTomlUnknownError = MkErrorDataType< + "tomlUnknownError", + { + filename: string; + problem: string; + } +>; +export const isErrTomlUnknownError = + mkErrorTypeGuard("tomlUnknownError"); +export const errTomlUnknownErrorMessage = ( + _: axiosErrorWithJson, +) => { + return `The Configuration has a schema error`; +}; + // Invalid configuration file(s) export type ErrInvalidConfigFiles = MkErrorDataType< "invalidConfigFile", @@ -123,5 +160,12 @@ export function resolveAgentJsonErrorMsg(err: axiosErrorWithJson) { return errInvalidTOMLMessage(err); } + if (isErrTOMLValidationError(err)) { + return errTOMLValidationErrorMessage(err); + } + + if (isErrTomlUnknownError(err)) { + return errTomlUnknownErrorMessage(err); + } return errUnknownMessage(err as axiosErrorWithJson); } diff --git a/extensions/vscode/src/views/homeView.ts b/extensions/vscode/src/views/homeView.ts index d0cff5e07..88a430be8 100644 --- a/extensions/vscode/src/views/homeView.ts +++ b/extensions/vscode/src/views/homeView.ts @@ -77,6 +77,7 @@ import { throttleWithLastPending } from "src/utils/throttle"; import { showAssociateGUID } from "src/actions/showAssociateGUID"; import { extensionSettings } from "src/extension"; import { openFileInEditor } from "src/commands"; +import { showImmediateDeploymentFailureMessage } from "./publishFailures"; enum HomeViewInitialized { initialized = "initialized", @@ -267,8 +268,9 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { ); deployProject(response.data.localId, this.stream); } catch (error: unknown) { - const summary = getSummaryStringFromError("homeView, deploy", error); - window.showErrorMessage(`Failed to deploy. ${summary}`); + // Most failures will occur on the event stream. These are the ones which + // are immediately rejected as part of the API request to initiate deployment. + showImmediateDeploymentFailureMessage(error); } } diff --git a/extensions/vscode/src/views/publishFailures.ts b/extensions/vscode/src/views/publishFailures.ts new file mode 100644 index 000000000..0c77ebde3 --- /dev/null +++ b/extensions/vscode/src/views/publishFailures.ts @@ -0,0 +1,59 @@ +// Copyright (C) 2024 by Posit Software, PBC. + +import { window } from "vscode"; +import { openFileInEditor } from "src/commands"; +import { + isErrInvalidConfigFile, + isErrInvalidTOMLFile, + isErrTomlUnknownError, + isErrTOMLValidationError, + isErrUnknownTOMLKey, +} from "src/utils/errorTypes"; +import { getSummaryStringFromError } from "src/utils/errors"; + +// Handler for deployment failures, but intended to only handled the +// errors reported back from the deployment API request. Most deployment +// failures will be returned and handled by the event stream processing +// once the agent has kicked off the anonymous go function which walks through +// the deployment process. +export const showImmediateDeploymentFailureMessage = async (error: unknown) => { + if ( + isErrTomlUnknownError(error) || + isErrUnknownTOMLKey(error) || + isErrInvalidTOMLFile(error) || + isErrTOMLValidationError(error) || + isErrInvalidConfigFile(error) + ) { + const editButtonStr = "Edit Configuration"; + const options = [editButtonStr]; + const summary = getSummaryStringFromError("homeView, deploy", error); + const selection = await window.showErrorMessage( + `Failed to deploy. ${summary}`, + ...options, + ); + // will not support line and column either. + if (selection === editButtonStr) { + if ( + isErrTOMLValidationError(error) || + isErrTomlUnknownError(error) || + isErrInvalidConfigFile(error) + ) { + openFileInEditor(error.response.data.details.filename); + return; + } + openFileInEditor(error.response.data.details.filename, { + selection: { + start: { + line: error.response.data.details.line - 1, + character: error.response.data.details.column - 1, + }, + }, + }); + } + return; + } + + // Default handling for deployment failures we are not handling in a particular way + const summary = getSummaryStringFromError("homeView, deploy", error); + return window.showErrorMessage(`Failed to deploy . ${summary}`); +}; diff --git a/internal/schema/schema.go b/internal/schema/schema.go index 55dfeb0f1..b572ce1a5 100644 --- a/internal/schema/schema.go +++ b/internal/schema/schema.go @@ -72,7 +72,7 @@ func (v *Validator[T]) ValidateContent(data any) error { if ok { // Return all causes in the Data field of a single error. e := toTomlValidationError(validationErr) - return types.NewAgentError(tomlValidationErrorCode, e, nil) + return types.NewAgentError(tomlValidationErrorCode, e, e) } else { return err } diff --git a/internal/services/api/post_deployment.go b/internal/services/api/post_deployment.go index cad64881d..e689c384b 100644 --- a/internal/services/api/post_deployment.go +++ b/internal/services/api/post_deployment.go @@ -9,6 +9,7 @@ import ( "github.com/gorilla/mux" "github.com/posit-dev/publisher/internal/accounts" + "github.com/posit-dev/publisher/internal/config" "github.com/posit-dev/publisher/internal/events" "github.com/posit-dev/publisher/internal/logging" "github.com/posit-dev/publisher/internal/publish" @@ -58,13 +59,14 @@ func PostDeploymentHandlerFunc( return } newState, err := stateFactory(projectDir, b.AccountName, b.ConfigName, name, "", accountList, b.Secrets, b.Insecure) - log.Debug("New account derived state created", "account", b.AccountName, "config", b.ConfigName) if err != nil { if errors.Is(err, accounts.ErrAccountNotFound) { + log.Error("Deployment initialization failure - account not found", "error", err.Error()) NotFound(w, log, err) return } if errors.Is(err, state.ErrServerURLMismatch) { + log.Error("Deployment initialization failure - Server URL Mismatch", "error", err.Error()) // Redeployments must go to the same server w.WriteHeader(http.StatusConflict) w.Write([]byte(err.Error())) @@ -73,11 +75,27 @@ func PostDeploymentHandlerFunc( if aerr, ok := types.IsAgentError(err); ok { if aerr.Code == types.ErrorUnknownTOMLKey { apiErr := types.APIErrorUnknownTOMLKeyFromAgentError(*aerr) + log.Error("Deployment initialization failure", "apiErr", apiErr.Error()) apiErr.JSONResponse(w) return } if aerr.Code == types.ErrorInvalidTOML { apiErr := types.APIErrorInvalidTOMLFileFromAgentError(*aerr) + log.Error("Deployment initialization failure", "apiErr", apiErr.Error()) + apiErr.JSONResponse(w) + return + } + if aerr.Code == types.ErrorTomlValidationError { + configPath := config.GetConfigPath(projectDir, b.ConfigName) + apiErr := types.APIErrorTomlValidationFromAgentError(*aerr, configPath.String()) + log.Error("Deployment initialization failure", "apiErr", apiErr.Error()) + apiErr.JSONResponse(w) + return + } + if aerr.Code == types.ErrorTomlUnknownError { + configPath := config.GetConfigPath(projectDir, b.ConfigName) + apiErr := types.APIErrorTomlUnknownErrorFromAgentError(*aerr, configPath.String()) + log.Error("Deployment initialization failure", "apiErr", apiErr.Error()) apiErr.JSONResponse(w) return } @@ -85,6 +103,7 @@ func PostDeploymentHandlerFunc( BadRequest(w, req, log, err) return } + log.Debug("New account derived state created", "account", b.AccountName, "config", b.ConfigName) response := PostDeploymentsReponse{ LocalID: localID, diff --git a/internal/types/api_errors.go b/internal/types/api_errors.go index 6e77890f6..042cd0e0e 100644 --- a/internal/types/api_errors.go +++ b/internal/types/api_errors.go @@ -4,6 +4,7 @@ package types import ( "encoding/json" + "fmt" "net/http" ) @@ -41,6 +42,11 @@ func APIErrorUnknownTOMLKeyFromAgentError(aerr AgentError) APIErrorUnknownTOMLKe } } +func (apierr *APIErrorUnknownTOMLKeyDetails) Error() string { + return fmt.Sprintf("Error: ErrorUnknownTOMLKey, Filename: %s, Key: %s, Line: %d, Column: %d", + apierr.Details.Filename, apierr.Details.Key, apierr.Details.Line, apierr.Details.Column) +} + func (apierr *APIErrorUnknownTOMLKeyDetails) JSONResponse(w http.ResponseWriter) { jsonResult(w, http.StatusBadRequest, apierr) } @@ -56,6 +62,11 @@ type APIErrorInvalidTOMLFileDetails struct { Details InvalidTOMLFileDetails `json:"details"` } +func (apierr *APIErrorInvalidTOMLFileDetails) Error() string { + return fmt.Sprintf("Error: ErrorInvalidTOML, Filename: %s, Line: %d, Column: %d", + apierr.Details.Filename, apierr.Details.Line, apierr.Details.Column) +} + func (apierr *APIErrorInvalidTOMLFileDetails) JSONResponse(w http.ResponseWriter) { jsonResult(w, http.StatusBadRequest, apierr) } @@ -71,6 +82,72 @@ func APIErrorInvalidTOMLFileFromAgentError(aerr AgentError) APIErrorInvalidTOMLF } } +// ErrorTomlValidationError +type ErrorTomlValidationDetails struct { + Filename string `json:"filename"` + Message string `json:"message"` + Key string `json:"key"` + Problem string `json:"problem"` + SchemaReference string `json:"schema-reference"` +} + +type APIErrorTomlValidationDetails struct { + Code ErrorCode `json:"code"` + Details ErrorTomlValidationDetails `json:"details"` +} + +func (apierr *APIErrorTomlValidationDetails) Error() string { + return fmt.Sprintf("Error: ErrorTomlValidationError, Filename: %s, Message: %s, Key: %s, Problem: %s, SchemaReference: %s", + apierr.Details.Filename, apierr.Details.Message, apierr.Details.Key, apierr.Details.Problem, apierr.Details.SchemaReference) +} + +func (apierr *APIErrorTomlValidationDetails) JSONResponse(w http.ResponseWriter) { + jsonResult(w, http.StatusBadRequest, apierr) +} + +func APIErrorTomlValidationFromAgentError(aerr AgentError, configPath string) APIErrorTomlValidationDetails { + return APIErrorTomlValidationDetails{ + Code: ErrorTomlValidationError, + Details: ErrorTomlValidationDetails{ + Filename: configPath, + Message: aerr.Err.Error(), + Key: aerr.Data["key"].(string), + Problem: aerr.Data["problem"].(string), + SchemaReference: aerr.Data["schema-reference"].(string), + }, + } +} + +// ErrorTomlUnknownError +type ErrorTomlUnknownErrorDetails struct { + Filename string `json:"filename"` + Problem string `json:"problem"` +} + +type APIErrorTomlUnknownErrorDetails struct { + Code ErrorCode `json:"code"` + Details ErrorTomlUnknownErrorDetails `json:"details"` +} + +func (apierr *APIErrorTomlUnknownErrorDetails) Error() string { + return fmt.Sprintf("Error: ErrorTomlUnknownError, Filename: %s, Problem: %s", + apierr.Details.Filename, apierr.Details.Problem) +} + +func (apierr *APIErrorTomlUnknownErrorDetails) JSONResponse(w http.ResponseWriter) { + jsonResult(w, http.StatusBadRequest, apierr) +} + +func APIErrorTomlUnknownErrorFromAgentError(aerr AgentError, configPath string) APIErrorTomlUnknownErrorDetails { + return APIErrorTomlUnknownErrorDetails{ + Code: ErrorTomlUnknownError, + Details: ErrorTomlUnknownErrorDetails{ + Filename: configPath, + Problem: aerr.Err.Error(), + }, + } +} + type APIErrorCredentialServiceUnavailable struct { Code ErrorCode `json:"code"` } diff --git a/internal/types/error.go b/internal/types/error.go index c8eaa4ff9..c652a678f 100644 --- a/internal/types/error.go +++ b/internal/types/error.go @@ -24,6 +24,8 @@ const ( ErrorRequirementsFileReading ErrorCode = "requirementsFileReadingError" ErrorDeployedContentNotRunning ErrorCode = "deployedContentNotRunning" ErrorUnknown ErrorCode = "unknown" + ErrorTomlValidationError ErrorCode = "tomlValidationError" + ErrorTomlUnknownError ErrorCode = "tomlUnknownError" ) type EventableError interface { diff --git a/internal/util/toml.go b/internal/util/toml.go index 3fd6b74c8..f962633ca 100644 --- a/internal/util/toml.go +++ b/internal/util/toml.go @@ -69,12 +69,15 @@ func ReadTOMLFile(path AbsolutePath, dest any) error { e := decodeErrFromTOMLErr(decodeErr, path) return types.NewAgentError(types.ErrorInvalidTOML, e, e) } - strictErr, ok := err.(*toml.StrictMissingError) - if ok { + if strictErr, ok := err.(*toml.StrictMissingError); ok { e := decodeErrFromTOMLErr(&strictErr.Errors[0], path) e.Problem = "unknown key" return types.NewAgentError(types.ErrorUnknownTOMLKey, e, e) } + errorStr := err.Error() + if strings.Contains(errorStr, "toml: ") { + return types.NewAgentError(types.ErrorTomlUnknownError, err, nil) + } return err } return nil