diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index 0ddb0e2aea5..1bfa3c73f53 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -449,14 +449,6 @@ func (hs *HTTPServer) InstallPlugin(c *contextmodel.ReqContext) response.Respons if errors.As(err, &dupeErr) { return response.Error(http.StatusConflict, "Plugin already installed", err) } - var versionUnsupportedErr repo.ErrVersionUnsupported - if errors.As(err, &versionUnsupportedErr) { - return response.Error(http.StatusConflict, "Plugin version not supported", err) - } - var versionNotFoundErr repo.ErrVersionNotFound - if errors.As(err, &versionNotFoundErr) { - return response.Error(http.StatusNotFound, "Plugin version not found", err) - } var clientError repo.ErrResponse4xx if errors.As(err, &clientError) { return response.Error(clientError.StatusCode(), clientError.Message(), err) @@ -464,12 +456,8 @@ func (hs *HTTPServer) InstallPlugin(c *contextmodel.ReqContext) response.Respons if errors.Is(err, plugins.ErrInstallCorePlugin) { return response.Error(http.StatusForbidden, "Cannot install or change a Core plugin", err) } - var archError repo.ErrArcNotFound - if errors.As(err, &archError) { - return response.Error(http.StatusNotFound, archError.Error(), nil) - } - return response.Error(http.StatusInternalServerError, "Failed to install plugin", err) + return response.ErrOrFallback(http.StatusInternalServerError, "Failed to install plugin", err) } if hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagExternalServiceAccounts) { diff --git a/pkg/plugins/repo/client.go b/pkg/plugins/repo/client.go index 661b752b7f2..c34979d0f9a 100644 --- a/pkg/plugins/repo/client.go +++ b/pkg/plugins/repo/client.go @@ -164,7 +164,7 @@ func (c *Client) downloadFile(tmpFile *os.File, pluginURL, checksum string, comp return fmt.Errorf("failed to write to %q: %w", tmpFile.Name(), err) } if len(checksum) > 0 && checksum != fmt.Sprintf("%x", h.Sum(nil)) { - return ErrChecksumMismatch{archiveURL: pluginURL} + return ErrChecksumMismatch(pluginURL) } return nil } diff --git a/pkg/plugins/repo/errors.go b/pkg/plugins/repo/errors.go index cdc9a03f85f..e6a873bc8f3 100644 --- a/pkg/plugins/repo/errors.go +++ b/pkg/plugins/repo/errors.go @@ -1,6 +1,10 @@ package repo -import "fmt" +import ( + "fmt" + + "github.com/grafana/grafana/pkg/util/errutil" +) type ErrResponse4xx struct { message string @@ -43,47 +47,44 @@ func (e ErrResponse4xx) Error() string { return fmt.Sprintf("%d", e.statusCode) } -type ErrVersionUnsupported struct { - pluginID string - requestedVersion string - systemInfo string +var ( + ErrVersionUnsupportedMsg = "{{.Public.PluginID}} v{{.Public.Version}} is not supported on your system {{.Public.SysInfo}}" + ErrVersionUnsupportedBase = errutil.Conflict("plugin.unsupportedVersion"). + MustTemplate(ErrVersionUnsupportedMsg, errutil.WithPublic(ErrVersionUnsupportedMsg)) + + ErrVersionNotFoundMsg = "{{.Public.PluginID}} v{{.Public.Version}} either does not exist or is not supported on your system {{.Public.SysInfo}}" + ErrVersionNotFoundBase = errutil.NotFound("plugin.versionNotFound"). + MustTemplate(ErrVersionNotFoundMsg, errutil.WithPublic(ErrVersionNotFoundMsg)) + + ErrArcNotFoundMsg = "{{.Public.PluginID}} is not compatible with your system architecture: {{.Public.SysInfo}}" + ErrArcNotFoundBase = errutil.NotFound("plugin.archNotFound"). + MustTemplate(ErrArcNotFoundMsg, errutil.WithPublic(ErrArcNotFoundMsg)) + + ErrChecksumMismatchMsg = "expected SHA256 checksum does not match the downloaded archive ({{.Public.ArchiveURL}}) - please contact security@grafana.com" + ErrChecksumMismatchBase = errutil.UnprocessableEntity("plugin.checksumMismatch"). + MustTemplate(ErrChecksumMismatchMsg, errutil.WithPublic(ErrChecksumMismatchMsg)) + + ErrCorePluginMsg = "plugin {{.Public.PluginID}} is a core plugin and cannot be installed separately" + ErrCorePluginBase = errutil.Forbidden("plugin.forbiddenCorePluginInstall"). + MustTemplate(ErrCorePluginMsg, errutil.WithPublic(ErrCorePluginMsg)) +) + +func ErrVersionUnsupported(pluginID, requestedVersion, systemInfo string) error { + return ErrVersionUnsupportedBase.Build(errutil.TemplateData{Public: map[string]any{"PluginID": pluginID, "Version": requestedVersion, "SysInfo": systemInfo}}) } -func (e ErrVersionUnsupported) Error() string { - return fmt.Sprintf("%s v%s is not supported on your system (%s)", e.pluginID, e.requestedVersion, e.systemInfo) +func ErrVersionNotFound(pluginID, requestedVersion, systemInfo string) error { + return ErrVersionNotFoundBase.Build(errutil.TemplateData{Public: map[string]any{"PluginID": pluginID, "Version": requestedVersion, "SysInfo": systemInfo}}) } -type ErrVersionNotFound struct { - pluginID string - requestedVersion string - systemInfo string +func ErrArcNotFound(pluginID, systemInfo string) error { + return ErrArcNotFoundBase.Build(errutil.TemplateData{Public: map[string]any{"PluginID": pluginID, "SysInfo": systemInfo}}) } -func (e ErrVersionNotFound) Error() string { - return fmt.Sprintf("%s v%s either does not exist or is not supported on your system (%s)", e.pluginID, e.requestedVersion, e.systemInfo) +func ErrChecksumMismatch(archiveURL string) error { + return ErrChecksumMismatchBase.Build(errutil.TemplateData{Public: map[string]any{"ArchiveURL": archiveURL}}) } -type ErrArcNotFound struct { - pluginID string - systemInfo string -} - -func (e ErrArcNotFound) Error() string { - return fmt.Sprintf("%s is not compatible with your system architecture: %s", e.pluginID, e.systemInfo) -} - -type ErrChecksumMismatch struct { - archiveURL string -} - -func (e ErrChecksumMismatch) Error() string { - return fmt.Sprintf("expected SHA256 checksum does not match the downloaded archive (%s) - please contact security@grafana.com", e.archiveURL) -} - -type ErrCorePlugin struct { - id string -} - -func (e ErrCorePlugin) Error() string { - return fmt.Sprintf("plugin %s is a core plugin and cannot be installed separately", e.id) +func ErrCorePlugin(pluginID string) error { + return ErrCorePluginBase.Build(errutil.TemplateData{Public: map[string]any{"PluginID": pluginID}}) } diff --git a/pkg/plugins/repo/errors_test.go b/pkg/plugins/repo/errors_test.go index d7870e534e0..a20298cee23 100644 --- a/pkg/plugins/repo/errors_test.go +++ b/pkg/plugins/repo/errors_test.go @@ -1,9 +1,11 @@ package repo import ( + "errors" "net/http" "testing" + "github.com/grafana/grafana/pkg/util/errutil" "github.com/stretchr/testify/require" ) @@ -24,3 +26,37 @@ func TestErrResponse4xx(t *testing.T) { require.Equal(t, compatInfo, err.compatibilityInfo) }) } + +func TestErrorTemplates(t *testing.T) { + base := &errutil.Error{} + + err := ErrVersionUnsupported("grafana-test-app", "1.0.0", "darwin-amd64") + require.True(t, errors.As(err, base)) + require.Equal(t, http.StatusConflict, base.Public().StatusCode) + require.Equal(t, "plugin.unsupportedVersion", base.Public().MessageID) + require.Equal(t, "grafana-test-app v1.0.0 is not supported on your system darwin-amd64", base.Public().Message) + + err = ErrVersionNotFound("grafana-test-app", "1.0.0", "darwin-amd64") + require.True(t, errors.As(err, base)) + require.Equal(t, http.StatusNotFound, base.Public().StatusCode) + require.Equal(t, "plugin.versionNotFound", base.Public().MessageID) + require.Equal(t, "grafana-test-app v1.0.0 either does not exist or is not supported on your system darwin-amd64", base.Public().Message) + + err = ErrArcNotFound("grafana-test-app", "darwin-amd64") + require.True(t, errors.As(err, base)) + require.Equal(t, http.StatusNotFound, base.Public().StatusCode) + require.Equal(t, "plugin.archNotFound", base.Public().MessageID) + require.Equal(t, "grafana-test-app is not compatible with your system architecture: darwin-amd64", base.Public().Message) + + err = ErrChecksumMismatch("http://localhost:6481/grafana-test-app/versions/1.0.0/download") + require.True(t, errors.As(err, base)) + require.Equal(t, http.StatusUnprocessableEntity, base.Public().StatusCode) + require.Equal(t, "plugin.checksumMismatch", base.Public().MessageID) + require.Equal(t, "expected SHA256 checksum does not match the downloaded archive (http://localhost:6481/grafana-test-app/versions/1.0.0/download) - please contact security@grafana.com", base.Public().Message) + + err = ErrCorePlugin("grafana-test-app") + require.True(t, errors.As(err, base)) + require.Equal(t, http.StatusForbidden, base.Public().StatusCode) + require.Equal(t, "plugin.forbiddenCorePluginInstall", base.Public().MessageID) + require.Equal(t, "plugin grafana-test-app is a core plugin and cannot be installed separately", base.Public().Message) +} diff --git a/pkg/plugins/repo/service.go b/pkg/plugins/repo/service.go index 4e1037ef29a..8b1a7a1654d 100644 --- a/pkg/plugins/repo/service.go +++ b/pkg/plugins/repo/service.go @@ -98,7 +98,7 @@ func (m *Manager) PluginVersion(pluginID, version string, compatOpts CompatOpts) _, hasAnyArch := compatibleVer.Arch["any"] if isGrafanaCorePlugin && hasAnyArch { // Trying to install a coupled core plugin - return VersionData{}, ErrCorePlugin{id: pluginID} + return VersionData{}, ErrCorePlugin(pluginID) } return compatibleVer, nil diff --git a/pkg/plugins/repo/service_test.go b/pkg/plugins/repo/service_test.go index a84fe224679..c2c0833e7ea 100644 --- a/pkg/plugins/repo/service_test.go +++ b/pkg/plugins/repo/service_test.go @@ -35,14 +35,14 @@ func TestGetPluginArchive(t *testing.T) { { name: "Incorrect SHA returns error", sha: "1a2b3c", - err: &ErrChecksumMismatch{}, + err: ErrChecksumMismatchBase, }, { name: "Core plugin", sha: "69f698961b6ea651211a187874434821c4727cc22de022e3a7059116d21c75b1", apiOpSys: "any", apiUrl: "https://github.com/grafana/grafana/tree/main/public/app/plugins/test", - err: &ErrCorePlugin{}, + err: ErrCorePluginBase, }, { name: "Decoupled core plugin", @@ -99,7 +99,7 @@ func TestGetPluginArchive(t *testing.T) { co := NewCompatOpts(grafanaVersion, opSys, arch) archive, err := m.GetPluginArchive(context.Background(), pluginID, version, co) if tc.err != nil { - require.ErrorAs(t, err, tc.err) + require.ErrorIs(t, err, tc.err) return } require.NoError(t, err) diff --git a/pkg/plugins/repo/version.go b/pkg/plugins/repo/version.go index fc73d6e5eaa..ad3716d1c2f 100644 --- a/pkg/plugins/repo/version.go +++ b/pkg/plugins/repo/version.go @@ -25,10 +25,7 @@ func SelectSystemCompatibleVersion(log log.PrettyLogger, versions []Version, plu var ver Version latestForArch, exists := latestSupportedVersion(versions, compatOpts) if !exists { - return VersionData{}, ErrArcNotFound{ - pluginID: pluginID, - systemInfo: compatOpts.OSAndArch(), - } + return VersionData{}, ErrArcNotFound(pluginID, compatOpts.OSAndArch()) } if version == "" { @@ -49,21 +46,13 @@ func SelectSystemCompatibleVersion(log log.PrettyLogger, versions []Version, plu if len(ver.Version) == 0 { log.Debugf("Requested plugin version %s v%s not found but potential fallback version '%s' was found", pluginID, version, latestForArch.Version) - return VersionData{}, ErrVersionNotFound{ - pluginID: pluginID, - requestedVersion: version, - systemInfo: compatOpts.OSAndArch(), - } + return VersionData{}, ErrVersionNotFound(pluginID, version, compatOpts.OSAndArch()) } if !supportsCurrentArch(ver, compatOpts) { log.Debugf("Requested plugin version %s v%s is not supported on your system but potential fallback version '%s' was found", pluginID, version, latestForArch.Version) - return VersionData{}, ErrVersionUnsupported{ - pluginID: pluginID, - requestedVersion: version, - systemInfo: compatOpts.OSAndArch(), - } + return VersionData{}, ErrVersionUnsupported(pluginID, version, compatOpts.OSAndArch()) } return VersionData{ diff --git a/pkg/util/errutil/errors.go b/pkg/util/errutil/errors.go index 01ba37e0dad..d8e26760b7b 100644 --- a/pkg/util/errutil/errors.go +++ b/pkg/util/errutil/errors.go @@ -55,6 +55,28 @@ func NotFound(msgID string, opts ...BaseOpt) Base { return NewBase(StatusNotFound, msgID, opts...) } +// UnprocessableContent initializes a new [Base] error with reason StatusUnprocessableEntity +// that is used to construct [Error]. The msgID is passed to the caller +// to serve as the base for user facing error messages. +// +// msgID should be structured as component.errorBrief, for example +// +// plugin.checksumMismatch +func UnprocessableEntity(msgID string, opts ...BaseOpt) Base { + return NewBase(StatusUnprocessableEntity, msgID, opts...) +} + +// Conflict initializes a new [Base] error with reason StatusConflict +// that is used to construct [Error]. The msgID is passed to the caller +// to serve as the base for user facing error messages. +// +// msgID should be structured as component.errorBrief, for example +// +// folder.alreadyExists +func Conflict(msgID string, opts ...BaseOpt) Base { + return NewBase(StatusConflict, msgID, opts...) +} + // BadRequest initializes a new [Base] error with reason StatusBadRequest // that is used to construct [Error]. The msgID is passed to the caller // to serve as the base for user facing error messages. diff --git a/pkg/util/errutil/status.go b/pkg/util/errutil/status.go index 22b1b40ab2c..69c9166ccae 100644 --- a/pkg/util/errutil/status.go +++ b/pkg/util/errutil/status.go @@ -20,6 +20,15 @@ const ( // corresponding document to return to the request. // HTTP status code 404. StatusNotFound CoreStatus = "Not found" + // StatusUnprocessableEntity means that the server understands the request, + // the content type and the syntax but it was unable to process the + // contained instructions. + // HTTP status code 422. + StatusUnprocessableEntity CoreStatus = "Unprocessable Entity" + // StatusConflict means that the server cannot fulfill the request + // there is a conflict in the current state of a resource + // HTTP status code 409. + StatusConflict CoreStatus = "Conflict" // StatusTooManyRequests means that the client is rate limited // by the server and should back-off before trying again. // HTTP status code 429. @@ -92,6 +101,10 @@ func (s CoreStatus) HTTPStatus() int { return http.StatusNotFound case StatusTimeout, StatusGatewayTimeout: return http.StatusGatewayTimeout + case StatusUnprocessableEntity: + return http.StatusUnprocessableEntity + case StatusConflict: + return http.StatusConflict case StatusTooManyRequests: return http.StatusTooManyRequests case StatusBadRequest, StatusValidationFailed: @@ -120,6 +133,10 @@ func (s CoreStatus) LogLevel() LogLevel { return LevelInfo case StatusTimeout: return LevelInfo + case StatusUnprocessableEntity: + return LevelInfo + case StatusConflict: + return LevelInfo case StatusTooManyRequests: return LevelInfo case StatusBadRequest: