Skip to content
Open
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions pkg/analysis/passes/analysis.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -103,4 +104,5 @@ var Analyzers = []*analysis.Analyzer{
virusscan.Analyzer,
circulardependencies.Analyzer,
codediff.Analyzer,
grafanadependency.Analyzer,
}
80 changes: 80 additions & 0 deletions pkg/analysis/passes/grafanadependency/grafanadependency.go
Original file line number Diff line number Diff line change
@@ -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(`(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)(?:-(?P<prerelease>(?: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<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?`)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't it better to use a semver library for this? there are some, more complex ranges out there like this

Copy link
Contributor

@s4kh s4kh Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we were to validate with semver lib, shouldn't we include it in the metadatavalid check? Remove the grafanaDependency object from the schema so schema validation passes and validate the actual value of grafanaDependency with SemVer lib.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think of this regex https://github.com/grafana/grafana/pull/118090/changes?

@s4kh Regex is good, but the current code added in this PR won't parse those correctly.

isn't it better to use a semver library for this? there are some, more complex ranges out there like this

@andresmgot I tried using the semver lib we use on Grafana's backend (https://github.com/Masterminds/semver) but there are no ways to parse a semver range and inspect its content (to ensure that the pre-release is part of the constraint) with that library. It looks like we can just validate if a version satisfies a semver range or not.

I just noticed that plugin-validator uses https://github.com/hashicorp/go-version instead: this one exports a Constraint.Prerelease function that might be what we need: https://pkg.go.dev/github.com/hashicorp/go-version#Constraint.Prerelease but it's a bit weird because there may be more than one semver version in the constraint and I'm not sure how the function behaves in such case. I'll take a look


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 ""
}
136 changes: 136 additions & 0 deletions pkg/analysis/passes/grafanadependency/grafanadependency_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
}
22 changes: 21 additions & 1 deletion pkg/analysis/passes/metadata/types.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package metadata

import "strings"

type Metadata struct {
ID string `json:"id"`
Name string `json:"name"`
Expand All @@ -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"`
Expand All @@ -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 {
Expand Down Expand Up @@ -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]
}
40 changes: 40 additions & 0 deletions pkg/cmd/plugincheck2/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading