diff --git a/README.md b/README.md index 3d9713da..bf13f880 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,7 @@ Run "mage gen:readme" to regenerate this section. | Discoverability / `discoverability` | Warns about missing keywords and description that are used for plugin indexing in the catalog. | None | | Go Manifest / `go-manifest` | Validates the build manifest. | None | | Go Security Checker / `go-sec` | Inspects source code for security problems by scanning the Go AST. | [gosec](https://github.com/securego/gosec), `sourceCodeUri` | +| Grafana Dependency / `grafanadependency` | Ensures the Grafana dependency specified in plugin.json is valid | None | | JS Source Map / `jsMap` | Checks for required `module.js.map` file(s) in archive. | `sourceCodeUri` | | Legacy Grafana Toolkit usage / `legacybuilder` | Detects the usage of the not longer supported Grafana Toolkit. | None | | Legacy Platform / `legacyplatform` | Detects use of Angular which is deprecated. | None | diff --git a/pkg/analysis/passes/analysis.go b/pkg/analysis/passes/analysis.go index 821561e4..be56b81f 100644 --- a/pkg/analysis/passes/analysis.go +++ b/pkg/analysis/passes/analysis.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/plugin-validator/pkg/analysis/passes/changelog" "github.com/grafana/plugin-validator/pkg/analysis/passes/checksum" "github.com/grafana/plugin-validator/pkg/analysis/passes/circulardependencies" + "github.com/grafana/plugin-validator/pkg/analysis/passes/grafanadependency" "github.com/grafana/plugin-validator/pkg/analysis/passes/codediff" "github.com/grafana/plugin-validator/pkg/analysis/passes/coderules" "github.com/grafana/plugin-validator/pkg/analysis/passes/discoverability" @@ -103,4 +104,5 @@ var Analyzers = []*analysis.Analyzer{ virusscan.Analyzer, circulardependencies.Analyzer, codediff.Analyzer, + grafanadependency.Analyzer, } diff --git a/pkg/analysis/passes/grafanadependency/grafanadependency.go b/pkg/analysis/passes/grafanadependency/grafanadependency.go new file mode 100644 index 00000000..7325d56c --- /dev/null +++ b/pkg/analysis/passes/grafanadependency/grafanadependency.go @@ -0,0 +1,80 @@ +package grafanadependency + +import ( + "encoding/json" + "fmt" + "regexp" + + "github.com/grafana/plugin-validator/pkg/analysis" + "github.com/grafana/plugin-validator/pkg/analysis/passes/metadata" +) + +// https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string +// Modified to work with semver range operators (>=, >, <, <=) by removing ^ and $ anchors +var semverRegex = regexp.MustCompile(`(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?`) + +var ( + missingCloudPreRelease = &analysis.Rule{ + Name: "missing-cloud-pre-release", + Severity: analysis.Warning, + } +) + +var Analyzer = &analysis.Analyzer{ + Name: "grafanadependency", + Requires: []*analysis.Analyzer{metadata.Analyzer}, + Run: run, + Rules: []*analysis.Rule{missingCloudPreRelease}, + ReadmeInfo: analysis.ReadmeInfo{ + Name: "Grafana Dependency", + Description: "Ensures the Grafana dependency specified in plugin.json is valid", + }, +} + +func run(pass *analysis.Pass) (interface{}, error) { + metadataBody, ok := pass.ResultOf[metadata.Analyzer].([]byte) + if !ok { + return nil, nil + } + var data metadata.Metadata + if err := json.Unmarshal(metadataBody, &data); err != nil { + return nil, err + } + pre := getPreRelease(data.Dependencies.GrafanaDependency) + if pre == "" && data.IsGrafanaLabs() { + // Ensure that Grafana Labs plugin specify a pre-release (-99999999999) in Grafana Dependency. + // If the pre-release part is missing and the grafanaDependency specifies a version that's not + // been released yet, which is often the case for Grafana Labs plugins and not community/commercial plugins, + // the plugin won't be loaded correctly in cloud because it doesn't satisfy the Grafana dependency. + // Example: on a Cloud instance we have Grafana 12.4.0-99999999999. This is a PRE-RELEASE of 12.4.0. + // If the plugin specifies 12.4.0 as grafanaDependency, it's incompatible with 12.4.0-99999999999. + // This is because 12.4.0-x (pre-release) < 12.4.0 ("stable") => the plugin can't be installed in Cloud. + pass.ReportResult( + pass.AnalyzerName, + missingCloudPreRelease, + fmt.Sprintf(`Grafana dependency %q has no pre-release value`, data.Dependencies.GrafanaDependency), + fmt.Sprintf(`The value of grafanaDependency in plugin.json (%q) is missing a pre-release value. `+ + `This may make the plugin uninstallable in Grafana Cloud. `+ + `Please add "-0" as a suffix of your grafanaDependency value ("%s-0")`, + data.Dependencies.GrafanaDependency, data.Dependencies.GrafanaDependency, + ), + ) + } + return nil, nil +} + +// getPreRelease extracts the pre-release identifier from a Grafana dependency version string. +// It handles semver range operators (>=, >, <, <=) and returns the pre-release part after the dash. +// Examples: ">=12.4.0-0" returns "0", ">=12.4.0" returns "", ">= 12.4.0-1234" returns "1234". +func getPreRelease(grafanaDependency string) string { + matches := semverRegex.FindStringSubmatch(grafanaDependency) + if matches == nil { + return "" + } + for i, name := range semverRegex.SubexpNames() { + if name == "prerelease" && i < len(matches) { + return matches[i] + } + } + return "" +} diff --git a/pkg/analysis/passes/grafanadependency/grafanadependency_test.go b/pkg/analysis/passes/grafanadependency/grafanadependency_test.go new file mode 100644 index 00000000..ef34c8ad --- /dev/null +++ b/pkg/analysis/passes/grafanadependency/grafanadependency_test.go @@ -0,0 +1,136 @@ +package grafanadependency + +import ( + "encoding/json" + "fmt" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/plugin-validator/pkg/analysis" + "github.com/grafana/plugin-validator/pkg/analysis/passes/archive" + "github.com/grafana/plugin-validator/pkg/analysis/passes/metadata" + "github.com/grafana/plugin-validator/pkg/analysis/passes/metadatavalid" + "github.com/grafana/plugin-validator/pkg/testpassinterceptor" +) + +func TestGrafanaDependency(t *testing.T) { + for _, tc := range []struct { + name string + pluginJSON string + expFlag bool + }{ + { + name: "non-grafana labs plugin without pre-release shouldn't be flagged", + pluginJSON: `{ + "id": "community-my-app", + "dependencies": { "grafanaDependency": ">=11.6.0" } + }`, + expFlag: false, + }, + { + name: "non-grafana labs plugin with pre-release shouldn't be flagged", + pluginJSON: `{ + "id": "community-my-app", + "dependencies": { "grafanaDependency": ">=11.6.0-0" } + }`, + expFlag: false, + }, + { + name: "grafana org plugin without pre-release should be flagged", + pluginJSON: `{ + "id": "grafana-my-app", + "dependencies": { "grafanaDependency": ">=11.6.0" } + }`, + expFlag: true, + }, + { + name: "grafana labs author plugin without pre-release should be flagged", + pluginJSON: `{ + "id": "adopted-my-app", + "info": { "author": { "name": "Grafana Labs" } }, + "dependencies": { "grafanaDependency": ">=11.6.0" } + }`, + expFlag: true, + }, + { + name: "grafana org plugin with pre-release should not be flagged", + pluginJSON: `{ + "id": "grafana-my-app", + "dependencies": { "grafanaDependency": ">= 11.6.0-0" } + }`, + expFlag: false, + }, + { + name: "grafana labs author plugin with pre-release should not be flagged", + pluginJSON: `{ + "id": "adopted-my-app", + "info": { "author": { "name": "Grafana Labs" } }, + "dependencies": { "grafanaDependency": ">= 11.6.0-0" } + }`, + expFlag: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + var interceptor testpassinterceptor.TestPassInterceptor + pass := &analysis.Pass{ + RootDir: filepath.Join("./"), + ResultOf: map[*analysis.Analyzer]interface{}{ + metadata.Analyzer: []byte(tc.pluginJSON), + archive.Analyzer: filepath.Join("."), + metadatavalid.Analyzer: nil, + }, + Report: interceptor.ReportInterceptor(), + } + + _, err := Analyzer.Run(pass) + require.NoError(t, err) + if tc.expFlag { + require.Len(t, interceptor.Diagnostics, 1) + + var meta metadata.Metadata + require.NoError(t, json.Unmarshal(pass.ResultOf[metadata.Analyzer].([]byte), &meta)) + grafanaDependency := meta.Dependencies.GrafanaDependency + require.NotEmpty(t, grafanaDependency, "grafana dependency should not be empty") + + d := interceptor.Diagnostics[0] + require.Equal(t, analysis.Warning, d.Severity, "severity should be warning") + require.Equal(t, "missing-cloud-pre-release", d.Name, "name should match") + require.Equal(t, fmt.Sprintf(`Grafana dependency "%s" has no pre-release value`, grafanaDependency), d.Title, "title should match") + require.Equal(t, fmt.Sprintf(`The value of grafanaDependency in plugin.json ("%s") is missing a pre-release value. This may make the plugin uninstallable in Grafana Cloud. Please add "-0" as a suffix of your grafanaDependency value ("%s-0")`, grafanaDependency, grafanaDependency), d.Detail, "detail should match") + } else { + ok := assert.Len(t, interceptor.Diagnostics, 0, "expecting no diagnostics but got %d", len(interceptor.Diagnostics)) + // Log for debugging + if !ok { + for _, d := range interceptor.Diagnostics { + t.Logf("%+v", d) + } + } + } + }) + } +} + +func TestGrafanaDependency_GetPreRelease(t *testing.T) { + for _, tc := range []struct { + name string + dependency string + expPre string + }{ + {"no pre-release", ">=12.4.0", ""}, + {"no pre-release with space", ">= 12.4.0", ""}, + {"zero pre-release", ">=12.4.0-0", "0"}, + {"zero pre-release with space", ">= 12.4.0-0", "0"}, + {"non-zero pre-release", ">=12.4.0-1189998819991197253", "1189998819991197253"}, + {"non-zero pre-release with space", ">= 12.4.0-1189998819991197253", "1189998819991197253"}, + {"non-numeric pre-release", ">=12.4.0-alpha.1", "alpha.1"}, + {"non-numeric pre-release with space", ">= 12.4.0-alpha-2", "alpha-2"}, + } { + t.Run(tc.name, func(t *testing.T) { + pre := getPreRelease(tc.dependency) + require.Equal(t, tc.expPre, pre, "extracted pre-release value should match") + }) + } +} diff --git a/pkg/analysis/passes/metadata/types.go b/pkg/analysis/passes/metadata/types.go index 663a82c8..6e9f00a4 100644 --- a/pkg/analysis/passes/metadata/types.go +++ b/pkg/analysis/passes/metadata/types.go @@ -1,5 +1,7 @@ package metadata +import "strings" + type Metadata struct { ID string `json:"id"` Name string `json:"name"` @@ -12,6 +14,13 @@ type Metadata struct { Dependencies MetadataDependencies `json:"dependencies"` } +// IsGrafanaLabs returns true if the plugin was developed by Grafana Labs. +// A plugin is considered developed by Grafana Labs if either +// the author name is "Grafana Labs" or the org name in the slug is "grafana" +func (m Metadata) IsGrafanaLabs() bool { + return strings.EqualFold(m.Info.Author.Name, "grafana labs") || strings.EqualFold(orgFromPluginID(m.ID), "grafana") +} + type Info struct { Author Author `json:"author"` Screenshots []Screenshots `json:"screenshots"` @@ -23,7 +32,8 @@ type Info struct { } type Author struct { - URL string `json:"url"` + Name string `json:"name"` + URL string `json:"url"` } type Screenshots struct { @@ -64,3 +74,13 @@ type MetadataPluginDependency struct { Name string `json:"name"` Type string `json:"type"` } + +// orgFromPluginID extracts and returns the organization prefix from a plugin ID by splitting on the first hyphen. +// Returns an empty string if the plugin ID is empty or invalid. +func orgFromPluginID(id string) string { + parts := strings.SplitN(id, "-", 3) + if len(parts) < 1 { + return "" + } + return parts[0] +} diff --git a/pkg/cmd/plugincheck2/main_test.go b/pkg/cmd/plugincheck2/main_test.go index 0d8a39e7..8b3e0561 100644 --- a/pkg/cmd/plugincheck2/main_test.go +++ b/pkg/cmd/plugincheck2/main_test.go @@ -61,6 +61,14 @@ func TestIntegration(t *testing.T) { Id: "grafana-clock-panel", Version: "2.1.5", PluginValidator: map[string][]Issue{ + "grafanadependency": { + { + Severity: "warning", + Title: "Grafana dependency \">=8.0.0\" has no pre-release value", + Detail: "The value of grafanaDependency in plugin.json (\">=8.0.0\") is missing a pre-release value. This may make the plugin uninstallable in Grafana Cloud. Please add \"-0\" as a suffix of your grafanaDependency value (\">=8.0.0-0\")", + Name: "missing-cloud-pre-release", + }, + }, "jargon": { { Severity: "warning", @@ -87,6 +95,14 @@ func TestIntegration(t *testing.T) { Id: "alexanderzobnin-zabbix-app", Version: "4.4.9", PluginValidator: map[string][]Issue{ + "grafanadependency": { + { + Severity: "warning", + Title: "Grafana dependency \">=9.3.0\" has no pre-release value", + Detail: "The value of grafanaDependency in plugin.json (\">=9.3.0\") is missing a pre-release value. This may make the plugin uninstallable in Grafana Cloud. Please add \"-0\" as a suffix of your grafanaDependency value (\">=9.3.0-0\")", + Name: "missing-cloud-pre-release", + }, + }, "includesnested": { { Severity: "error", @@ -151,6 +167,14 @@ func TestIntegration(t *testing.T) { Id: "yesoreyeram-infinity-datasource", Version: "2.6.3", PluginValidator: map[string][]Issue{ + "grafanadependency": { + { + Severity: "warning", + Title: "Grafana dependency \">=9.5.15\" has no pre-release value", + Detail: "The value of grafanaDependency in plugin.json (\">=9.5.15\") is missing a pre-release value. This may make the plugin uninstallable in Grafana Cloud. Please add \"-0\" as a suffix of your grafanaDependency value (\">=9.5.15-0\")", + Name: "missing-cloud-pre-release", + }, + }, "sponsorshiplink": { { Severity: "recommendation", @@ -352,6 +376,14 @@ func TestIntegration(t *testing.T) { Id: "grafana-clock-panel", Version: "2.1.5", PluginValidator: map[string][]Issue{ + "grafanadependency": { + { + Severity: "warning", + Title: "Grafana dependency \">=8.0.0\" has no pre-release value", + Detail: "The value of grafanaDependency in plugin.json (\">=8.0.0\") is missing a pre-release value. This may make the plugin uninstallable in Grafana Cloud. Please add \"-0\" as a suffix of your grafanaDependency value (\">=8.0.0-0\")", + Name: "missing-cloud-pre-release", + }, + }, "jargon": { { Severity: "warning", @@ -379,6 +411,14 @@ func TestIntegration(t *testing.T) { Id: "grafana-clock-panel", Version: "2.1.5", PluginValidator: map[string][]Issue{ + "grafanadependency": { + { + Severity: "warning", + Title: "Grafana dependency \">=8.0.0\" has no pre-release value", + Detail: "The value of grafanaDependency in plugin.json (\">=8.0.0\") is missing a pre-release value. This may make the plugin uninstallable in Grafana Cloud. Please add \"-0\" as a suffix of your grafanaDependency value (\">=8.0.0-0\")", + Name: "missing-cloud-pre-release", + }, + }, "jargon": { { Severity: "warning",