diff --git a/pkg/services/alerting/notifier_test.go b/pkg/services/alerting/notifier_test.go index 71d75c4aea3..a1732eb89b1 100644 --- a/pkg/services/alerting/notifier_test.go +++ b/pkg/services/alerting/notifier_test.go @@ -345,6 +345,10 @@ type testRenderService struct { renderErrorImageProvider func(error error) (*rendering.RenderResult, error) } +func (s *testRenderService) HasCapability(feature rendering.CapabilityName) (rendering.CapabilitySupportRequestResult, error) { + return rendering.CapabilitySupportRequestResult{}, nil +} + func (s *testRenderService) IsAvailable() bool { if s.isAvailableProvider != nil { return s.isAvailableProvider() diff --git a/pkg/services/rendering/capabilities.go b/pkg/services/rendering/capabilities.go new file mode 100644 index 00000000000..9339bfc4915 --- /dev/null +++ b/pkg/services/rendering/capabilities.go @@ -0,0 +1,55 @@ +package rendering + +import ( + "errors" + + "github.com/Masterminds/semver" +) + +type Capability struct { + name CapabilityName + semverConstraint string +} + +type CapabilityName string + +const ( + ScalingDownImages CapabilityName = "ScalingDownImages" + FullHeightImages CapabilityName = "FullHeightImages" +) + +var ErrUnknownCapability = errors.New("unknown capability") +var ErrInvalidPluginVersion = errors.New("invalid plugin version") + +func (rs *RenderingService) HasCapability(capability CapabilityName) (CapabilitySupportRequestResult, error) { + if !rs.IsAvailable() { + return CapabilitySupportRequestResult{IsSupported: false, SemverConstraint: ""}, ErrRenderUnavailable + } + + var semverConstraint string + for i := range rs.capabilities { + if rs.capabilities[i].name == capability { + semverConstraint = rs.capabilities[i].semverConstraint + break + } + } + + if semverConstraint == "" { + return CapabilitySupportRequestResult{}, ErrUnknownCapability + } + + compiledSemverConstraint, err := semver.NewConstraint(semverConstraint) + if err != nil { + rs.log.Error("Failed to parse semver constraint", "constraint", semverConstraint, "capability", capability, "error", err.Error()) + return CapabilitySupportRequestResult{IsSupported: false, SemverConstraint: semverConstraint}, ErrUnknownCapability + } + + imageRendererVersion := rs.Version() + compiledImageRendererVersion, err := semver.NewVersion(imageRendererVersion) + if err != nil { + rs.log.Error("Failed to parse plugin version", "version", imageRendererVersion, "error", err.Error()) + return CapabilitySupportRequestResult{IsSupported: false, SemverConstraint: semverConstraint}, ErrInvalidPluginVersion + } + + return CapabilitySupportRequestResult{IsSupported: compiledSemverConstraint.Check(compiledImageRendererVersion), SemverConstraint: semverConstraint}, nil +} diff --git a/pkg/services/rendering/capabilities_test.go b/pkg/services/rendering/capabilities_test.go new file mode 100644 index 00000000000..eec3382c96f --- /dev/null +++ b/pkg/services/rendering/capabilities_test.go @@ -0,0 +1,136 @@ +package rendering + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/setting" +) + +type dummyPluginManager struct{} + +func (d *dummyPluginManager) Renderer() *plugins.Plugin { + return nil +} + +var dummyRendererUrl = "http://dummyurl.com" +var testCapabilitySemverConstraint = "> 1.0.0" +var testCapabilityName = CapabilityName("TestCap") +var testCapabilityNameInvalidSemver = CapabilityName("TestCapInvalidSemver") + +func TestCapabilities(t *testing.T) { + cfg := setting.NewCfg() + rs := &RenderingService{ + Cfg: cfg, + RendererPluginManager: &dummyPluginManager{}, + log: log.New("test-capabilities-rendering-service"), + capabilities: []Capability{ + {name: testCapabilityName, semverConstraint: testCapabilitySemverConstraint}, + {name: testCapabilityNameInvalidSemver, semverConstraint: "asfasf"}, + }, + } + + tests := []struct { + name string + rendererUrl string + rendererVersion string + capabilityName CapabilityName + expectedError error + expectedResult CapabilitySupportRequestResult + }{ + { + name: "when image-renderer plugin is not available", + rendererUrl: "", + rendererVersion: "", + capabilityName: testCapabilityName, + expectedError: ErrRenderUnavailable, + expectedResult: CapabilitySupportRequestResult{ + IsSupported: false, + SemverConstraint: "", + }, + }, + { + name: "when image-renderer plugin version is not populated", + rendererUrl: dummyRendererUrl, + rendererVersion: "", + capabilityName: testCapabilityName, + expectedError: ErrInvalidPluginVersion, + expectedResult: CapabilitySupportRequestResult{ + IsSupported: false, + SemverConstraint: testCapabilitySemverConstraint, + }, + }, + { + name: "when image-renderer plugin version is not valid", + rendererUrl: dummyRendererUrl, + rendererVersion: "abcd", + capabilityName: testCapabilityName, + expectedError: ErrInvalidPluginVersion, + expectedResult: CapabilitySupportRequestResult{ + IsSupported: false, + SemverConstraint: testCapabilitySemverConstraint, + }, + }, + { + name: "when image-renderer version does not match target constraint", + rendererUrl: dummyRendererUrl, + rendererVersion: "1.0.0", + capabilityName: testCapabilityName, + expectedError: nil, + expectedResult: CapabilitySupportRequestResult{ + IsSupported: false, + SemverConstraint: testCapabilitySemverConstraint, + }, + }, + { + name: "when image-renderer version matches target constraint", + rendererUrl: dummyRendererUrl, + rendererVersion: "2.0.0", + capabilityName: testCapabilityName, + expectedError: nil, + expectedResult: CapabilitySupportRequestResult{ + IsSupported: true, + SemverConstraint: testCapabilitySemverConstraint, + }, + }, + { + name: "when capability is unknown", + rendererUrl: dummyRendererUrl, + rendererVersion: "1.0.0", + capabilityName: CapabilityName("unknown"), + expectedError: ErrUnknownCapability, + expectedResult: CapabilitySupportRequestResult{ + IsSupported: false, + SemverConstraint: "", + }, + }, + { + name: "when capability has invalid semver constraint", + rendererUrl: dummyRendererUrl, + rendererVersion: "1.0.0", + capabilityName: testCapabilityNameInvalidSemver, + expectedError: ErrUnknownCapability, + expectedResult: CapabilitySupportRequestResult{ + IsSupported: false, + SemverConstraint: "asfasf", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rs.Cfg.RendererUrl = tt.rendererUrl + rs.version = tt.rendererVersion + res, err := rs.HasCapability(tt.capabilityName) + + if tt.expectedError == nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, tt.expectedError) + } + require.Equal(t, tt.expectedResult, res) + }) + } +} diff --git a/pkg/services/rendering/interface.go b/pkg/services/rendering/interface.go index 8868d43c921..9eaa166b2a1 100644 --- a/pkg/services/rendering/interface.go +++ b/pkg/services/rendering/interface.go @@ -96,6 +96,11 @@ type Session interface { Dispose(ctx context.Context) } +type CapabilitySupportRequestResult struct { + IsSupported bool + SemverConstraint string +} + type Service interface { IsAvailable() bool Version() string @@ -103,5 +108,6 @@ type Service interface { RenderCSV(ctx context.Context, opts CSVOpts, session Session) (*RenderCSVResult, error) RenderErrorImage(theme Theme, error error) (*RenderResult, error) GetRenderUser(ctx context.Context, key string) (*RenderUser, bool) + HasCapability(capability CapabilityName) (CapabilitySupportRequestResult, error) CreateRenderingSession(ctx context.Context, authOpts AuthOpts, sessionOpts SessionOpts) (Session, error) } diff --git a/pkg/services/rendering/rendering.go b/pkg/services/rendering/rendering.go index d3b0fe1cde5..6e069cd4eb7 100644 --- a/pkg/services/rendering/rendering.go +++ b/pkg/services/rendering/rendering.go @@ -37,6 +37,7 @@ type RenderingService struct { inProgressCount int32 version string versionMutex sync.RWMutex + capabilities []Capability perRequestRenderKeyProvider renderKeyProvider Cfg *setting.Cfg @@ -81,6 +82,16 @@ func ProvideService(cfg *setting.Cfg, remoteCache *remotecache.RemoteCache, rm p log: logger, keyExpiry: 5 * time.Minute, }, + capabilities: []Capability{ + { + name: FullHeightImages, + semverConstraint: ">= 3.4.0", + }, + { + name: ScalingDownImages, + semverConstraint: ">= 3.4.0", + }, + }, Cfg: cfg, RemoteCacheService: remoteCache, RendererPluginManager: rm,