Compare commits
71 Commits
ismail/imp
...
kristina/r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b727586dd7 | ||
|
|
0a2fae7f49 | ||
|
|
fdc3603ffc | ||
|
|
d0217588a3 | ||
|
|
ce9ab6a89a | ||
|
|
8c8efd2494 | ||
|
|
69ccfd6bfc | ||
|
|
53aa5e8f7f | ||
|
|
69bf3068b3 | ||
|
|
1263a3d364 | ||
|
|
e4b79e2fc8 | ||
|
|
5c05cbae99 | ||
|
|
c2acc65ed6 | ||
|
|
7c52ba7e11 | ||
|
|
6a41f99439 | ||
|
|
f9ae7b14d2 | ||
|
|
022582809e | ||
|
|
05de2ccbd7 | ||
|
|
da6cc641a5 | ||
|
|
f9b7e5743f | ||
|
|
2326454af2 | ||
|
|
7cd212a5ed | ||
|
|
1ec0f01717 | ||
|
|
7a3ecaece0 | ||
|
|
a523ca1e7e | ||
|
|
9f93948f0b | ||
|
|
3ef8251351 | ||
|
|
e2f6d99028 | ||
|
|
ec6d82e1ba | ||
|
|
9ccba75ee3 | ||
|
|
6f549cbdac | ||
|
|
b40a5fe52b | ||
|
|
d3e3d4252b | ||
|
|
0ea1b197fd | ||
|
|
c0147297b7 | ||
|
|
a44eb98456 | ||
|
|
caa95b2d95 | ||
|
|
b380118034 | ||
|
|
2e38e852dc | ||
|
|
af43f94ef2 | ||
|
|
f7273f415b | ||
|
|
b95899c698 | ||
|
|
c7d58a32c6 | ||
|
|
7e2a6a7222 | ||
|
|
f935a6579f | ||
|
|
df6d5cc628 | ||
|
|
ccd9d18e40 | ||
|
|
7a7b529e81 | ||
|
|
5ff8961662 | ||
|
|
403e483fac | ||
|
|
50b8721a0a | ||
|
|
1265bcffcf | ||
|
|
38811ac9d1 | ||
|
|
9e9b1c660f | ||
|
|
e61912c721 | ||
|
|
17c57a1fb5 | ||
|
|
95bccb76fa | ||
|
|
8329b12240 | ||
|
|
b849787b2b | ||
|
|
25faf7260b | ||
|
|
5613a9ffbe | ||
|
|
5092e0129a | ||
|
|
9b2c224a9d | ||
|
|
5aa18cbfe2 | ||
|
|
38e38a3f7a | ||
|
|
31a6e63bae | ||
|
|
0602d85706 | ||
|
|
fc7184b339 | ||
|
|
5e456394a3 | ||
|
|
31bb3a7533 | ||
|
|
32ab61746f |
@@ -1,10 +1,18 @@
|
||||
include ../sdk.mk
|
||||
|
||||
.PHONY: generate # Run Grafana App SDK code generation
|
||||
generate: install-app-sdk update-app-sdk
|
||||
generate: do-generate post-generate-cleanup
|
||||
|
||||
.PHONY: do-generate
|
||||
do-generate: install-app-sdk update-app-sdk
|
||||
@$(APP_SDK_BIN) generate \
|
||||
--source=./kinds/ \
|
||||
--gogenpath=./pkg/apis \
|
||||
--grouping=group \
|
||||
--genoperatorstate=false \
|
||||
--defencoding=none
|
||||
--defencoding=none
|
||||
|
||||
.PHONY: post-generate-cleanup
|
||||
post-generate-cleanup: ## Fix TargetSpec OpenAPI schema
|
||||
# Fix the TargetSpec schema in manifest - remove nested additionalProperties
|
||||
@sed -i.bak 's|"TargetSpec":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"}|"TargetSpec":{"additionalProperties":{},"type":"object"}|g' ./pkg/apis/correlation_manifest.go && rm ./pkg/apis/correlation_manifest.go.bak
|
||||
@@ -31,7 +31,9 @@ ConfigSpec: {
|
||||
transformations?: [...TransformationSpec]
|
||||
}
|
||||
|
||||
TargetSpec: [string]: _
|
||||
TargetSpec: {
|
||||
...
|
||||
}
|
||||
|
||||
TransformationSpec: {
|
||||
type: "regex" | "logfmt"
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
rawSchemaCorrelationv0alpha1 = []byte(`{"ConfigSpec":{"additionalProperties":false,"description":"there was a deprecated field here called type, we will need to move that for conversion and provisioning","properties":{"field":{"type":"string"},"target":{"$ref":"#/components/schemas/TargetSpec"},"transformations":{"items":{"$ref":"#/components/schemas/TransformationSpec"},"type":"array"}},"required":["field","target"],"type":"object"},"Correlation":{"properties":{"spec":{"$ref":"#/components/schemas/spec"}},"required":["spec"]},"CorrelationType":{"enum":["query","external"],"type":"string"},"DataSourceRef":{"additionalProperties":false,"properties":{"group":{"description":"same as pluginId","type":"string"},"name":{"description":"same as grafana uid","type":"string"}},"required":["group","name"],"type":"object"},"TargetSpec":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"TransformationSpec":{"additionalProperties":false,"properties":{"expression":{"type":"string"},"field":{"type":"string"},"mapValue":{"type":"string"},"type":{"enum":["regex","logfmt"],"type":"string"}},"required":["type","expression","field","mapValue"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"config":{"$ref":"#/components/schemas/ConfigSpec"},"description":{"type":"string"},"label":{"type":"string"},"source":{"$ref":"#/components/schemas/DataSourceRef"},"target":{"$ref":"#/components/schemas/DataSourceRef"},"type":{"$ref":"#/components/schemas/CorrelationType"}},"required":["type","source","label","config"],"type":"object"}}`)
|
||||
rawSchemaCorrelationv0alpha1 = []byte(`{"ConfigSpec":{"additionalProperties":false,"description":"there was a deprecated field here called type, we will need to move that for conversion and provisioning","properties":{"field":{"type":"string"},"target":{"$ref":"#/components/schemas/TargetSpec"},"transformations":{"items":{"$ref":"#/components/schemas/TransformationSpec"},"type":"array"}},"required":["field","target"],"type":"object"},"Correlation":{"properties":{"spec":{"$ref":"#/components/schemas/spec"}},"required":["spec"]},"CorrelationType":{"enum":["query","external"],"type":"string"},"DataSourceRef":{"additionalProperties":false,"properties":{"group":{"description":"same as pluginId","type":"string"},"name":{"description":"same as grafana uid","type":"string"}},"required":["group","name"],"type":"object"},"TargetSpec":{"additionalProperties":{},"type":"object"},"TransformationSpec":{"additionalProperties":false,"properties":{"expression":{"type":"string"},"field":{"type":"string"},"mapValue":{"type":"string"},"type":{"enum":["regex","logfmt"],"type":"string"}},"required":["type","expression","field","mapValue"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"config":{"$ref":"#/components/schemas/ConfigSpec"},"description":{"type":"string"},"label":{"type":"string"},"source":{"$ref":"#/components/schemas/DataSourceRef"},"target":{"$ref":"#/components/schemas/DataSourceRef"},"type":{"$ref":"#/components/schemas/CorrelationType"}},"required":["type","source","label","config"],"type":"object"}}`)
|
||||
versionSchemaCorrelationv0alpha1 app.VersionSchema
|
||||
_ = json.Unmarshal(rawSchemaCorrelationv0alpha1, &versionSchemaCorrelationv0alpha1)
|
||||
)
|
||||
|
||||
@@ -32,7 +32,7 @@ type ConnectionSecure struct {
|
||||
|
||||
// Token is the reference of the token used to act as the Connection.
|
||||
// This value is stored securely and cannot be read back
|
||||
Token common.InlineSecureValue `json:"webhook,omitzero,omitempty"`
|
||||
Token common.InlineSecureValue `json:"token,omitzero,omitempty"`
|
||||
}
|
||||
|
||||
func (v ConnectionSecure) IsZero() bool {
|
||||
|
||||
@@ -320,7 +320,7 @@ func schema_pkg_apis_provisioning_v0alpha1_ConnectionSecure(ref common.Reference
|
||||
Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.InlineSecureValue"),
|
||||
},
|
||||
},
|
||||
"webhook": {
|
||||
"token": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Token is the reference of the token used to act as the Connection. This value is stored securely and cannot be read back",
|
||||
Default: map[string]interface{}{},
|
||||
|
||||
@@ -22,7 +22,6 @@ API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioni
|
||||
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ResourceList,Items
|
||||
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,TestResults,Errors
|
||||
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,WebhookStatus,SubscribedEvents
|
||||
API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ConnectionSecure,Token
|
||||
API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ConnectionSpec,GitHub
|
||||
API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobSpec,PullRequest
|
||||
API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobStatus,URLs
|
||||
|
||||
16
apps/provisioning/pkg/connection/connection.go
Normal file
16
apps/provisioning/pkg/connection/connection.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package connection
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
//go:generate mockery --name Connection --structname MockConnection --inpackage --filename connection_mock.go --with-expecter
|
||||
type Connection interface {
|
||||
// Validate ensures the resource _looks_ correct.
|
||||
// It should be called before trying to upsert a resource into the Kubernetes API server.
|
||||
// This is not an indication that the connection information works, just that they are reasonably configured.
|
||||
Validate(ctx context.Context) error
|
||||
|
||||
// Mutate performs in place mutation of the underneath resource.
|
||||
Mutate(context.Context) error
|
||||
}
|
||||
128
apps/provisioning/pkg/connection/connection_mock.go
Normal file
128
apps/provisioning/pkg/connection/connection_mock.go
Normal file
@@ -0,0 +1,128 @@
|
||||
// Code generated by mockery v2.53.4. DO NOT EDIT.
|
||||
|
||||
package connection
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// MockConnection is an autogenerated mock type for the Connection type
|
||||
type MockConnection struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type MockConnection_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *MockConnection) EXPECT() *MockConnection_Expecter {
|
||||
return &MockConnection_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// Mutate provides a mock function with given fields: _a0
|
||||
func (_m *MockConnection) Mutate(_a0 context.Context) error {
|
||||
ret := _m.Called(_a0)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Mutate")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context) error); ok {
|
||||
r0 = rf(_a0)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockConnection_Mutate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Mutate'
|
||||
type MockConnection_Mutate_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Mutate is a helper method to define mock.On call
|
||||
// - _a0 context.Context
|
||||
func (_e *MockConnection_Expecter) Mutate(_a0 interface{}) *MockConnection_Mutate_Call {
|
||||
return &MockConnection_Mutate_Call{Call: _e.mock.On("Mutate", _a0)}
|
||||
}
|
||||
|
||||
func (_c *MockConnection_Mutate_Call) Run(run func(_a0 context.Context)) *MockConnection_Mutate_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockConnection_Mutate_Call) Return(_a0 error) *MockConnection_Mutate_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockConnection_Mutate_Call) RunAndReturn(run func(context.Context) error) *MockConnection_Mutate_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Validate provides a mock function with given fields: ctx
|
||||
func (_m *MockConnection) Validate(ctx context.Context) error {
|
||||
ret := _m.Called(ctx)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Validate")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context) error); ok {
|
||||
r0 = rf(ctx)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockConnection_Validate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Validate'
|
||||
type MockConnection_Validate_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Validate is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
func (_e *MockConnection_Expecter) Validate(ctx interface{}) *MockConnection_Validate_Call {
|
||||
return &MockConnection_Validate_Call{Call: _e.mock.On("Validate", ctx)}
|
||||
}
|
||||
|
||||
func (_c *MockConnection_Validate_Call) Run(run func(ctx context.Context)) *MockConnection_Validate_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockConnection_Validate_Call) Return(_a0 error) *MockConnection_Validate_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockConnection_Validate_Call) RunAndReturn(run func(context.Context) error) *MockConnection_Validate_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// NewMockConnection creates a new instance of MockConnection. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewMockConnection(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *MockConnection {
|
||||
mock := &MockConnection{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
141
apps/provisioning/pkg/connection/extra_mock.go
Normal file
141
apps/provisioning/pkg/connection/extra_mock.go
Normal file
@@ -0,0 +1,141 @@
|
||||
// Code generated by mockery v2.53.4. DO NOT EDIT.
|
||||
|
||||
package connection
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// MockExtra is an autogenerated mock type for the Extra type
|
||||
type MockExtra struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type MockExtra_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *MockExtra) EXPECT() *MockExtra_Expecter {
|
||||
return &MockExtra_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// Build provides a mock function with given fields: ctx, r
|
||||
func (_m *MockExtra) Build(ctx context.Context, r *v0alpha1.Connection) (Connection, error) {
|
||||
ret := _m.Called(ctx, r)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Build")
|
||||
}
|
||||
|
||||
var r0 Connection
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Connection) (Connection, error)); ok {
|
||||
return rf(ctx, r)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Connection) Connection); ok {
|
||||
r0 = rf(ctx, r)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(Connection)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, *v0alpha1.Connection) error); ok {
|
||||
r1 = rf(ctx, r)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockExtra_Build_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Build'
|
||||
type MockExtra_Build_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Build is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - r *v0alpha1.Connection
|
||||
func (_e *MockExtra_Expecter) Build(ctx interface{}, r interface{}) *MockExtra_Build_Call {
|
||||
return &MockExtra_Build_Call{Call: _e.mock.On("Build", ctx, r)}
|
||||
}
|
||||
|
||||
func (_c *MockExtra_Build_Call) Run(run func(ctx context.Context, r *v0alpha1.Connection)) *MockExtra_Build_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context), args[1].(*v0alpha1.Connection))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockExtra_Build_Call) Return(_a0 Connection, _a1 error) *MockExtra_Build_Call {
|
||||
_c.Call.Return(_a0, _a1)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockExtra_Build_Call) RunAndReturn(run func(context.Context, *v0alpha1.Connection) (Connection, error)) *MockExtra_Build_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Type provides a mock function with no fields
|
||||
func (_m *MockExtra) Type() v0alpha1.ConnectionType {
|
||||
ret := _m.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Type")
|
||||
}
|
||||
|
||||
var r0 v0alpha1.ConnectionType
|
||||
if rf, ok := ret.Get(0).(func() v0alpha1.ConnectionType); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
r0 = ret.Get(0).(v0alpha1.ConnectionType)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockExtra_Type_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Type'
|
||||
type MockExtra_Type_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Type is a helper method to define mock.On call
|
||||
func (_e *MockExtra_Expecter) Type() *MockExtra_Type_Call {
|
||||
return &MockExtra_Type_Call{Call: _e.mock.On("Type")}
|
||||
}
|
||||
|
||||
func (_c *MockExtra_Type_Call) Run(run func()) *MockExtra_Type_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run()
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockExtra_Type_Call) Return(_a0 v0alpha1.ConnectionType) *MockExtra_Type_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockExtra_Type_Call) RunAndReturn(run func() v0alpha1.ConnectionType) *MockExtra_Type_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// NewMockExtra creates a new instance of MockExtra. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewMockExtra(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *MockExtra {
|
||||
mock := &MockExtra{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
75
apps/provisioning/pkg/connection/factory.go
Normal file
75
apps/provisioning/pkg/connection/factory.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package connection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
)
|
||||
|
||||
//go:generate mockery --name=Extra --structname=MockExtra --inpackage --filename=extra_mock.go --with-expecter
|
||||
type Extra interface {
|
||||
Type() provisioning.ConnectionType
|
||||
Build(ctx context.Context, r *provisioning.Connection) (Connection, error)
|
||||
}
|
||||
|
||||
//go:generate mockery --name=Factory --structname=MockFactory --inpackage --filename=factory_mock.go --with-expecter
|
||||
type Factory interface {
|
||||
Types() []provisioning.ConnectionType
|
||||
Build(ctx context.Context, r *provisioning.Connection) (Connection, error)
|
||||
}
|
||||
|
||||
type factory struct {
|
||||
extras map[provisioning.ConnectionType]Extra
|
||||
enabled map[provisioning.ConnectionType]struct{}
|
||||
}
|
||||
|
||||
func ProvideFactory(enabled map[provisioning.ConnectionType]struct{}, extras []Extra) (Factory, error) {
|
||||
f := &factory{
|
||||
enabled: enabled,
|
||||
extras: make(map[provisioning.ConnectionType]Extra, len(extras)),
|
||||
}
|
||||
|
||||
for _, e := range extras {
|
||||
if _, exists := f.extras[e.Type()]; exists {
|
||||
return nil, fmt.Errorf("connection type %q is already registered", e.Type())
|
||||
}
|
||||
f.extras[e.Type()] = e
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (f *factory) Types() []provisioning.ConnectionType {
|
||||
var types []provisioning.ConnectionType
|
||||
for t := range f.enabled {
|
||||
if _, exists := f.extras[t]; exists {
|
||||
types = append(types, t)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(types, func(i, j int) bool {
|
||||
return string(types[i]) < string(types[j])
|
||||
})
|
||||
|
||||
return types
|
||||
}
|
||||
|
||||
func (f *factory) Build(ctx context.Context, c *provisioning.Connection) (Connection, error) {
|
||||
for _, e := range f.extras {
|
||||
if e.Type() == c.Spec.Type {
|
||||
if _, enabled := f.enabled[e.Type()]; !enabled {
|
||||
return nil, fmt.Errorf("connection type %q is not enabled", e.Type())
|
||||
}
|
||||
|
||||
return e.Build(ctx, c)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("connection type %q is not supported", c.Spec.Type)
|
||||
}
|
||||
|
||||
var (
|
||||
_ Factory = (*factory)(nil)
|
||||
)
|
||||
143
apps/provisioning/pkg/connection/factory_mock.go
Normal file
143
apps/provisioning/pkg/connection/factory_mock.go
Normal file
@@ -0,0 +1,143 @@
|
||||
// Code generated by mockery v2.53.4. DO NOT EDIT.
|
||||
|
||||
package connection
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// MockFactory is an autogenerated mock type for the Factory type
|
||||
type MockFactory struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type MockFactory_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *MockFactory) EXPECT() *MockFactory_Expecter {
|
||||
return &MockFactory_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// Build provides a mock function with given fields: ctx, r
|
||||
func (_m *MockFactory) Build(ctx context.Context, r *v0alpha1.Connection) (Connection, error) {
|
||||
ret := _m.Called(ctx, r)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Build")
|
||||
}
|
||||
|
||||
var r0 Connection
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Connection) (Connection, error)); ok {
|
||||
return rf(ctx, r)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Connection) Connection); ok {
|
||||
r0 = rf(ctx, r)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(Connection)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, *v0alpha1.Connection) error); ok {
|
||||
r1 = rf(ctx, r)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockFactory_Build_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Build'
|
||||
type MockFactory_Build_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Build is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - r *v0alpha1.Connection
|
||||
func (_e *MockFactory_Expecter) Build(ctx interface{}, r interface{}) *MockFactory_Build_Call {
|
||||
return &MockFactory_Build_Call{Call: _e.mock.On("Build", ctx, r)}
|
||||
}
|
||||
|
||||
func (_c *MockFactory_Build_Call) Run(run func(ctx context.Context, r *v0alpha1.Connection)) *MockFactory_Build_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context), args[1].(*v0alpha1.Connection))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockFactory_Build_Call) Return(_a0 Connection, _a1 error) *MockFactory_Build_Call {
|
||||
_c.Call.Return(_a0, _a1)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockFactory_Build_Call) RunAndReturn(run func(context.Context, *v0alpha1.Connection) (Connection, error)) *MockFactory_Build_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Types provides a mock function with no fields
|
||||
func (_m *MockFactory) Types() []v0alpha1.ConnectionType {
|
||||
ret := _m.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Types")
|
||||
}
|
||||
|
||||
var r0 []v0alpha1.ConnectionType
|
||||
if rf, ok := ret.Get(0).(func() []v0alpha1.ConnectionType); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]v0alpha1.ConnectionType)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockFactory_Types_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Types'
|
||||
type MockFactory_Types_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Types is a helper method to define mock.On call
|
||||
func (_e *MockFactory_Expecter) Types() *MockFactory_Types_Call {
|
||||
return &MockFactory_Types_Call{Call: _e.mock.On("Types")}
|
||||
}
|
||||
|
||||
func (_c *MockFactory_Types_Call) Run(run func()) *MockFactory_Types_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run()
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockFactory_Types_Call) Return(_a0 []v0alpha1.ConnectionType) *MockFactory_Types_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockFactory_Types_Call) RunAndReturn(run func() []v0alpha1.ConnectionType) *MockFactory_Types_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// NewMockFactory creates a new instance of MockFactory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewMockFactory(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *MockFactory {
|
||||
mock := &MockFactory{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
309
apps/provisioning/pkg/connection/factory_test.go
Normal file
309
apps/provisioning/pkg/connection/factory_test.go
Normal file
@@ -0,0 +1,309 @@
|
||||
package connection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
func TestProvideFactory(t *testing.T) {
|
||||
t.Run("should create factory with valid extras", func(t *testing.T) {
|
||||
extra1 := NewMockExtra(t)
|
||||
extra1.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
|
||||
extra2 := NewMockExtra(t)
|
||||
extra2.EXPECT().Type().Return(provisioning.GitlabConnectionType)
|
||||
|
||||
enabled := map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
provisioning.GitlabConnectionType: {},
|
||||
}
|
||||
|
||||
factory, err := ProvideFactory(enabled, []Extra{extra1, extra2})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, factory)
|
||||
})
|
||||
|
||||
t.Run("should create factory with empty extras", func(t *testing.T) {
|
||||
enabled := map[provisioning.ConnectionType]struct{}{}
|
||||
|
||||
factory, err := ProvideFactory(enabled, []Extra{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, factory)
|
||||
})
|
||||
|
||||
t.Run("should create factory with nil enabled map", func(t *testing.T) {
|
||||
extra1 := NewMockExtra(t)
|
||||
extra1.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
|
||||
factory, err := ProvideFactory(nil, []Extra{extra1})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, factory)
|
||||
})
|
||||
|
||||
t.Run("should return error when duplicate repository types", func(t *testing.T) {
|
||||
extra1 := NewMockExtra(t)
|
||||
extra1.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
|
||||
extra2 := NewMockExtra(t)
|
||||
extra2.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
|
||||
enabled := map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
}
|
||||
|
||||
factory, err := ProvideFactory(enabled, []Extra{extra1, extra2})
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, factory)
|
||||
assert.Contains(t, err.Error(), "connection type \"github\" is already registered")
|
||||
})
|
||||
}
|
||||
|
||||
func TestFactory_Types(t *testing.T) {
|
||||
t.Run("should return only enabled types that have extras", func(t *testing.T) {
|
||||
extra1 := NewMockExtra(t)
|
||||
extra1.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
|
||||
extra2 := NewMockExtra(t)
|
||||
extra2.EXPECT().Type().Return(provisioning.GitlabConnectionType)
|
||||
|
||||
enabled := map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
provisioning.GitlabConnectionType: {},
|
||||
}
|
||||
|
||||
factory, err := ProvideFactory(enabled, []Extra{extra1, extra2})
|
||||
require.NoError(t, err)
|
||||
|
||||
types := factory.Types()
|
||||
assert.Len(t, types, 2)
|
||||
assert.Contains(t, types, provisioning.GithubConnectionType)
|
||||
assert.Contains(t, types, provisioning.GitlabConnectionType)
|
||||
})
|
||||
|
||||
t.Run("should return sorted list of types", func(t *testing.T) {
|
||||
extra1 := NewMockExtra(t)
|
||||
extra1.EXPECT().Type().Return(provisioning.GitlabConnectionType)
|
||||
|
||||
extra2 := NewMockExtra(t)
|
||||
extra2.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
|
||||
enabled := map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
provisioning.GitlabConnectionType: {},
|
||||
}
|
||||
|
||||
factory, err := ProvideFactory(enabled, []Extra{extra1, extra2})
|
||||
require.NoError(t, err)
|
||||
|
||||
types := factory.Types()
|
||||
assert.Len(t, types, 2)
|
||||
// github should come before gitlab alphabetically
|
||||
assert.Equal(t, provisioning.GithubConnectionType, types[0])
|
||||
assert.Equal(t, provisioning.GitlabConnectionType, types[1])
|
||||
})
|
||||
|
||||
t.Run("should return empty list when no types are enabled", func(t *testing.T) {
|
||||
extra1 := NewMockExtra(t)
|
||||
extra1.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
|
||||
enabled := map[provisioning.ConnectionType]struct{}{}
|
||||
|
||||
factory, err := ProvideFactory(enabled, []Extra{extra1})
|
||||
require.NoError(t, err)
|
||||
|
||||
types := factory.Types()
|
||||
assert.Empty(t, types)
|
||||
})
|
||||
|
||||
t.Run("should not return types that are enabled but have no extras", func(t *testing.T) {
|
||||
extra1 := NewMockExtra(t)
|
||||
extra1.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
|
||||
enabled := map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
provisioning.GitlabConnectionType: {},
|
||||
}
|
||||
|
||||
factory, err := ProvideFactory(enabled, []Extra{extra1})
|
||||
require.NoError(t, err)
|
||||
|
||||
types := factory.Types()
|
||||
assert.Len(t, types, 1)
|
||||
assert.Contains(t, types, provisioning.GithubConnectionType)
|
||||
assert.NotContains(t, types, provisioning.GitlabConnectionType)
|
||||
})
|
||||
|
||||
t.Run("should not return types that have extras but are not enabled", func(t *testing.T) {
|
||||
extra1 := NewMockExtra(t)
|
||||
extra1.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
|
||||
extra2 := NewMockExtra(t)
|
||||
extra2.EXPECT().Type().Return(provisioning.GitlabConnectionType)
|
||||
|
||||
enabled := map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
}
|
||||
|
||||
factory, err := ProvideFactory(enabled, []Extra{extra1, extra2})
|
||||
require.NoError(t, err)
|
||||
|
||||
types := factory.Types()
|
||||
assert.Len(t, types, 1)
|
||||
assert.Contains(t, types, provisioning.GithubConnectionType)
|
||||
assert.NotContains(t, types, provisioning.GitlabConnectionType)
|
||||
})
|
||||
|
||||
t.Run("should return empty list when no extras are provided", func(t *testing.T) {
|
||||
enabled := map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
}
|
||||
|
||||
factory, err := ProvideFactory(enabled, []Extra{})
|
||||
require.NoError(t, err)
|
||||
|
||||
types := factory.Types()
|
||||
assert.Empty(t, types)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFactory_Build(t *testing.T) {
|
||||
t.Run("should successfully build connection when type is enabled and has extra", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conn := &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
},
|
||||
}
|
||||
|
||||
mockConnection := NewMockConnection(t)
|
||||
extra := NewMockExtra(t)
|
||||
extra.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
extra.EXPECT().Build(ctx, conn).Return(mockConnection, nil)
|
||||
|
||||
enabled := map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
}
|
||||
|
||||
factory, err := ProvideFactory(enabled, []Extra{extra})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := factory.Build(ctx, conn)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, mockConnection, result)
|
||||
})
|
||||
|
||||
t.Run("should return error when type is not enabled", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conn := &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GitlabConnectionType,
|
||||
},
|
||||
}
|
||||
|
||||
extra := NewMockExtra(t)
|
||||
extra.EXPECT().Type().Return(provisioning.GitlabConnectionType)
|
||||
|
||||
enabled := map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
}
|
||||
|
||||
factory, err := ProvideFactory(enabled, []Extra{extra})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := factory.Build(ctx, conn)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "connection type \"gitlab\" is not enabled")
|
||||
})
|
||||
|
||||
t.Run("should return error when type is not supported", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conn := &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GitlabConnectionType,
|
||||
},
|
||||
}
|
||||
|
||||
extra := NewMockExtra(t)
|
||||
extra.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
|
||||
enabled := map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
}
|
||||
|
||||
factory, err := ProvideFactory(enabled, []Extra{extra})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := factory.Build(ctx, conn)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "connection type \"gitlab\" is not supported")
|
||||
})
|
||||
|
||||
t.Run("should pass through errors from extra.Build()", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conn := &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
},
|
||||
}
|
||||
|
||||
expectedErr := errors.New("build error")
|
||||
extra := NewMockExtra(t)
|
||||
extra.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
extra.EXPECT().Build(ctx, conn).Return(nil, expectedErr)
|
||||
|
||||
enabled := map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
}
|
||||
|
||||
factory, err := ProvideFactory(enabled, []Extra{extra})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := factory.Build(ctx, conn)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Equal(t, expectedErr, err)
|
||||
})
|
||||
|
||||
t.Run("should build with multiple extras registered", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conn := &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GitlabConnectionType,
|
||||
},
|
||||
}
|
||||
|
||||
mockConnection := NewMockConnection(t)
|
||||
|
||||
extra1 := NewMockExtra(t)
|
||||
extra1.EXPECT().Type().Return(provisioning.GithubConnectionType)
|
||||
|
||||
extra2 := NewMockExtra(t)
|
||||
extra2.EXPECT().Type().Return(provisioning.GitlabConnectionType)
|
||||
extra2.EXPECT().Build(ctx, conn).Return(mockConnection, nil)
|
||||
|
||||
enabled := map[provisioning.ConnectionType]struct{}{
|
||||
provisioning.GithubConnectionType: {},
|
||||
provisioning.GitlabConnectionType: {},
|
||||
}
|
||||
|
||||
factory, err := ProvideFactory(enabled, []Extra{extra1, extra2})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := factory.Build(ctx, conn)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, mockConnection, result)
|
||||
})
|
||||
}
|
||||
93
apps/provisioning/pkg/connection/github/client.go
Normal file
93
apps/provisioning/pkg/connection/github/client.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/google/go-github/v70/github"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
)
|
||||
|
||||
// API errors that we need to convey after parsing real GH errors (or faking them).
|
||||
var (
|
||||
//lint:ignore ST1005 this is not punctuation
|
||||
ErrServiceUnavailable = apierrors.NewServiceUnavailable("github is unavailable")
|
||||
)
|
||||
|
||||
//go:generate mockery --name Client --structname MockClient --inpackage --filename client_mock.go --with-expecter
|
||||
type Client interface {
|
||||
// Apps and installations
|
||||
GetApp(ctx context.Context) (App, error)
|
||||
GetAppInstallation(ctx context.Context, installationID string) (AppInstallation, error)
|
||||
}
|
||||
|
||||
// App represents a Github App.
|
||||
type App struct {
|
||||
// ID represents the GH app ID.
|
||||
ID int64
|
||||
// Slug represents the GH app slug.
|
||||
Slug string
|
||||
// Owner represents the GH account/org owning the app
|
||||
Owner string
|
||||
}
|
||||
|
||||
// AppInstallation represents a Github App Installation.
|
||||
type AppInstallation struct {
|
||||
// ID represents the GH installation ID.
|
||||
ID int64
|
||||
// Whether the installation is enabled or not.
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
type githubClient struct {
|
||||
gh *github.Client
|
||||
}
|
||||
|
||||
func NewClient(client *github.Client) Client {
|
||||
return &githubClient{client}
|
||||
}
|
||||
|
||||
// GetApp gets the app by using the given token.
|
||||
func (r *githubClient) GetApp(ctx context.Context) (App, error) {
|
||||
app, _, err := r.gh.Apps.Get(ctx, "")
|
||||
if err != nil {
|
||||
var ghErr *github.ErrorResponse
|
||||
if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusServiceUnavailable {
|
||||
return App{}, ErrServiceUnavailable
|
||||
}
|
||||
return App{}, err
|
||||
}
|
||||
|
||||
// TODO(ferruvich): do we need any other info?
|
||||
return App{
|
||||
ID: app.GetID(),
|
||||
Slug: app.GetSlug(),
|
||||
Owner: app.GetOwner().GetLogin(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAppInstallation gets the installation of the app related to the given token.
|
||||
func (r *githubClient) GetAppInstallation(ctx context.Context, installationID string) (AppInstallation, error) {
|
||||
id, err := strconv.Atoi(installationID)
|
||||
if err != nil {
|
||||
return AppInstallation{}, fmt.Errorf("invalid installation ID: %s", installationID)
|
||||
}
|
||||
|
||||
installation, _, err := r.gh.Apps.GetInstallation(ctx, int64(id))
|
||||
if err != nil {
|
||||
var ghErr *github.ErrorResponse
|
||||
if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusServiceUnavailable {
|
||||
return AppInstallation{}, ErrServiceUnavailable
|
||||
}
|
||||
return AppInstallation{}, err
|
||||
}
|
||||
|
||||
// TODO(ferruvich): do we need any other info?
|
||||
return AppInstallation{
|
||||
ID: installation.GetID(),
|
||||
Enabled: installation.GetSuspendedAt().IsZero(),
|
||||
}, nil
|
||||
}
|
||||
149
apps/provisioning/pkg/connection/github/client_mock.go
Normal file
149
apps/provisioning/pkg/connection/github/client_mock.go
Normal file
@@ -0,0 +1,149 @@
|
||||
// Code generated by mockery v2.53.4. DO NOT EDIT.
|
||||
|
||||
package github
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// MockClient is an autogenerated mock type for the Client type
|
||||
type MockClient struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type MockClient_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *MockClient) EXPECT() *MockClient_Expecter {
|
||||
return &MockClient_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// GetApp provides a mock function with given fields: ctx
|
||||
func (_m *MockClient) GetApp(ctx context.Context) (App, error) {
|
||||
ret := _m.Called(ctx)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetApp")
|
||||
}
|
||||
|
||||
var r0 App
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context) (App, error)); ok {
|
||||
return rf(ctx)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context) App); ok {
|
||||
r0 = rf(ctx)
|
||||
} else {
|
||||
r0 = ret.Get(0).(App)
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context) error); ok {
|
||||
r1 = rf(ctx)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockClient_GetApp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetApp'
|
||||
type MockClient_GetApp_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// GetApp is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
func (_e *MockClient_Expecter) GetApp(ctx interface{}) *MockClient_GetApp_Call {
|
||||
return &MockClient_GetApp_Call{Call: _e.mock.On("GetApp", ctx)}
|
||||
}
|
||||
|
||||
func (_c *MockClient_GetApp_Call) Run(run func(ctx context.Context)) *MockClient_GetApp_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockClient_GetApp_Call) Return(_a0 App, _a1 error) *MockClient_GetApp_Call {
|
||||
_c.Call.Return(_a0, _a1)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockClient_GetApp_Call) RunAndReturn(run func(context.Context) (App, error)) *MockClient_GetApp_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// GetAppInstallation provides a mock function with given fields: ctx, installationID
|
||||
func (_m *MockClient) GetAppInstallation(ctx context.Context, installationID string) (AppInstallation, error) {
|
||||
ret := _m.Called(ctx, installationID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetAppInstallation")
|
||||
}
|
||||
|
||||
var r0 AppInstallation
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, string) (AppInstallation, error)); ok {
|
||||
return rf(ctx, installationID)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, string) AppInstallation); ok {
|
||||
r0 = rf(ctx, installationID)
|
||||
} else {
|
||||
r0 = ret.Get(0).(AppInstallation)
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
|
||||
r1 = rf(ctx, installationID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockClient_GetAppInstallation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAppInstallation'
|
||||
type MockClient_GetAppInstallation_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// GetAppInstallation is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - installationID string
|
||||
func (_e *MockClient_Expecter) GetAppInstallation(ctx interface{}, installationID interface{}) *MockClient_GetAppInstallation_Call {
|
||||
return &MockClient_GetAppInstallation_Call{Call: _e.mock.On("GetAppInstallation", ctx, installationID)}
|
||||
}
|
||||
|
||||
func (_c *MockClient_GetAppInstallation_Call) Run(run func(ctx context.Context, installationID string)) *MockClient_GetAppInstallation_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context), args[1].(string))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockClient_GetAppInstallation_Call) Return(_a0 AppInstallation, _a1 error) *MockClient_GetAppInstallation_Call {
|
||||
_c.Call.Return(_a0, _a1)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockClient_GetAppInstallation_Call) RunAndReturn(run func(context.Context, string) (AppInstallation, error)) *MockClient_GetAppInstallation_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// NewMockClient creates a new instance of MockClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewMockClient(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *MockClient {
|
||||
mock := &MockClient{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
297
apps/provisioning/pkg/connection/github/client_test.go
Normal file
297
apps/provisioning/pkg/connection/github/client_test.go
Normal file
@@ -0,0 +1,297 @@
|
||||
package github_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-github/v70/github"
|
||||
conngh "github.com/grafana/grafana/apps/provisioning/pkg/connection/github"
|
||||
mockhub "github.com/migueleliasweb/go-github-mock/src/mock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGithubClient_GetApp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mockHandler *http.Client
|
||||
token string
|
||||
wantApp conngh.App
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "get app successfully",
|
||||
mockHandler: mockhub.NewMockedHTTPClient(
|
||||
mockhub.WithRequestMatchHandler(
|
||||
mockhub.GetApp,
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
app := &github.App{
|
||||
ID: github.Ptr(int64(12345)),
|
||||
Slug: github.Ptr("my-test-app"),
|
||||
Owner: &github.User{
|
||||
Login: github.Ptr("grafana"),
|
||||
},
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
require.NoError(t, json.NewEncoder(w).Encode(app))
|
||||
}),
|
||||
),
|
||||
),
|
||||
token: "test-token",
|
||||
wantApp: conngh.App{
|
||||
ID: 12345,
|
||||
Slug: "my-test-app",
|
||||
Owner: "grafana",
|
||||
},
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "service unavailable",
|
||||
mockHandler: mockhub.NewMockedHTTPClient(
|
||||
mockhub.WithRequestMatchHandler(
|
||||
mockhub.GetApp,
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
|
||||
Response: &http.Response{
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
},
|
||||
Message: "Service unavailable",
|
||||
}))
|
||||
}),
|
||||
),
|
||||
),
|
||||
token: "test-token",
|
||||
wantApp: conngh.App{},
|
||||
wantErr: conngh.ErrServiceUnavailable,
|
||||
},
|
||||
{
|
||||
name: "other error",
|
||||
mockHandler: mockhub.NewMockedHTTPClient(
|
||||
mockhub.WithRequestMatchHandler(
|
||||
mockhub.GetApp,
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
|
||||
Response: &http.Response{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
},
|
||||
Message: "Internal server error",
|
||||
}))
|
||||
}),
|
||||
),
|
||||
),
|
||||
token: "test-token",
|
||||
wantApp: conngh.App{},
|
||||
wantErr: &github.ErrorResponse{
|
||||
Response: &http.Response{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
},
|
||||
Message: "Internal server error",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unauthorized error",
|
||||
mockHandler: mockhub.NewMockedHTTPClient(
|
||||
mockhub.WithRequestMatchHandler(
|
||||
mockhub.GetApp,
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
|
||||
Response: &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
},
|
||||
Message: "Bad credentials",
|
||||
}))
|
||||
}),
|
||||
),
|
||||
),
|
||||
token: "invalid-token",
|
||||
wantApp: conngh.App{},
|
||||
wantErr: &github.ErrorResponse{
|
||||
Response: &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
},
|
||||
Message: "Bad credentials",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create a mock client
|
||||
ghClient := github.NewClient(tt.mockHandler)
|
||||
client := conngh.NewClient(ghClient)
|
||||
|
||||
// Call the method being tested
|
||||
app, err := client.GetApp(context.Background())
|
||||
|
||||
// Check the error
|
||||
if tt.wantErr != nil {
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, tt.wantApp, app)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.wantApp, app)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGithubClient_GetAppInstallation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mockHandler *http.Client
|
||||
appToken string
|
||||
installationID string
|
||||
wantInstallation conngh.AppInstallation
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "get disabled app installation successfully",
|
||||
mockHandler: mockhub.NewMockedHTTPClient(
|
||||
mockhub.WithRequestMatchHandler(
|
||||
mockhub.GetAppInstallationsByInstallationId,
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
installation := &github.Installation{
|
||||
ID: github.Ptr(int64(67890)),
|
||||
SuspendedAt: github.Ptr(github.Timestamp{Time: time.Now()}),
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
require.NoError(t, json.NewEncoder(w).Encode(installation))
|
||||
}),
|
||||
),
|
||||
),
|
||||
appToken: "test-app-token",
|
||||
installationID: "67890",
|
||||
wantInstallation: conngh.AppInstallation{
|
||||
ID: 67890,
|
||||
Enabled: false,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "get enabled app installation successfully",
|
||||
mockHandler: mockhub.NewMockedHTTPClient(
|
||||
mockhub.WithRequestMatchHandler(
|
||||
mockhub.GetAppInstallationsByInstallationId,
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
installation := &github.Installation{
|
||||
ID: github.Ptr(int64(67890)),
|
||||
SuspendedAt: nil,
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
require.NoError(t, json.NewEncoder(w).Encode(installation))
|
||||
}),
|
||||
),
|
||||
),
|
||||
appToken: "test-app-token",
|
||||
installationID: "67890",
|
||||
wantInstallation: conngh.AppInstallation{
|
||||
ID: 67890,
|
||||
Enabled: true,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid installation ID",
|
||||
mockHandler: mockhub.NewMockedHTTPClient(),
|
||||
appToken: "test-app-token",
|
||||
installationID: "not-a-number",
|
||||
wantInstallation: conngh.AppInstallation{},
|
||||
wantErr: true,
|
||||
errContains: "invalid installation ID",
|
||||
},
|
||||
{
|
||||
name: "service unavailable",
|
||||
mockHandler: mockhub.NewMockedHTTPClient(
|
||||
mockhub.WithRequestMatchHandler(
|
||||
mockhub.GetAppInstallationsByInstallationId,
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
|
||||
Response: &http.Response{
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
},
|
||||
Message: "Service unavailable",
|
||||
}))
|
||||
}),
|
||||
),
|
||||
),
|
||||
appToken: "test-app-token",
|
||||
installationID: "67890",
|
||||
wantInstallation: conngh.AppInstallation{},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "installation not found",
|
||||
mockHandler: mockhub.NewMockedHTTPClient(
|
||||
mockhub.WithRequestMatchHandler(
|
||||
mockhub.GetAppInstallationsByInstallationId,
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
|
||||
Response: &http.Response{
|
||||
StatusCode: http.StatusNotFound,
|
||||
},
|
||||
Message: "Not Found",
|
||||
}))
|
||||
}),
|
||||
),
|
||||
),
|
||||
appToken: "test-app-token",
|
||||
installationID: "99999",
|
||||
wantInstallation: conngh.AppInstallation{},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "other error",
|
||||
mockHandler: mockhub.NewMockedHTTPClient(
|
||||
mockhub.WithRequestMatchHandler(
|
||||
mockhub.GetAppInstallationsByInstallationId,
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
|
||||
Response: &http.Response{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
},
|
||||
Message: "Internal server error",
|
||||
}))
|
||||
}),
|
||||
),
|
||||
),
|
||||
appToken: "test-app-token",
|
||||
installationID: "67890",
|
||||
wantInstallation: conngh.AppInstallation{},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create a mock client
|
||||
ghClient := github.NewClient(tt.mockHandler)
|
||||
client := conngh.NewClient(ghClient)
|
||||
|
||||
// Call the method being tested
|
||||
installation, err := client.GetAppInstallation(context.Background(), tt.installationID)
|
||||
|
||||
// Check the error
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
if tt.errContains != "" {
|
||||
assert.Contains(t, err.Error(), tt.errContains)
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// Check the result
|
||||
assert.Equal(t, tt.wantInstallation, installation)
|
||||
})
|
||||
}
|
||||
}
|
||||
192
apps/provisioning/pkg/connection/github/connection.go
Normal file
192
apps/provisioning/pkg/connection/github/connection.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/connection"
|
||||
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||
)
|
||||
|
||||
//go:generate mockery --name GithubFactory --structname MockGithubFactory --inpackage --filename factory_mock.go --with-expecter
|
||||
type GithubFactory interface {
|
||||
New(ctx context.Context, ghToken common.RawSecureValue) Client
|
||||
}
|
||||
|
||||
type Connection struct {
|
||||
obj *provisioning.Connection
|
||||
ghFactory GithubFactory
|
||||
}
|
||||
|
||||
func NewConnection(
|
||||
obj *provisioning.Connection,
|
||||
factory GithubFactory,
|
||||
) Connection {
|
||||
return Connection{
|
||||
obj: obj,
|
||||
ghFactory: factory,
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
//TODO(ferruvich): these probably need to be setup in API configuration.
|
||||
githubInstallationURL = "https://github.com/settings/installations"
|
||||
jwtExpirationMinutes = 10 // GitHub Apps JWT tokens expire in 10 minutes maximum
|
||||
)
|
||||
|
||||
// Mutate performs in place mutation of the underneath resource.
|
||||
func (c *Connection) Mutate(_ context.Context) error {
|
||||
// Do nothing in case spec.Github is nil.
|
||||
// If this field is required, we should fail at validation time.
|
||||
if c.obj.Spec.GitHub == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
c.obj.Spec.URL = fmt.Sprintf("%s/%s", githubInstallationURL, c.obj.Spec.GitHub.InstallationID)
|
||||
|
||||
// Generate JWT token if private key is being provided.
|
||||
// Same as for the spec.Github, if such a field is required, Validation will take care of that.
|
||||
if !c.obj.Secure.PrivateKey.Create.IsZero() {
|
||||
token, err := generateToken(c.obj.Spec.GitHub.AppID, c.obj.Secure.PrivateKey.Create)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate JWT token: %w", err)
|
||||
}
|
||||
|
||||
// Store the generated token
|
||||
c.obj.Secure.Token = common.InlineSecureValue{Create: token}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Token generates and returns the Connection token.
|
||||
func generateToken(appID string, privateKey common.RawSecureValue) (common.RawSecureValue, error) {
|
||||
// Decode base64-encoded private key
|
||||
privateKeyPEM, err := base64.StdEncoding.DecodeString(string(privateKey))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode base64 private key: %w", err)
|
||||
}
|
||||
|
||||
// Parse the private key
|
||||
key, err := jwt.ParseRSAPrivateKeyFromPEM(privateKeyPEM)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse private key: %w", err)
|
||||
}
|
||||
|
||||
// Create the JWT token
|
||||
now := time.Now()
|
||||
claims := jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(jwtExpirationMinutes) * time.Minute)),
|
||||
Issuer: appID,
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
signedToken, err := token.SignedString(key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign JWT token: %w", err)
|
||||
}
|
||||
|
||||
return common.RawSecureValue(signedToken), nil
|
||||
}
|
||||
|
||||
// Validate ensures the resource _looks_ correct.
|
||||
func (c *Connection) Validate(ctx context.Context) error {
|
||||
list := field.ErrorList{}
|
||||
|
||||
if c.obj.Spec.Type != provisioning.GithubConnectionType {
|
||||
list = append(list, field.Invalid(field.NewPath("spec", "type"), c.obj.Spec.Type, "invalid connection type"))
|
||||
|
||||
// Doesn't make much sense to continue validating a connection which is not a Github one.
|
||||
return toError(c.obj.GetName(), list)
|
||||
}
|
||||
|
||||
if c.obj.Spec.GitHub == nil {
|
||||
list = append(
|
||||
list, field.Required(field.NewPath("spec", "github"), "github info must be specified for GitHub connection"),
|
||||
)
|
||||
|
||||
// Doesn't make much sense to continue validating a connection with no information.
|
||||
return toError(c.obj.GetName(), list)
|
||||
}
|
||||
|
||||
if c.obj.Secure.PrivateKey.IsZero() {
|
||||
list = append(list, field.Required(field.NewPath("secure", "privateKey"), "privateKey must be specified for GitHub connection"))
|
||||
}
|
||||
if c.obj.Secure.Token.IsZero() {
|
||||
list = append(list, field.Required(field.NewPath("secure", "token"), "token must be specified for GitHub connection"))
|
||||
}
|
||||
if !c.obj.Secure.ClientSecret.IsZero() {
|
||||
list = append(list, field.Forbidden(field.NewPath("secure", "clientSecret"), "clientSecret is forbidden in GitHub connection"))
|
||||
}
|
||||
|
||||
// Validate GitHub configuration fields
|
||||
if c.obj.Spec.GitHub.AppID == "" {
|
||||
list = append(list, field.Required(field.NewPath("spec", "github", "appID"), "appID must be specified for GitHub connection"))
|
||||
}
|
||||
if c.obj.Spec.GitHub.InstallationID == "" {
|
||||
list = append(list, field.Required(field.NewPath("spec", "github", "installationID"), "installationID must be specified for GitHub connection"))
|
||||
}
|
||||
|
||||
// In case we have any error above, we don't go forward with the validation, and return the errors.
|
||||
if len(list) > 0 {
|
||||
return toError(c.obj.GetName(), list)
|
||||
}
|
||||
|
||||
// Validating app content via GH API
|
||||
if err := c.validateAppAndInstallation(ctx); err != nil {
|
||||
list = append(list, err)
|
||||
}
|
||||
|
||||
return toError(c.obj.GetName(), list)
|
||||
}
|
||||
|
||||
// validateAppAndInstallation validates the appID and installationID against the given github token.
|
||||
func (c *Connection) validateAppAndInstallation(ctx context.Context) *field.Error {
|
||||
ghClient := c.ghFactory.New(ctx, c.obj.Secure.Token.Create)
|
||||
|
||||
app, err := ghClient.GetApp(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrServiceUnavailable) {
|
||||
return field.InternalError(field.NewPath("spec", "token"), ErrServiceUnavailable)
|
||||
}
|
||||
return field.Invalid(field.NewPath("spec", "token"), "[REDACTED]", "invalid token")
|
||||
}
|
||||
|
||||
if fmt.Sprintf("%d", app.ID) != c.obj.Spec.GitHub.AppID {
|
||||
return field.Invalid(field.NewPath("spec", "appID"), c.obj.Spec.GitHub.AppID, "appID mismatch")
|
||||
}
|
||||
|
||||
_, err = ghClient.GetAppInstallation(ctx, c.obj.Spec.GitHub.InstallationID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrServiceUnavailable) {
|
||||
return field.InternalError(field.NewPath("spec", "token"), ErrServiceUnavailable)
|
||||
}
|
||||
return field.Invalid(field.NewPath("spec", "installationID"), c.obj.Spec.GitHub.InstallationID, "invalid installation ID")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// toError converts a field.ErrorList to an error, returning nil if the list is empty
|
||||
func toError(name string, list field.ErrorList) error {
|
||||
if len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
return apierrors.NewInvalid(
|
||||
provisioning.ConnectionResourceInfo.GroupVersionKind().GroupKind(),
|
||||
name,
|
||||
list,
|
||||
)
|
||||
}
|
||||
|
||||
var (
|
||||
_ connection.Connection = (*Connection)(nil)
|
||||
)
|
||||
434
apps/provisioning/pkg/connection/github/connection_test.go
Normal file
434
apps/provisioning/pkg/connection/github/connection_test.go
Normal file
@@ -0,0 +1,434 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
//nolint:gosec // Test RSA private key (generated for testing purposes only)
|
||||
const testPrivateKeyPEM = `-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEowIBAAKCAQEAoInVbLY9io2Q/wHvUIXlEHg2Qyvd8eRzBAVEJ92DS6fx9H10
|
||||
06V0VRm78S0MXyo6i+n8ZAbZ0/R+GWpP2Ephxm0Gs2zo+iO2mpB19xQFI4o6ZTOw
|
||||
b2WyjSaa2Vr4oyDkqti6AvfjW4VUAu932e08GkgwmmQSHXj7FX2CMWjgUwTTcuaX
|
||||
65SHNKLNYLUP0HTumLzoZeqDTdoMMpKNdgH9Avr4/8vkVJ0mD6rqvxnw3JHsseNO
|
||||
WdQTxf2aApBNHIIKxWZ2i/ZmjLNey7kltgjEquGiBdJvip3fHhH5XHdkrXcjRtnw
|
||||
OJDnDmi5lQwv5yUBOSkbvbXRv/L/m0YLoD/fbwIDAQABAoIBAFfl//hM8/cnuesV
|
||||
+R1Con/ZAgTXQOdPqPXbmEyniVrkMqMmCdBUOBTcST4s5yg36+RtkeaGpb/ajyyF
|
||||
PAB2AYDucwvMpudGpJWOYTiOOp4R8hU1LvZfXVrRd1lo6NgQi4NLtNUpOtACeVQ+
|
||||
H4Yv0YemXQ47mnuOoRNMK/u3q5NoIdSahWptXBgUno8KklNpUrH3IYWaUxfBzDN3
|
||||
2xsVRTn2SfTSyoDmTDdTgptJONmoK1/sV7UsgWksdFc6XyYhsFAZgOGEJrBABRvF
|
||||
546dyQ0cWxuPyVXpM7CN3tqC5ssvLjElg3LicK1V6gnjpdRnnvX88d1Eh3Uc/9IM
|
||||
OZInT2ECgYEA6W8sQXTWinyEwl8SDKKMbB2ApIghAcFgdRxprZE4WFxjsYNCNL70
|
||||
dnSB7MRuzmxf5W77cV0N7JhH66N8HvY6Xq9olrpQ5dNttR4w8Pyv3wavDe8x7seL
|
||||
5L2Xtbu7ihDr8Dk27MjiBSin3IxhBP5CJS910+pR6LrAWtEuU+FzFfECgYEAsA6y
|
||||
qxHhCMXlTnauXhsnmPd1g61q7chW8kLQFYtHMLlQlgjHTW7irDZ9cPbPYDNjwRLO
|
||||
7KLorcpv2NKe7rqq2ZyCm6hf1b9WnlQjo3dLpNWMu6fhy/smK8MgbRqcWpX+oTKF
|
||||
79mK6hbY7o6eBzsQHBl7Z+LBNuwYmp9qOodPa18CgYEArv6ipKdcNhFGzRfMRiCN
|
||||
OHederp6VACNuP2F05IsNUF9kxOdTEFirnKE++P+VU01TqA2azOhPp6iO+ohIGzi
|
||||
MR06QNSH1OL9OWvasK4dggpWrRGF00VQgDgJRTnpS4WH+lxJ6pRlrAxgWpv6F24s
|
||||
VAgSQr1Ejj2B+hMasdMvHWECgYBJ4uE4yhgXBnZlp4kmFV9Y4wF+cZkekaVrpn6N
|
||||
jBYkbKFVVfnOlWqru3KJpgsB5I9IyAvvY68iwIKQDFSG+/AXw4dMrC0MF3DSoZ0T
|
||||
TU2Br92QI7SvVod+djV1lGVp3ukt3XY4YqPZ+hywgUnw3uiz4j3YK2HLGup4ec6r
|
||||
IX5DIQKBgHRLzvT3zqtlR1Oh0vv098clLwt+pGzXOxzJpxioOa5UqK13xIpFXbcg
|
||||
iWUVh5YXCcuqaICUv4RLIEac5xQitk9Is/9IhP0NJ/81rHniosvdSpCeFXzxTImS
|
||||
B8Uc0WUgheB4+yVKGnYpYaSOgFFI5+1BYUva/wDHLy2pWHz39Usb
|
||||
-----END RSA PRIVATE KEY-----`
|
||||
|
||||
func TestConnection_Mutate(t *testing.T) {
|
||||
t.Run("should add URL to Github connection", func(t *testing.T) {
|
||||
c := &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
GitHub: &provisioning.GitHubConnectionConfig{
|
||||
AppID: "123",
|
||||
InstallationID: "456",
|
||||
},
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
PrivateKey: common.InlineSecureValue{
|
||||
Name: "test-private-key",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockFactory := NewMockGithubFactory(t)
|
||||
conn := NewConnection(c, mockFactory)
|
||||
|
||||
require.NoError(t, conn.Mutate(context.Background()))
|
||||
assert.Equal(t, "https://github.com/settings/installations/456", c.Spec.URL)
|
||||
})
|
||||
|
||||
t.Run("should generate JWT token when private key is provided", func(t *testing.T) {
|
||||
privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM))
|
||||
|
||||
c := &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
GitHub: &provisioning.GitHubConnectionConfig{
|
||||
AppID: "123",
|
||||
InstallationID: "456",
|
||||
},
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
PrivateKey: common.InlineSecureValue{
|
||||
Create: common.NewSecretValue(privateKeyBase64),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockFactory := NewMockGithubFactory(t)
|
||||
conn := NewConnection(c, mockFactory)
|
||||
|
||||
require.NoError(t, conn.Mutate(context.Background()))
|
||||
assert.Equal(t, "https://github.com/settings/installations/456", c.Spec.URL)
|
||||
assert.False(t, c.Secure.Token.Create.IsZero(), "JWT token should be generated")
|
||||
})
|
||||
|
||||
t.Run("should do nothing when GitHub config is nil", func(t *testing.T) {
|
||||
c := &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GitlabConnectionType,
|
||||
Gitlab: &provisioning.GitlabConnectionConfig{
|
||||
ClientID: "clientID",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockFactory := NewMockGithubFactory(t)
|
||||
conn := NewConnection(c, mockFactory)
|
||||
|
||||
require.NoError(t, conn.Mutate(context.Background()))
|
||||
})
|
||||
|
||||
t.Run("should fail when private key is not base64", func(t *testing.T) {
|
||||
c := &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
GitHub: &provisioning.GitHubConnectionConfig{
|
||||
AppID: "123",
|
||||
InstallationID: "456",
|
||||
},
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
PrivateKey: common.InlineSecureValue{
|
||||
Create: common.NewSecretValue("invalid-key"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockFactory := NewMockGithubFactory(t)
|
||||
conn := NewConnection(c, mockFactory)
|
||||
|
||||
err := conn.Mutate(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to generate JWT token")
|
||||
assert.Contains(t, err.Error(), "failed to decode base64 private key")
|
||||
})
|
||||
|
||||
t.Run("should fail when private key is invalid", func(t *testing.T) {
|
||||
c := &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
GitHub: &provisioning.GitHubConnectionConfig{
|
||||
AppID: "123",
|
||||
InstallationID: "456",
|
||||
},
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
PrivateKey: common.InlineSecureValue{
|
||||
Create: common.NewSecretValue(base64.StdEncoding.EncodeToString([]byte("invalid-key"))),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockFactory := NewMockGithubFactory(t)
|
||||
conn := NewConnection(c, mockFactory)
|
||||
|
||||
err := conn.Mutate(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to generate JWT token")
|
||||
assert.Contains(t, err.Error(), "failed to parse private key")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConnection_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
connection *provisioning.Connection
|
||||
setupMock func(*MockGithubFactory)
|
||||
wantErr bool
|
||||
errMsgContains []string
|
||||
}{
|
||||
{
|
||||
name: "invalid type returns error",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: "invalid",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsgContains: []string{"spec.type"},
|
||||
},
|
||||
{
|
||||
name: "github type without github config returns error",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsgContains: []string{"spec.github"},
|
||||
},
|
||||
{
|
||||
name: "github type without private key returns error",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
GitHub: &provisioning.GitHubConnectionConfig{
|
||||
AppID: "123",
|
||||
InstallationID: "456",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsgContains: []string{"secure.privateKey"},
|
||||
},
|
||||
{
|
||||
name: "github type without token returns error",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
GitHub: &provisioning.GitHubConnectionConfig{
|
||||
AppID: "123",
|
||||
InstallationID: "456",
|
||||
},
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
PrivateKey: common.InlineSecureValue{
|
||||
Create: common.NewSecretValue("test-private-key"),
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsgContains: []string{"secure.token"},
|
||||
},
|
||||
{
|
||||
name: "github type with client secret returns error",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
GitHub: &provisioning.GitHubConnectionConfig{
|
||||
AppID: "123",
|
||||
InstallationID: "456",
|
||||
},
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
ClientSecret: common.InlineSecureValue{
|
||||
Create: common.NewSecretValue("test-client-secret"),
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsgContains: []string{"secure.clientSecret"},
|
||||
},
|
||||
{
|
||||
name: "github type without appID returns error",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
GitHub: &provisioning.GitHubConnectionConfig{
|
||||
InstallationID: "456",
|
||||
},
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
PrivateKey: common.InlineSecureValue{
|
||||
Create: common.NewSecretValue("test-private-key"),
|
||||
},
|
||||
Token: common.InlineSecureValue{
|
||||
Create: common.NewSecretValue("test-token"),
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsgContains: []string{"spec.github.appID"},
|
||||
},
|
||||
{
|
||||
name: "github type without installationID returns error",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
GitHub: &provisioning.GitHubConnectionConfig{
|
||||
AppID: "123",
|
||||
},
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
PrivateKey: common.InlineSecureValue{
|
||||
Name: "test-private-key",
|
||||
},
|
||||
Token: common.InlineSecureValue{
|
||||
Name: "test-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsgContains: []string{"spec.github.installationID"},
|
||||
},
|
||||
{
|
||||
name: "github type with valid config is valid",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
GitHub: &provisioning.GitHubConnectionConfig{
|
||||
AppID: "123",
|
||||
InstallationID: "456",
|
||||
},
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
PrivateKey: common.InlineSecureValue{
|
||||
Create: common.NewSecretValue("test-private-key"),
|
||||
},
|
||||
Token: common.InlineSecureValue{
|
||||
Create: common.NewSecretValue("test-token"),
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
setupMock: func(mockFactory *MockGithubFactory) {
|
||||
mockClient := NewMockClient(t)
|
||||
|
||||
mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient)
|
||||
mockClient.EXPECT().GetApp(mock.Anything).Return(App{ID: 123, Slug: "test-app"}, nil)
|
||||
mockClient.EXPECT().GetAppInstallation(mock.Anything, "456").Return(AppInstallation{ID: 456}, nil)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "problem getting app returns error",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
GitHub: &provisioning.GitHubConnectionConfig{
|
||||
AppID: "123",
|
||||
InstallationID: "456",
|
||||
},
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
PrivateKey: common.InlineSecureValue{
|
||||
Create: common.NewSecretValue("test-private-key"),
|
||||
},
|
||||
Token: common.InlineSecureValue{
|
||||
Create: common.NewSecretValue("test-token"),
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsgContains: []string{"spec.token", "[REDACTED]"},
|
||||
setupMock: func(mockFactory *MockGithubFactory) {
|
||||
mockClient := NewMockClient(t)
|
||||
|
||||
mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient)
|
||||
mockClient.EXPECT().GetApp(mock.Anything).Return(App{}, assert.AnError)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mismatched app ID returns error",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
GitHub: &provisioning.GitHubConnectionConfig{
|
||||
AppID: "123",
|
||||
InstallationID: "456",
|
||||
},
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
PrivateKey: common.InlineSecureValue{
|
||||
Create: common.NewSecretValue("test-private-key"),
|
||||
},
|
||||
Token: common.InlineSecureValue{
|
||||
Create: common.NewSecretValue("test-token"),
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsgContains: []string{"spec.appID"},
|
||||
setupMock: func(mockFactory *MockGithubFactory) {
|
||||
mockClient := NewMockClient(t)
|
||||
|
||||
mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient)
|
||||
mockClient.EXPECT().GetApp(mock.Anything).Return(App{ID: 444, Slug: "test-app"}, nil)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "problem when getting installation returns error",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
GitHub: &provisioning.GitHubConnectionConfig{
|
||||
AppID: "123",
|
||||
InstallationID: "456",
|
||||
},
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
PrivateKey: common.InlineSecureValue{
|
||||
Create: common.NewSecretValue("test-private-key"),
|
||||
},
|
||||
Token: common.InlineSecureValue{
|
||||
Create: common.NewSecretValue("test-token"),
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsgContains: []string{"spec.installationID", "456"},
|
||||
setupMock: func(mockFactory *MockGithubFactory) {
|
||||
mockClient := NewMockClient(t)
|
||||
|
||||
mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient)
|
||||
mockClient.EXPECT().GetApp(mock.Anything).Return(App{ID: 123, Slug: "test-app"}, nil)
|
||||
mockClient.EXPECT().GetAppInstallation(mock.Anything, "456").Return(AppInstallation{}, assert.AnError)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mockFactory := NewMockGithubFactory(t)
|
||||
if tt.setupMock != nil {
|
||||
tt.setupMock(mockFactory)
|
||||
}
|
||||
|
||||
conn := NewConnection(tt.connection, mockFactory)
|
||||
err := conn.Validate(context.Background())
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
for _, msg := range tt.errMsgContains {
|
||||
assert.Contains(t, err.Error(), msg)
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
36
apps/provisioning/pkg/connection/github/extra.go
Normal file
36
apps/provisioning/pkg/connection/github/extra.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/logging"
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/connection"
|
||||
)
|
||||
|
||||
type extra struct {
|
||||
factory GithubFactory
|
||||
}
|
||||
|
||||
func (e *extra) Type() provisioning.ConnectionType {
|
||||
return provisioning.GithubConnectionType
|
||||
}
|
||||
|
||||
func (e *extra) Build(ctx context.Context, connection *provisioning.Connection) (connection.Connection, error) {
|
||||
logger := logging.FromContext(ctx)
|
||||
if connection == nil || connection.Spec.GitHub == nil {
|
||||
logger.Error("connection is nil or github info is nil")
|
||||
|
||||
return nil, fmt.Errorf("invalid github connection")
|
||||
}
|
||||
|
||||
c := NewConnection(connection, e.factory)
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func Extra(factory GithubFactory) connection.Extra {
|
||||
return &extra{
|
||||
factory: factory,
|
||||
}
|
||||
}
|
||||
126
apps/provisioning/pkg/connection/github/extra_test.go
Normal file
126
apps/provisioning/pkg/connection/github/extra_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package github_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/connection/github"
|
||||
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
func TestExtra_Type(t *testing.T) {
|
||||
t.Run("should return GithubConnectionType", func(t *testing.T) {
|
||||
mockFactory := github.NewMockGithubFactory(t)
|
||||
e := github.Extra(mockFactory)
|
||||
result := e.Type()
|
||||
assert.Equal(t, provisioning.GithubConnectionType, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtra_Build(t *testing.T) {
|
||||
t.Run("should successfully build connection", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conn := &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
GitHub: &provisioning.GitHubConnectionConfig{
|
||||
AppID: "123",
|
||||
InstallationID: "456",
|
||||
},
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
PrivateKey: common.InlineSecureValue{
|
||||
Create: common.NewSecretValue("test-private-key"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockFactory := github.NewMockGithubFactory(t)
|
||||
|
||||
e := github.Extra(mockFactory)
|
||||
|
||||
result, err := e.Build(ctx, conn)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
})
|
||||
|
||||
t.Run("should handle different connection configurations", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conn := &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "another-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
GitHub: &provisioning.GitHubConnectionConfig{
|
||||
AppID: "789",
|
||||
InstallationID: "101112",
|
||||
},
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
PrivateKey: common.InlineSecureValue{
|
||||
Name: "existing-private-key",
|
||||
},
|
||||
Token: common.InlineSecureValue{
|
||||
Name: "existing-token",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockFactory := github.NewMockGithubFactory(t)
|
||||
|
||||
e := github.Extra(mockFactory)
|
||||
|
||||
result, err := e.Build(ctx, conn)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
})
|
||||
|
||||
t.Run("should build connection with background context", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conn := &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
GitHub: &provisioning.GitHubConnectionConfig{
|
||||
AppID: "123",
|
||||
InstallationID: "456",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockFactory := github.NewMockGithubFactory(t)
|
||||
e := github.Extra(mockFactory)
|
||||
result, err := e.Build(ctx, conn)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
})
|
||||
|
||||
t.Run("should always pass empty token to factory.New", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conn := &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
GitHub: &provisioning.GitHubConnectionConfig{
|
||||
AppID: "123",
|
||||
InstallationID: "456",
|
||||
},
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
Token: common.InlineSecureValue{
|
||||
Create: common.NewSecretValue("some-token"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockFactory := github.NewMockGithubFactory(t)
|
||||
e := github.Extra(mockFactory)
|
||||
result, err := e.Build(ctx, conn)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
})
|
||||
}
|
||||
39
apps/provisioning/pkg/connection/github/factory.go
Normal file
39
apps/provisioning/pkg/connection/github/factory.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/go-github/v70/github"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||
)
|
||||
|
||||
// Factory creates new GitHub clients.
|
||||
// It exists only for the ability to test the code easily.
|
||||
type Factory struct {
|
||||
// Client allows overriding the client to use in the GH client returned. It exists primarily for testing.
|
||||
// FIXME: we should replace in this way. We should add some options pattern for the factory.
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
func ProvideFactory() GithubFactory {
|
||||
return &Factory{}
|
||||
}
|
||||
|
||||
func (r *Factory) New(ctx context.Context, ghToken common.RawSecureValue) Client {
|
||||
if r.Client != nil {
|
||||
return NewClient(github.NewClient(r.Client))
|
||||
}
|
||||
|
||||
if !ghToken.IsZero() {
|
||||
tokenSrc := oauth2.StaticTokenSource(
|
||||
&oauth2.Token{AccessToken: string(ghToken)},
|
||||
)
|
||||
tokenClient := oauth2.NewClient(ctx, tokenSrc)
|
||||
return NewClient(github.NewClient(tokenClient))
|
||||
}
|
||||
|
||||
return NewClient(github.NewClient(&http.Client{}))
|
||||
}
|
||||
86
apps/provisioning/pkg/connection/github/factory_mock.go
Normal file
86
apps/provisioning/pkg/connection/github/factory_mock.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// Code generated by mockery v2.53.4. DO NOT EDIT.
|
||||
|
||||
package github
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
v0alpha1 "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// MockGithubFactory is an autogenerated mock type for the GithubFactory type
|
||||
type MockGithubFactory struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type MockGithubFactory_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *MockGithubFactory) EXPECT() *MockGithubFactory_Expecter {
|
||||
return &MockGithubFactory_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// New provides a mock function with given fields: ctx, ghToken
|
||||
func (_m *MockGithubFactory) New(ctx context.Context, ghToken v0alpha1.RawSecureValue) Client {
|
||||
ret := _m.Called(ctx, ghToken)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for New")
|
||||
}
|
||||
|
||||
var r0 Client
|
||||
if rf, ok := ret.Get(0).(func(context.Context, v0alpha1.RawSecureValue) Client); ok {
|
||||
r0 = rf(ctx, ghToken)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(Client)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockGithubFactory_New_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'New'
|
||||
type MockGithubFactory_New_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// New is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - ghToken v0alpha1.RawSecureValue
|
||||
func (_e *MockGithubFactory_Expecter) New(ctx interface{}, ghToken interface{}) *MockGithubFactory_New_Call {
|
||||
return &MockGithubFactory_New_Call{Call: _e.mock.On("New", ctx, ghToken)}
|
||||
}
|
||||
|
||||
func (_c *MockGithubFactory_New_Call) Run(run func(ctx context.Context, ghToken v0alpha1.RawSecureValue)) *MockGithubFactory_New_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context), args[1].(v0alpha1.RawSecureValue))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockGithubFactory_New_Call) Return(_a0 Client) *MockGithubFactory_New_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockGithubFactory_New_Call) RunAndReturn(run func(context.Context, v0alpha1.RawSecureValue) Client) *MockGithubFactory_New_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// NewMockGithubFactory creates a new instance of MockGithubFactory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewMockGithubFactory(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *MockGithubFactory {
|
||||
mock := &MockGithubFactory{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package connection
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
)
|
||||
|
||||
const (
|
||||
githubInstallationURL = "https://github.com/settings/installations"
|
||||
)
|
||||
|
||||
func MutateConnection(connection *provisioning.Connection) error {
|
||||
switch connection.Spec.Type {
|
||||
case provisioning.GithubConnectionType:
|
||||
// Do nothing in case spec.Github is nil.
|
||||
// If this field is required, we should fail at validation time.
|
||||
if connection.Spec.GitHub == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
connection.Spec.URL = fmt.Sprintf("%s/%s", githubInstallationURL, connection.Spec.GitHub.InstallationID)
|
||||
return nil
|
||||
default:
|
||||
// TODO: we need to setup the URL for bitbucket and gitlab.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package connection_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/connection"
|
||||
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
func TestMutateConnection(t *testing.T) {
|
||||
t.Run("should add URL to Github connection", func(t *testing.T) {
|
||||
c := &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
GitHub: &provisioning.GitHubConnectionConfig{
|
||||
AppID: "123",
|
||||
InstallationID: "456",
|
||||
},
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
PrivateKey: common.InlineSecureValue{
|
||||
Name: "test-private-key",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, connection.MutateConnection(c))
|
||||
assert.Equal(t, "https://github.com/settings/installations/456", c.Spec.URL)
|
||||
})
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
package connection
|
||||
|
||||
import (
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||
)
|
||||
|
||||
func ValidateConnection(connection *provisioning.Connection) error {
|
||||
list := field.ErrorList{}
|
||||
|
||||
if connection.Spec.Type == "" {
|
||||
list = append(list, field.Required(field.NewPath("spec", "type"), "type must be specified"))
|
||||
}
|
||||
|
||||
switch connection.Spec.Type {
|
||||
case provisioning.GithubConnectionType:
|
||||
list = append(list, validateGithubConnection(connection)...)
|
||||
case provisioning.BitbucketConnectionType:
|
||||
list = append(list, validateBitbucketConnection(connection)...)
|
||||
case provisioning.GitlabConnectionType:
|
||||
list = append(list, validateGitlabConnection(connection)...)
|
||||
default:
|
||||
list = append(
|
||||
list, field.NotSupported(
|
||||
field.NewPath("spec", "type"),
|
||||
connection.Spec.Type,
|
||||
[]provisioning.ConnectionType{
|
||||
provisioning.GithubConnectionType,
|
||||
provisioning.BitbucketConnectionType,
|
||||
provisioning.GitlabConnectionType,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return toError(connection.GetName(), list)
|
||||
}
|
||||
|
||||
func validateGithubConnection(connection *provisioning.Connection) field.ErrorList {
|
||||
list := field.ErrorList{}
|
||||
|
||||
if connection.Spec.GitHub == nil {
|
||||
list = append(
|
||||
list, field.Required(field.NewPath("spec", "github"), "github info must be specified for GitHub connection"),
|
||||
)
|
||||
}
|
||||
|
||||
if connection.Secure.PrivateKey.IsZero() {
|
||||
list = append(list, field.Required(field.NewPath("secure", "privateKey"), "privateKey must be specified for GitHub connection"))
|
||||
}
|
||||
if !connection.Secure.ClientSecret.IsZero() {
|
||||
list = append(list, field.Forbidden(field.NewPath("secure", "clientSecret"), "clientSecret is forbidden in GitHub connection"))
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
func validateBitbucketConnection(connection *provisioning.Connection) field.ErrorList {
|
||||
list := field.ErrorList{}
|
||||
|
||||
if connection.Spec.Bitbucket == nil {
|
||||
list = append(
|
||||
list, field.Required(field.NewPath("spec", "bitbucket"), "bitbucket info must be specified in Bitbucket connection"),
|
||||
)
|
||||
}
|
||||
if connection.Secure.ClientSecret.IsZero() {
|
||||
list = append(list, field.Required(field.NewPath("secure", "clientSecret"), "clientSecret must be specified for Bitbucket connection"))
|
||||
}
|
||||
if !connection.Secure.PrivateKey.IsZero() {
|
||||
list = append(list, field.Forbidden(field.NewPath("secure", "privateKey"), "privateKey is forbidden in Bitbucket connection"))
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
func validateGitlabConnection(connection *provisioning.Connection) field.ErrorList {
|
||||
list := field.ErrorList{}
|
||||
|
||||
if connection.Spec.Gitlab == nil {
|
||||
list = append(
|
||||
list, field.Required(field.NewPath("spec", "gitlab"), "gitlab info must be specified in Gitlab connection"),
|
||||
)
|
||||
}
|
||||
if connection.Secure.ClientSecret.IsZero() {
|
||||
list = append(list, field.Required(field.NewPath("secure", "clientSecret"), "clientSecret must be specified for Gitlab connection"))
|
||||
}
|
||||
if !connection.Secure.PrivateKey.IsZero() {
|
||||
list = append(list, field.Forbidden(field.NewPath("secure", "privateKey"), "privateKey is forbidden in Gitlab connection"))
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
// toError converts a field.ErrorList to an error, returning nil if the list is empty
|
||||
func toError(name string, list field.ErrorList) error {
|
||||
if len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
return apierrors.NewInvalid(
|
||||
provisioning.ConnectionResourceInfo.GroupVersionKind().GroupKind(),
|
||||
name,
|
||||
list,
|
||||
)
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
package connection_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/connection"
|
||||
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
func TestValidateConnection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
connection *provisioning.Connection
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "empty type returns error",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "spec.type",
|
||||
},
|
||||
{
|
||||
name: "invalid type returns error",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: "invalid",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "spec.type",
|
||||
},
|
||||
{
|
||||
name: "github type without github config returns error",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "spec.github",
|
||||
},
|
||||
{
|
||||
name: "github type without private key returns error",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
GitHub: &provisioning.GitHubConnectionConfig{
|
||||
AppID: "123",
|
||||
InstallationID: "456",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "secure.privateKey",
|
||||
},
|
||||
{
|
||||
name: "github type with client secret returns error",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
GitHub: &provisioning.GitHubConnectionConfig{
|
||||
AppID: "123",
|
||||
InstallationID: "456",
|
||||
},
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
PrivateKey: common.InlineSecureValue{
|
||||
Name: "test-private-key",
|
||||
},
|
||||
ClientSecret: common.InlineSecureValue{
|
||||
Name: "test-client-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "secure.clientSecret",
|
||||
},
|
||||
{
|
||||
name: "github type with github config is valid",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GithubConnectionType,
|
||||
GitHub: &provisioning.GitHubConnectionConfig{
|
||||
AppID: "123",
|
||||
InstallationID: "456",
|
||||
},
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
PrivateKey: common.InlineSecureValue{
|
||||
Name: "test-private-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "bitbucket type without bitbucket config returns error",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.BitbucketConnectionType,
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "spec.bitbucket",
|
||||
},
|
||||
{
|
||||
name: "bitbucket type without client secret returns error",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.BitbucketConnectionType,
|
||||
Bitbucket: &provisioning.BitbucketConnectionConfig{
|
||||
ClientID: "client-123",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "secure.clientSecret",
|
||||
},
|
||||
{
|
||||
name: "bitbucket type with private key returns error",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.BitbucketConnectionType,
|
||||
Bitbucket: &provisioning.BitbucketConnectionConfig{
|
||||
ClientID: "client-123",
|
||||
},
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
PrivateKey: common.InlineSecureValue{
|
||||
Name: "test-private-key",
|
||||
},
|
||||
ClientSecret: common.InlineSecureValue{
|
||||
Name: "test-client-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "secure.privateKey",
|
||||
},
|
||||
{
|
||||
name: "bitbucket type with bitbucket config is valid",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.BitbucketConnectionType,
|
||||
Bitbucket: &provisioning.BitbucketConnectionConfig{
|
||||
ClientID: "client-123",
|
||||
},
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
ClientSecret: common.InlineSecureValue{
|
||||
Name: "test-client-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "gitlab type without gitlab config returns error",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GitlabConnectionType,
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "spec.gitlab",
|
||||
},
|
||||
{
|
||||
name: "gitlab type without client secret returns error",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GitlabConnectionType,
|
||||
Gitlab: &provisioning.GitlabConnectionConfig{
|
||||
ClientID: "client-456",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "secure.clientSecret",
|
||||
},
|
||||
{
|
||||
name: "gitlab type with private key returns error",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GitlabConnectionType,
|
||||
Gitlab: &provisioning.GitlabConnectionConfig{
|
||||
ClientID: "client-456",
|
||||
},
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
PrivateKey: common.InlineSecureValue{
|
||||
Name: "test-private-key",
|
||||
},
|
||||
ClientSecret: common.InlineSecureValue{
|
||||
Name: "test-client-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "secure.privateKey",
|
||||
},
|
||||
{
|
||||
name: "gitlab type with gitlab config is valid",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
|
||||
Spec: provisioning.ConnectionSpec{
|
||||
Type: provisioning.GitlabConnectionType,
|
||||
Gitlab: &provisioning.GitlabConnectionConfig{
|
||||
ClientID: "client-456",
|
||||
},
|
||||
},
|
||||
Secure: provisioning.ConnectionSecure{
|
||||
ClientSecret: common.InlineSecureValue{
|
||||
Name: "test-client-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := connection.ValidateConnection(tt.connection)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
if tt.errMsg != "" {
|
||||
assert.Contains(t, err.Error(), tt.errMsg)
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
type ConnectionSecureApplyConfiguration struct {
|
||||
PrivateKey *commonv0alpha1.InlineSecureValue `json:"privateKey,omitempty"`
|
||||
ClientSecret *commonv0alpha1.InlineSecureValue `json:"clientSecret,omitempty"`
|
||||
Token *commonv0alpha1.InlineSecureValue `json:"webhook,omitempty"`
|
||||
Token *commonv0alpha1.InlineSecureValue `json:"token,omitempty"`
|
||||
}
|
||||
|
||||
// ConnectionSecureApplyConfiguration constructs a declarative configuration of the ConnectionSecure type for use with
|
||||
|
||||
@@ -336,7 +336,7 @@ rudderstack_data_plane_url =
|
||||
rudderstack_sdk_url =
|
||||
|
||||
# Rudderstack v3 SDK, optional, defaults to false. If set, Rudderstack v3 SDK will be used instead of v1
|
||||
rudderstack_v3_sdk_url =
|
||||
rudderstack_v3_sdk_url =
|
||||
|
||||
# Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config
|
||||
rudderstack_config_url =
|
||||
@@ -2079,8 +2079,14 @@ enable =
|
||||
# To enable features by default, set `Expression: "true"` in:
|
||||
# https://github.com/grafana/grafana/blob/main/pkg/services/featuremgmt/registry.go
|
||||
|
||||
# The feature_toggles section supports feature flags of a number of types,
|
||||
# including boolean, string, integer, float, and structured values, following the OpenFeature specification.
|
||||
#
|
||||
# feature1 = true
|
||||
# feature2 = false
|
||||
# feature3 = "foobar"
|
||||
# feature4 = 1.5
|
||||
# feature5 = { "foo": "bar" }
|
||||
|
||||
[feature_toggles.openfeature]
|
||||
# This is EXPERIMENTAL. Please, do not use this section
|
||||
|
||||
@@ -323,7 +323,7 @@
|
||||
;rudderstack_sdk_url =
|
||||
|
||||
# Rudderstack v3 SDK, optional, defaults to false. If set, Rudderstack v3 SDK will be used instead of v1
|
||||
;rudderstack_v3_sdk_url =
|
||||
;rudderstack_v3_sdk_url =
|
||||
|
||||
# Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config
|
||||
;rudderstack_config_url =
|
||||
@@ -1913,7 +1913,7 @@ default_datasource_uid =
|
||||
|
||||
# client_queue_max_size is the maximum size in bytes of the client queue
|
||||
# for Live connections. Defaults to 4MB.
|
||||
;client_queue_max_size =
|
||||
;client_queue_max_size =
|
||||
|
||||
#################################### Grafana Image Renderer Plugin ##########################
|
||||
[plugin.grafana-image-renderer]
|
||||
@@ -1996,9 +1996,14 @@ default_datasource_uid =
|
||||
|
||||
;enable = feature1,feature2
|
||||
|
||||
# The feature_toggles section supports feature flags of a number of types,
|
||||
# including boolean, string, integer, float, and structured values, following the OpenFeature specification.
|
||||
|
||||
;feature1 = true
|
||||
;feature2 = false
|
||||
|
||||
;feature3 = "foobar"
|
||||
;feature4 = 1.5
|
||||
;feature5 = { "foo": "bar" }
|
||||
[date_formats]
|
||||
# For information on what formatting patterns that are supported https://momentjs.com/docs/#/displaying/
|
||||
|
||||
|
||||
@@ -2836,9 +2836,11 @@ For more information about Grafana Enterprise, refer to [Grafana Enterprise](../
|
||||
|
||||
Keys of features to enable, separated by space.
|
||||
|
||||
#### `FEATURE_TOGGLE_NAME = false`
|
||||
#### `FEATURE_NAME = <value>`
|
||||
|
||||
Some feature toggles for stable features are on by default. Use this setting to disable an on-by-default feature toggle with the name FEATURE_TOGGLE_NAME, for example, `exploreMixedDatasource = false`.
|
||||
Use a key-value pair to set feature flag values explicitly, overriding any default values. A few different types are supported, following the OpenFeature specification. See the defaults.ini file for more details.
|
||||
|
||||
For example, to disable an on-by-default feature toggle named `exploreMixedDatasource`, specify `exploreMixedDatasource = false`.
|
||||
|
||||
<hr>
|
||||
|
||||
|
||||
@@ -1740,11 +1740,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"public/app/features/correlations/CorrelationsPage.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/correlations/Forms/ConfigureCorrelationBasicInfoForm.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 2
|
||||
|
||||
@@ -408,9 +408,7 @@ export type ObjectMeta = {
|
||||
uid?: string;
|
||||
};
|
||||
export type CorrelationTargetSpec = {
|
||||
[key: string]: {
|
||||
[key: string]: any;
|
||||
};
|
||||
[key: string]: any;
|
||||
};
|
||||
export type CorrelationTransformationSpec = {
|
||||
expression: string;
|
||||
|
||||
@@ -1452,7 +1452,7 @@ export type ConnectionSecure = {
|
||||
/** PrivateKey is the reference to the private key used for GitHub App authentication. This value is stored securely and cannot be read back */
|
||||
privateKey?: InlineSecureValue;
|
||||
/** Token is the reference of the token used to act as the Connection. This value is stored securely and cannot be read back */
|
||||
webhook?: InlineSecureValue;
|
||||
token?: InlineSecureValue;
|
||||
};
|
||||
export type BitbucketConnectionConfig = {
|
||||
/** App client ID */
|
||||
|
||||
@@ -622,10 +622,6 @@ export interface FeatureToggles {
|
||||
*/
|
||||
exploreLogsAggregatedMetrics?: boolean;
|
||||
/**
|
||||
* Used in Logs Drilldown to limit the time range
|
||||
*/
|
||||
exploreLogsLimitedTimeRange?: boolean;
|
||||
/**
|
||||
* Enables the gRPC client to authenticate with the App Platform by using ID & access tokens
|
||||
*/
|
||||
appPlatformGrpcClientAuth?: boolean;
|
||||
@@ -695,10 +691,6 @@ export interface FeatureToggles {
|
||||
*/
|
||||
passwordlessMagicLinkAuthentication?: boolean;
|
||||
/**
|
||||
* Display Related Logs in Grafana Metrics Drilldown
|
||||
*/
|
||||
exploreMetricsRelatedLogs?: boolean;
|
||||
/**
|
||||
* Adds support for quotes and special characters in label values for Prometheus queries
|
||||
*/
|
||||
prometheusSpecialCharsInLabelValues?: boolean;
|
||||
|
||||
@@ -63,11 +63,6 @@
|
||||
"not IE 11"
|
||||
],
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.12.0",
|
||||
"@codemirror/commands": "^6.3.3",
|
||||
"@codemirror/language": "^6.10.0",
|
||||
"@codemirror/state": "^6.4.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@emotion/css": "11.13.5",
|
||||
"@emotion/react": "11.14.0",
|
||||
"@emotion/serialize": "1.3.3",
|
||||
@@ -78,7 +73,6 @@
|
||||
"@grafana/i18n": "12.4.0-pre",
|
||||
"@grafana/schema": "12.4.0-pre",
|
||||
"@hello-pangea/dnd": "18.0.1",
|
||||
"@lezer/highlight": "^1.2.0",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@rc-component/drawer": "1.3.0",
|
||||
|
||||
@@ -1,404 +0,0 @@
|
||||
import { Extension } from '@codemirror/state';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useEffect } from 'react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { createTheme, GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
import { CodeMirrorEditor } from './CodeMirrorEditor';
|
||||
import { createGenericHighlighter } from './highlight';
|
||||
import { createGenericTheme } from './styles';
|
||||
import { HighlighterFactory, SyntaxHighlightConfig, ThemeFactory } from './types';
|
||||
|
||||
// Mock DOM elements required by CodeMirror
|
||||
beforeAll(() => {
|
||||
Range.prototype.getClientRects = jest.fn(() => ({
|
||||
item: () => null,
|
||||
length: 0,
|
||||
[Symbol.iterator]: jest.fn(),
|
||||
}));
|
||||
Range.prototype.getBoundingClientRect = jest.fn(() => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
toJSON: () => {},
|
||||
}));
|
||||
});
|
||||
|
||||
describe('CodeMirrorEditor', () => {
|
||||
describe('basic rendering', () => {
|
||||
it('renders with initial value', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(<CodeMirrorEditor value="Hello World" onChange={onChange} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const editor = screen.getByRole('textbox');
|
||||
expect(editor).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders with placeholder when value is empty', async () => {
|
||||
const onChange = jest.fn();
|
||||
const placeholder = 'Enter text here';
|
||||
|
||||
render(<CodeMirrorEditor value="" onChange={onChange} placeholder={placeholder} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const editor = screen.getByRole('textbox');
|
||||
expect(editor).toHaveAttribute('aria-placeholder', placeholder);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders with aria-label', async () => {
|
||||
const onChange = jest.fn();
|
||||
const ariaLabel = 'Code editor';
|
||||
|
||||
render(<CodeMirrorEditor value="" onChange={onChange} ariaLabel={ariaLabel} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const editor = screen.getByRole('textbox');
|
||||
// aria-label is set on the parent .cm-editor element
|
||||
expect(editor.closest('.cm-editor')).toHaveAttribute('aria-label', ariaLabel);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('user interaction', () => {
|
||||
it('calls onChange when user types', async () => {
|
||||
const onChange = jest.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<CodeMirrorEditor value="" onChange={onChange} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editor = screen.getByRole('textbox');
|
||||
await user.click(editor);
|
||||
await user.keyboard('test');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('updates when external value prop changes', async () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
function TestWrapper({ initialValue }: { initialValue: string }) {
|
||||
const [value, setValue] = React.useState(initialValue);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
return <CodeMirrorEditor value={value} onChange={onChange} />;
|
||||
}
|
||||
|
||||
const { rerender } = render(<TestWrapper initialValue="first" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
rerender(<TestWrapper initialValue="second" />);
|
||||
|
||||
await waitFor(() => {
|
||||
const editor = screen.getByRole('textbox');
|
||||
expect(editor).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('highlight functionality', () => {
|
||||
it('renders with default highlighter using highlightConfig', async () => {
|
||||
const onChange = jest.fn();
|
||||
const highlightConfig: SyntaxHighlightConfig = {
|
||||
pattern: /\$\{[^}]+\}/g,
|
||||
className: 'variable-highlight',
|
||||
};
|
||||
|
||||
render(<CodeMirrorEditor value="${test}" onChange={onChange} highlightConfig={highlightConfig} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const editor = screen.getByRole('textbox');
|
||||
expect(editor).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders with custom highlighter factory', async () => {
|
||||
const onChange = jest.fn();
|
||||
const customHighlighter: HighlighterFactory = (config) => {
|
||||
return config ? createGenericHighlighter(config) : [];
|
||||
};
|
||||
const highlightConfig: SyntaxHighlightConfig = {
|
||||
pattern: /\btest\b/g,
|
||||
className: 'keyword',
|
||||
};
|
||||
|
||||
render(
|
||||
<CodeMirrorEditor
|
||||
value="test keyword"
|
||||
onChange={onChange}
|
||||
highlighterFactory={customHighlighter}
|
||||
highlightConfig={highlightConfig}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const editor = screen.getByRole('textbox');
|
||||
expect(editor).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('updates highlights when highlightConfig changes', async () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
function TestWrapper({ pattern }: { pattern: RegExp }) {
|
||||
const [config, setConfig] = React.useState<SyntaxHighlightConfig>({
|
||||
pattern,
|
||||
className: 'highlight',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setConfig({ pattern, className: 'highlight' });
|
||||
}, [pattern]);
|
||||
|
||||
return <CodeMirrorEditor value="${var}" onChange={onChange} highlightConfig={config} />;
|
||||
}
|
||||
|
||||
const { rerender } = render(<TestWrapper pattern={/\$\{[^}]+\}/g} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
rerender(<TestWrapper pattern={/\d+/g} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const editor = screen.getByRole('textbox');
|
||||
expect(editor).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders without highlighting when highlightConfig is not provided', async () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
render(<CodeMirrorEditor value="plain text" onChange={onChange} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const editor = screen.getByRole('textbox');
|
||||
expect(editor).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('theme functionality', () => {
|
||||
it('renders with default theme', async () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
render(<CodeMirrorEditor value="test" onChange={onChange} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const editor = screen.getByRole('textbox');
|
||||
expect(editor).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders with custom theme factory', async () => {
|
||||
const onChange = jest.fn();
|
||||
const customTheme: ThemeFactory = (theme) => {
|
||||
return createGenericTheme(theme);
|
||||
};
|
||||
|
||||
render(<CodeMirrorEditor value="test" onChange={onChange} themeFactory={customTheme} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const editor = screen.getByRole('textbox');
|
||||
expect(editor).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('updates theme when themeFactory changes', async () => {
|
||||
const onChange = jest.fn();
|
||||
const theme1: ThemeFactory = (theme) => createGenericTheme(theme);
|
||||
const theme2: ThemeFactory = (theme) => createGenericTheme(theme);
|
||||
|
||||
function TestWrapper({ themeFactory }: { themeFactory: ThemeFactory }) {
|
||||
return <CodeMirrorEditor value="test" onChange={onChange} themeFactory={themeFactory} />;
|
||||
}
|
||||
|
||||
const { rerender } = render(<TestWrapper themeFactory={theme1} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
rerender(<TestWrapper themeFactory={theme2} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const editor = screen.getByRole('textbox');
|
||||
expect(editor).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined highlight and theme', () => {
|
||||
it('renders with both custom theme and highlighter', async () => {
|
||||
const onChange = jest.fn();
|
||||
const customTheme: ThemeFactory = (theme) => createGenericTheme(theme);
|
||||
const highlightConfig: SyntaxHighlightConfig = {
|
||||
pattern: /\$\{[^}]+\}/g,
|
||||
className: 'variable',
|
||||
};
|
||||
|
||||
render(
|
||||
<CodeMirrorEditor
|
||||
value="${variable} test"
|
||||
onChange={onChange}
|
||||
themeFactory={customTheme}
|
||||
highlightConfig={highlightConfig}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const editor = screen.getByRole('textbox');
|
||||
expect(editor).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('updates both theme and highlights together', async () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
function TestWrapper({ pattern, mode }: { pattern: RegExp; mode: 'light' | 'dark' }) {
|
||||
const [config, setConfig] = React.useState<SyntaxHighlightConfig>({
|
||||
pattern,
|
||||
className: 'highlight',
|
||||
});
|
||||
const [themeFactory, setThemeFactory] = React.useState<ThemeFactory>(
|
||||
() => (theme: GrafanaTheme2) => createGenericTheme(theme)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setConfig({ pattern, className: 'highlight' });
|
||||
setThemeFactory(() => (theme: GrafanaTheme2) => {
|
||||
const customTheme = createTheme({ colors: { mode } });
|
||||
return createGenericTheme(customTheme);
|
||||
});
|
||||
}, [pattern, mode]);
|
||||
|
||||
return (
|
||||
<CodeMirrorEditor
|
||||
value="${var} 123"
|
||||
onChange={onChange}
|
||||
themeFactory={themeFactory}
|
||||
highlightConfig={config}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { rerender } = render(<TestWrapper pattern={/\$\{[^}]+\}/g} mode="light" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
rerender(<TestWrapper pattern={/\d+/g} mode="dark" />);
|
||||
|
||||
await waitFor(() => {
|
||||
const editor = screen.getByRole('textbox');
|
||||
expect(editor).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('additional features with highlight and theme', () => {
|
||||
it('renders with showLineNumbers and highlighting', async () => {
|
||||
const onChange = jest.fn();
|
||||
const highlightConfig: SyntaxHighlightConfig = {
|
||||
pattern: /\d+/g,
|
||||
className: 'number',
|
||||
};
|
||||
|
||||
render(
|
||||
<CodeMirrorEditor
|
||||
value="Line 1\nLine 2\nLine 3"
|
||||
onChange={onChange}
|
||||
showLineNumbers={true}
|
||||
highlightConfig={highlightConfig}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const editor = screen.getByRole('textbox');
|
||||
expect(editor).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders with custom extensions alongside theme and highlighter', async () => {
|
||||
const onChange = jest.fn();
|
||||
const customExtension: Extension[] = [];
|
||||
const highlightConfig: SyntaxHighlightConfig = {
|
||||
pattern: /test/g,
|
||||
className: 'keyword',
|
||||
};
|
||||
|
||||
render(
|
||||
<CodeMirrorEditor
|
||||
value="test"
|
||||
onChange={onChange}
|
||||
extensions={customExtension}
|
||||
highlightConfig={highlightConfig}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const editor = screen.getByRole('textbox');
|
||||
expect(editor).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('applies custom className with theme', async () => {
|
||||
const onChange = jest.fn();
|
||||
const customClassName = 'custom-editor';
|
||||
|
||||
render(<CodeMirrorEditor value="test" onChange={onChange} className={customClassName} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const editor = screen.getByRole('textbox');
|
||||
expect(editor).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useInputStyles prop', () => {
|
||||
it('renders with input styles enabled', async () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
render(<CodeMirrorEditor value="test" onChange={onChange} useInputStyles={true} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const editor = screen.getByRole('textbox');
|
||||
expect(editor).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders with input styles disabled', async () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
render(<CodeMirrorEditor value="test" onChange={onChange} useInputStyles={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const editor = screen.getByRole('textbox');
|
||||
expect(editor).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,193 +0,0 @@
|
||||
import { closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete';
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||||
import { bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
|
||||
import { Compartment, EditorState } from '@codemirror/state';
|
||||
import {
|
||||
drawSelection,
|
||||
dropCursor,
|
||||
EditorView,
|
||||
highlightActiveLine,
|
||||
highlightSpecialChars,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
placeholder as placeholderExtension,
|
||||
rectangularSelection,
|
||||
ViewUpdate,
|
||||
} from '@codemirror/view';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
import { useStyles2, useTheme2 } from '../../themes/ThemeContext';
|
||||
import { getInputStyles } from '../Input/Input';
|
||||
|
||||
import { createGenericHighlighter } from './highlight';
|
||||
import { createGenericTheme } from './styles';
|
||||
import { CodeMirrorEditorProps } from './types';
|
||||
|
||||
export const CodeMirrorEditor = memo((props: CodeMirrorEditorProps) => {
|
||||
const {
|
||||
value,
|
||||
onChange,
|
||||
placeholder = '',
|
||||
themeFactory,
|
||||
highlighterFactory,
|
||||
highlightConfig,
|
||||
autocompletion: autocompletionExtension,
|
||||
extensions = [],
|
||||
showLineNumbers = false,
|
||||
lineWrapping = true,
|
||||
ariaLabel,
|
||||
className,
|
||||
useInputStyles = true,
|
||||
closeBrackets: enableCloseBrackets = true,
|
||||
} = props;
|
||||
const editorContainerRef = useRef<HTMLDivElement>(null);
|
||||
const editorViewRef = useRef<EditorView | null>(null);
|
||||
const styles = useStyles2((theme) => getStyles(theme, useInputStyles));
|
||||
const theme = useTheme2();
|
||||
const themeCompartment = useRef(new Compartment());
|
||||
const autocompletionCompartment = useRef(new Compartment());
|
||||
|
||||
const customKeymap = keymap.of([...closeBracketsKeymap, ...completionKeymap, ...historyKeymap, ...defaultKeymap]);
|
||||
|
||||
// Build theme extensions
|
||||
const getThemeExtensions = () => {
|
||||
const themeExt = themeFactory ? themeFactory(theme) : createGenericTheme(theme);
|
||||
const highlighterExt = highlighterFactory
|
||||
? highlighterFactory(highlightConfig)
|
||||
: highlightConfig
|
||||
? createGenericHighlighter(highlightConfig)
|
||||
: [];
|
||||
|
||||
return [themeExt, highlighterExt];
|
||||
};
|
||||
|
||||
// Initialize CodeMirror editor
|
||||
useEffect(() => {
|
||||
if (!editorContainerRef.current || editorViewRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseExtensions = [
|
||||
highlightActiveLine(),
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
foldGutter(),
|
||||
drawSelection(),
|
||||
dropCursor(),
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
indentOnInput(),
|
||||
bracketMatching(),
|
||||
rectangularSelection(),
|
||||
customKeymap,
|
||||
placeholderExtension(placeholder),
|
||||
EditorView.updateListener.of((update: ViewUpdate) => {
|
||||
if (update.docChanged) {
|
||||
const newValue = update.state.doc.toString();
|
||||
onChange(newValue);
|
||||
}
|
||||
}),
|
||||
themeCompartment.current.of(getThemeExtensions()),
|
||||
EditorState.phrases.of({
|
||||
next: 'Next',
|
||||
previous: 'Previous',
|
||||
Completions: 'Completions',
|
||||
}),
|
||||
EditorView.editorAttributes.of({ 'aria-label': ariaLabel || placeholder }),
|
||||
];
|
||||
|
||||
// Conditionally add closeBrackets extension
|
||||
if (enableCloseBrackets) {
|
||||
baseExtensions.push(closeBrackets());
|
||||
}
|
||||
|
||||
// Add optional extensions
|
||||
if (showLineNumbers) {
|
||||
baseExtensions.push(lineNumbers());
|
||||
}
|
||||
|
||||
if (lineWrapping) {
|
||||
baseExtensions.push(EditorView.lineWrapping);
|
||||
}
|
||||
|
||||
if (autocompletionExtension) {
|
||||
baseExtensions.push(autocompletionCompartment.current.of(autocompletionExtension));
|
||||
}
|
||||
|
||||
// Add custom extensions
|
||||
if (extensions.length > 0) {
|
||||
baseExtensions.push(...extensions);
|
||||
}
|
||||
|
||||
const startState = EditorState.create({
|
||||
doc: value,
|
||||
extensions: baseExtensions,
|
||||
});
|
||||
|
||||
const view = new EditorView({
|
||||
state: startState,
|
||||
parent: editorContainerRef.current,
|
||||
});
|
||||
|
||||
editorViewRef.current = view;
|
||||
|
||||
return () => {
|
||||
view.destroy();
|
||||
editorViewRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Update editor value when prop changes
|
||||
useEffect(() => {
|
||||
if (editorViewRef.current) {
|
||||
const currentValue = editorViewRef.current.state.doc.toString();
|
||||
if (currentValue !== value) {
|
||||
editorViewRef.current.dispatch({
|
||||
changes: { from: 0, to: currentValue.length, insert: value },
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// Update theme when it changes
|
||||
useEffect(() => {
|
||||
if (editorViewRef.current) {
|
||||
editorViewRef.current.dispatch({
|
||||
effects: themeCompartment.current.reconfigure(getThemeExtensions()),
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [theme, themeFactory, highlighterFactory, highlightConfig]);
|
||||
|
||||
// Update autocompletion when it changes
|
||||
useEffect(() => {
|
||||
if (editorViewRef.current && autocompletionExtension) {
|
||||
editorViewRef.current.dispatch({
|
||||
effects: autocompletionCompartment.current.reconfigure(autocompletionExtension),
|
||||
});
|
||||
}
|
||||
}, [autocompletionExtension]);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.container, className)}>
|
||||
<div className={styles.input} ref={editorContainerRef} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
CodeMirrorEditor.displayName = 'CodeMirrorEditor';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2, useInputStyles: boolean) => {
|
||||
const baseInputStyles = useInputStyles ? getInputStyles({ theme, invalid: false }).input : {};
|
||||
|
||||
return {
|
||||
container: css({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}),
|
||||
input: css(baseInputStyles),
|
||||
};
|
||||
};
|
||||
@@ -1,246 +0,0 @@
|
||||
# CodeMirror Editor Component
|
||||
|
||||
A reusable CodeMirror editor component for Grafana that provides a flexible and themeable code editing experience.
|
||||
|
||||
## Overview
|
||||
|
||||
The `CodeMirrorEditor` component is a generic, theme-aware editor built on CodeMirror 6. Use it anywhere you need code editing functionality with syntax highlighting, autocompletion, and Grafana theme integration.
|
||||
|
||||
## Basic usage
|
||||
|
||||
```typescript
|
||||
import { CodeMirrorEditor } from '@grafana/ui';
|
||||
|
||||
function MyComponent() {
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
return (
|
||||
<CodeMirrorEditor
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
placeholder="Enter your code here"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced usage
|
||||
|
||||
### Custom syntax highlighting
|
||||
|
||||
Create a custom highlighter for your specific syntax:
|
||||
|
||||
```typescript
|
||||
import { CodeMirrorEditor, SyntaxHighlightConfig } from '@grafana/ui';
|
||||
|
||||
function MyComponent() {
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
const highlightConfig: SyntaxHighlightConfig = {
|
||||
pattern: /\b(SELECT|FROM|WHERE)\b/gi, // Highlight SQL keywords
|
||||
className: 'cm-keyword',
|
||||
};
|
||||
|
||||
return (
|
||||
<CodeMirrorEditor
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
highlightConfig={highlightConfig}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Custom theme
|
||||
|
||||
Extend the default theme with your own styling:
|
||||
|
||||
```typescript
|
||||
import { CodeMirrorEditor, ThemeFactory } from '@grafana/ui';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { createGenericTheme } from '@grafana/ui';
|
||||
|
||||
const myCustomTheme: ThemeFactory = (theme) => {
|
||||
const baseTheme = createGenericTheme(theme);
|
||||
|
||||
const customStyles = EditorView.theme({
|
||||
'.cm-keyword': {
|
||||
color: theme.colors.primary.text,
|
||||
fontWeight: theme.typography.fontWeightBold,
|
||||
},
|
||||
'.cm-string': {
|
||||
color: theme.colors.success.text,
|
||||
},
|
||||
});
|
||||
|
||||
return [baseTheme, customStyles];
|
||||
};
|
||||
|
||||
function MyComponent() {
|
||||
return (
|
||||
<CodeMirrorEditor
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
themeFactory={myCustomTheme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Custom autocompletion
|
||||
|
||||
Add autocompletion for your specific use case:
|
||||
|
||||
```typescript
|
||||
import { CodeMirrorEditor } from '@grafana/ui';
|
||||
import { autocompletion, CompletionContext } from '@codemirror/autocomplete';
|
||||
|
||||
function MyComponent() {
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
const autocompletionExtension = useMemo(() => {
|
||||
return autocompletion({
|
||||
override: [(context: CompletionContext) => {
|
||||
const word = context.matchBefore(/\w*/);
|
||||
if (!word || word.from === word.to) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
from: word.from,
|
||||
options: [
|
||||
{ label: 'hello', type: 'keyword' },
|
||||
{ label: 'world', type: 'keyword' },
|
||||
],
|
||||
};
|
||||
}],
|
||||
activateOnTyping: true,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CodeMirrorEditor
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
autocompletion={autocompletionExtension}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Additional extensions
|
||||
|
||||
Add custom CodeMirror extensions:
|
||||
|
||||
```typescript
|
||||
import { CodeMirrorEditor } from '@grafana/ui';
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { linter } from '@codemirror/lint';
|
||||
|
||||
function MyComponent() {
|
||||
const extensions = useMemo(() => [
|
||||
javascript(),
|
||||
linter(/* your linting logic */),
|
||||
], []);
|
||||
|
||||
return (
|
||||
<CodeMirrorEditor
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
extensions={extensions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `value` | `string` | required | The current value of the editor |
|
||||
| `onChange` | `(value: string, callback?: () => void) => void` | required | Callback when the editor value changes |
|
||||
| `placeholder` | `string` | `''` | Placeholder text when editor is empty |
|
||||
| `themeFactory` | `ThemeFactory` | `createGenericTheme` | Custom theme factory function |
|
||||
| `highlighterFactory` | `HighlighterFactory` | `createGenericHighlighter` | Custom syntax highlighter factory |
|
||||
| `highlightConfig` | `SyntaxHighlightConfig` | `undefined` | Configuration for syntax highlighting |
|
||||
| `autocompletion` | `Extension` | `undefined` | Custom autocompletion extension |
|
||||
| `extensions` | `Extension[]` | `[]` | Additional CodeMirror extensions |
|
||||
| `showLineNumbers` | `boolean` | `false` | Whether to show line numbers |
|
||||
| `lineWrapping` | `boolean` | `true` | Whether to enable line wrapping |
|
||||
| `ariaLabel` | `string` | `placeholder` | Aria label for accessibility |
|
||||
| `className` | `string` | `undefined` | Custom CSS class for the container |
|
||||
| `useInputStyles` | `boolean` | `true` | Whether to apply Grafana input styles |
|
||||
|
||||
## Example: DataLink editor
|
||||
|
||||
Here's how the DataLink component uses the CodeMirror editor:
|
||||
|
||||
```typescript
|
||||
import { CodeMirrorEditor } from '@grafana/ui';
|
||||
import { createDataLinkAutocompletion, createDataLinkHighlighter, createDataLinkTheme } from './codemirrorUtils';
|
||||
|
||||
export const DataLinkInput = memo(({ value, onChange, suggestions, placeholder }) => {
|
||||
const autocompletionExtension = useMemo(
|
||||
() => createDataLinkAutocompletion(suggestions),
|
||||
[suggestions]
|
||||
);
|
||||
|
||||
return (
|
||||
<CodeMirrorEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
themeFactory={createDataLinkTheme}
|
||||
highlighterFactory={createDataLinkHighlighter}
|
||||
autocompletion={autocompletionExtension}
|
||||
ariaLabel={placeholder}
|
||||
/>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## Utilities
|
||||
|
||||
### `createGenericTheme(theme: GrafanaTheme2): Extension`
|
||||
|
||||
Creates a generic CodeMirror theme based on Grafana's theme.
|
||||
|
||||
### `createGenericHighlighter(theme: GrafanaTheme2, config: SyntaxHighlightConfig): Extension`
|
||||
|
||||
Creates a generic syntax highlighter based on a regex pattern and CSS class name.
|
||||
|
||||
## Types
|
||||
|
||||
```typescript
|
||||
interface SyntaxHighlightConfig {
|
||||
pattern: RegExp;
|
||||
className: string;
|
||||
}
|
||||
|
||||
type ThemeFactory = (theme: GrafanaTheme2) => Extension;
|
||||
type HighlighterFactory = (theme: GrafanaTheme2, config?: SyntaxHighlightConfig) => Extension;
|
||||
type AutocompletionFactory<T = unknown> = (data: T) => Extension;
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Theme-aware**: Automatically adapts to Grafana's light and dark themes
|
||||
- **Syntax highlighting**: Configurable pattern-based syntax highlighting
|
||||
- **Autocompletion**: Customizable autocompletion with keyboard shortcuts
|
||||
- **Accessibility**: Built-in ARIA support
|
||||
- **Line numbers**: Optional line number display
|
||||
- **Line wrapping**: Configurable line wrapping
|
||||
- **Modal-friendly**: Tooltips render at body level to prevent clipping
|
||||
- **Extensible**: Support for custom CodeMirror extensions
|
||||
|
||||
## Best practices
|
||||
|
||||
1. **Memoize extensions**: Use `useMemo` to create autocompletion and other extensions to avoid recreating them on every render.
|
||||
|
||||
2. **Custom themes**: Extend the generic theme rather than replacing it to maintain consistency with Grafana's design system.
|
||||
|
||||
3. **Pattern efficiency**: Use efficient regex patterns for syntax highlighting to avoid performance issues with large documents.
|
||||
|
||||
4. **Accessibility**: Always provide meaningful `ariaLabel` or `placeholder` text for screen readers.
|
||||
|
||||
5. **Type safety**: Use the provided TypeScript types for better type safety and IDE support.
|
||||
@@ -1,246 +0,0 @@
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
import { createGenericHighlighter } from './highlight';
|
||||
import { SyntaxHighlightConfig } from './types';
|
||||
|
||||
// Mock DOM elements required by CodeMirror
|
||||
beforeAll(() => {
|
||||
Range.prototype.getClientRects = jest.fn(() => ({
|
||||
item: () => null,
|
||||
length: 0,
|
||||
[Symbol.iterator]: jest.fn(),
|
||||
}));
|
||||
Range.prototype.getBoundingClientRect = jest.fn(() => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
toJSON: () => {},
|
||||
}));
|
||||
});
|
||||
|
||||
describe('createGenericHighlighter', () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(container);
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to create editor with highlighter
|
||||
*/
|
||||
function createEditorWithHighlighter(config: SyntaxHighlightConfig, text: string) {
|
||||
const highlighter = createGenericHighlighter(config);
|
||||
const state = EditorState.create({
|
||||
doc: text,
|
||||
extensions: [highlighter],
|
||||
});
|
||||
return new EditorView({ state, parent: container });
|
||||
}
|
||||
|
||||
describe('basic highlighting', () => {
|
||||
it('highlights text matching the pattern', () => {
|
||||
const config: SyntaxHighlightConfig = {
|
||||
pattern: /\$\{[^}]+\}/g,
|
||||
className: 'test-highlight',
|
||||
};
|
||||
|
||||
const view = createEditorWithHighlighter(config, 'Hello ${world}!');
|
||||
const content = view.dom.textContent;
|
||||
|
||||
expect(content).toBe('Hello ${world}!');
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('highlights multiple matches', () => {
|
||||
const config: SyntaxHighlightConfig = {
|
||||
pattern: /\$\{[^}]+\}/g,
|
||||
className: 'variable',
|
||||
};
|
||||
|
||||
const view = createEditorWithHighlighter(config, '${first} and ${second} and ${third}');
|
||||
const content = view.dom.textContent;
|
||||
|
||||
expect(content).toBe('${first} and ${second} and ${third}');
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('handles text with no matches', () => {
|
||||
const config: SyntaxHighlightConfig = {
|
||||
pattern: /\$\{[^}]+\}/g,
|
||||
className: 'variable',
|
||||
};
|
||||
|
||||
const view = createEditorWithHighlighter(config, 'No variables here');
|
||||
const content = view.dom.textContent;
|
||||
|
||||
expect(content).toBe('No variables here');
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('handles empty text', () => {
|
||||
const config: SyntaxHighlightConfig = {
|
||||
pattern: /\$\{[^}]+\}/g,
|
||||
className: 'variable',
|
||||
};
|
||||
|
||||
const view = createEditorWithHighlighter(config, '');
|
||||
const content = view.dom.textContent;
|
||||
|
||||
expect(content).toBe('');
|
||||
view.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pattern variations', () => {
|
||||
it('highlights with simple word pattern', () => {
|
||||
const config: SyntaxHighlightConfig = {
|
||||
pattern: /\btest\b/g,
|
||||
className: 'keyword',
|
||||
};
|
||||
|
||||
const view = createEditorWithHighlighter(config, 'This is a test of the test word');
|
||||
const content = view.dom.textContent;
|
||||
|
||||
expect(content).toBe('This is a test of the test word');
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('highlights with number pattern', () => {
|
||||
const config: SyntaxHighlightConfig = {
|
||||
pattern: /\d+/g,
|
||||
className: 'number',
|
||||
};
|
||||
|
||||
const view = createEditorWithHighlighter(config, 'Numbers: 123, 456, 789');
|
||||
const content = view.dom.textContent;
|
||||
|
||||
expect(content).toBe('Numbers: 123, 456, 789');
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('highlights with URL pattern', () => {
|
||||
const config: SyntaxHighlightConfig = {
|
||||
pattern: /https?:\/\/[^\s]+/g,
|
||||
className: 'url',
|
||||
};
|
||||
|
||||
const view = createEditorWithHighlighter(config, 'Visit https://grafana.com and http://example.com');
|
||||
const content = view.dom.textContent;
|
||||
|
||||
expect(content).toBe('Visit https://grafana.com and http://example.com');
|
||||
view.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dynamic updates', () => {
|
||||
it('updates highlights when document changes', () => {
|
||||
const config: SyntaxHighlightConfig = {
|
||||
pattern: /\$\{[^}]+\}/g,
|
||||
className: 'variable',
|
||||
};
|
||||
|
||||
const view = createEditorWithHighlighter(config, 'Initial text');
|
||||
|
||||
// Update document
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: 'New ${variable} text' },
|
||||
});
|
||||
|
||||
const content = view.dom.textContent;
|
||||
expect(content).toBe('New ${variable} text');
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('updates highlights when adding to document', () => {
|
||||
const config: SyntaxHighlightConfig = {
|
||||
pattern: /\$\{[^}]+\}/g,
|
||||
className: 'variable',
|
||||
};
|
||||
|
||||
const view = createEditorWithHighlighter(config, 'Start ');
|
||||
|
||||
// Insert text
|
||||
view.dispatch({
|
||||
changes: { from: view.state.doc.length, insert: '${var}' },
|
||||
});
|
||||
|
||||
const content = view.dom.textContent;
|
||||
expect(content).toBe('Start ${var}');
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('removes highlights when pattern no longer matches', () => {
|
||||
const config: SyntaxHighlightConfig = {
|
||||
pattern: /\$\{[^}]+\}/g,
|
||||
className: 'variable',
|
||||
};
|
||||
|
||||
const view = createEditorWithHighlighter(config, '${variable}');
|
||||
|
||||
// Replace with non-matching text
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: 'plain text' },
|
||||
});
|
||||
|
||||
const content = view.dom.textContent;
|
||||
expect(content).toBe('plain text');
|
||||
view.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('complex patterns', () => {
|
||||
it('highlights nested brackets', () => {
|
||||
const config: SyntaxHighlightConfig = {
|
||||
pattern: /\$\{[^}]+\}/g,
|
||||
className: 'variable',
|
||||
};
|
||||
|
||||
const view = createEditorWithHighlighter(config, 'Text with ${var1} and ${var2} variables');
|
||||
const content = view.dom.textContent;
|
||||
|
||||
expect(content).toBe('Text with ${var1} and ${var2} variables');
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('highlights overlapping patterns correctly', () => {
|
||||
const config: SyntaxHighlightConfig = {
|
||||
pattern: /test/g,
|
||||
className: 'keyword',
|
||||
};
|
||||
|
||||
const view = createEditorWithHighlighter(config, 'testtesttest');
|
||||
const content = view.dom.textContent;
|
||||
|
||||
expect(content).toBe('testtesttest');
|
||||
view.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiline text', () => {
|
||||
it('highlights patterns across multiple lines', () => {
|
||||
const config: SyntaxHighlightConfig = {
|
||||
pattern: /\$\{[^}]+\}/g,
|
||||
className: 'variable',
|
||||
};
|
||||
|
||||
const text = 'Line 1 ${var1}\nLine 2 ${var2}\nLine 3';
|
||||
const view = createEditorWithHighlighter(config, text);
|
||||
|
||||
// Check the document state instead of textContent (which doesn't preserve newlines in DOM)
|
||||
const docContent = view.state.doc.toString();
|
||||
expect(docContent).toBe(text);
|
||||
view.destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Extension } from '@codemirror/state';
|
||||
import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view';
|
||||
|
||||
import { SyntaxHighlightConfig } from './types';
|
||||
|
||||
/**
|
||||
* Creates a generic syntax highlighter based on a pattern and class name
|
||||
*/
|
||||
export function createGenericHighlighter(config: SyntaxHighlightConfig): Extension {
|
||||
const { pattern, className } = config;
|
||||
|
||||
const decoration = Decoration.mark({
|
||||
class: className,
|
||||
});
|
||||
|
||||
const viewPlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = this.buildDecorations(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = this.buildDecorations(update.view);
|
||||
}
|
||||
}
|
||||
|
||||
buildDecorations(view: EditorView): DecorationSet {
|
||||
const decorations: Array<{ from: number; to: number }> = [];
|
||||
const text = view.state.doc.toString();
|
||||
let match;
|
||||
|
||||
// Reset regex state
|
||||
pattern.lastIndex = 0;
|
||||
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
decorations.push({
|
||||
from: match.index,
|
||||
to: match.index + match[0].length,
|
||||
});
|
||||
}
|
||||
|
||||
return Decoration.set(decorations.map((range) => decoration.range(range.from, range.to)));
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations: (v) => v.decorations,
|
||||
}
|
||||
);
|
||||
|
||||
return viewPlugin;
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
import { Compartment, EditorState } from '@codemirror/state';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
import { createTheme } from '@grafana/data';
|
||||
|
||||
import { createGenericTheme } from './styles';
|
||||
|
||||
// Mock DOM elements required by CodeMirror
|
||||
beforeAll(() => {
|
||||
Range.prototype.getClientRects = jest.fn(() => ({
|
||||
item: () => null,
|
||||
length: 0,
|
||||
[Symbol.iterator]: jest.fn(),
|
||||
}));
|
||||
Range.prototype.getBoundingClientRect = jest.fn(() => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
toJSON: () => {},
|
||||
}));
|
||||
});
|
||||
|
||||
describe('createGenericTheme', () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(container);
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to create editor with theme
|
||||
*/
|
||||
function createEditorWithTheme(themeMode: 'light' | 'dark', text = 'test') {
|
||||
const theme = createTheme({ colors: { mode: themeMode } });
|
||||
const themeExtension = createGenericTheme(theme);
|
||||
const state = EditorState.create({
|
||||
doc: text,
|
||||
extensions: [themeExtension],
|
||||
});
|
||||
return new EditorView({ state, parent: container });
|
||||
}
|
||||
|
||||
describe('theme creation', () => {
|
||||
it('creates theme for light mode', () => {
|
||||
const theme = createTheme({ colors: { mode: 'light' } });
|
||||
const themeExtension = createGenericTheme(theme);
|
||||
|
||||
expect(themeExtension).toBeDefined();
|
||||
});
|
||||
|
||||
it('creates theme for dark mode', () => {
|
||||
const theme = createTheme({ colors: { mode: 'dark' } });
|
||||
const themeExtension = createGenericTheme(theme);
|
||||
|
||||
expect(themeExtension).toBeDefined();
|
||||
});
|
||||
|
||||
it('applies theme to editor in light mode', () => {
|
||||
const view = createEditorWithTheme('light');
|
||||
|
||||
expect(view).toBeDefined();
|
||||
expect(view.dom).toBeInstanceOf(HTMLElement);
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('applies theme to editor in dark mode', () => {
|
||||
const view = createEditorWithTheme('dark');
|
||||
|
||||
expect(view).toBeDefined();
|
||||
expect(view.dom).toBeInstanceOf(HTMLElement);
|
||||
view.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('theme properties', () => {
|
||||
it('applies typography settings from theme', () => {
|
||||
const theme = createTheme({ colors: { mode: 'light' } });
|
||||
const themeExtension = createGenericTheme(theme);
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: 'test',
|
||||
extensions: [themeExtension],
|
||||
});
|
||||
const view = new EditorView({ state, parent: container });
|
||||
|
||||
// Check that editor is created successfully
|
||||
expect(view.dom).toBeInstanceOf(HTMLElement);
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('applies color settings from theme', () => {
|
||||
const theme = createTheme({ colors: { mode: 'dark' } });
|
||||
const themeExtension = createGenericTheme(theme);
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: 'test',
|
||||
extensions: [themeExtension],
|
||||
});
|
||||
const view = new EditorView({ state, parent: container });
|
||||
|
||||
expect(view.dom).toBeInstanceOf(HTMLElement);
|
||||
view.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('theme updates', () => {
|
||||
it('switches from light to dark theme', () => {
|
||||
const themeCompartment = new Compartment();
|
||||
const lightTheme = createTheme({ colors: { mode: 'light' } });
|
||||
const lightThemeExtension = createGenericTheme(lightTheme);
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: 'test',
|
||||
extensions: [themeCompartment.of(lightThemeExtension)],
|
||||
});
|
||||
const view = new EditorView({ state, parent: container });
|
||||
|
||||
// Update to dark theme
|
||||
const darkTheme = createTheme({ colors: { mode: 'dark' } });
|
||||
const darkThemeExtension = createGenericTheme(darkTheme);
|
||||
|
||||
view.dispatch({
|
||||
effects: themeCompartment.reconfigure(darkThemeExtension),
|
||||
});
|
||||
|
||||
expect(view.dom).toBeInstanceOf(HTMLElement);
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('switches from dark to light theme', () => {
|
||||
const themeCompartment = new Compartment();
|
||||
const darkTheme = createTheme({ colors: { mode: 'dark' } });
|
||||
const darkThemeExtension = createGenericTheme(darkTheme);
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: 'test',
|
||||
extensions: [themeCompartment.of(darkThemeExtension)],
|
||||
});
|
||||
const view = new EditorView({ state, parent: container });
|
||||
|
||||
// Update to light theme
|
||||
const lightTheme = createTheme({ colors: { mode: 'light' } });
|
||||
const lightThemeExtension = createGenericTheme(lightTheme);
|
||||
|
||||
view.dispatch({
|
||||
effects: themeCompartment.reconfigure(lightThemeExtension),
|
||||
});
|
||||
|
||||
expect(view.dom).toBeInstanceOf(HTMLElement);
|
||||
view.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('editor rendering', () => {
|
||||
it('renders editor with light theme and content', () => {
|
||||
const view = createEditorWithTheme('light', 'Hello world!');
|
||||
|
||||
expect(view.dom).toHaveTextContent('Hello world!');
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('renders editor with dark theme and content', () => {
|
||||
const view = createEditorWithTheme('dark', 'Hello world!');
|
||||
|
||||
expect(view.dom).toHaveTextContent('Hello world!');
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('renders multiline content with theme', () => {
|
||||
const text = 'Line 1\nLine 2\nLine 3';
|
||||
const view = createEditorWithTheme('light', text);
|
||||
|
||||
// Check the document state instead of textContent (which doesn't preserve newlines in DOM)
|
||||
const docContent = view.state.doc.toString();
|
||||
expect(docContent).toBe(text);
|
||||
view.destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import { Extension } from '@codemirror/state';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
/**
|
||||
* Creates a generic CodeMirror theme based on Grafana's theme
|
||||
*/
|
||||
export function createGenericTheme(theme: GrafanaTheme2): Extension {
|
||||
const isDark = theme.colors.mode === 'dark';
|
||||
|
||||
return EditorView.theme(
|
||||
{
|
||||
'&': {
|
||||
fontSize: theme.typography.body.fontSize,
|
||||
fontFamily: theme.typography.fontFamilyMonospace,
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
},
|
||||
'.cm-placeholder': {
|
||||
color: theme.colors.text.disabled,
|
||||
fontStyle: 'normal',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
fontFamily: theme.typography.fontFamilyMonospace,
|
||||
},
|
||||
'.cm-content': {
|
||||
padding: '3px 0',
|
||||
color: theme.colors.text.primary,
|
||||
caretColor: theme.colors.text.primary,
|
||||
},
|
||||
'.cm-line': {
|
||||
padding: '0 2px',
|
||||
},
|
||||
'.cm-cursor': {
|
||||
borderLeftColor: theme.colors.text.primary,
|
||||
},
|
||||
'.cm-selectionBackground': {
|
||||
backgroundColor: `${theme.colors.action.selected} !important`,
|
||||
},
|
||||
'&.cm-focused .cm-selectionBackground': {
|
||||
backgroundColor: `${theme.colors.action.focus} !important`,
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
display: 'none',
|
||||
},
|
||||
'.cm-tooltip.cm-tooltip-autocomplete': {
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
border: `1px solid ${theme.colors.border.weak}`,
|
||||
boxShadow: theme.shadows.z3,
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
'.cm-tooltip.cm-tooltip-autocomplete > ul': {
|
||||
fontFamily: theme.typography.fontFamily,
|
||||
maxHeight: '300px',
|
||||
},
|
||||
'.cm-tooltip.cm-tooltip-autocomplete > ul > li': {
|
||||
padding: '2px 8px',
|
||||
color: theme.colors.text.primary,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
'.cm-tooltip.cm-tooltip-autocomplete > ul > li:hover': {
|
||||
backgroundColor: theme.colors.background.secondary,
|
||||
},
|
||||
'.cm-tooltip-autocomplete ul li[aria-selected]': {
|
||||
backgroundColor: theme.colors.background.secondary,
|
||||
color: theme.colors.text.primary,
|
||||
},
|
||||
'.cm-completionLabel': {
|
||||
fontFamily: theme.typography.fontFamilyMonospace,
|
||||
fontSize: theme.typography.size.sm,
|
||||
},
|
||||
'.cm-completionDetail': {
|
||||
color: theme.colors.text.secondary,
|
||||
fontStyle: 'normal',
|
||||
marginLeft: theme.spacing(1),
|
||||
},
|
||||
'.cm-completionInfo': {
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
border: `1px solid ${theme.colors.border.weak}`,
|
||||
color: theme.colors.text.primary,
|
||||
padding: theme.spacing(1),
|
||||
},
|
||||
},
|
||||
{ dark: isDark }
|
||||
);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { Extension } from '@codemirror/state';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
/**
|
||||
* Configuration options for syntax highlighting
|
||||
*/
|
||||
export interface SyntaxHighlightConfig {
|
||||
/**
|
||||
* Pattern to match for highlighting
|
||||
*/
|
||||
pattern: RegExp;
|
||||
/**
|
||||
* CSS class to apply to matched text
|
||||
*/
|
||||
className: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to create a theme extension
|
||||
*/
|
||||
export type ThemeFactory = (theme: GrafanaTheme2) => Extension;
|
||||
|
||||
/**
|
||||
* Function to create a syntax highlighter extension
|
||||
*/
|
||||
export type HighlighterFactory = (config?: SyntaxHighlightConfig) => Extension;
|
||||
|
||||
/**
|
||||
* Function to create an autocompletion extension
|
||||
*/
|
||||
export type AutocompletionFactory<T = unknown> = (data: T) => Extension;
|
||||
|
||||
/**
|
||||
* Props for the CodeMirrorEditor component
|
||||
*/
|
||||
export interface CodeMirrorEditorProps {
|
||||
/**
|
||||
* The current value of the editor
|
||||
*/
|
||||
value: string;
|
||||
|
||||
/**
|
||||
* Callback when the editor value changes
|
||||
*/
|
||||
onChange: (value: string, callback?: () => void) => void;
|
||||
|
||||
/**
|
||||
* Placeholder text to display when editor is empty
|
||||
*/
|
||||
placeholder?: string;
|
||||
|
||||
/**
|
||||
* Custom theme factory function
|
||||
*/
|
||||
themeFactory?: ThemeFactory;
|
||||
|
||||
/**
|
||||
* Custom syntax highlighter factory function
|
||||
*/
|
||||
highlighterFactory?: HighlighterFactory;
|
||||
|
||||
/**
|
||||
* Configuration for syntax highlighting
|
||||
*/
|
||||
highlightConfig?: SyntaxHighlightConfig;
|
||||
|
||||
/**
|
||||
* Custom autocompletion extension
|
||||
*/
|
||||
autocompletion?: Extension;
|
||||
|
||||
/**
|
||||
* Additional CodeMirror extensions to apply
|
||||
*/
|
||||
extensions?: Extension[];
|
||||
|
||||
/**
|
||||
* Whether to show line numbers (default: false)
|
||||
*/
|
||||
showLineNumbers?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to enable line wrapping (default: true)
|
||||
*/
|
||||
lineWrapping?: boolean;
|
||||
|
||||
/**
|
||||
* Aria label for accessibility
|
||||
*/
|
||||
ariaLabel?: string;
|
||||
|
||||
/**
|
||||
* Custom CSS class for the container
|
||||
*/
|
||||
className?: string;
|
||||
|
||||
/**
|
||||
* Whether to apply input styles (default: true)
|
||||
*/
|
||||
useInputStyles?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to enable automatic closing of brackets and braces (default: true)
|
||||
*/
|
||||
closeBrackets?: boolean;
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export const DataLinkEditor = memo(
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={t('grafana-ui.data-link-editor.url-label', 'URL')} className={styles.urlField}>
|
||||
<Field label={t('grafana-ui.data-link-editor.url-label', 'URL')}>
|
||||
<DataLinkInput value={value.url} onChange={onUrlChange} suggestions={suggestions} />
|
||||
</Field>
|
||||
|
||||
@@ -88,10 +88,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
listItem: css({
|
||||
marginBottom: theme.spacing(),
|
||||
}),
|
||||
urlField: css({
|
||||
position: 'relative',
|
||||
zIndex: theme.zIndex.typeahead,
|
||||
}),
|
||||
infoText: css({
|
||||
paddingBottom: theme.spacing(2),
|
||||
marginLeft: '66px',
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useEffect } from 'react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { DataLinkBuiltInVars, VariableOrigin, VariableSuggestion } from '@grafana/data';
|
||||
|
||||
import { DataLinkInput } from './DataLinkInput';
|
||||
|
||||
// Mock getClientRects for CodeMirror in JSDOM
|
||||
beforeAll(() => {
|
||||
Range.prototype.getClientRects = jest.fn(() => ({
|
||||
item: () => null,
|
||||
length: 0,
|
||||
[Symbol.iterator]: jest.fn(),
|
||||
}));
|
||||
Range.prototype.getBoundingClientRect = jest.fn(() => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
toJSON: () => {},
|
||||
}));
|
||||
});
|
||||
|
||||
const mockSuggestions: VariableSuggestion[] = [
|
||||
{
|
||||
value: DataLinkBuiltInVars.seriesName,
|
||||
label: '__series.name',
|
||||
documentation: 'Series name',
|
||||
origin: VariableOrigin.Series,
|
||||
},
|
||||
{
|
||||
value: DataLinkBuiltInVars.fieldName,
|
||||
label: '__field.name',
|
||||
documentation: 'Field name',
|
||||
origin: VariableOrigin.Field,
|
||||
},
|
||||
{
|
||||
value: 'myVar',
|
||||
label: 'myVar',
|
||||
documentation: 'Custom variable',
|
||||
origin: VariableOrigin.Template,
|
||||
},
|
||||
];
|
||||
|
||||
describe('DataLinkInput', () => {
|
||||
it('renders with initial value', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<DataLinkInput value="https://grafana.com" onChange={onChange} suggestions={mockSuggestions} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const editor = screen.getByRole('textbox');
|
||||
expect(editor).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders with placeholder when value is empty', async () => {
|
||||
const onChange = jest.fn();
|
||||
const placeholder = 'Enter URL here';
|
||||
|
||||
render(<DataLinkInput value="" onChange={onChange} suggestions={mockSuggestions} placeholder={placeholder} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const editor = screen.getByRole('textbox');
|
||||
expect(editor).toHaveAttribute('aria-placeholder', placeholder);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onChange when value changes', async () => {
|
||||
const onChange = jest.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<DataLinkInput value="" onChange={onChange} suggestions={mockSuggestions} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editor = screen.getByRole('textbox');
|
||||
await user.click(editor);
|
||||
await user.keyboard('test');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows suggestions menu when $ is typed', async () => {
|
||||
const onChange = jest.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<DataLinkInput value="" onChange={onChange} suggestions={mockSuggestions} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editor = screen.getByRole('textbox');
|
||||
await user.click(editor);
|
||||
await user.keyboard('$');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows suggestions menu when = is typed', async () => {
|
||||
const onChange = jest.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<DataLinkInput value="" onChange={onChange} suggestions={mockSuggestions} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editor = screen.getByRole('textbox');
|
||||
await user.click(editor);
|
||||
await user.keyboard('=');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('closes suggestions on Escape key', async () => {
|
||||
const onChange = jest.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<DataLinkInput value="" onChange={onChange} suggestions={mockSuggestions} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editor = screen.getByRole('textbox');
|
||||
await user.click(editor);
|
||||
await user.keyboard('$');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates suggestions with arrow keys', async () => {
|
||||
const onChange = jest.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<DataLinkInput value="" onChange={onChange} suggestions={mockSuggestions} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editor = screen.getByRole('textbox');
|
||||
await user.click(editor);
|
||||
await user.keyboard('$');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Navigate with arrow keys
|
||||
await user.keyboard('{ArrowDown}');
|
||||
await user.keyboard('{ArrowUp}');
|
||||
|
||||
// Menu should still be visible
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('inserts variable on Enter key', async () => {
|
||||
const onChange = jest.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<DataLinkInput value="" onChange={onChange} suggestions={mockSuggestions} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editor = screen.getByRole('textbox');
|
||||
await user.click(editor);
|
||||
await user.keyboard('$');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Should have called onChange with the inserted variable
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates when external value prop changes', async () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
function TestWrapper({ initialValue }: { initialValue: string }) {
|
||||
const [value, setValue] = React.useState(initialValue);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
return <DataLinkInput value={value} onChange={onChange} suggestions={mockSuggestions} />;
|
||||
}
|
||||
|
||||
const { rerender } = render(<TestWrapper initialValue="first" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
rerender(<TestWrapper initialValue="second" />);
|
||||
|
||||
await waitFor(() => {
|
||||
const editor = screen.getByRole('textbox');
|
||||
expect(editor).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays component with default placeholder', async () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
render(<DataLinkInput value="" onChange={onChange} suggestions={mockSuggestions} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const editor = screen.getByRole('textbox');
|
||||
expect(editor).toHaveAttribute('aria-placeholder', 'http://your-grafana.com/d/000000010/annotations');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,27 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { autoUpdate, offset, useFloating } from '@floating-ui/react';
|
||||
import Prism, { Grammar, LanguageMap } from 'prismjs';
|
||||
import { memo, useEffect, useRef, useState } from 'react';
|
||||
import * as React from 'react';
|
||||
import { usePrevious } from 'react-use';
|
||||
import { Value } from 'slate';
|
||||
import Plain from 'slate-plain-serializer';
|
||||
import { Editor } from 'slate-react';
|
||||
|
||||
import { VariableSuggestion } from '@grafana/data';
|
||||
import { DataLinkBuiltInVars, GrafanaTheme2, VariableOrigin, VariableSuggestion } from '@grafana/data';
|
||||
|
||||
import { CodeMirrorEditor } from '../CodeMirror/CodeMirrorEditor';
|
||||
import { SlatePrism } from '../../slate-plugins/slate-prism';
|
||||
import { useStyles2 } from '../../themes/ThemeContext';
|
||||
import { getPositioningMiddleware } from '../../utils/floating';
|
||||
import { SCHEMA, makeValue } from '../../utils/slate';
|
||||
import { getInputStyles } from '../Input/Input';
|
||||
import { Portal } from '../Portal/Portal';
|
||||
import { ScrollContainer } from '../ScrollContainer/ScrollContainer';
|
||||
|
||||
import { createDataLinkAutocompletion, createDataLinkHighlighter, createDataLinkTheme } from './codemirrorUtils';
|
||||
import { DataLinkSuggestions } from './DataLinkSuggestions';
|
||||
import { SelectionReference } from './SelectionReference';
|
||||
|
||||
const modulo = (a: number, n: number) => a - n * Math.floor(a / n);
|
||||
|
||||
interface DataLinkInputProps {
|
||||
value: string;
|
||||
@@ -13,6 +30,49 @@ interface DataLinkInputProps {
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const datalinksSyntax: Grammar = {
|
||||
builtInVariable: {
|
||||
pattern: /(\${\S+?})/,
|
||||
},
|
||||
};
|
||||
|
||||
const plugins = [
|
||||
SlatePrism(
|
||||
{
|
||||
onlyIn: (node) => 'type' in node && node.type === 'code_block',
|
||||
getSyntax: () => 'links',
|
||||
},
|
||||
{ ...(Prism.languages as LanguageMap), links: datalinksSyntax }
|
||||
),
|
||||
];
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
input: getInputStyles({ theme, invalid: false }).input,
|
||||
editor: css({
|
||||
'.token.builtInVariable': {
|
||||
color: theme.colors.success.text,
|
||||
},
|
||||
'.token.variable': {
|
||||
color: theme.colors.primary.text,
|
||||
},
|
||||
}),
|
||||
suggestionsWrapper: css({
|
||||
boxShadow: theme.shadows.z2,
|
||||
}),
|
||||
// Wrapper with child selector needed.
|
||||
// When classnames are applied to the same element as the wrapper, it causes the suggestions to stop working
|
||||
wrapperOverrides: css({
|
||||
width: '100%',
|
||||
'> .slate-query-field__wrapper': {
|
||||
padding: 0,
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// This memoised also because rerendering the slate editor grabs focus which created problem in some cases this
|
||||
// was used and changes to different state were propagated here.
|
||||
export const DataLinkInput = memo(
|
||||
({
|
||||
value,
|
||||
@@ -20,22 +80,175 @@ export const DataLinkInput = memo(
|
||||
suggestions,
|
||||
placeholder = 'http://your-grafana.com/d/000000010/annotations',
|
||||
}: DataLinkInputProps) => {
|
||||
// Memoize autocompletion extension to avoid recreating on every render
|
||||
const autocompletionExtension = useMemo(() => createDataLinkAutocompletion(suggestions), [suggestions]);
|
||||
const editorRef = useRef<Editor>(null);
|
||||
const styles = useStyles2(getStyles);
|
||||
const [showingSuggestions, setShowingSuggestions] = useState(false);
|
||||
const [suggestionsIndex, setSuggestionsIndex] = useState(0);
|
||||
const [linkUrl, setLinkUrl] = useState<Value>(makeValue(value));
|
||||
const prevLinkUrl = usePrevious<Value>(linkUrl);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo(0, scrollTop);
|
||||
}, [scrollTop]);
|
||||
|
||||
// the order of middleware is important!
|
||||
const middleware = [
|
||||
offset(({ rects }) => ({
|
||||
alignmentAxis: rects.reference.width,
|
||||
})),
|
||||
...getPositioningMiddleware(),
|
||||
];
|
||||
|
||||
const { refs, floatingStyles } = useFloating({
|
||||
open: showingSuggestions,
|
||||
placement: 'bottom-start',
|
||||
onOpenChange: setShowingSuggestions,
|
||||
middleware,
|
||||
whileElementsMounted: autoUpdate,
|
||||
strategy: 'fixed',
|
||||
});
|
||||
|
||||
// Workaround for https://github.com/ianstormtaylor/slate/issues/2927
|
||||
const stateRef = useRef({ showingSuggestions, suggestions, suggestionsIndex, linkUrl, onChange });
|
||||
stateRef.current = { showingSuggestions, suggestions, suggestionsIndex, linkUrl, onChange };
|
||||
|
||||
// Used to get the height of the suggestion elements in order to scroll to them.
|
||||
const activeRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
setScrollTop(getElementPosition(activeRef.current, suggestionsIndex));
|
||||
}, [suggestionsIndex]);
|
||||
|
||||
const onKeyDown = React.useCallback((event: React.KeyboardEvent, next: () => void) => {
|
||||
if (!stateRef.current.showingSuggestions) {
|
||||
if (event.key === '=' || event.key === '$' || (event.keyCode === 32 && event.ctrlKey)) {
|
||||
const selectionRef = new SelectionReference();
|
||||
refs.setReference(selectionRef);
|
||||
return setShowingSuggestions(true);
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'Backspace':
|
||||
if (stateRef.current.linkUrl.focusText.getText().length === 1) {
|
||||
next();
|
||||
}
|
||||
case 'Escape':
|
||||
setShowingSuggestions(false);
|
||||
return setSuggestionsIndex(0);
|
||||
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
return onVariableSelect(stateRef.current.suggestions[stateRef.current.suggestionsIndex]);
|
||||
|
||||
case 'ArrowDown':
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
const direction = event.key === 'ArrowDown' ? 1 : -1;
|
||||
return setSuggestionsIndex((index) => modulo(index + direction, stateRef.current.suggestions.length));
|
||||
default:
|
||||
return next();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Update the state of the link in the parent. This is basically done on blur but we need to do it after
|
||||
// our state have been updated. The duplicity of state is done for perf reasons and also because local
|
||||
// state also contains things like selection and formating.
|
||||
if (prevLinkUrl && prevLinkUrl.selection.isFocused && !linkUrl.selection.isFocused) {
|
||||
stateRef.current.onChange(Plain.serialize(linkUrl));
|
||||
}
|
||||
}, [linkUrl, prevLinkUrl]);
|
||||
|
||||
const onUrlChange = React.useCallback(({ value }: { value: Value }) => {
|
||||
setLinkUrl(value);
|
||||
}, []);
|
||||
|
||||
const onVariableSelect = (item: VariableSuggestion, editor = editorRef.current!) => {
|
||||
const precedingChar: string = getCharactersAroundCaret();
|
||||
const precedingDollar = precedingChar === '$';
|
||||
if (item.origin !== VariableOrigin.Template || item.value === DataLinkBuiltInVars.includeVars) {
|
||||
editor.insertText(`${precedingDollar ? '' : '$'}\{${item.value}}`);
|
||||
} else {
|
||||
editor.insertText(`${precedingDollar ? '' : '$'}\{${item.value}:queryparam}`);
|
||||
}
|
||||
|
||||
setLinkUrl(editor.value);
|
||||
setShowingSuggestions(false);
|
||||
|
||||
setSuggestionsIndex(0);
|
||||
stateRef.current.onChange(Plain.serialize(editor.value));
|
||||
};
|
||||
|
||||
const getCharactersAroundCaret = () => {
|
||||
const input: HTMLSpanElement | null = document.getElementById('data-link-input')!;
|
||||
let precedingChar = '',
|
||||
sel: Selection | null,
|
||||
range: Range;
|
||||
if (window.getSelection) {
|
||||
sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0) {
|
||||
range = sel.getRangeAt(0).cloneRange();
|
||||
// Collapse to the start of the range
|
||||
range.collapse(true);
|
||||
range.setStart(input, 0);
|
||||
precedingChar = range.toString().slice(-1);
|
||||
}
|
||||
}
|
||||
return precedingChar;
|
||||
};
|
||||
|
||||
return (
|
||||
<CodeMirrorEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
themeFactory={createDataLinkTheme}
|
||||
highlighterFactory={createDataLinkHighlighter}
|
||||
autocompletion={autocompletionExtension}
|
||||
ariaLabel={placeholder}
|
||||
closeBrackets={false}
|
||||
/>
|
||||
<div className={styles.wrapperOverrides}>
|
||||
<div className="slate-query-field__wrapper">
|
||||
<div id="data-link-input" className="slate-query-field">
|
||||
{showingSuggestions && (
|
||||
<Portal>
|
||||
<div ref={refs.setFloating} style={floatingStyles}>
|
||||
<ScrollContainer
|
||||
maxHeight="300px"
|
||||
ref={scrollRef}
|
||||
onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
|
||||
>
|
||||
<DataLinkSuggestions
|
||||
activeRef={activeRef}
|
||||
suggestions={stateRef.current.suggestions}
|
||||
onSuggestionSelect={onVariableSelect}
|
||||
onClose={() => setShowingSuggestions(false)}
|
||||
activeIndex={suggestionsIndex}
|
||||
/>
|
||||
</ScrollContainer>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
<Editor
|
||||
schema={SCHEMA}
|
||||
ref={editorRef}
|
||||
placeholder={placeholder}
|
||||
value={stateRef.current.linkUrl}
|
||||
onChange={onUrlChange}
|
||||
onKeyDown={(event, _editor, next) => onKeyDown(event, next)}
|
||||
plugins={plugins}
|
||||
className={cx(
|
||||
styles.editor,
|
||||
styles.input,
|
||||
css({
|
||||
padding: '3px 8px',
|
||||
})
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
DataLinkInput.displayName = 'DataLinkInput';
|
||||
|
||||
function getElementPosition(suggestionElement: HTMLElement | null, activeIndex: number) {
|
||||
return (suggestionElement?.clientHeight ?? 0) * activeIndex;
|
||||
}
|
||||
|
||||
@@ -1,480 +0,0 @@
|
||||
import { CompletionContext } from '@codemirror/autocomplete';
|
||||
import { EditorState, Extension } from '@codemirror/state';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
import { createTheme, DataLinkBuiltInVars, VariableOrigin, VariableSuggestion } from '@grafana/data';
|
||||
|
||||
import {
|
||||
createDataLinkAutocompletion,
|
||||
createDataLinkHighlighter,
|
||||
createDataLinkTheme,
|
||||
dataLinkAutocompletion,
|
||||
} from './codemirrorUtils';
|
||||
|
||||
// Mock DOM elements required by CodeMirror
|
||||
beforeAll(() => {
|
||||
Range.prototype.getClientRects = jest.fn(() => ({
|
||||
item: () => null,
|
||||
length: 0,
|
||||
[Symbol.iterator]: jest.fn(),
|
||||
}));
|
||||
Range.prototype.getBoundingClientRect = jest.fn(() => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
toJSON: () => {},
|
||||
}));
|
||||
});
|
||||
|
||||
const mockSuggestions: VariableSuggestion[] = [
|
||||
{
|
||||
value: DataLinkBuiltInVars.seriesName,
|
||||
label: '__series.name',
|
||||
documentation: 'Series name',
|
||||
origin: VariableOrigin.Series,
|
||||
},
|
||||
{
|
||||
value: DataLinkBuiltInVars.fieldName,
|
||||
label: '__field.name',
|
||||
documentation: 'Field name',
|
||||
origin: VariableOrigin.Field,
|
||||
},
|
||||
{
|
||||
value: 'myVar',
|
||||
label: 'myVar',
|
||||
documentation: 'Custom variable',
|
||||
origin: VariableOrigin.Template,
|
||||
},
|
||||
{
|
||||
value: DataLinkBuiltInVars.includeVars,
|
||||
label: '__all_variables',
|
||||
documentation: 'Include all variables',
|
||||
origin: VariableOrigin.Template,
|
||||
},
|
||||
];
|
||||
|
||||
describe('codemirrorUtils', () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(container);
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to create editor with extensions
|
||||
*/
|
||||
function createEditor(text: string, extensions: Extension | Extension[]) {
|
||||
const state = EditorState.create({
|
||||
doc: text,
|
||||
extensions,
|
||||
});
|
||||
return new EditorView({ state, parent: container });
|
||||
}
|
||||
|
||||
describe('createDataLinkTheme', () => {
|
||||
it('creates theme for light mode', () => {
|
||||
const theme = createTheme({ colors: { mode: 'light' } });
|
||||
const themeExtension = createDataLinkTheme(theme);
|
||||
|
||||
expect(themeExtension).toBeDefined();
|
||||
expect(Array.isArray(themeExtension)).toBe(true);
|
||||
});
|
||||
|
||||
it('creates theme for dark mode', () => {
|
||||
const theme = createTheme({ colors: { mode: 'dark' } });
|
||||
const themeExtension = createDataLinkTheme(theme);
|
||||
|
||||
expect(themeExtension).toBeDefined();
|
||||
expect(Array.isArray(themeExtension)).toBe(true);
|
||||
});
|
||||
|
||||
it('applies theme to editor', () => {
|
||||
const theme = createTheme({ colors: { mode: 'light' } });
|
||||
const themeExtension = createDataLinkTheme(theme);
|
||||
const view = createEditor('${test}', themeExtension);
|
||||
|
||||
expect(view.dom).toBeInstanceOf(HTMLElement);
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('applies theme with variable highlighting', () => {
|
||||
const theme = createTheme({ colors: { mode: 'dark' } });
|
||||
const themeExtension = createDataLinkTheme(theme);
|
||||
const highlighter = createDataLinkHighlighter();
|
||||
const view = createEditor('${variable}', [themeExtension, highlighter]);
|
||||
|
||||
expect(view.dom).toBeInstanceOf(HTMLElement);
|
||||
const content = view.dom.textContent;
|
||||
expect(content).toBe('${variable}');
|
||||
view.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDataLinkHighlighter', () => {
|
||||
it('creates highlighter extension', () => {
|
||||
const highlighter = createDataLinkHighlighter();
|
||||
|
||||
expect(highlighter).toBeDefined();
|
||||
});
|
||||
|
||||
it('highlights single variable', () => {
|
||||
const highlighter = createDataLinkHighlighter();
|
||||
const view = createEditor('${variable}', [highlighter]);
|
||||
|
||||
const content = view.dom.textContent;
|
||||
expect(content).toBe('${variable}');
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('highlights multiple variables', () => {
|
||||
const highlighter = createDataLinkHighlighter();
|
||||
const view = createEditor('${var1} and ${var2}', [highlighter]);
|
||||
|
||||
const content = view.dom.textContent;
|
||||
expect(content).toBe('${var1} and ${var2}');
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('highlights variables in URLs', () => {
|
||||
const highlighter = createDataLinkHighlighter();
|
||||
const view = createEditor('https://example.com?id=${id}&name=${name}', [highlighter]);
|
||||
|
||||
const content = view.dom.textContent;
|
||||
expect(content).toBe('https://example.com?id=${id}&name=${name}');
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('does not highlight incomplete variables', () => {
|
||||
const highlighter = createDataLinkHighlighter();
|
||||
const view = createEditor('${incomplete', [highlighter]);
|
||||
|
||||
const content = view.dom.textContent;
|
||||
expect(content).toBe('${incomplete');
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('highlights variables with dots', () => {
|
||||
const highlighter = createDataLinkHighlighter();
|
||||
const view = createEditor('${__series.name}', [highlighter]);
|
||||
|
||||
const content = view.dom.textContent;
|
||||
expect(content).toBe('${__series.name}');
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('highlights variables with underscores', () => {
|
||||
const highlighter = createDataLinkHighlighter();
|
||||
const view = createEditor('${__field_name}', [highlighter]);
|
||||
|
||||
const content = view.dom.textContent;
|
||||
expect(content).toBe('${__field_name}');
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('updates highlights when document changes', () => {
|
||||
const highlighter = createDataLinkHighlighter();
|
||||
const view = createEditor('initial', [highlighter]);
|
||||
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: '${newVar}' },
|
||||
});
|
||||
|
||||
const content = view.dom.textContent;
|
||||
expect(content).toBe('${newVar}');
|
||||
view.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dataLinkAutocompletion', () => {
|
||||
/**
|
||||
* Helper to create a mock completion context
|
||||
*/
|
||||
function createMockContext(
|
||||
text: string,
|
||||
pos: number,
|
||||
explicit = false
|
||||
): CompletionContext {
|
||||
const state = EditorState.create({ doc: text });
|
||||
return {
|
||||
state,
|
||||
pos,
|
||||
explicit,
|
||||
matchBefore: (regex: RegExp) => {
|
||||
const before = text.slice(0, pos);
|
||||
const match = before.match(regex);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const from = pos - match[0].length;
|
||||
return {
|
||||
from,
|
||||
to: pos,
|
||||
text: match[0],
|
||||
};
|
||||
},
|
||||
aborted: false,
|
||||
addEventListener: jest.fn(),
|
||||
} as unknown as CompletionContext;
|
||||
}
|
||||
|
||||
describe('explicit completion', () => {
|
||||
it('shows all suggestions on explicit trigger', () => {
|
||||
const autocomplete = dataLinkAutocompletion(mockSuggestions);
|
||||
const context = createMockContext('', 0, true);
|
||||
|
||||
const result = autocomplete(context);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.options).toHaveLength(4);
|
||||
expect(result?.from).toBe(0);
|
||||
});
|
||||
|
||||
it('formats series variable correctly', () => {
|
||||
const autocomplete = dataLinkAutocompletion(mockSuggestions);
|
||||
const context = createMockContext('', 0, true);
|
||||
|
||||
const result = autocomplete(context);
|
||||
|
||||
const seriesOption = result?.options.find((opt) => opt.label === '__series.name');
|
||||
expect(seriesOption).toBeDefined();
|
||||
expect(seriesOption?.apply).toBe('${__series.name}');
|
||||
});
|
||||
|
||||
it('formats field variable correctly', () => {
|
||||
const autocomplete = dataLinkAutocompletion(mockSuggestions);
|
||||
const context = createMockContext('', 0, true);
|
||||
|
||||
const result = autocomplete(context);
|
||||
|
||||
const fieldOption = result?.options.find((opt) => opt.label === '__field.name');
|
||||
expect(fieldOption).toBeDefined();
|
||||
expect(fieldOption?.apply).toBe('${__field.name}');
|
||||
});
|
||||
|
||||
it('formats template variable with queryparam', () => {
|
||||
const autocomplete = dataLinkAutocompletion(mockSuggestions);
|
||||
const context = createMockContext('', 0, true);
|
||||
|
||||
const result = autocomplete(context);
|
||||
|
||||
const templateOption = result?.options.find((opt) => opt.label === 'myVar');
|
||||
expect(templateOption).toBeDefined();
|
||||
expect(templateOption?.apply).toBe('${myVar:queryparam}');
|
||||
});
|
||||
|
||||
it('formats includeVars without queryparam', () => {
|
||||
const autocomplete = dataLinkAutocompletion(mockSuggestions);
|
||||
const context = createMockContext('', 0, true);
|
||||
|
||||
const result = autocomplete(context);
|
||||
|
||||
const includeVarsOption = result?.options.find((opt) => opt.label === '__all_variables');
|
||||
expect(includeVarsOption).toBeDefined();
|
||||
expect(includeVarsOption?.apply).toBe('${__all_variables}');
|
||||
});
|
||||
|
||||
it('returns null when no suggestions available', () => {
|
||||
const autocomplete = dataLinkAutocompletion([]);
|
||||
const context = createMockContext('', 0, true);
|
||||
|
||||
const result = autocomplete(context);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('trigger on $ character', () => {
|
||||
it('shows completions after typing $', () => {
|
||||
const autocomplete = dataLinkAutocompletion(mockSuggestions);
|
||||
const context = createMockContext('$', 1, false);
|
||||
|
||||
const result = autocomplete(context);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.options).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('shows completions after typing ${', () => {
|
||||
const autocomplete = dataLinkAutocompletion(mockSuggestions);
|
||||
const context = createMockContext('${', 2, false);
|
||||
|
||||
const result = autocomplete(context);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.options).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('shows completions while typing variable name', () => {
|
||||
const autocomplete = dataLinkAutocompletion(mockSuggestions);
|
||||
const context = createMockContext('${ser', 5, false);
|
||||
|
||||
const result = autocomplete(context);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.options).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('does not show completions without trigger', () => {
|
||||
const autocomplete = dataLinkAutocompletion(mockSuggestions);
|
||||
const context = createMockContext('test', 4, false);
|
||||
|
||||
const result = autocomplete(context);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('trigger on = character', () => {
|
||||
it('shows completions after typing =', () => {
|
||||
const autocomplete = dataLinkAutocompletion(mockSuggestions);
|
||||
const context = createMockContext('url?param=', 10, false);
|
||||
|
||||
const result = autocomplete(context);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.options).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('shows completions after typing =${', () => {
|
||||
const autocomplete = dataLinkAutocompletion(mockSuggestions);
|
||||
const context = createMockContext('url?param=${', 12, false);
|
||||
|
||||
const result = autocomplete(context);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.options).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('option metadata', () => {
|
||||
it('includes label for all options', () => {
|
||||
const autocomplete = dataLinkAutocompletion(mockSuggestions);
|
||||
const context = createMockContext('$', 1, false);
|
||||
|
||||
const result = autocomplete(context);
|
||||
|
||||
result?.options.forEach((option) => {
|
||||
expect(option.label).toBeDefined();
|
||||
expect(typeof option.label).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
it('includes detail (origin) for all options', () => {
|
||||
const autocomplete = dataLinkAutocompletion(mockSuggestions);
|
||||
const context = createMockContext('$', 1, false);
|
||||
|
||||
const result = autocomplete(context);
|
||||
|
||||
result?.options.forEach((option) => {
|
||||
expect(option.detail).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('includes documentation info for all options', () => {
|
||||
const autocomplete = dataLinkAutocompletion(mockSuggestions);
|
||||
const context = createMockContext('$', 1, false);
|
||||
|
||||
const result = autocomplete(context);
|
||||
|
||||
result?.options.forEach((option) => {
|
||||
expect(option.info).toBeDefined();
|
||||
expect(typeof option.info).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
it('sets type to variable for all options', () => {
|
||||
const autocomplete = dataLinkAutocompletion(mockSuggestions);
|
||||
const context = createMockContext('$', 1, false);
|
||||
|
||||
const result = autocomplete(context);
|
||||
|
||||
result?.options.forEach((option) => {
|
||||
expect(option.type).toBe('variable');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDataLinkAutocompletion', () => {
|
||||
it('creates autocompletion extension', () => {
|
||||
const extension = createDataLinkAutocompletion(mockSuggestions);
|
||||
|
||||
expect(extension).toBeDefined();
|
||||
});
|
||||
|
||||
it('applies autocompletion to editor', () => {
|
||||
const extension = createDataLinkAutocompletion(mockSuggestions);
|
||||
const view = createEditor('', [extension]);
|
||||
|
||||
expect(view.dom).toBeInstanceOf(HTMLElement);
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('works with empty suggestions', () => {
|
||||
const extension = createDataLinkAutocompletion([]);
|
||||
const view = createEditor('', [extension]);
|
||||
|
||||
expect(view.dom).toBeInstanceOf(HTMLElement);
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('integrates with theme and highlighter', () => {
|
||||
const theme = createTheme({ colors: { mode: 'light' } });
|
||||
const themeExtension = createDataLinkTheme(theme);
|
||||
const highlighter = createDataLinkHighlighter();
|
||||
const autocompletion = createDataLinkAutocompletion(mockSuggestions);
|
||||
|
||||
const view = createEditor('${test}', [themeExtension, highlighter, autocompletion]);
|
||||
|
||||
expect(view.dom).toBeInstanceOf(HTMLElement);
|
||||
const content = view.dom.textContent;
|
||||
expect(content).toBe('${test}');
|
||||
view.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration tests', () => {
|
||||
it('combines all utilities together', () => {
|
||||
const theme = createTheme({ colors: { mode: 'dark' } });
|
||||
const themeExtension = createDataLinkTheme(theme);
|
||||
const highlighter = createDataLinkHighlighter();
|
||||
const autocompletion = createDataLinkAutocompletion(mockSuggestions);
|
||||
|
||||
const view = createEditor(
|
||||
'https://example.com?id=${id}&name=${name}',
|
||||
[themeExtension, highlighter, autocompletion]
|
||||
);
|
||||
|
||||
expect(view.dom).toBeInstanceOf(HTMLElement);
|
||||
const content = view.dom.textContent;
|
||||
expect(content).toBe('https://example.com?id=${id}&name=${name}');
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('handles dynamic content updates', () => {
|
||||
const theme = createTheme({ colors: { mode: 'light' } });
|
||||
const themeExtension = createDataLinkTheme(theme);
|
||||
const highlighter = createDataLinkHighlighter();
|
||||
|
||||
const view = createEditor('initial', [themeExtension, highlighter]);
|
||||
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: '${variable} updated' },
|
||||
});
|
||||
|
||||
const content = view.dom.textContent;
|
||||
expect(content).toBe('${variable} updated');
|
||||
view.destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,145 +0,0 @@
|
||||
import { autocompletion, Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
||||
import { Extension } from '@codemirror/state';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
import { DataLinkBuiltInVars, GrafanaTheme2, VariableOrigin, VariableSuggestion } from '@grafana/data';
|
||||
|
||||
import { createGenericHighlighter } from '../CodeMirror/highlight';
|
||||
import { createGenericTheme } from '../CodeMirror/styles';
|
||||
|
||||
/**
|
||||
* Creates a CodeMirror theme for data link input with custom variable styling
|
||||
* This extends the generic theme with data link-specific styles
|
||||
*/
|
||||
export function createDataLinkTheme(theme: GrafanaTheme2): Extension {
|
||||
const genericTheme = createGenericTheme(theme);
|
||||
|
||||
// Add data link-specific variable styling
|
||||
const dataLinkStyles = EditorView.theme({
|
||||
'.cm-variable': {
|
||||
color: theme.colors.success.text,
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
},
|
||||
});
|
||||
|
||||
return [genericTheme, dataLinkStyles];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a syntax highlighter for data link variables (${...})
|
||||
* Matches the pattern from the old Prism implementation: (\${\S+?})
|
||||
*/
|
||||
export function createDataLinkHighlighter(): Extension {
|
||||
// Regular expression matching ${...} patterns (same as old implementation)
|
||||
const variablePattern = /\$\{[^}]+\}/g;
|
||||
|
||||
return createGenericHighlighter({
|
||||
pattern: variablePattern,
|
||||
className: 'cm-variable',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to generate the apply text for a variable suggestion
|
||||
*/
|
||||
function getApplyText(suggestion: VariableSuggestion): string {
|
||||
if (suggestion.origin !== VariableOrigin.Template || suggestion.value === DataLinkBuiltInVars.includeVars) {
|
||||
return `\${${suggestion.value}}`;
|
||||
}
|
||||
return `\${${suggestion.value}:queryparam}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create a completion option from a suggestion
|
||||
*/
|
||||
function createCompletionOption(
|
||||
suggestion: VariableSuggestion,
|
||||
customApply?: (view: EditorView, completion: Completion, from: number, to: number) => void
|
||||
): Completion {
|
||||
const applyText = getApplyText(suggestion);
|
||||
|
||||
return {
|
||||
label: suggestion.label,
|
||||
apply: customApply ?? applyText,
|
||||
type: 'variable',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates autocomplete source function for data link variables
|
||||
* Triggers on $ and = characters
|
||||
*/
|
||||
export function dataLinkAutocompletion(
|
||||
suggestions: VariableSuggestion[]
|
||||
): (context: CompletionContext) => CompletionResult | null {
|
||||
return (context: CompletionContext): CompletionResult | null => {
|
||||
// Don't show completions if there are no suggestions
|
||||
if (suggestions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// For explicit completion (Ctrl+Space), show at cursor position
|
||||
if (context.explicit) {
|
||||
const options = suggestions.map((suggestion) => createCompletionOption(suggestion));
|
||||
return {
|
||||
from: context.pos,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
// Match $ or = followed by optional { and word characters
|
||||
// This will match: $, ${, ${word, =, etc.
|
||||
const word = context.matchBefore(/[$=]\{?[\w.]*$/);
|
||||
|
||||
// If no match on typing, don't show completions
|
||||
if (!word) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if the match starts with a trigger character
|
||||
const triggerChar = word.text.charAt(0);
|
||||
if (triggerChar !== '$' && triggerChar !== '=') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// For single trigger character ($ or =), use custom apply function to handle replacement
|
||||
const isSingleChar = word.text.length === 1;
|
||||
|
||||
const options = suggestions.map((suggestion) => {
|
||||
if (!isSingleChar) {
|
||||
return createCompletionOption(suggestion);
|
||||
}
|
||||
|
||||
const applyText = getApplyText(suggestion);
|
||||
const customApply = (view: EditorView, completion: Completion, from: number, to: number) => {
|
||||
// Replace from the trigger character position
|
||||
const wordFrom = triggerChar === '=' ? context.pos : word.from;
|
||||
view.dispatch({
|
||||
changes: { from: wordFrom, to, insert: applyText },
|
||||
selection: { anchor: wordFrom + applyText.length },
|
||||
});
|
||||
};
|
||||
|
||||
return createCompletionOption(suggestion, customApply);
|
||||
});
|
||||
|
||||
return {
|
||||
from: isSingleChar ? context.pos : word.from,
|
||||
options,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a data link autocompletion extension with configured suggestions
|
||||
*/
|
||||
export function createDataLinkAutocompletion(suggestions: VariableSuggestion[]): Extension {
|
||||
return autocompletion({
|
||||
override: [dataLinkAutocompletion(suggestions)],
|
||||
activateOnTyping: true,
|
||||
closeOnBlur: true,
|
||||
maxRenderedOptions: 100,
|
||||
defaultKeymap: true,
|
||||
interactionDelay: 0,
|
||||
});
|
||||
}
|
||||
@@ -16,4 +16,16 @@ describe('Pagination component', () => {
|
||||
expect(screen.getAllByRole('button')).toHaveLength(9);
|
||||
expect(screen.getAllByTestId('pagination-ellipsis-icon')).toHaveLength(2);
|
||||
});
|
||||
it('should only render the page number if number of pages is 0', () => {
|
||||
render(<Pagination currentPage={8} numberOfPages={0} onNavigate={() => {}} />);
|
||||
expect(screen.getAllByRole('button')).toHaveLength(2);
|
||||
expect(screen.getAllByRole('button')[1]).toBeEnabled();
|
||||
expect(screen.getByText('8')).toBeVisible();
|
||||
});
|
||||
it('should disable the next page button if hasNextPage is false', () => {
|
||||
render(<Pagination currentPage={8} numberOfPages={0} onNavigate={() => {}} hasNextPage={false} />);
|
||||
expect(screen.getAllByRole('button')).toHaveLength(2);
|
||||
expect(screen.getAllByRole('button')[0]).toBeEnabled();
|
||||
expect(screen.getAllByRole('button')[1]).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface Props {
|
||||
/** Small version only shows the current page and the navigation buttons. */
|
||||
showSmallVersion?: boolean;
|
||||
className?: string;
|
||||
/** If we are using cursor based pagination, disable next page button when we have no cursor */
|
||||
hasNextPage?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,6 +35,7 @@ export const Pagination = ({
|
||||
hideWhenSinglePage,
|
||||
showSmallVersion,
|
||||
className,
|
||||
hasNextPage,
|
||||
}: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const pageLengthToCondense = showSmallVersion ? 1 : 8;
|
||||
@@ -122,13 +125,14 @@ export const Pagination = ({
|
||||
</Button>
|
||||
</li>
|
||||
{pageButtons}
|
||||
{pageButtons.length === 0 && <li className={styles.item}>{currentPage}</li>}
|
||||
<li className={styles.item}>
|
||||
<Button
|
||||
aria-label={nextPageLabel}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => onNavigate(currentPage + 1)}
|
||||
disabled={currentPage === numberOfPages}
|
||||
disabled={hasNextPage === false || currentPage === numberOfPages}
|
||||
>
|
||||
<Icon name="angle-right" />
|
||||
</Button>
|
||||
|
||||
@@ -92,18 +92,6 @@ export {
|
||||
} from './components/Monaco/types';
|
||||
export { variableSuggestionToCodeEditorSuggestion } from './components/Monaco/utils';
|
||||
|
||||
// CodeMirror
|
||||
export { CodeMirrorEditor } from './components/CodeMirror/CodeMirrorEditor';
|
||||
export { createGenericTheme } from './components/CodeMirror/styles';
|
||||
export { createGenericHighlighter } from './components/CodeMirror/highlight';
|
||||
export type {
|
||||
CodeMirrorEditorProps,
|
||||
ThemeFactory,
|
||||
HighlighterFactory,
|
||||
AutocompletionFactory,
|
||||
SyntaxHighlightConfig,
|
||||
} from './components/CodeMirror/types';
|
||||
|
||||
// TODO: namespace
|
||||
export { Modal, type Props as ModalProps } from './components/Modal/Modal';
|
||||
export { ModalHeader } from './components/Modal/ModalHeader';
|
||||
|
||||
@@ -552,6 +552,7 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
|
||||
// gets dashboards that the user was granted read access to
|
||||
permissions := user.GetPermissions()
|
||||
dashboardPermissions := permissions[dashboards.ActionDashboardsRead]
|
||||
folderPermissions := permissions[dashboards.ActionFoldersRead]
|
||||
dashboardUids := make([]string, 0)
|
||||
sharedDashboards := make([]string, 0)
|
||||
|
||||
@@ -562,6 +563,13 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, folderPermission := range folderPermissions {
|
||||
if folderUid, found := strings.CutPrefix(folderPermission, dashboards.ScopeFoldersPrefix); found {
|
||||
if !slices.Contains(dashboardUids, folderUid) && folderUid != foldermodel.SharedWithMeFolderUID && folderUid != foldermodel.GeneralFolderUID {
|
||||
dashboardUids = append(dashboardUids, folderUid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(dashboardUids) == 0 {
|
||||
return sharedDashboards, nil
|
||||
@@ -572,9 +580,15 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
|
||||
return sharedDashboards, err
|
||||
}
|
||||
|
||||
folderKey, err := asResourceKey(user.GetNamespace(), folders.RESOURCE)
|
||||
if err != nil {
|
||||
return sharedDashboards, err
|
||||
}
|
||||
|
||||
dashboardSearchRequest := &resourcepb.ResourceSearchRequest{
|
||||
Fields: []string{"folder"},
|
||||
Limit: int64(len(dashboardUids)),
|
||||
Federated: []*resourcepb.ResourceKey{folderKey},
|
||||
Fields: []string{"folder"},
|
||||
Limit: int64(len(dashboardUids)),
|
||||
Options: &resourcepb.ListOptions{
|
||||
Key: key,
|
||||
Fields: []*resourcepb.Requirement{{
|
||||
@@ -610,12 +624,6 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
|
||||
}
|
||||
}
|
||||
|
||||
// only folders the user has access to will be returned here
|
||||
folderKey, err := asResourceKey(user.GetNamespace(), folders.RESOURCE)
|
||||
if err != nil {
|
||||
return sharedDashboards, err
|
||||
}
|
||||
|
||||
folderSearchRequest := &resourcepb.ResourceSearchRequest{
|
||||
Fields: []string{"folder"},
|
||||
Limit: int64(len(allFolders)),
|
||||
@@ -628,6 +636,7 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
|
||||
}},
|
||||
},
|
||||
}
|
||||
// only folders the user has access to will be returned here
|
||||
foldersResult, err := s.client.Search(ctx, folderSearchRequest)
|
||||
if err != nil {
|
||||
return sharedDashboards, err
|
||||
|
||||
@@ -507,6 +507,15 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
|
||||
[]byte("publicfolder"), // folder uid
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Name: "sharedfolder",
|
||||
Resource: "folder",
|
||||
},
|
||||
Cells: [][]byte{
|
||||
[]byte("privatefolder"), // folder uid
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -550,6 +559,15 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
|
||||
[]byte("privatefolder"), // folder uid
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Name: "sharedfolder",
|
||||
Resource: "folder",
|
||||
},
|
||||
Cells: [][]byte{
|
||||
[]byte("privatefolder"), // folder uid
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -571,6 +589,7 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
|
||||
allPermissions := make(map[int64]map[string][]string)
|
||||
permissions := make(map[string][]string)
|
||||
permissions[dashboards.ActionDashboardsRead] = []string{"dashboards:uid:dashboardinroot", "dashboards:uid:dashboardinprivatefolder", "dashboards:uid:dashboardinpublicfolder"}
|
||||
permissions[dashboards.ActionFoldersRead] = []string{"folders:uid:sharedfolder"}
|
||||
allPermissions[1] = permissions
|
||||
// "Permissions" is where we store the uid of dashboards shared with the user
|
||||
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test", OrgID: 1, Permissions: allPermissions}))
|
||||
@@ -581,14 +600,19 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
|
||||
|
||||
// first call gets all dashboards user has permission for
|
||||
firstCall := mockClient.MockCalls[0]
|
||||
assert.Equal(t, firstCall.Options.Fields[0].Values, []string{"dashboardinroot", "dashboardinprivatefolder", "dashboardinpublicfolder"})
|
||||
assert.Equal(t, firstCall.Options.Fields[0].Values, []string{"dashboardinroot", "dashboardinprivatefolder", "dashboardinpublicfolder", "sharedfolder"})
|
||||
// verify federated field is set to include folders
|
||||
assert.NotNil(t, firstCall.Federated)
|
||||
assert.Equal(t, 1, len(firstCall.Federated))
|
||||
assert.Equal(t, "folder.grafana.app", firstCall.Federated[0].Group)
|
||||
assert.Equal(t, "folders", firstCall.Federated[0].Resource)
|
||||
// second call gets folders associated with the previous dashboards
|
||||
secondCall := mockClient.MockCalls[1]
|
||||
assert.Equal(t, secondCall.Options.Fields[0].Values, []string{"privatefolder", "publicfolder"})
|
||||
// lastly, search ONLY for dashboards user has permission to read that are within folders the user does NOT have
|
||||
// lastly, search ONLY for dashboards and folders user has permission to read that are within folders the user does NOT have
|
||||
// permission to read
|
||||
thirdCall := mockClient.MockCalls[2]
|
||||
assert.Equal(t, thirdCall.Options.Fields[0].Values, []string{"dashboardinprivatefolder"})
|
||||
assert.Equal(t, thirdCall.Options.Fields[0].Values, []string{"dashboardinprivatefolder", "sharedfolder"})
|
||||
|
||||
resp := rr.Result()
|
||||
defer func() {
|
||||
|
||||
@@ -2,6 +2,8 @@ package extras
|
||||
|
||||
import (
|
||||
apisprovisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/connection"
|
||||
ghconnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/repository/git"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/repository/github"
|
||||
@@ -42,6 +44,15 @@ func ProvideProvisioningOSSRepositoryExtras(
|
||||
}
|
||||
}
|
||||
|
||||
func ProvideProvisioningOSSConnectionExtras(
|
||||
_ *setting.Cfg,
|
||||
ghFactory ghconnection.GithubFactory,
|
||||
) []connection.Extra {
|
||||
return []connection.Extra{
|
||||
ghconnection.Extra(ghFactory),
|
||||
}
|
||||
}
|
||||
|
||||
func ProvideExtraWorkers(pullRequestWorker *pullrequest.PullRequestWorker) []jobs.Worker {
|
||||
return []jobs.Worker{pullRequestWorker}
|
||||
}
|
||||
@@ -54,3 +65,12 @@ func ProvideFactoryFromConfig(cfg *setting.Cfg, extras []repository.Extra) (repo
|
||||
|
||||
return repository.ProvideFactory(enabledTypes, extras)
|
||||
}
|
||||
|
||||
func ProvideConnectionFactoryFromConfig(cfg *setting.Cfg, extras []connection.Extra) (connection.Factory, error) {
|
||||
enabledTypes := make(map[apisprovisioning.ConnectionType]struct{}, len(cfg.ProvisioningRepositoryTypes))
|
||||
for _, e := range cfg.ProvisioningRepositoryTypes {
|
||||
enabledTypes[apisprovisioning.ConnectionType(e)] = struct{}{}
|
||||
}
|
||||
|
||||
return connection.ProvideFactory(enabledTypes, extras)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ import (
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/auth"
|
||||
connectionvalidation "github.com/grafana/grafana/apps/provisioning/pkg/connection"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/connection"
|
||||
appcontroller "github.com/grafana/grafana/apps/provisioning/pkg/controller"
|
||||
clientset "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned"
|
||||
client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1"
|
||||
@@ -105,20 +105,21 @@ type APIBuilder struct {
|
||||
jobs.Queue
|
||||
jobs.Store
|
||||
}
|
||||
jobHistoryConfig *JobHistoryConfig
|
||||
jobHistoryLoki *jobs.LokiJobHistory
|
||||
resourceLister resources.ResourceLister
|
||||
dashboardAccess legacy.MigrationDashboardAccessor
|
||||
unified resource.ResourceClient
|
||||
repoFactory repository.Factory
|
||||
client client.ProvisioningV0alpha1Interface
|
||||
access auth.AccessChecker
|
||||
accessWithAdmin auth.AccessChecker
|
||||
accessWithEditor auth.AccessChecker
|
||||
accessWithViewer auth.AccessChecker
|
||||
statusPatcher *appcontroller.RepositoryStatusPatcher
|
||||
healthChecker *controller.HealthChecker
|
||||
validator repository.RepositoryValidator
|
||||
jobHistoryConfig *JobHistoryConfig
|
||||
jobHistoryLoki *jobs.LokiJobHistory
|
||||
resourceLister resources.ResourceLister
|
||||
dashboardAccess legacy.MigrationDashboardAccessor
|
||||
unified resource.ResourceClient
|
||||
repoFactory repository.Factory
|
||||
connectionFactory connection.Factory
|
||||
client client.ProvisioningV0alpha1Interface
|
||||
access auth.AccessChecker
|
||||
accessWithAdmin auth.AccessChecker
|
||||
accessWithEditor auth.AccessChecker
|
||||
accessWithViewer auth.AccessChecker
|
||||
statusPatcher *appcontroller.RepositoryStatusPatcher
|
||||
healthChecker *controller.HealthChecker
|
||||
repoValidator repository.RepositoryValidator
|
||||
// Extras provides additional functionality to the API.
|
||||
extras []Extra
|
||||
extraWorkers []jobs.Worker
|
||||
@@ -133,6 +134,7 @@ type APIBuilder struct {
|
||||
func NewAPIBuilder(
|
||||
onlyApiServer bool,
|
||||
repoFactory repository.Factory,
|
||||
connectionFactory connection.Factory,
|
||||
features featuremgmt.FeatureToggles,
|
||||
unified resource.ResourceClient,
|
||||
configProvider apiserver.RestConfigProvider,
|
||||
@@ -176,6 +178,7 @@ func NewAPIBuilder(
|
||||
usageStats: usageStats,
|
||||
features: features,
|
||||
repoFactory: repoFactory,
|
||||
connectionFactory: connectionFactory,
|
||||
clients: clients,
|
||||
parsers: parsers,
|
||||
repositoryResources: resources.NewRepositoryResourcesFactory(parsers, clients, resourceLister),
|
||||
@@ -192,7 +195,7 @@ func NewAPIBuilder(
|
||||
allowedTargets: allowedTargets,
|
||||
allowImageRendering: allowImageRendering,
|
||||
registry: registry,
|
||||
validator: repository.NewValidator(minSyncInterval, allowedTargets, allowImageRendering),
|
||||
repoValidator: repository.NewValidator(minSyncInterval, allowedTargets, allowImageRendering),
|
||||
useExclusivelyAccessCheckerForAuthz: useExclusivelyAccessCheckerForAuthz,
|
||||
}
|
||||
|
||||
@@ -253,6 +256,7 @@ func RegisterAPIService(
|
||||
extraBuilders []ExtraBuilder,
|
||||
extraWorkers []jobs.Worker,
|
||||
repoFactory repository.Factory,
|
||||
connectionFactory connection.Factory,
|
||||
) (*APIBuilder, error) {
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if !features.IsEnabledGlobally(featuremgmt.FlagProvisioning) {
|
||||
@@ -271,6 +275,7 @@ func RegisterAPIService(
|
||||
builder := NewAPIBuilder(
|
||||
cfg.DisableControllers,
|
||||
repoFactory,
|
||||
connectionFactory,
|
||||
features,
|
||||
client,
|
||||
configProvider,
|
||||
@@ -641,7 +646,7 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI
|
||||
storage[provisioning.ConnectionResourceInfo.StoragePath("repositories")] = NewConnectionRepositoriesConnector()
|
||||
|
||||
// TODO: Add some logic so that the connectors can registered themselves and we don't have logic all over the place
|
||||
storage[provisioning.RepositoryResourceInfo.StoragePath("test")] = NewTestConnector(b, repository.NewRepositoryTesterWithExistingChecker(repository.NewSimpleRepositoryTester(b.validator), b.VerifyAgainstExistingRepositories))
|
||||
storage[provisioning.RepositoryResourceInfo.StoragePath("test")] = NewTestConnector(b, repository.NewRepositoryTesterWithExistingChecker(repository.NewSimpleRepositoryTester(b.repoValidator), b.VerifyAgainstExistingRepositories))
|
||||
storage[provisioning.RepositoryResourceInfo.StoragePath("files")] = NewFilesConnector(b, b.parsers, b.clients, b.accessWithAdmin)
|
||||
storage[provisioning.RepositoryResourceInfo.StoragePath("refs")] = NewRefsConnector(b)
|
||||
storage[provisioning.RepositoryResourceInfo.StoragePath("resources")] = &listConnector{
|
||||
@@ -682,10 +687,15 @@ func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admis
|
||||
if ok {
|
||||
return nil
|
||||
}
|
||||
// TODO: complete this as part of https://github.com/grafana/git-ui-sync-project/issues/700
|
||||
|
||||
c, ok := obj.(*provisioning.Connection)
|
||||
if ok {
|
||||
return connectionvalidation.MutateConnection(c)
|
||||
conn, err := b.asConnection(ctx, c, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return conn.Mutate(ctx)
|
||||
}
|
||||
|
||||
r, ok := obj.(*provisioning.Repository)
|
||||
@@ -736,9 +746,15 @@ func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o adm
|
||||
return nil
|
||||
}
|
||||
|
||||
connection, ok := obj.(*provisioning.Connection)
|
||||
// Validate connections
|
||||
c, ok := obj.(*provisioning.Connection)
|
||||
if ok {
|
||||
return connectionvalidation.ValidateConnection(connection)
|
||||
conn, err := b.asConnection(ctx, c, a.GetOldObject())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return conn.Validate(ctx)
|
||||
}
|
||||
|
||||
// Validate Jobs
|
||||
@@ -758,7 +774,7 @@ func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o adm
|
||||
// the only time to add configuration checks here is if you need to compare
|
||||
// the incoming change to the current configuration
|
||||
isCreate := a.GetOperation() == admission.Create
|
||||
list := b.validator.ValidateRepository(repo, isCreate)
|
||||
list := b.repoValidator.ValidateRepository(repo, isCreate)
|
||||
cfg := repo.Config()
|
||||
|
||||
if a.GetOperation() == admission.Update {
|
||||
@@ -831,7 +847,7 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH
|
||||
}
|
||||
|
||||
b.statusPatcher = appcontroller.NewRepositoryStatusPatcher(b.GetClient())
|
||||
b.healthChecker = controller.NewHealthChecker(b.statusPatcher, b.registry, repository.NewSimpleRepositoryTester(b.validator))
|
||||
b.healthChecker = controller.NewHealthChecker(b.statusPatcher, b.registry, repository.NewSimpleRepositoryTester(b.repoValidator))
|
||||
|
||||
// if running solely CRUD, skip the rest of the setup
|
||||
if b.onlyApiServer {
|
||||
@@ -1449,6 +1465,35 @@ func (b *APIBuilder) asRepository(ctx context.Context, obj runtime.Object, old r
|
||||
return b.repoFactory.Build(ctx, r)
|
||||
}
|
||||
|
||||
func (b *APIBuilder) asConnection(ctx context.Context, obj runtime.Object, old runtime.Object) (connection.Connection, error) {
|
||||
if obj == nil {
|
||||
return nil, fmt.Errorf("missing connection object")
|
||||
}
|
||||
|
||||
c, ok := obj.(*provisioning.Connection)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected connection object")
|
||||
}
|
||||
|
||||
// Copy previous values if they exist
|
||||
if old != nil {
|
||||
o, ok := old.(*provisioning.Connection)
|
||||
if ok && !o.Secure.IsZero() {
|
||||
if c.Secure.PrivateKey.IsZero() {
|
||||
c.Secure.PrivateKey = o.Secure.PrivateKey
|
||||
}
|
||||
if c.Secure.Token.IsZero() {
|
||||
c.Secure.Token = o.Secure.Token
|
||||
}
|
||||
if c.Secure.ClientSecret.IsZero() {
|
||||
c.Secure.ClientSecret = o.Secure.ClientSecret
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return b.connectionFactory.Build(ctx, c)
|
||||
}
|
||||
|
||||
func getJSONResponse(ref string) *spec3.Responses {
|
||||
return &spec3.Responses{
|
||||
ResponsesProps: spec3.ResponsesProps{
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestAPIBuilderValidate(t *testing.T) {
|
||||
repoFactory: factory,
|
||||
allowedTargets: []v0alpha1.SyncTargetType{v0alpha1.SyncTargetTypeFolder},
|
||||
allowImageRendering: false,
|
||||
validator: validator,
|
||||
repoValidator: validator,
|
||||
}
|
||||
|
||||
t.Run("min sync interval is less than 10 seconds", func(t *testing.T) {
|
||||
|
||||
@@ -44,6 +44,7 @@ var provisioningExtras = wire.NewSet(
|
||||
pullrequest.ProvidePullRequestWorker,
|
||||
webhooks.ProvideWebhooksWithImages,
|
||||
extras.ProvideFactoryFromConfig,
|
||||
extras.ProvideConnectionFactoryFromConfig,
|
||||
extras.ProvideProvisioningExtraAPIs,
|
||||
extras.ProvideExtraWorkers,
|
||||
)
|
||||
|
||||
@@ -2,7 +2,9 @@ package correlations
|
||||
|
||||
import (
|
||||
"context"
|
||||
b64 "encoding/base64"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
|
||||
@@ -78,27 +80,53 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO
|
||||
}
|
||||
}
|
||||
|
||||
if options.Continue != "" {
|
||||
return nil, fmt.Errorf("paging not yet supported")
|
||||
page := int64(0)
|
||||
limit := int64(100000)
|
||||
if options != nil {
|
||||
if options.Limit > 0 {
|
||||
limit = options.Limit
|
||||
}
|
||||
if options.Continue != "" {
|
||||
token, err := decodeContinueToken(options.Continue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if token.Limit != limit {
|
||||
return nil, fmt.Errorf("continue token limit does not match the previous request")
|
||||
}
|
||||
page = token.Page
|
||||
}
|
||||
}
|
||||
|
||||
rsp, err := s.service.GetCorrelations(ctx, correlations.GetCorrelationsQuery{
|
||||
OrgId: orgID,
|
||||
Limit: 1000,
|
||||
Limit: limit + 1, // the plus one indicates we have reached the limit
|
||||
Page: page,
|
||||
SourceUIDs: uids,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list := &correlationsV0.CorrelationList{
|
||||
Items: make([]correlationsV0.Correlation, len(rsp.Correlations)),
|
||||
Items: make([]correlationsV0.Correlation, 0, len(rsp.Correlations)),
|
||||
}
|
||||
|
||||
for i, orig := range rsp.Correlations {
|
||||
if i >= int(limit) {
|
||||
remaining := rsp.TotalCount - (page * limit) - int64(len(list.Items))
|
||||
if remaining > 0 {
|
||||
list.RemainingItemCount = &remaining
|
||||
}
|
||||
|
||||
list.Continue = encodeContinueToken(page+1, limit)
|
||||
break
|
||||
}
|
||||
|
||||
c, err := correlations.ToResource(orig, s.namespacer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list.Items[i] = *c
|
||||
list.Items = append(list.Items, *c)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
@@ -193,3 +221,33 @@ func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidatio
|
||||
func (s *legacyStorage) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) {
|
||||
return nil, fmt.Errorf("DeleteCollection for shorturl not implemented")
|
||||
}
|
||||
|
||||
type continueToken struct {
|
||||
Page int64
|
||||
Limit int64
|
||||
}
|
||||
|
||||
func encodeContinueToken(page, limit int64) string {
|
||||
data := fmt.Sprintf("%d/%d", page, limit)
|
||||
return b64.StdEncoding.EncodeToString([]byte(data)) // use base64 so it is not treated like query params
|
||||
}
|
||||
|
||||
func decodeContinueToken(s string) (token continueToken, err error) {
|
||||
decoded, err := b64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return token, fmt.Errorf("invalid continue token")
|
||||
}
|
||||
parts := strings.Split(string(decoded), "/")
|
||||
if len(parts) != 2 {
|
||||
return token, fmt.Errorf("invalid continue token")
|
||||
}
|
||||
token.Page, err = strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil {
|
||||
return token, fmt.Errorf("invalid continue token (page)")
|
||||
}
|
||||
token.Limit, err = strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil {
|
||||
return token, fmt.Errorf("invalid continue token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"github.com/stretchr/testify/mock"
|
||||
|
||||
githubconnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/repository/github"
|
||||
"github.com/grafana/grafana/apps/secret/pkg/decrypt"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
@@ -34,24 +35,26 @@ func ProvideTestEnv(
|
||||
featureMgmt featuremgmt.FeatureToggles,
|
||||
resourceClient resource.ResourceClient,
|
||||
idService auth.IDService,
|
||||
githubFactory *github.Factory,
|
||||
githubRepoFactory *github.Factory,
|
||||
githubConnectionFactory githubconnection.GithubFactory,
|
||||
decryptService decrypt.DecryptService,
|
||||
) (*TestEnv, error) {
|
||||
return &TestEnv{
|
||||
TestingT: testingT,
|
||||
Server: server,
|
||||
SQLStore: db,
|
||||
Cfg: cfg,
|
||||
NotificationService: ns,
|
||||
GRPCServer: grpcServer,
|
||||
PluginRegistry: pluginRegistry,
|
||||
HTTPClientProvider: httpClientProvider,
|
||||
OAuthTokenService: oAuthTokenService,
|
||||
FeatureToggles: featureMgmt,
|
||||
ResourceClient: resourceClient,
|
||||
IDService: idService,
|
||||
GitHubFactory: githubFactory,
|
||||
DecryptService: decryptService,
|
||||
TestingT: testingT,
|
||||
Server: server,
|
||||
SQLStore: db,
|
||||
Cfg: cfg,
|
||||
NotificationService: ns,
|
||||
GRPCServer: grpcServer,
|
||||
PluginRegistry: pluginRegistry,
|
||||
HTTPClientProvider: httpClientProvider,
|
||||
OAuthTokenService: oAuthTokenService,
|
||||
FeatureToggles: featureMgmt,
|
||||
ResourceClient: resourceClient,
|
||||
IDService: idService,
|
||||
GithubRepoFactory: githubRepoFactory,
|
||||
GithubConnectionFactory: githubConnectionFactory,
|
||||
DecryptService: decryptService,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -60,18 +63,19 @@ type TestEnv struct {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}
|
||||
Server *Server
|
||||
SQLStore db.DB
|
||||
Cfg *setting.Cfg
|
||||
NotificationService *notifications.NotificationServiceMock
|
||||
GRPCServer grpcserver.Provider
|
||||
PluginRegistry registry.Service
|
||||
HTTPClientProvider httpclient.Provider
|
||||
OAuthTokenService *oauthtokentest.Service
|
||||
RequestMiddleware web.Middleware
|
||||
FeatureToggles featuremgmt.FeatureToggles
|
||||
ResourceClient resource.ResourceClient
|
||||
IDService auth.IDService
|
||||
GitHubFactory *github.Factory
|
||||
DecryptService decrypt.DecryptService
|
||||
Server *Server
|
||||
SQLStore db.DB
|
||||
Cfg *setting.Cfg
|
||||
NotificationService *notifications.NotificationServiceMock
|
||||
GRPCServer grpcserver.Provider
|
||||
PluginRegistry registry.Service
|
||||
HTTPClientProvider httpclient.Provider
|
||||
OAuthTokenService *oauthtokentest.Service
|
||||
RequestMiddleware web.Middleware
|
||||
FeatureToggles featuremgmt.FeatureToggles
|
||||
ResourceClient resource.ResourceClient
|
||||
IDService auth.IDService
|
||||
GithubRepoFactory *github.Factory
|
||||
GithubConnectionFactory githubconnection.GithubFactory
|
||||
DecryptService decrypt.DecryptService
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
|
||||
ghconnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/repository/github"
|
||||
"github.com/grafana/grafana/pkg/api"
|
||||
"github.com/grafana/grafana/pkg/api/avatar"
|
||||
@@ -297,6 +298,7 @@ var wireBasicSet = wire.NewSet(
|
||||
notifications.ProvideService,
|
||||
notifications.ProvideSmtpService,
|
||||
github.ProvideFactory,
|
||||
ghconnection.ProvideFactory,
|
||||
tracing.ProvideService,
|
||||
tracing.ProvideTracingConfig,
|
||||
wire.Bind(new(tracing.Tracer), new(*tracing.TracingService)),
|
||||
|
||||
21
pkg/server/wire_gen.go
generated
21
pkg/server/wire_gen.go
generated
File diff suppressed because one or more lines are too long
@@ -72,6 +72,7 @@ import (
|
||||
|
||||
var provisioningExtras = wire.NewSet(
|
||||
extras.ProvideProvisioningOSSRepositoryExtras,
|
||||
extras.ProvideProvisioningOSSConnectionExtras,
|
||||
)
|
||||
|
||||
var configProviderExtras = wire.NewSet(
|
||||
|
||||
@@ -133,7 +133,11 @@ type FeatureFlag struct {
|
||||
Stage FeatureFlagStage `json:"stage,omitempty"`
|
||||
Owner codeowner `json:"-"` // Owner person or team that owns this feature flag
|
||||
|
||||
// CEL-GO expression. Using the value "true" will mean this is on by default
|
||||
// Expression defined by the feature_toggles configuration.
|
||||
// Supports multiple types including boolean, string, integer, float,
|
||||
// and structured values following the OpenFeature specification.
|
||||
// Using the value "true" means the feature flag is enabled by default,
|
||||
// Using the value "1.0" means the default value of the feature flag is 1.0
|
||||
Expression string `json:"expression,omitempty"`
|
||||
|
||||
// Special behavior properties
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
clientauthmiddleware "github.com/grafana/grafana/pkg/clientauth/middleware"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/open-feature/go-sdk/openfeature/memprovider"
|
||||
|
||||
sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
|
||||
"github.com/open-feature/go-sdk/openfeature"
|
||||
@@ -26,7 +27,7 @@ type OpenFeatureConfig struct {
|
||||
// HTTPClient is a pre-configured HTTP client (optional, used by features-service + OFREP providers)
|
||||
HTTPClient *http.Client
|
||||
// StaticFlags are the feature flags to use with static provider
|
||||
StaticFlags map[string]bool
|
||||
StaticFlags map[string]memprovider.InMemoryFlag
|
||||
// TargetingKey is used for evaluation context
|
||||
TargetingKey string
|
||||
// ContextAttrs are additional attributes for evaluation context
|
||||
@@ -100,7 +101,7 @@ func InitOpenFeatureWithCfg(cfg *setting.Cfg) error {
|
||||
func createProvider(
|
||||
providerType string,
|
||||
u *url.URL,
|
||||
staticFlags map[string]bool,
|
||||
staticFlags map[string]memprovider.InMemoryFlag,
|
||||
httpClient *http.Client,
|
||||
) (openfeature.FeatureProvider, error) {
|
||||
if providerType == setting.FeaturesServiceProviderType || providerType == setting.OFREPProviderType {
|
||||
@@ -117,7 +118,7 @@ func createProvider(
|
||||
}
|
||||
}
|
||||
|
||||
return newStaticProvider(staticFlags)
|
||||
return newStaticProvider(staticFlags, standardFeatureFlags)
|
||||
}
|
||||
|
||||
func createHTTPClient(m *clientauthmiddleware.TokenExchangeMiddleware) (*http.Client, error) {
|
||||
|
||||
@@ -1031,13 +1031,6 @@ var (
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaObservabilityLogsSquad,
|
||||
},
|
||||
{
|
||||
Name: "exploreLogsLimitedTimeRange",
|
||||
Description: "Used in Logs Drilldown to limit the time range",
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaObservabilityLogsSquad,
|
||||
},
|
||||
{
|
||||
Name: "appPlatformGrpcClientAuth",
|
||||
Description: "Enables the gRPC client to authenticate with the App Platform by using ID & access tokens",
|
||||
@@ -1148,14 +1141,6 @@ var (
|
||||
Owner: identityAccessTeam,
|
||||
HideFromDocs: true,
|
||||
},
|
||||
{
|
||||
Name: "exploreMetricsRelatedLogs",
|
||||
Description: "Display Related Logs in Grafana Metrics Drilldown",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaObservabilityMetricsSquad,
|
||||
FrontendOnly: true,
|
||||
HideFromDocs: false,
|
||||
},
|
||||
{
|
||||
Name: "prometheusSpecialCharsInLabelValues",
|
||||
Description: "Adds support for quotes and special characters in label values for Prometheus queries",
|
||||
|
||||
@@ -47,7 +47,8 @@ func ProvideManagerService(cfg *setting.Cfg) (*FeatureManager, error) {
|
||||
}
|
||||
mgmt.warnings[key] = "unknown flag in config"
|
||||
}
|
||||
mgmt.startup[key] = val
|
||||
|
||||
mgmt.startup[key] = val.Variants[val.DefaultVariant] == true
|
||||
}
|
||||
|
||||
// update the values
|
||||
|
||||
@@ -29,7 +29,7 @@ func CreateStaticEvaluator(cfg *setting.Cfg) (StaticFlagEvaluator, error) {
|
||||
return nil, fmt.Errorf("failed to read feature flags from config: %w", err)
|
||||
}
|
||||
|
||||
staticProvider, err := newStaticProvider(staticFlags)
|
||||
staticProvider, err := newStaticProvider(staticFlags, standardFeatureFlags)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create static provider: %w", err)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package featuremgmt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
|
||||
"github.com/open-feature/go-sdk/openfeature"
|
||||
"github.com/open-feature/go-sdk/openfeature/memprovider"
|
||||
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
// inMemoryBulkProvider is a wrapper around memprovider.InMemoryProvider that
|
||||
@@ -28,37 +33,21 @@ func (p *inMemoryBulkProvider) ListFlags() ([]string, error) {
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func newStaticProvider(confFlags map[string]bool) (openfeature.FeatureProvider, error) {
|
||||
flags := make(map[string]memprovider.InMemoryFlag, len(standardFeatureFlags))
|
||||
func newStaticProvider(confFlags map[string]memprovider.InMemoryFlag, standardFlags []FeatureFlag) (openfeature.FeatureProvider, error) {
|
||||
flags := make(map[string]memprovider.InMemoryFlag, len(standardFlags))
|
||||
|
||||
// Parse and add standard flags
|
||||
for _, flag := range standardFlags {
|
||||
inMemFlag, err := setting.ParseFlag(flag.Name, flag.Expression)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse flag %s: %w", flag.Name, err)
|
||||
}
|
||||
|
||||
flags[flag.Name] = inMemFlag
|
||||
}
|
||||
|
||||
// Add flags from config.ini file
|
||||
for name, value := range confFlags {
|
||||
flags[name] = createInMemoryFlag(name, value)
|
||||
}
|
||||
|
||||
// Add standard flags
|
||||
for _, flag := range standardFeatureFlags {
|
||||
if _, exists := flags[flag.Name]; !exists {
|
||||
enabled := flag.Expression == "true"
|
||||
flags[flag.Name] = createInMemoryFlag(flag.Name, enabled)
|
||||
}
|
||||
}
|
||||
maps.Copy(flags, confFlags)
|
||||
|
||||
return newInMemoryBulkProvider(flags), nil
|
||||
}
|
||||
|
||||
func createInMemoryFlag(name string, enabled bool) memprovider.InMemoryFlag {
|
||||
variant := "disabled"
|
||||
if enabled {
|
||||
variant = "enabled"
|
||||
}
|
||||
|
||||
return memprovider.InMemoryFlag{
|
||||
Key: name,
|
||||
DefaultVariant: variant,
|
||||
Variants: map[string]interface{}{
|
||||
"enabled": true,
|
||||
"disabled": false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/open-feature/go-sdk/openfeature/memprovider"
|
||||
|
||||
"github.com/open-feature/go-sdk/openfeature"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -93,3 +94,144 @@ ABCD = true
|
||||
enabledFeatureManager := mgr.GetEnabled(ctx)
|
||||
assert.Equal(t, openFeatureEnabledFlags, enabledFeatureManager)
|
||||
}
|
||||
|
||||
func Test_StaticProvider_TypedFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
flags FeatureFlag
|
||||
defaultValue any
|
||||
expectedValue any
|
||||
}{
|
||||
{
|
||||
flags: FeatureFlag{
|
||||
Name: "Flag",
|
||||
Expression: "true",
|
||||
},
|
||||
defaultValue: false,
|
||||
expectedValue: true,
|
||||
},
|
||||
{
|
||||
flags: FeatureFlag{
|
||||
Name: "Flag",
|
||||
Expression: "1.0",
|
||||
},
|
||||
defaultValue: 0.0,
|
||||
expectedValue: 1.0,
|
||||
},
|
||||
{
|
||||
flags: FeatureFlag{
|
||||
Name: "Flag",
|
||||
Expression: "blue",
|
||||
},
|
||||
defaultValue: "red",
|
||||
expectedValue: "blue",
|
||||
},
|
||||
{
|
||||
flags: FeatureFlag{
|
||||
Name: "Flag",
|
||||
Expression: "1",
|
||||
},
|
||||
defaultValue: int64(0),
|
||||
expectedValue: int64(1),
|
||||
},
|
||||
{
|
||||
flags: FeatureFlag{
|
||||
Name: "Flag",
|
||||
Expression: `{ "foo": "bar" }`,
|
||||
},
|
||||
expectedValue: map[string]any{"foo": "bar"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
provider, err := newStaticProvider(nil, []FeatureFlag{tt.flags})
|
||||
assert.NoError(t, err)
|
||||
|
||||
var result any
|
||||
switch tt.expectedValue.(type) {
|
||||
case bool:
|
||||
result = provider.BooleanEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(bool), openfeature.FlattenedContext{}).Value
|
||||
case float64:
|
||||
result = provider.FloatEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(float64), openfeature.FlattenedContext{}).Value
|
||||
case string:
|
||||
result = provider.StringEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(string), openfeature.FlattenedContext{}).Value
|
||||
case int64:
|
||||
result = provider.IntEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(int64), openfeature.FlattenedContext{}).Value
|
||||
case map[string]any:
|
||||
result = provider.ObjectEvaluation(t.Context(), tt.flags.Name, tt.defaultValue, openfeature.FlattenedContext{}).Value
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.expectedValue, result)
|
||||
}
|
||||
}
|
||||
func Test_StaticProvider_ConfigOverride(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
originalValue string
|
||||
configValue any
|
||||
}{
|
||||
{
|
||||
name: "bool",
|
||||
originalValue: "false",
|
||||
configValue: true,
|
||||
},
|
||||
{
|
||||
name: "int",
|
||||
originalValue: "0",
|
||||
configValue: int64(1),
|
||||
},
|
||||
{
|
||||
name: "float",
|
||||
originalValue: "0.0",
|
||||
configValue: 1.0,
|
||||
},
|
||||
{
|
||||
name: "string",
|
||||
originalValue: "foo",
|
||||
configValue: "bar",
|
||||
},
|
||||
{
|
||||
name: "structure",
|
||||
originalValue: "{}",
|
||||
configValue: make(map[string]any),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
configFlags, standardFlags := makeFlags(tt)
|
||||
provider, err := newStaticProvider(configFlags, standardFlags)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var result any
|
||||
switch tt.configValue.(type) {
|
||||
case bool:
|
||||
result = provider.BooleanEvaluation(t.Context(), tt.name, false, openfeature.FlattenedContext{}).Value
|
||||
case float64:
|
||||
result = provider.FloatEvaluation(t.Context(), tt.name, 0.0, openfeature.FlattenedContext{}).Value
|
||||
case string:
|
||||
result = provider.StringEvaluation(t.Context(), tt.name, "foo", openfeature.FlattenedContext{}).Value
|
||||
case int64:
|
||||
result = provider.IntEvaluation(t.Context(), tt.name, 1, openfeature.FlattenedContext{}).Value
|
||||
case map[string]any:
|
||||
result = provider.ObjectEvaluation(t.Context(), tt.name, make(map[string]any), openfeature.FlattenedContext{}).Value
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.configValue, result)
|
||||
}
|
||||
}
|
||||
|
||||
func makeFlags(tt struct {
|
||||
name string
|
||||
originalValue string
|
||||
configValue any
|
||||
}) (map[string]memprovider.InMemoryFlag, []FeatureFlag) {
|
||||
orig := FeatureFlag{
|
||||
Name: tt.name,
|
||||
Expression: tt.originalValue,
|
||||
}
|
||||
|
||||
config := map[string]memprovider.InMemoryFlag{
|
||||
tt.name: setting.NewInMemoryFlag(tt.name, tt.configValue),
|
||||
}
|
||||
|
||||
return config, []FeatureFlag{orig}
|
||||
}
|
||||
|
||||
2
pkg/services/featuremgmt/toggles_gen.csv
generated
2
pkg/services/featuremgmt/toggles_gen.csv
generated
@@ -142,7 +142,6 @@ vizActionsAuth,preview,@grafana/dataviz-squad,false,false,true
|
||||
alertingPrometheusRulesPrimary,experimental,@grafana/alerting-squad,false,false,true
|
||||
exploreLogsShardSplitting,experimental,@grafana/observability-logs,false,false,true
|
||||
exploreLogsAggregatedMetrics,experimental,@grafana/observability-logs,false,false,true
|
||||
exploreLogsLimitedTimeRange,experimental,@grafana/observability-logs,false,false,true
|
||||
appPlatformGrpcClientAuth,experimental,@grafana/identity-access-team,false,false,false
|
||||
groupAttributeSync,privatePreview,@grafana/identity-access-team,false,false,false
|
||||
alertingQueryAndExpressionsStepMode,GA,@grafana/alerting-squad,false,false,true
|
||||
@@ -159,7 +158,6 @@ newTimeRangeZoomShortcuts,experimental,@grafana/dataviz-squad,false,false,true
|
||||
azureMonitorDisableLogLimit,GA,@grafana/partner-datasources,false,false,false
|
||||
playlistsReconciler,experimental,@grafana/grafana-app-platform-squad,false,true,false
|
||||
passwordlessMagicLinkAuthentication,experimental,@grafana/identity-access-team,false,false,false
|
||||
exploreMetricsRelatedLogs,experimental,@grafana/observability-metrics,false,false,true
|
||||
prometheusSpecialCharsInLabelValues,experimental,@grafana/oss-big-tent,false,false,true
|
||||
enableExtensionsAdminPage,experimental,@grafana/plugins-platform-backend,false,true,false
|
||||
enableSCIM,preview,@grafana/identity-access-team,false,false,false
|
||||
|
||||
|
6
pkg/services/featuremgmt/toggles_gen.json
generated
6
pkg/services/featuremgmt/toggles_gen.json
generated
@@ -1382,7 +1382,8 @@
|
||||
"metadata": {
|
||||
"name": "exploreLogsLimitedTimeRange",
|
||||
"resourceVersion": "1764664939750",
|
||||
"creationTimestamp": "2024-08-29T13:55:59Z"
|
||||
"creationTimestamp": "2024-08-29T13:55:59Z",
|
||||
"deletionTimestamp": "2026-01-12T22:18:14Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Used in Logs Drilldown to limit the time range",
|
||||
@@ -1408,7 +1409,8 @@
|
||||
"metadata": {
|
||||
"name": "exploreMetricsRelatedLogs",
|
||||
"resourceVersion": "1764664939750",
|
||||
"creationTimestamp": "2024-11-05T16:28:43Z"
|
||||
"creationTimestamp": "2024-11-05T16:28:43Z",
|
||||
"deletionTimestamp": "2026-01-09T22:14:53Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Display Related Logs in Grafana Metrics Drilldown",
|
||||
|
||||
@@ -190,9 +190,6 @@ func verifyFlagsConfiguration(t *testing.T) {
|
||||
if flag.Stage == FeatureStageGeneralAvailability && flag.Expression == "" {
|
||||
t.Errorf("GA features must be explicitly enabled or disabled, please add the `Expression` property for %s", flag.Name)
|
||||
}
|
||||
if flag.Expression != "" && flag.Expression != "true" && flag.Expression != "false" {
|
||||
t.Errorf("the `Expression` property for %s is incorrect. valid values are: `true`, `false` or empty string for default", flag.Name)
|
||||
}
|
||||
// Check camel case names
|
||||
if flag.Name != strcase.ToLowerCamel(flag.Name) && !legacyNames[flag.Name] {
|
||||
invalidNames = append(invalidNames, flag.Name)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/open-feature/go-sdk/openfeature"
|
||||
"github.com/open-feature/go-sdk/openfeature/memprovider"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
@@ -378,8 +379,10 @@ func setupOpenFeatureProvider(t *testing.T, flagValue bool) {
|
||||
|
||||
err := featuremgmt.InitOpenFeature(featuremgmt.OpenFeatureConfig{
|
||||
ProviderType: setting.StaticProviderType,
|
||||
StaticFlags: map[string]bool{
|
||||
featuremgmt.FlagPluginsAutoUpdate: flagValue,
|
||||
StaticFlags: map[string]memprovider.InMemoryFlag{
|
||||
featuremgmt.FlagPluginsAutoUpdate: {
|
||||
Key: featuremgmt.FlagPluginsAutoUpdate, Variants: map[string]any{"": flagValue},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
package setting
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
|
||||
"github.com/open-feature/go-sdk/openfeature/memprovider"
|
||||
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
// DefaultVariantName a placeholder name for config-based Feature Flags
|
||||
const DefaultVariantName = "default"
|
||||
|
||||
// Deprecated: should use `featuremgmt.FeatureToggles`
|
||||
func (cfg *Cfg) readFeatureToggles(iniFile *ini.File) error {
|
||||
section := iniFile.Section("feature_toggles")
|
||||
@@ -15,18 +22,27 @@ func (cfg *Cfg) readFeatureToggles(iniFile *ini.File) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// TODO IsFeatureToggleEnabled has been deprecated for 2 years now, we should remove this function completely
|
||||
// nolint:staticcheck
|
||||
cfg.IsFeatureToggleEnabled = func(key string) bool { return toggles[key] }
|
||||
cfg.IsFeatureToggleEnabled = func(key string) bool {
|
||||
toggle, ok := toggles[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
value, ok := toggle.Variants[toggle.DefaultVariant].(bool)
|
||||
return value && ok
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[string]bool, error) {
|
||||
featureToggles := make(map[string]bool, 10)
|
||||
func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[string]memprovider.InMemoryFlag, error) {
|
||||
featureToggles := make(map[string]memprovider.InMemoryFlag, 10)
|
||||
|
||||
// parse the comma separated list in `enable`.
|
||||
featuresTogglesStr := valueAsString(featureTogglesSection, "enable", "")
|
||||
for _, feature := range util.SplitString(featuresTogglesStr) {
|
||||
featureToggles[feature] = true
|
||||
featureToggles[feature] = memprovider.InMemoryFlag{Key: feature, DefaultVariant: DefaultVariantName, Variants: map[string]any{DefaultVariantName: true}}
|
||||
}
|
||||
|
||||
// read all other settings under [feature_toggles]. If a toggle is
|
||||
@@ -36,7 +52,7 @@ func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[str
|
||||
continue
|
||||
}
|
||||
|
||||
b, err := strconv.ParseBool(v.Value())
|
||||
b, err := ParseFlag(v.Name(), v.Value())
|
||||
if err != nil {
|
||||
return featureToggles, err
|
||||
}
|
||||
@@ -45,3 +61,57 @@ func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[str
|
||||
}
|
||||
return featureToggles, nil
|
||||
}
|
||||
|
||||
func ParseFlag(name, value string) (memprovider.InMemoryFlag, error) {
|
||||
var structure map[string]any
|
||||
|
||||
if integer, err := strconv.Atoi(value); err == nil {
|
||||
return NewInMemoryFlag(name, integer), nil
|
||||
}
|
||||
if float, err := strconv.ParseFloat(value, 64); err == nil {
|
||||
return NewInMemoryFlag(name, float), nil
|
||||
}
|
||||
if err := json.Unmarshal([]byte(value), &structure); err == nil {
|
||||
return NewInMemoryFlag(name, structure), nil
|
||||
}
|
||||
if boolean, err := strconv.ParseBool(value); err == nil {
|
||||
return NewInMemoryFlag(name, boolean), nil
|
||||
}
|
||||
|
||||
return NewInMemoryFlag(name, value), nil
|
||||
}
|
||||
|
||||
func NewInMemoryFlag(name string, value any) memprovider.InMemoryFlag {
|
||||
return memprovider.InMemoryFlag{Key: name, DefaultVariant: DefaultVariantName, Variants: map[string]any{DefaultVariantName: value}}
|
||||
}
|
||||
|
||||
func AsStringMap(m map[string]memprovider.InMemoryFlag) map[string]string {
|
||||
var res = map[string]string{}
|
||||
for k, v := range m {
|
||||
res[k] = serializeFlagValue(v)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func serializeFlagValue(flag memprovider.InMemoryFlag) string {
|
||||
value := flag.Variants[flag.DefaultVariant]
|
||||
|
||||
switch castedValue := value.(type) {
|
||||
case bool:
|
||||
return strconv.FormatBool(castedValue)
|
||||
case int64:
|
||||
return strconv.FormatInt(castedValue, 10)
|
||||
case float64:
|
||||
// handle cases with a single or no zeros after the decimal point
|
||||
if math.Trunc(castedValue) == castedValue {
|
||||
return strconv.FormatFloat(castedValue, 'f', 1, 64)
|
||||
}
|
||||
|
||||
return strconv.FormatFloat(castedValue, 'g', -1, 64)
|
||||
case string:
|
||||
return castedValue
|
||||
default:
|
||||
val, _ := json.Marshal(value)
|
||||
return string(val)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package setting
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/open-feature/go-sdk/openfeature/memprovider"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
@@ -12,17 +14,16 @@ func TestFeatureToggles(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
conf map[string]string
|
||||
err error
|
||||
expectedToggles map[string]bool
|
||||
expectedToggles map[string]memprovider.InMemoryFlag
|
||||
}{
|
||||
{
|
||||
name: "can parse feature toggles passed in the `enable` array",
|
||||
conf: map[string]string{
|
||||
"enable": "feature1,feature2",
|
||||
},
|
||||
expectedToggles: map[string]bool{
|
||||
"feature1": true,
|
||||
"feature2": true,
|
||||
expectedToggles: map[string]memprovider.InMemoryFlag{
|
||||
"feature1": NewInMemoryFlag("feature1", true),
|
||||
"feature2": NewInMemoryFlag("feature2", true),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -31,10 +32,10 @@ func TestFeatureToggles(t *testing.T) {
|
||||
"enable": "feature1,feature2",
|
||||
"feature3": "true",
|
||||
},
|
||||
expectedToggles: map[string]bool{
|
||||
"feature1": true,
|
||||
"feature2": true,
|
||||
"feature3": true,
|
||||
expectedToggles: map[string]memprovider.InMemoryFlag{
|
||||
"feature1": NewInMemoryFlag("feature1", true),
|
||||
"feature2": NewInMemoryFlag("feature2", true),
|
||||
"feature3": NewInMemoryFlag("feature3", true),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -43,19 +44,26 @@ func TestFeatureToggles(t *testing.T) {
|
||||
"enable": "feature1,feature2",
|
||||
"feature2": "false",
|
||||
},
|
||||
expectedToggles: map[string]bool{
|
||||
"feature1": true,
|
||||
"feature2": false,
|
||||
expectedToggles: map[string]memprovider.InMemoryFlag{
|
||||
"feature1": NewInMemoryFlag("feature1", true),
|
||||
"feature2": NewInMemoryFlag("feature2", false),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid boolean value should return syntax error",
|
||||
name: "feature flags of different types are handled correctly",
|
||||
conf: map[string]string{
|
||||
"enable": "feature1,feature2",
|
||||
"feature2": "invalid",
|
||||
"feature1": "1", "feature2": "1.0",
|
||||
"feature3": `{"foo":"bar"}`, "feature4": "bar",
|
||||
"feature5": "t", "feature6": "T",
|
||||
},
|
||||
expectedToggles: map[string]memprovider.InMemoryFlag{
|
||||
"feature1": NewInMemoryFlag("feature1", 1),
|
||||
"feature2": NewInMemoryFlag("feature2", 1.0),
|
||||
"feature3": NewInMemoryFlag("feature3", map[string]any{"foo": "bar"}),
|
||||
"feature4": NewInMemoryFlag("feature4", "bar"),
|
||||
"feature5": NewInMemoryFlag("feature5", true),
|
||||
"feature6": NewInMemoryFlag("feature6", true),
|
||||
},
|
||||
expectedToggles: map[string]bool{},
|
||||
err: strconv.ErrSyntax,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -69,12 +77,35 @@ func TestFeatureToggles(t *testing.T) {
|
||||
}
|
||||
|
||||
featureToggles, err := ReadFeatureTogglesFromInitFile(toggles)
|
||||
require.ErrorIs(t, err, tc.err)
|
||||
require.NoError(t, err)
|
||||
|
||||
if err == nil {
|
||||
for k, v := range featureToggles {
|
||||
require.Equal(t, tc.expectedToggles[k], v, tc.name)
|
||||
}
|
||||
for k, v := range featureToggles {
|
||||
toggle := tc.expectedToggles[k]
|
||||
require.Equal(t, toggle, v, tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagValueSerialization(t *testing.T) {
|
||||
testCases := []memprovider.InMemoryFlag{
|
||||
NewInMemoryFlag("int", 1),
|
||||
NewInMemoryFlag("1.0f", 1.0),
|
||||
NewInMemoryFlag("1.01f", 1.01),
|
||||
NewInMemoryFlag("1.10f", 1.10),
|
||||
NewInMemoryFlag("struct", map[string]any{"foo": "bar"}),
|
||||
NewInMemoryFlag("string", "bar"),
|
||||
NewInMemoryFlag("true", true),
|
||||
NewInMemoryFlag("false", false),
|
||||
}
|
||||
|
||||
for _, tt := range testCases {
|
||||
asStringMap := AsStringMap(map[string]memprovider.InMemoryFlag{tt.Key: tt})
|
||||
|
||||
deserialized, err := ParseFlag(tt.Key, asStringMap[tt.Key])
|
||||
assert.NoError(t, err)
|
||||
|
||||
if diff := cmp.Diff(tt, deserialized); diff != "" {
|
||||
t.Errorf("(-want, +got) = %v", diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,13 +78,13 @@ func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event {
|
||||
cache := gocache.New(cacheTTL, cacheCleanupInterval)
|
||||
events := make(chan Event, opts.BufferSize)
|
||||
|
||||
initialRV, err := n.lastEventResourceVersion(ctx)
|
||||
lastRV, err := n.lastEventResourceVersion(ctx)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
initialRV = snowflakeFromTime(time.Now()) // No events yet, start from the beginning
|
||||
lastRV = 0 // No events yet, start from the beginning
|
||||
} else if err != nil {
|
||||
n.log.Error("Failed to get last event resource version", "error", err)
|
||||
}
|
||||
lastRV := initialRV + 1 // We want to start watching from the next event
|
||||
lastRV = lastRV + 1 // We want to start watching from the next event
|
||||
|
||||
go func() {
|
||||
defer close(events)
|
||||
@@ -110,7 +110,7 @@ func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event {
|
||||
}
|
||||
|
||||
// Skip old events lower than the requested resource version
|
||||
if evt.ResourceVersion <= initialRV {
|
||||
if evt.ResourceVersion < lastRV {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ func setupTestNotifier(t *testing.T) (*notifier, *eventStore) {
|
||||
return notifier, eventStore
|
||||
}
|
||||
|
||||
// nolint:unused
|
||||
func setupTestNotifierSqlKv(t *testing.T) (*notifier, *eventStore) {
|
||||
dbstore := db.InitTestDB(t)
|
||||
eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil)
|
||||
@@ -60,8 +59,7 @@ func runNotifierTestWith(t *testing.T, storeName string, newStoreFn func(*testin
|
||||
|
||||
func TestNotifier_lastEventResourceVersion(t *testing.T) {
|
||||
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierLastEventResourceVersion)
|
||||
// enable this when sqlkv is ready
|
||||
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierLastEventResourceVersion)
|
||||
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierLastEventResourceVersion)
|
||||
}
|
||||
|
||||
func testNotifierLastEventResourceVersion(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
|
||||
@@ -112,8 +110,7 @@ func testNotifierLastEventResourceVersion(t *testing.T, ctx context.Context, not
|
||||
|
||||
func TestNotifier_cachekey(t *testing.T) {
|
||||
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierCachekey)
|
||||
// enable this when sqlkv is ready
|
||||
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierCachekey)
|
||||
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierCachekey)
|
||||
}
|
||||
|
||||
func testNotifierCachekey(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
|
||||
@@ -167,8 +164,7 @@ func testNotifierCachekey(t *testing.T, ctx context.Context, notifier *notifier,
|
||||
|
||||
func TestNotifier_Watch_NoEvents(t *testing.T) {
|
||||
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchNoEvents)
|
||||
// enable this when sqlkv is ready
|
||||
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchNoEvents)
|
||||
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchNoEvents)
|
||||
}
|
||||
|
||||
func testNotifierWatchNoEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
|
||||
@@ -209,8 +205,7 @@ func testNotifierWatchNoEvents(t *testing.T, ctx context.Context, notifier *noti
|
||||
|
||||
func TestNotifier_Watch_WithExistingEvents(t *testing.T) {
|
||||
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchWithExistingEvents)
|
||||
// enable this when sqlkv is ready
|
||||
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchWithExistingEvents)
|
||||
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchWithExistingEvents)
|
||||
}
|
||||
|
||||
func testNotifierWatchWithExistingEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
|
||||
@@ -284,8 +279,7 @@ func testNotifierWatchWithExistingEvents(t *testing.T, ctx context.Context, noti
|
||||
|
||||
func TestNotifier_Watch_EventDeduplication(t *testing.T) {
|
||||
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchEventDeduplication)
|
||||
// enable this when sqlkv is ready
|
||||
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchEventDeduplication)
|
||||
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchEventDeduplication)
|
||||
}
|
||||
|
||||
func testNotifierWatchEventDeduplication(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
|
||||
@@ -351,8 +345,7 @@ func testNotifierWatchEventDeduplication(t *testing.T, ctx context.Context, noti
|
||||
|
||||
func TestNotifier_Watch_ContextCancellation(t *testing.T) {
|
||||
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchContextCancellation)
|
||||
// enable this when sqlkv is ready
|
||||
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchContextCancellation)
|
||||
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchContextCancellation)
|
||||
}
|
||||
|
||||
func testNotifierWatchContextCancellation(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
|
||||
@@ -398,8 +391,7 @@ func testNotifierWatchContextCancellation(t *testing.T, ctx context.Context, not
|
||||
|
||||
func TestNotifier_Watch_MultipleEvents(t *testing.T) {
|
||||
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchMultipleEvents)
|
||||
// enable this when sqlkv is ready
|
||||
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchMultipleEvents)
|
||||
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchMultipleEvents)
|
||||
}
|
||||
|
||||
func testNotifierWatchMultipleEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
|
||||
|
||||
@@ -346,7 +346,8 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in
|
||||
return 0, fmt.Errorf("failed to write data: %w", err)
|
||||
}
|
||||
|
||||
dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(rv)
|
||||
rv = rvmanager.SnowflakeFromRv(rv)
|
||||
dataKey.ResourceVersion = rv
|
||||
} else {
|
||||
err := k.dataStore.Save(ctx, dataKey, bytes.NewReader(event.Value))
|
||||
if err != nil {
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/snowflake"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
claims "github.com/grafana/authlib/types"
|
||||
@@ -82,6 +81,12 @@ func RunSQLStorageBackendCompatibilityTest(t *testing.T, newSqlBackend, newKvBac
|
||||
|
||||
kvbackend, db := newKvBackend(t.Context())
|
||||
sqlbackend, _ := newSqlBackend(t.Context())
|
||||
|
||||
// Skip on SQLite due to concurrency limitations
|
||||
if db.DriverName() == "sqlite3" {
|
||||
t.Skip("Skipping concurrent operations stress test on SQLite")
|
||||
}
|
||||
|
||||
tc.fn(t, sqlbackend, kvbackend, opts.NSPrefix, db)
|
||||
})
|
||||
}
|
||||
@@ -187,13 +192,30 @@ func runKeyPathTest(t *testing.T, backend resource.StorageBackend, nsPrefix stri
|
||||
|
||||
// verifyKeyPath is a helper function to verify key_path generation
|
||||
func verifyKeyPath(t *testing.T, db sqldb.DB, ctx context.Context, key *resourcepb.ResourceKey, action string, resourceVersion int64, expectedFolder string) {
|
||||
// For SQL backend (namespace contains "-sql"), resourceVersion is in microsecond format
|
||||
// but key_path stores snowflake RV, so convert to snowflake
|
||||
// For KV backend (namespace contains "-kv"), resourceVersion is already in snowflake format
|
||||
isSqlBackend := strings.Contains(key.Namespace, "-sql")
|
||||
|
||||
var keyPathRV int64
|
||||
if isSqlBackend {
|
||||
// Convert microsecond RV to snowflake for key_path construction
|
||||
keyPathRV = rvmanager.SnowflakeFromRv(resourceVersion)
|
||||
} else {
|
||||
// KV backend already provides snowflake RV
|
||||
keyPathRV = resourceVersion
|
||||
}
|
||||
|
||||
// Build the expected key_path using DataKey format: unified/data/group/resource/namespace/name/resourceVersion~action~folder
|
||||
expectedKeyPath := fmt.Sprintf("unified/data/%s/%s/%s/%s/%d~%s~%s", key.Group, key.Resource, key.Namespace, key.Name, keyPathRV, action, expectedFolder)
|
||||
|
||||
var query string
|
||||
if db.DriverName() == "postgres" {
|
||||
query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE namespace = $1 AND name = $2 AND resource_version = $3"
|
||||
query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE key_path = $1"
|
||||
} else {
|
||||
query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE namespace = ? AND name = ? AND resource_version = ?"
|
||||
query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE key_path = ?"
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, query, key.Namespace, key.Name, resourceVersion)
|
||||
rows, err := db.QueryContext(ctx, query, expectedKeyPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, rows.Next(), "Resource not found in resource_history table - both SQL and KV backends should write to this table")
|
||||
@@ -220,10 +242,6 @@ func verifyKeyPath(t *testing.T, db sqldb.DB, ctx context.Context, key *resource
|
||||
// Verify action suffix
|
||||
require.Contains(t, keyPath, fmt.Sprintf("~%s~", action))
|
||||
|
||||
// Verify snowflake calculation
|
||||
expectedSnowflake := (((resourceVersion / 1000) - snowflake.Epoch) << (snowflake.NodeBits + snowflake.StepBits)) + (resourceVersion % 1000)
|
||||
require.Contains(t, keyPath, fmt.Sprintf("/%d~", expectedSnowflake), "actual RV: %d", actualRV)
|
||||
|
||||
// Verify folder if specified
|
||||
if expectedFolder != "" {
|
||||
require.Equal(t, expectedFolder, actualFolder)
|
||||
@@ -492,10 +510,10 @@ func verifyResourceHistoryRecord(t *testing.T, record ResourceHistoryRecord, exp
|
||||
}
|
||||
|
||||
// Validate previous_resource_version
|
||||
// For KV backend operations, resource versions are stored as snowflake format
|
||||
// but expectedPrevRV is in microsecond format, so we need to use IsRvEqual for comparison
|
||||
// For KV backend operations, expectedPrevRV is now in snowflake format (returned by KV backend)
|
||||
// but resource_history table stores microsecond RV, so we need to use IsRvEqual for comparison
|
||||
if strings.Contains(record.Namespace, "-kv") {
|
||||
require.True(t, rvmanager.IsRvEqual(record.PreviousResourceVersion, expectedPrevRV),
|
||||
require.True(t, rvmanager.IsRvEqual(expectedPrevRV, record.PreviousResourceVersion),
|
||||
"Previous resource version should match (KV backend snowflake format)")
|
||||
} else {
|
||||
require.Equal(t, expectedPrevRV, record.PreviousResourceVersion)
|
||||
@@ -505,9 +523,10 @@ func verifyResourceHistoryRecord(t *testing.T, record ResourceHistoryRecord, exp
|
||||
require.Equal(t, expectedGeneration, record.Generation)
|
||||
|
||||
// Validate resource_version
|
||||
// For KV backend operations, resource versions are stored as snowflake format
|
||||
// For KV backend operations, expectedRV is now in snowflake format (returned by KV backend)
|
||||
// but resource_history table stores microsecond RV, so we need to use IsRvEqual for comparison
|
||||
if strings.Contains(record.Namespace, "-kv") {
|
||||
require.True(t, rvmanager.IsRvEqual(record.ResourceVersion, expectedRV),
|
||||
require.True(t, rvmanager.IsRvEqual(expectedRV, record.ResourceVersion),
|
||||
"Resource version should match (KV backend snowflake format)")
|
||||
} else {
|
||||
require.Equal(t, expectedRV, record.ResourceVersion)
|
||||
@@ -574,7 +593,7 @@ func verifyResourceTable(t *testing.T, db sqldb.DB, namespace string, resources
|
||||
// Resource version should match the expected version for test-resource-3 (updated version)
|
||||
expectedRV := resourceVersions[2][1] // test-resource-3's update version
|
||||
if strings.Contains(namespace, "-kv") {
|
||||
require.True(t, rvmanager.IsRvEqual(record.ResourceVersion, expectedRV),
|
||||
require.True(t, rvmanager.IsRvEqual(expectedRV, record.ResourceVersion),
|
||||
"Resource version should match (KV backend snowflake format)")
|
||||
} else {
|
||||
require.Equal(t, expectedRV, record.ResourceVersion)
|
||||
@@ -625,9 +644,16 @@ func verifyResourceVersionTable(t *testing.T, db sqldb.DB, namespace string, res
|
||||
|
||||
// The resource_version table should contain the latest RV for the group+resource
|
||||
// It might be slightly higher due to RV manager operations, so check it's at least our max
|
||||
require.GreaterOrEqual(t, record.ResourceVersion, maxRV, "resource_version should be at least the latest RV we tracked")
|
||||
// But it shouldn't be too much higher (within a reasonable range)
|
||||
require.LessOrEqual(t, record.ResourceVersion, maxRV+100, "resource_version shouldn't be much higher than expected")
|
||||
// For KV backend, maxRV is in snowflake format but record.ResourceVersion is in microsecond format
|
||||
// Use IsRvEqual for proper comparison between different RV formats
|
||||
isKvBackend := strings.Contains(namespace, "-kv")
|
||||
recordResourceVersion := record.ResourceVersion
|
||||
if isKvBackend {
|
||||
recordResourceVersion = rvmanager.SnowflakeFromRv(record.ResourceVersion)
|
||||
}
|
||||
|
||||
require.Less(t, recordResourceVersion, int64(9223372036854775807), "resource_version should be reasonable")
|
||||
require.Greater(t, recordResourceVersion, maxRV, "resource_version should be at least the latest RV we tracked")
|
||||
}
|
||||
|
||||
// runTestCrossBackendConsistency tests basic consistency between SQL and KV backends (lightweight)
|
||||
@@ -666,11 +692,6 @@ func runTestCrossBackendConsistency(t *testing.T, sqlBackend, kvBackend resource
|
||||
|
||||
// runTestConcurrentOperationsStress tests heavy concurrent operations between SQL and KV backends
|
||||
func runTestConcurrentOperationsStress(t *testing.T, sqlBackend, kvBackend resource.StorageBackend, nsPrefix string, db sqldb.DB) {
|
||||
// Skip on SQLite due to concurrency limitations
|
||||
if db.DriverName() == "sqlite3" {
|
||||
t.Skip("Skipping concurrent operations stress test on SQLite")
|
||||
}
|
||||
|
||||
ctx := testutil.NewDefaultTestContext(t)
|
||||
|
||||
// Create storage servers from both backends
|
||||
|
||||
@@ -38,7 +38,6 @@ func TestBadgerKVStorageBackend(t *testing.T) {
|
||||
|
||||
func TestSQLKVStorageBackend(t *testing.T) {
|
||||
skipTests := map[string]bool{
|
||||
TestHappyPath: true,
|
||||
TestWatchWriteEvents: true,
|
||||
TestList: true,
|
||||
TestBlobSupport: true,
|
||||
@@ -51,21 +50,24 @@ func TestSQLKVStorageBackend(t *testing.T) {
|
||||
TestGetResourceLastImportTime: true,
|
||||
TestOptimisticLocking: true,
|
||||
}
|
||||
// without RvManager
|
||||
RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend {
|
||||
backend, _ := NewTestSqlKvBackend(t, ctx, false)
|
||||
return backend
|
||||
}, &TestOptions{
|
||||
NSPrefix: "sqlkvstorage-test",
|
||||
SkipTests: skipTests,
|
||||
|
||||
t.Run("Without RvManager", func(t *testing.T) {
|
||||
RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend {
|
||||
backend, _ := NewTestSqlKvBackend(t, ctx, false)
|
||||
return backend
|
||||
}, &TestOptions{
|
||||
NSPrefix: "sqlkvstorage-test",
|
||||
SkipTests: skipTests,
|
||||
})
|
||||
})
|
||||
|
||||
// with RvManager
|
||||
RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend {
|
||||
backend, _ := NewTestSqlKvBackend(t, ctx, true)
|
||||
return backend
|
||||
}, &TestOptions{
|
||||
NSPrefix: "sqlkvstorage-withrvmanager-test",
|
||||
SkipTests: skipTests,
|
||||
t.Run("With RvManager", func(t *testing.T) {
|
||||
RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend {
|
||||
backend, _ := NewTestSqlKvBackend(t, ctx, true)
|
||||
return backend
|
||||
}, &TestOptions{
|
||||
NSPrefix: "sqlkvstorage-withrvmanager-test",
|
||||
SkipTests: skipTests,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -44,6 +44,6 @@ func TestIntegrationFeatures(t *testing.T) {
|
||||
"value": true,
|
||||
"key":"`+flag+`",
|
||||
"reason":"static provider evaluation result",
|
||||
"variant":"enabled"}`, string(rsp.Body))
|
||||
"variant":"default"}`, string(rsp.Body))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
githubConnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/api/errors"
|
||||
@@ -207,6 +208,10 @@ func (c *K8sTestHelper) GetEnv() server.TestEnv {
|
||||
return c.env
|
||||
}
|
||||
|
||||
func (c *K8sTestHelper) SetGithubConnectionFactory(f githubConnection.GithubFactory) {
|
||||
c.env.GithubConnectionFactory = f
|
||||
}
|
||||
|
||||
func (c *K8sTestHelper) GetListenerAddress() string {
|
||||
return c.listenerAddress
|
||||
}
|
||||
|
||||
@@ -1028,10 +1028,7 @@
|
||||
},
|
||||
"com.github.grafana.grafana.apps.correlations.pkg.apis.correlation.v0alpha1.CorrelationTargetSpec": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
}
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"com.github.grafana.grafana.apps.correlations.pkg.apis.correlation.v0alpha1.CorrelationTransformationSpec": {
|
||||
"type": "object",
|
||||
|
||||
@@ -4559,7 +4559,7 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"webhook": {
|
||||
"token": {
|
||||
"description": "Token is the reference of the token used to act as the Connection. This value is stored securely and cannot be read back",
|
||||
"default": {},
|
||||
"allOf": [
|
||||
|
||||
@@ -2,13 +2,13 @@ package provisioning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
@@ -20,7 +20,7 @@ func TestIntegrationProvisioning_ConnectionRepositories(t *testing.T) {
|
||||
|
||||
helper := runGrafana(t)
|
||||
ctx := context.Background()
|
||||
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
|
||||
privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM))
|
||||
|
||||
// Create a connection for testing
|
||||
connection := &unstructured.Unstructured{Object: map[string]any{
|
||||
@@ -39,13 +39,12 @@ func TestIntegrationProvisioning_ConnectionRepositories(t *testing.T) {
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": "someSecret",
|
||||
"create": privateKeyBase64,
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
|
||||
require.NoError(t, err, "failed to create connection")
|
||||
_, err := helper.CreateGithubConnection(t, ctx, connection)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("endpoint returns not implemented", func(t *testing.T) {
|
||||
var statusCode int
|
||||
@@ -129,14 +128,14 @@ func TestIntegrationProvisioning_ConnectionRepositoriesResponseType(t *testing.T
|
||||
|
||||
helper := runGrafana(t)
|
||||
ctx := context.Background()
|
||||
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
|
||||
privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM))
|
||||
|
||||
// Create a connection for testing
|
||||
connection := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "provisioning.grafana.app/v0alpha1",
|
||||
"kind": "Connection",
|
||||
"metadata": map[string]any{
|
||||
"name": "connection-repositories-type-test",
|
||||
"name": "connection-repositories-test",
|
||||
"namespace": "default",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
@@ -148,13 +147,12 @@ func TestIntegrationProvisioning_ConnectionRepositoriesResponseType(t *testing.T
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": "someSecret",
|
||||
"create": privateKeyBase64,
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
|
||||
require.NoError(t, err, "failed to create connection")
|
||||
_, err := helper.CreateGithubConnection(t, ctx, connection)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("verify ExternalRepositoryList type exists in API", func(t *testing.T) {
|
||||
// Verify the type is registered and can be instantiated
|
||||
|
||||
@@ -2,12 +2,12 @@ package provisioning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
|
||||
"github.com/grafana/grafana/pkg/util/testutil"
|
||||
@@ -18,7 +18,7 @@ func TestIntegrationProvisioning_ConnectionStatusAuthorization(t *testing.T) {
|
||||
|
||||
helper := runGrafana(t)
|
||||
ctx := context.Background()
|
||||
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
|
||||
privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM))
|
||||
|
||||
// Create a connection for testing
|
||||
connection := &unstructured.Unstructured{Object: map[string]any{
|
||||
@@ -37,13 +37,12 @@ func TestIntegrationProvisioning_ConnectionStatusAuthorization(t *testing.T) {
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": "someSecret",
|
||||
"create": privateKeyBase64,
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
|
||||
require.NoError(t, err, "failed to create connection")
|
||||
_, err := helper.CreateGithubConnection(t, ctx, connection)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("admin can GET connection status", func(t *testing.T) {
|
||||
var statusCode int
|
||||
|
||||
@@ -2,11 +2,20 @@ package provisioning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/google/go-github/v70/github"
|
||||
githubConnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github"
|
||||
"github.com/grafana/grafana/pkg/extensions"
|
||||
"github.com/grafana/grafana/pkg/util/testutil"
|
||||
ghmock "github.com/migueleliasweb/go-github-mock/src/mock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
@@ -17,12 +26,55 @@ import (
|
||||
clientset "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned"
|
||||
)
|
||||
|
||||
//nolint:gosec // Test RSA private key (generated for testing purposes only)
|
||||
const testPrivateKeyPEM = `-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEoQIBAAKCAQBn1MuM5hIfH6d3TNStI1ofWv/gcjQ4joi9cFijEwVLuPYkF1nD
|
||||
KkSbaMGFUWiOTaB/H9fxmd/V2u04NlBY3av6m5T/sHfVSiEWAEUblh3cA34HVCmD
|
||||
cqyyVty5HLGJJlSs2C7W2x7yUc9ImzyDBsyjpKOXuojJ9wN9a17D2cYU5WkXjoDC
|
||||
4BHid61jn9WBTtPZXSgOdirwahNzxZQSIP7DA9T8yiZwIWPp5YesgsAPyQLCFPgM
|
||||
s77xz/CEUnEYQ35zI/k/mQrwKdQ/ZP8xLwQohUID0BIxE7G5quL069RuuCZWZkoF
|
||||
oPiZbp7HSryz1+19jD3rFT7eHGUYvAyCnXmXAgMBAAECggEADSs4Bc7ITZo+Kytb
|
||||
bfol3AQ2n8jcRrANN7mgBE7NRSVYUouDnvUlbnCC2t3QXPwLdxQa11GkygLSQ2bg
|
||||
GeVDgq1o4GUJTcvxFlFCcpU/hEANI/DQsxNAQ/4wUGoLOlHaO3HPvwBblHA70gGe
|
||||
Ux/xpG+lMAFAiB0EHEwZ4M0mClBEOQv3NzaFTWuBHtIMS8eid7M1q5qz9+rCgZSL
|
||||
KBBHo0OvUbajG4CWl8SM6LUYapASGg+U17E+4xA3npwpIdsk+CbtX+vvX324n4kn
|
||||
0EkrJqCjv8M1KiCKAP+UxwP00ywxOg4PN+x+dHI/I7xBvEKe/x6BltVSdGA+PlUK
|
||||
02wagQKBgQDF7gdQLFIagPH7X7dBP6qEGxj/Ck9Qdz3S1gotPkVeq+1/UtQijYZ1
|
||||
j44up/0yB2B9P4kW091n+iWcyfoU5UwBua9dHvCZP3QH05LR1ZscUHxLGjDPBASt
|
||||
l2xSq0hqqNWBspb1M0eCY0Yxi65iDkj3xsI2iN35BEb1FlWdR5KGvwKBgQCGS0ce
|
||||
wASWbZIPU2UoKGOQkIJU6QmLy0KZbfYkpyfE8IxGttYVEQ8puNvDDNZWHNf+LP85
|
||||
c8iV6SfnWiLmu1XkG2YmJFBCCAWgJ8Mq2XQD8E+a/xcaW3NqlcC5+I2czX367j3r
|
||||
69wZSxRbzR+DCfOiIkrekJImwN183ZYy2cBbKQKBgFj86IrSMmO6H5Ft+j06u5ZD
|
||||
fJyF7Rz3T3NwSgkHWzbyQ4ggHEIgsRg/36P4YSzSBj6phyAdRwkNfUWdxXMJmH+a
|
||||
FU7frzqnPaqbJAJ1cBRt10QI1XLtkpDdaJVObvONTtjOC3LYiEkGCzQRYeiyFXpZ
|
||||
AU51gJ8JnkFotjtNR4KPAoGAehVREDlLcl0lnN0ZZspgyPk2Im6/iOA9KTH3xBZZ
|
||||
ZwWu4FIyiHA7spgk4Ep5R0ttZ9oMI3SIcw/EgONGOy8uw/HMiPwWIhEc3B2JpRiO
|
||||
CU6bb7JalFFyuQBudiHoyxVcY5PVovWF31CLr3DoJr4TR9+Y5H/U/XnzYCIo+w1N
|
||||
exECgYBFAGKYTIeGAvhIvD5TphLpbCyeVLBIq5hRyrdRY+6Iwqdr5PGvLPKwin5+
|
||||
+4CDhWPW4spq8MYPCRiMrvRSctKt/7FhVGL2vE/0VY3TcLk14qLC+2+0lnPVgnYn
|
||||
u5/wOyuHp1cIBnjeN41/pluOWFBHI9xLW3ExLtmYMiecJ8VdRA==
|
||||
-----END RSA PRIVATE KEY-----`
|
||||
|
||||
//nolint:gosec // Test RSA public key (generated for testing purposes only)
|
||||
const testPublicKeyPem = `-----BEGIN PUBLIC KEY-----
|
||||
MIIBITANBgkqhkiG9w0BAQEFAAOCAQ4AMIIBCQKCAQBn1MuM5hIfH6d3TNStI1of
|
||||
Wv/gcjQ4joi9cFijEwVLuPYkF1nDKkSbaMGFUWiOTaB/H9fxmd/V2u04NlBY3av6
|
||||
m5T/sHfVSiEWAEUblh3cA34HVCmDcqyyVty5HLGJJlSs2C7W2x7yUc9ImzyDBsyj
|
||||
pKOXuojJ9wN9a17D2cYU5WkXjoDC4BHid61jn9WBTtPZXSgOdirwahNzxZQSIP7D
|
||||
A9T8yiZwIWPp5YesgsAPyQLCFPgMs77xz/CEUnEYQ35zI/k/mQrwKdQ/ZP8xLwQo
|
||||
hUID0BIxE7G5quL069RuuCZWZkoFoPiZbp7HSryz1+19jD3rFT7eHGUYvAyCnXmX
|
||||
AgMBAAE=
|
||||
-----END PUBLIC KEY-----`
|
||||
|
||||
func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
helper := runGrafana(t)
|
||||
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
|
||||
ctx := context.Background()
|
||||
privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM))
|
||||
|
||||
decryptService := helper.GetEnv().DecryptService
|
||||
require.NotNil(t, decryptService, "decrypt service not wired properly")
|
||||
|
||||
t.Run("should perform CRUDL requests on connection", func(t *testing.T) {
|
||||
connection := &unstructured.Unstructured{Object: map[string]any{
|
||||
@@ -41,12 +93,12 @@ func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) {
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": "someSecret",
|
||||
"create": privateKeyBase64,
|
||||
},
|
||||
},
|
||||
}}
|
||||
// CREATE
|
||||
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
|
||||
_, err := helper.CreateGithubConnection(t, ctx, connection)
|
||||
require.NoError(t, err, "failed to create resource")
|
||||
|
||||
// READ
|
||||
@@ -64,6 +116,22 @@ func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) {
|
||||
require.Contains(t, output.Object, "secure", "object should contain secure")
|
||||
assert.Contains(t, output.Object["secure"], "privateKey", "secure should contain PrivateKey")
|
||||
|
||||
// Verifying token
|
||||
assert.Contains(t, output.Object["secure"], "token", "token should be created")
|
||||
secretName, found, err := unstructured.NestedString(output.Object, "secure", "token", "name")
|
||||
require.NoError(t, err, "error getting secret name")
|
||||
require.True(t, found, "secret name should exist: %v", output.Object)
|
||||
decrypted, err := decryptService.Decrypt(ctx, "provisioning.grafana.app", output.GetNamespace(), secretName)
|
||||
require.NoError(t, err, "decryption error")
|
||||
require.Len(t, decrypted, 1)
|
||||
|
||||
val := decrypted[secretName].Value()
|
||||
require.NotNil(t, val)
|
||||
k := val.DangerouslyExposeAndConsumeValue()
|
||||
valid, err := verifyToken(t, "123456", testPublicKeyPem, k)
|
||||
require.NoError(t, err, "error verifying token: %s", k)
|
||||
require.True(t, valid, "token should be valid: %s", k)
|
||||
|
||||
// LIST
|
||||
list, err := helper.Connections.Resource.List(ctx, metav1.ListOptions{})
|
||||
require.NoError(t, err, "failed to list resource")
|
||||
@@ -81,22 +149,22 @@ func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) {
|
||||
"spec": map[string]any{
|
||||
"type": "github",
|
||||
"github": map[string]any{
|
||||
"appID": "456789",
|
||||
"installationID": "454545",
|
||||
"appID": "123456",
|
||||
"installationID": "454546",
|
||||
},
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": "someSecret",
|
||||
"create": privateKeyBase64,
|
||||
},
|
||||
},
|
||||
}}
|
||||
res, err := helper.Connections.Resource.Update(ctx, updatedConnection, metav1.UpdateOptions{})
|
||||
res, err := helper.UpdateGithubConnection(t, ctx, updatedConnection)
|
||||
require.NoError(t, err, "failed to update resource")
|
||||
spec = res.Object["spec"].(map[string]any)
|
||||
require.Contains(t, spec, "github")
|
||||
githubInfo = spec["github"].(map[string]any)
|
||||
assert.Equal(t, "456789", githubInfo["appID"], "appID should be updated")
|
||||
assert.Equal(t, "454546", githubInfo["installationID"], "installationID should be updated")
|
||||
|
||||
// DELETE
|
||||
require.NoError(t, helper.Connections.Resource.Delete(ctx, "connection", metav1.DeleteOptions{}), "failed to delete resource")
|
||||
@@ -122,7 +190,7 @@ func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) {
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": "someSecret",
|
||||
"create": privateKeyBase64,
|
||||
},
|
||||
},
|
||||
}}
|
||||
@@ -155,9 +223,12 @@ func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
helper := runGrafana(t)
|
||||
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
|
||||
ctx := context.Background()
|
||||
privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM))
|
||||
|
||||
t.Run("should fail when type is empty", func(t *testing.T) {
|
||||
connection := &unstructured.Unstructured{Object: map[string]any{
|
||||
@@ -172,13 +243,13 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) {
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": "someSecret",
|
||||
"create": privateKeyBase64,
|
||||
},
|
||||
},
|
||||
}}
|
||||
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
|
||||
require.Error(t, err, "failed to create resource")
|
||||
assert.Contains(t, err.Error(), "type must be specified")
|
||||
assert.Contains(t, err.Error(), "connection type \"\" is not supported")
|
||||
})
|
||||
|
||||
t.Run("should fail when type is invalid", func(t *testing.T) {
|
||||
@@ -194,13 +265,57 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) {
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": "someSecret",
|
||||
"create": privateKeyBase64,
|
||||
},
|
||||
},
|
||||
}}
|
||||
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
|
||||
require.Error(t, err, "failed to create resource")
|
||||
assert.Contains(t, err.Error(), "spec.type: Unsupported value: \"some-invalid-type\"")
|
||||
assert.Contains(t, err.Error(), "connection type \"some-invalid-type\" is not supported")
|
||||
})
|
||||
|
||||
t.Run("should fail when type is 'git'", func(t *testing.T) {
|
||||
connection := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "provisioning.grafana.app/v0alpha1",
|
||||
"kind": "Connection",
|
||||
"metadata": map[string]any{
|
||||
"name": "connection",
|
||||
"namespace": "default",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"type": "git",
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": privateKeyBase64,
|
||||
},
|
||||
},
|
||||
}}
|
||||
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
|
||||
require.Error(t, err, "failed to create resource")
|
||||
assert.Contains(t, err.Error(), "connection type \"git\" is not supported")
|
||||
})
|
||||
|
||||
t.Run("should fail when type is 'local'", func(t *testing.T) {
|
||||
connection := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "provisioning.grafana.app/v0alpha1",
|
||||
"kind": "Connection",
|
||||
"metadata": map[string]any{
|
||||
"name": "connection",
|
||||
"namespace": "default",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"type": "local",
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": privateKeyBase64,
|
||||
},
|
||||
},
|
||||
}}
|
||||
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
|
||||
require.Error(t, err, "failed to create resource")
|
||||
assert.Contains(t, err.Error(), "connection type \"local\" is not supported")
|
||||
})
|
||||
|
||||
t.Run("should fail when type is github but 'github' field is not there", func(t *testing.T) {
|
||||
@@ -216,13 +331,13 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) {
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": "someSecret",
|
||||
"create": privateKeyBase64,
|
||||
},
|
||||
},
|
||||
}}
|
||||
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
|
||||
require.Error(t, err, "failed to create resource")
|
||||
assert.Contains(t, err.Error(), "github info must be specified for GitHub connection")
|
||||
assert.Contains(t, err.Error(), "invalid github connection")
|
||||
})
|
||||
|
||||
t.Run("should fail when type is github but private key is not there", func(t *testing.T) {
|
||||
@@ -246,7 +361,7 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "privateKey must be specified for GitHub connection")
|
||||
})
|
||||
|
||||
t.Run("should fail when type is github but a client Secret is specified", func(t *testing.T) {
|
||||
t.Run("should fail when type is github but a client Secret is also specified", func(t *testing.T) {
|
||||
connection := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "provisioning.grafana.app/v0alpha1",
|
||||
"kind": "Connection",
|
||||
@@ -263,7 +378,7 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) {
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": "someSecret",
|
||||
"create": privateKeyBase64,
|
||||
},
|
||||
"clientSecret": map[string]any{
|
||||
"create": "someSecret",
|
||||
@@ -275,6 +390,100 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "clientSecret is forbidden in GitHub connection")
|
||||
})
|
||||
|
||||
t.Run("should fail when type is github and github API is unavailable", func(t *testing.T) {
|
||||
connectionFactory := helper.GetEnv().GithubConnectionFactory.(*githubConnection.Factory)
|
||||
connectionFactory.Client = ghmock.NewMockedHTTPClient(
|
||||
ghmock.WithRequestMatchHandler(
|
||||
ghmock.GetApp,
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
|
||||
Response: &http.Response{
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
},
|
||||
Message: "Service unavailable",
|
||||
}))
|
||||
}),
|
||||
),
|
||||
)
|
||||
helper.SetGithubConnectionFactory(connectionFactory)
|
||||
|
||||
connection := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "provisioning.grafana.app/v0alpha1",
|
||||
"kind": "Connection",
|
||||
"metadata": map[string]any{
|
||||
"name": "connection",
|
||||
"namespace": "default",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"type": "github",
|
||||
"github": map[string]any{
|
||||
"appID": "123456",
|
||||
"installationID": "454545",
|
||||
},
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": privateKeyBase64,
|
||||
},
|
||||
},
|
||||
}}
|
||||
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
|
||||
require.Error(t, err, "failed to create resource")
|
||||
assert.Contains(t, err.Error(), "spec.token: Internal error: github is unavailable")
|
||||
})
|
||||
|
||||
t.Run("should fail when type is github and returned app ID doesn't match given one", func(t *testing.T) {
|
||||
var appID int64 = 123455
|
||||
appSlug := "appSlug"
|
||||
connectionFactory := helper.GetEnv().GithubConnectionFactory.(*githubConnection.Factory)
|
||||
connectionFactory.Client = ghmock.NewMockedHTTPClient(
|
||||
ghmock.WithRequestMatch(
|
||||
ghmock.GetApp, github.App{
|
||||
ID: &appID,
|
||||
Slug: &appSlug,
|
||||
},
|
||||
),
|
||||
)
|
||||
helper.SetGithubConnectionFactory(connectionFactory)
|
||||
|
||||
connection := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "provisioning.grafana.app/v0alpha1",
|
||||
"kind": "Connection",
|
||||
"metadata": map[string]any{
|
||||
"name": "connection",
|
||||
"namespace": "default",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"type": "github",
|
||||
"github": map[string]any{
|
||||
"appID": "123456",
|
||||
"installationID": "454545",
|
||||
},
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": privateKeyBase64,
|
||||
},
|
||||
},
|
||||
}}
|
||||
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
|
||||
require.Error(t, err, "failed to create resource")
|
||||
assert.Contains(t, err.Error(), "spec.appID: Invalid value: \"123456\": appID mismatch")
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationProvisioning_ConnectionEnterpriseValidation(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
if !extensions.IsEnterprise {
|
||||
t.Skip("Skipping integration test when not enterprise")
|
||||
}
|
||||
|
||||
helper := runGrafana(t)
|
||||
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("should fail when type is bitbucket but 'bitbucket' field is not there", func(t *testing.T) {
|
||||
connection := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "provisioning.grafana.app/v0alpha1",
|
||||
@@ -294,7 +503,7 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) {
|
||||
}}
|
||||
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
|
||||
require.Error(t, err, "failed to create resource")
|
||||
assert.Contains(t, err.Error(), "bitbucket info must be specified in Bitbucket connection")
|
||||
assert.Contains(t, err.Error(), "invalid bitbucket connection")
|
||||
})
|
||||
|
||||
t.Run("should fail when type is bitbucket but client secret is not there", func(t *testing.T) {
|
||||
@@ -364,7 +573,7 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) {
|
||||
}}
|
||||
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
|
||||
require.Error(t, err, "failed to create resource")
|
||||
assert.Contains(t, err.Error(), "gitlab info must be specified in Gitlab connection")
|
||||
assert.Contains(t, err.Error(), "invalid gitlab connection")
|
||||
})
|
||||
|
||||
t.Run("should fail when type is gitlab but client secret is not there", func(t *testing.T) {
|
||||
@@ -428,6 +637,7 @@ func TestIntegrationConnectionController_HealthCheckUpdates(t *testing.T) {
|
||||
provisioningClient, err := clientset.NewForConfig(restConfig)
|
||||
require.NoError(t, err)
|
||||
connClient := provisioningClient.ProvisioningV0alpha1().Connections(namespace)
|
||||
privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM))
|
||||
|
||||
t.Run("health check gets updated after initial creation", func(t *testing.T) {
|
||||
// Create a connection using unstructured (like other connection tests)
|
||||
@@ -447,12 +657,12 @@ func TestIntegrationConnectionController_HealthCheckUpdates(t *testing.T) {
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": "test-private-key",
|
||||
"create": privateKeyBase64,
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
createdUnstructured, err := helper.Connections.Resource.Create(ctx, connUnstructured, metav1.CreateOptions{})
|
||||
createdUnstructured, err := helper.CreateGithubConnection(t, ctx, connUnstructured)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, createdUnstructured)
|
||||
|
||||
@@ -501,12 +711,12 @@ func TestIntegrationConnectionController_HealthCheckUpdates(t *testing.T) {
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": "test-private-key-2",
|
||||
"create": privateKeyBase64,
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
createdUnstructured, err := helper.Connections.Resource.Create(ctx, connUnstructured, metav1.CreateOptions{})
|
||||
createdUnstructured, err := helper.CreateGithubConnection(t, ctx, connUnstructured)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, createdUnstructured)
|
||||
|
||||
@@ -538,7 +748,7 @@ func TestIntegrationConnectionController_HealthCheckUpdates(t *testing.T) {
|
||||
updatedUnstructured := latestUnstructured.DeepCopy()
|
||||
githubSpec := updatedUnstructured.Object["spec"].(map[string]any)["github"].(map[string]any)
|
||||
githubSpec["appID"] = "99999"
|
||||
_, err = helper.Connections.Resource.Update(ctx, updatedUnstructured, metav1.UpdateOptions{})
|
||||
_, err = helper.UpdateGithubConnection(t, ctx, updatedUnstructured)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait for reconciliation after spec change
|
||||
@@ -566,6 +776,7 @@ func TestIntegrationProvisioning_RepositoryFieldSelectorByConnection(t *testing.
|
||||
helper := runGrafana(t)
|
||||
ctx := context.Background()
|
||||
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
|
||||
privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM))
|
||||
|
||||
// Create a connection first
|
||||
connection := &unstructured.Unstructured{Object: map[string]any{
|
||||
@@ -584,12 +795,12 @@ func TestIntegrationProvisioning_RepositoryFieldSelectorByConnection(t *testing.
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": "test-private-key",
|
||||
"create": privateKeyBase64,
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
|
||||
_, err := helper.CreateGithubConnection(t, ctx, connection)
|
||||
require.NoError(t, err, "failed to create connection")
|
||||
|
||||
t.Cleanup(func() {
|
||||
@@ -731,3 +942,27 @@ func TestIntegrationProvisioning_RepositoryFieldSelectorByConnection(t *testing.
|
||||
assert.Contains(t, names, "repo-with-different-connection")
|
||||
})
|
||||
}
|
||||
|
||||
func verifyToken(t *testing.T, appID, publicKey, token string) (bool, error) {
|
||||
t.Helper()
|
||||
|
||||
// Parse the private key
|
||||
key, err := jwt.ParseRSAPublicKeyFromPEM([]byte(publicKey))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
parsedToken, err := jwt.Parse(token, func(token *jwt.Token) (any, error) {
|
||||
return key, nil
|
||||
}, jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()}))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
claims, ok := parsedToken.Claims.(jwt.MapClaims)
|
||||
if !ok || !parsedToken.Valid {
|
||||
return false, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
return claims.VerifyIssuer(appID, true), nil
|
||||
}
|
||||
|
||||
@@ -10,11 +10,14 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-github/v70/github"
|
||||
"github.com/grafana/grafana/pkg/extensions"
|
||||
ghmock "github.com/migueleliasweb/go-github-mock/src/mock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -30,6 +33,7 @@ import (
|
||||
dashboardsV2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1"
|
||||
folder "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
githubConnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github"
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
@@ -699,13 +703,18 @@ func runGrafana(t *testing.T, options ...grafanaOption) *provisioningTestHelper
|
||||
// (instance is needed for export jobs, folder for most operations)
|
||||
ProvisioningAllowedTargets: []string{"folder", "instance"},
|
||||
}
|
||||
|
||||
if extensions.IsEnterprise {
|
||||
opts.ProvisioningRepositoryTypes = []string{"local", "github", "gitlab", "bitbucket"}
|
||||
}
|
||||
|
||||
for _, o := range options {
|
||||
o(&opts)
|
||||
}
|
||||
helper := apis.NewK8sTestHelper(t, opts)
|
||||
|
||||
// FIXME: keeping this line here to keep the dependency around until we have tests which use this again.
|
||||
helper.GetEnv().GitHubFactory.Client = ghmock.NewMockedHTTPClient()
|
||||
// FIXME: keeping these lines here to keep the dependency around until we have tests which use this again.
|
||||
helper.GetEnv().GithubRepoFactory.Client = ghmock.NewMockedHTTPClient()
|
||||
|
||||
repositories := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: helper.Org1.Admin,
|
||||
@@ -973,6 +982,79 @@ func (h *provisioningTestHelper) CleanupAllRepos(t *testing.T) {
|
||||
}, waitTimeoutDefault, waitIntervalDefault, "repositories should be cleaned up between subtests")
|
||||
}
|
||||
|
||||
func (h *provisioningTestHelper) CreateGithubConnection(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
connection *unstructured.Unstructured,
|
||||
) (*unstructured.Unstructured, error) {
|
||||
t.Helper()
|
||||
|
||||
err := h.setGithubClient(t, connection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return h.Connections.Resource.Create(ctx, connection, metav1.CreateOptions{FieldValidation: "Strict"})
|
||||
}
|
||||
|
||||
func (h *provisioningTestHelper) UpdateGithubConnection(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
connection *unstructured.Unstructured,
|
||||
) (*unstructured.Unstructured, error) {
|
||||
t.Helper()
|
||||
|
||||
err := h.setGithubClient(t, connection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return h.Connections.Resource.Update(ctx, connection, metav1.UpdateOptions{FieldValidation: "Strict"})
|
||||
}
|
||||
|
||||
func (h *provisioningTestHelper) setGithubClient(t *testing.T, connection *unstructured.Unstructured) error {
|
||||
t.Helper()
|
||||
|
||||
objectSpec := connection.Object["spec"].(map[string]interface{})
|
||||
githubObj := objectSpec["github"].(map[string]interface{})
|
||||
appID := githubObj["appID"].(string)
|
||||
id, err := strconv.ParseInt(appID, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
appSlug := "someSlug"
|
||||
connectionFactory := h.GetEnv().GithubConnectionFactory.(*githubConnection.Factory)
|
||||
connectionFactory.Client = ghmock.NewMockedHTTPClient(
|
||||
ghmock.WithRequestMatchHandler(
|
||||
ghmock.GetApp,
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
app := github.App{
|
||||
ID: &id,
|
||||
Slug: &appSlug,
|
||||
}
|
||||
_, _ = w.Write(ghmock.MustMarshal(app))
|
||||
}),
|
||||
),
|
||||
ghmock.WithRequestMatchHandler(
|
||||
ghmock.GetAppInstallationsByInstallationId,
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("installation_id")
|
||||
idInt, _ := strconv.ParseInt(id, 10, 64)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
installation := github.Installation{
|
||||
ID: &idInt,
|
||||
}
|
||||
_, _ = w.Write(ghmock.MustMarshal(installation))
|
||||
}),
|
||||
),
|
||||
)
|
||||
h.SetGithubConnectionFactory(connectionFactory)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func postHelper(t *testing.T, helper apis.K8sTestHelper, path string, body interface{}, user apis.User) (map[string]interface{}, int, error) {
|
||||
return requestHelper(t, helper, http.MethodPost, path, body, user)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/extensions"
|
||||
provisioningAPIServer "github.com/grafana/grafana/pkg/registry/apis/provisioning"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -149,10 +150,19 @@ func TestIntegrationProvisioning_CreatingAndGetting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
assert.ElementsMatch(collect, []provisioning.RepositoryType{
|
||||
provisioning.LocalRepositoryType,
|
||||
provisioning.GitHubRepositoryType,
|
||||
}, settings.AvailableRepositoryTypes)
|
||||
if extensions.IsEnterprise {
|
||||
assert.ElementsMatch(collect, []provisioning.RepositoryType{
|
||||
provisioning.LocalRepositoryType,
|
||||
provisioning.GitHubRepositoryType,
|
||||
provisioning.BitbucketRepositoryType,
|
||||
provisioning.GitLabRepositoryType,
|
||||
}, settings.AvailableRepositoryTypes)
|
||||
} else {
|
||||
assert.ElementsMatch(collect, []provisioning.RepositoryType{
|
||||
provisioning.LocalRepositoryType,
|
||||
provisioning.GitHubRepositoryType,
|
||||
}, settings.AvailableRepositoryTypes)
|
||||
}
|
||||
}, time.Second*10, time.Millisecond*100, "Expected settings to match")
|
||||
})
|
||||
|
||||
|
||||
@@ -622,6 +622,12 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) {
|
||||
_, err = provisioningSect.NewKey("allowed_targets", strings.Join(opts.ProvisioningAllowedTargets, "|"))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
if len(opts.ProvisioningRepositoryTypes) > 0 {
|
||||
provisioningSect, err := getOrCreateSection("provisioning")
|
||||
require.NoError(t, err)
|
||||
_, err = provisioningSect.NewKey("repository_types", strings.Join(opts.ProvisioningRepositoryTypes, "|"))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
if opts.EnableSCIM {
|
||||
scimSection, err := getOrCreateSection("auth.scim")
|
||||
require.NoError(t, err)
|
||||
@@ -731,6 +737,7 @@ type GrafanaOpts struct {
|
||||
UnifiedStorageMaxPageSizeBytes int
|
||||
PermittedProvisioningPaths string
|
||||
ProvisioningAllowedTargets []string
|
||||
ProvisioningRepositoryTypes []string
|
||||
GrafanaComSSOAPIToken string
|
||||
LicensePath string
|
||||
EnableRecordingRules bool
|
||||
|
||||
@@ -15,7 +15,10 @@ import (
|
||||
func TestMain(m *testing.M) {
|
||||
// make sure we don't leak goroutines after tests in this package have
|
||||
// finished, which means we haven't leaked contexts either
|
||||
goleak.VerifyTestMain(m)
|
||||
// (Except for goroutines running specific functions. If possible we should fix this.)
|
||||
goleak.VerifyTestMain(m,
|
||||
goleak.IgnoreTopFunction("github.com/open-feature/go-sdk/openfeature.(*eventExecutor).startEventListener.func1.1"),
|
||||
)
|
||||
}
|
||||
|
||||
func TestTestContextFunc(t *testing.T) {
|
||||
|
||||
@@ -23,7 +23,7 @@ import { configureStore } from 'app/store/configureStore';
|
||||
|
||||
import { mockDataSource } from '../alerting/unified/mocks';
|
||||
|
||||
import CorrelationsPage from './CorrelationsPage';
|
||||
import { CorrelationsPageLegacy } from './CorrelationsPageWrapper';
|
||||
import {
|
||||
createCreateCorrelationResponse,
|
||||
createFetchCorrelationsError,
|
||||
@@ -112,7 +112,7 @@ const renderWithContext = async (
|
||||
|
||||
const renderResult = render(
|
||||
<TestProvider store={configureStore({})} grafanaContext={grafanaContext}>
|
||||
<CorrelationsPage />
|
||||
<CorrelationsPageLegacy />
|
||||
</TestProvider>,
|
||||
{
|
||||
queries: {
|
||||
|
||||
@@ -2,9 +2,10 @@ import { css } from '@emotion/css';
|
||||
import { negate } from 'lodash';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { Status } from '@grafana/api-clients/rtkq/correlations/v0alpha1';
|
||||
import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { CorrelationData, isFetchError, reportInteraction } from '@grafana/runtime';
|
||||
import { CorrelationData, CorrelationsData, FetchError, isFetchError, reportInteraction } from '@grafana/runtime';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -27,11 +28,27 @@ import { AccessControlAction } from 'app/types/accessControl';
|
||||
import { AddCorrelationForm } from './Forms/AddCorrelationForm';
|
||||
import { EditCorrelationForm } from './Forms/EditCorrelationForm';
|
||||
import { EmptyCorrelationsCTA } from './components/EmptyCorrelationsCTA';
|
||||
import type { Correlation, RemoveCorrelationParams } from './types';
|
||||
import { useCorrelations } from './useCorrelations';
|
||||
import type { Correlation, GetCorrelationsParams, RemoveCorrelationParams } from './types';
|
||||
|
||||
type CorrelationsPageProps = {
|
||||
fetchCorrelations: (params: GetCorrelationsParams) => Promise<CorrelationsData> | CorrelationsData;
|
||||
correlations?: CorrelationsData;
|
||||
isLoading: boolean;
|
||||
changePageFn?: (page: number) => void;
|
||||
removeFn?: (params: RemoveCorrelationParams) => Promise<
|
||||
| {
|
||||
message: string;
|
||||
}
|
||||
| Status
|
||||
>;
|
||||
error?: Error | FetchError;
|
||||
hasNextPage?: boolean;
|
||||
};
|
||||
|
||||
const collator = new Intl.Collator();
|
||||
|
||||
const sortDatasource: SortByFn<CorrelationData> = (a, b, column) =>
|
||||
a.values[column].name.localeCompare(b.values[column].name);
|
||||
collator.compare(a.values[column].name, b.values[column].name);
|
||||
|
||||
const isCorrelationsReadOnly = (correlation: CorrelationData) => correlation.provisioned;
|
||||
|
||||
@@ -40,7 +57,8 @@ const loaderWrapper = css({
|
||||
justifyContent: 'center',
|
||||
});
|
||||
|
||||
export default function CorrelationsPage() {
|
||||
export default function CorrelationsPage(props: CorrelationsPageProps) {
|
||||
const { fetchCorrelations, correlations, isLoading, error, removeFn, changePageFn, hasNextPage } = props;
|
||||
const navModel = useNavModel('correlations');
|
||||
const [isAdding, setIsAddingValue] = useState(false);
|
||||
const page = useRef(1);
|
||||
@@ -52,11 +70,6 @@ export default function CorrelationsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const {
|
||||
remove,
|
||||
get: { execute: fetchCorrelations, ...get },
|
||||
} = useCorrelations();
|
||||
|
||||
const canWriteCorrelations = contextSrv.hasPermission(AccessControlAction.DataSourcesWrite);
|
||||
|
||||
const handleAdded = useCallback(() => {
|
||||
@@ -72,15 +85,17 @@ export default function CorrelationsPage() {
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (params: RemoveCorrelationParams, isLastRow: boolean) => {
|
||||
await remove.execute(params);
|
||||
reportInteraction('grafana_correlations_deleted');
|
||||
if (removeFn) {
|
||||
await removeFn(params);
|
||||
reportInteraction('grafana_correlations_deleted');
|
||||
|
||||
if (isLastRow) {
|
||||
page.current--;
|
||||
if (isLastRow) {
|
||||
page.current--;
|
||||
}
|
||||
fetchCorrelations({ page: page.current });
|
||||
}
|
||||
fetchCorrelations({ page: page.current });
|
||||
},
|
||||
[remove, fetchCorrelations]
|
||||
[removeFn, fetchCorrelations]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -102,9 +117,7 @@ export default function CorrelationsPage() {
|
||||
!provisioned && (
|
||||
<DeleteButton
|
||||
aria-label={t('correlations.list.delete', 'delete correlation')}
|
||||
onConfirm={() =>
|
||||
handleDelete({ sourceUID, uid }, page.current > 1 && index === 0 && data?.correlations.length === 1)
|
||||
}
|
||||
onConfirm={() => handleDelete({ sourceUID, uid }, page.current > 1 && index === 0 && corrData.length === 1)}
|
||||
closeOnConfirm
|
||||
/>
|
||||
)
|
||||
@@ -145,9 +158,9 @@ export default function CorrelationsPage() {
|
||||
[RowActions, canWriteCorrelations]
|
||||
);
|
||||
|
||||
const data = useMemo(() => get.value, [get.value]);
|
||||
const showEmptyListCTA = data?.correlations.length === 0 && !isAdding && !get.error;
|
||||
const addButton = canWriteCorrelations && data?.correlations?.length !== 0 && data !== undefined && !isAdding && (
|
||||
const corrData = correlations?.correlations ?? [];
|
||||
const showEmptyListCTA = corrData.length === 0 && !isAdding && !error;
|
||||
const addButton = canWriteCorrelations && corrData.length !== 0 && !isAdding && (
|
||||
<Button icon="plus" onClick={() => setIsAdding(true)}>
|
||||
<Trans i18nKey="correlations.add-new">Add new</Trans>
|
||||
</Button>
|
||||
@@ -170,25 +183,23 @@ export default function CorrelationsPage() {
|
||||
>
|
||||
<Page.Contents>
|
||||
<div>
|
||||
{!data && get.loading && (
|
||||
{isLoading && (
|
||||
<div className={loaderWrapper}>
|
||||
<LoadingPlaceholder text={t('correlations.list.loading', 'loading...')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showEmptyListCTA && (
|
||||
<EmptyCorrelationsCTA canWriteCorrelations={canWriteCorrelations} onClick={() => setIsAdding(true)} />
|
||||
)}
|
||||
|
||||
{
|
||||
// This error is not actionable, it'd be nice to have a recovery button
|
||||
get.error && (
|
||||
error && (
|
||||
<Alert
|
||||
severity="error"
|
||||
title={t('correlations.alert.title', 'Error fetching correlation data')}
|
||||
topSpacing={2}
|
||||
>
|
||||
{(isFetchError(get.error) && get.error.data?.message) ||
|
||||
{(isFetchError(error) && error.data?.message) ||
|
||||
t(
|
||||
'correlations.alert.error-message',
|
||||
'An unknown error occurred while fetching correlation data. Please try again.'
|
||||
@@ -196,10 +207,9 @@ export default function CorrelationsPage() {
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
{isAdding && <AddCorrelationForm onClose={() => setIsAdding(false)} onCreated={handleAdded} />}
|
||||
|
||||
{data && data.correlations.length >= 1 && (
|
||||
{correlations && corrData.length >= 1 && (
|
||||
<>
|
||||
<InteractiveTable
|
||||
renderExpandedRow={(correlation) => (
|
||||
@@ -210,15 +220,19 @@ export default function CorrelationsPage() {
|
||||
/>
|
||||
)}
|
||||
columns={columns}
|
||||
data={data.correlations}
|
||||
data={corrData}
|
||||
getRowId={(correlation) => `${correlation.source.uid}-${correlation.uid}`}
|
||||
/>
|
||||
<Pagination
|
||||
currentPage={page.current}
|
||||
numberOfPages={Math.ceil(data.totalCount / data.limit)}
|
||||
numberOfPages={Math.ceil(correlations?.totalCount / correlations?.limit)}
|
||||
onNavigate={(toPage: number) => {
|
||||
if (changePageFn) {
|
||||
changePageFn(toPage);
|
||||
}
|
||||
fetchCorrelations({ page: (page.current = toPage) });
|
||||
}}
|
||||
hasNextPage={hasNextPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { render } from 'test/test-utils';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import CorrelationsPageWrapper from './CorrelationsPageWrapper';
|
||||
|
||||
jest.mock('app/core/services/context_srv');
|
||||
|
||||
const mockUseCorrelations = jest.fn().mockReturnValue({
|
||||
remove: { execute: jest.fn() },
|
||||
get: { execute: jest.fn(), value: [], loading: false, error: undefined },
|
||||
});
|
||||
const mockUseCorrelationsK8s = jest.fn().mockReturnValue({
|
||||
currentData: [],
|
||||
isLoading: false,
|
||||
error: undefined,
|
||||
remainingItems: 0,
|
||||
});
|
||||
|
||||
jest.mock('./useCorrelations', () => ({
|
||||
useCorrelations: () => mockUseCorrelations(),
|
||||
}));
|
||||
|
||||
jest.mock('./useCorrelationsK8s', () => ({
|
||||
useCorrelationsK8s: () => mockUseCorrelationsK8s(),
|
||||
}));
|
||||
|
||||
describe('CorrelationsPageWrapper', () => {
|
||||
const originalFeatureToggles = config.featureToggles;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
config.featureToggles = originalFeatureToggles;
|
||||
});
|
||||
|
||||
describe('with the kubernetes feature toggle on', () => {
|
||||
it('uses the K8s correlations hook', () => {
|
||||
config.featureToggles = { ...originalFeatureToggles, kubernetesCorrelations: true };
|
||||
render(<CorrelationsPageWrapper />);
|
||||
expect(mockUseCorrelationsK8s).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with the kubernetes feature toggle off', () => {
|
||||
it('uses the legacy correlations hook', () => {
|
||||
config.featureToggles = { ...originalFeatureToggles, kubernetesCorrelations: false };
|
||||
render(<CorrelationsPageWrapper />);
|
||||
expect(mockUseCorrelations).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
67
public/app/features/correlations/CorrelationsPageWrapper.tsx
Normal file
67
public/app/features/correlations/CorrelationsPageWrapper.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { handleRequestError } from '@grafana/api-clients';
|
||||
import { useDeleteCorrelationMutation } from '@grafana/api-clients/rtkq/correlations/v0alpha1';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import CorrelationsPage from './CorrelationsPage';
|
||||
import { GetCorrelationsParams, RemoveCorrelationParams } from './types';
|
||||
import { useCorrelations } from './useCorrelations';
|
||||
import { useCorrelationsK8s } from './useCorrelationsK8s';
|
||||
|
||||
export function CorrelationsPageLegacy() {
|
||||
const { remove, get } = useCorrelations();
|
||||
return (
|
||||
<CorrelationsPage
|
||||
fetchCorrelations={get.execute}
|
||||
correlations={get.value}
|
||||
isLoading={get.loading}
|
||||
error={get.error}
|
||||
removeFn={remove.execute}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CorrelationsPageAppPlatform() {
|
||||
const [page, setPage] = useState(1);
|
||||
const limit = 100;
|
||||
const { currentData, isLoading, error, doesContinue } = useCorrelationsK8s(limit, page);
|
||||
const [deleteCorrelation] = useDeleteCorrelationMutation();
|
||||
|
||||
// we cant do a straight refetch, we have to pass in new pages if necessary
|
||||
const enhRefetch = (params: GetCorrelationsParams) => {
|
||||
return { correlations: currentData, page: params.page, limit, totalCount: 0 };
|
||||
};
|
||||
|
||||
const fmtedError = error ? handleRequestError(error) : undefined;
|
||||
|
||||
return (
|
||||
<CorrelationsPage
|
||||
fetchCorrelations={enhRefetch}
|
||||
changePageFn={(toPage) => {
|
||||
setPage(toPage);
|
||||
}}
|
||||
correlations={{
|
||||
correlations: currentData,
|
||||
page: 0,
|
||||
limit: limit,
|
||||
totalCount: 0,
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
error={fmtedError?.error}
|
||||
removeFn={(params: RemoveCorrelationParams) => {
|
||||
const deleteData = deleteCorrelation({ name: params.uid });
|
||||
return deleteData.unwrap();
|
||||
}}
|
||||
hasNextPage={doesContinue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CorrelationsPageWrapper() {
|
||||
if (config.featureToggles.kubernetesCorrelations) {
|
||||
return <CorrelationsPageAppPlatform />;
|
||||
}
|
||||
|
||||
return <CorrelationsPageLegacy />;
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export interface CorrelationsResponse {
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
const toEnrichedCorrelationData = ({ sourceUID, ...correlation }: Correlation): CorrelationData | undefined => {
|
||||
export const toEnrichedCorrelationData = ({ sourceUID, ...correlation }: Correlation): CorrelationData | undefined => {
|
||||
const sourceDatasource = getDataSourceSrv().getInstanceSettings(sourceUID);
|
||||
const targetDatasource =
|
||||
correlation.type === 'query' ? getDataSourceSrv().getInstanceSettings(correlation.targetUID) : undefined;
|
||||
@@ -90,8 +90,8 @@ export const useCorrelations = () => {
|
||||
const { backend } = useGrafana();
|
||||
|
||||
const [getInfo, get] = useAsyncFn<(params: GetCorrelationsParams) => Promise<CorrelationsData>>(
|
||||
(params) =>
|
||||
lastValueFrom(
|
||||
async (params) => {
|
||||
return lastValueFrom(
|
||||
backend.fetch<CorrelationsResponse>({
|
||||
url: '/api/datasources/correlations',
|
||||
params: { page: params.page },
|
||||
@@ -100,13 +100,15 @@ export const useCorrelations = () => {
|
||||
})
|
||||
)
|
||||
.then(getData)
|
||||
.then(toEnrichedCorrelationsData),
|
||||
.then(toEnrichedCorrelationsData);
|
||||
},
|
||||
|
||||
[backend]
|
||||
);
|
||||
|
||||
const [createInfo, create] = useAsyncFn<(params: CreateCorrelationParams) => Promise<CorrelationData>>(
|
||||
({ sourceUID, ...correlation }) =>
|
||||
backend
|
||||
async ({ sourceUID, ...correlation }) => {
|
||||
return backend
|
||||
.post<CreateCorrelationResponse>(`/api/datasources/uid/${sourceUID}/correlations`, correlation)
|
||||
.then((response) => {
|
||||
const enrichedCorrelation = toEnrichedCorrelationData(response.result);
|
||||
@@ -115,7 +117,8 @@ export const useCorrelations = () => {
|
||||
} else {
|
||||
throw new Error('invalid sourceUID');
|
||||
}
|
||||
}),
|
||||
});
|
||||
},
|
||||
[backend]
|
||||
);
|
||||
|
||||
|
||||
94
public/app/features/correlations/useCorrelationsK8s.ts
Normal file
94
public/app/features/correlations/useCorrelationsK8s.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { handleRequestError } from '@grafana/api-clients';
|
||||
import {
|
||||
Correlation as CorrelationK8s,
|
||||
useListCorrelationQuery,
|
||||
} from '@grafana/api-clients/rtkq/correlations/v0alpha1';
|
||||
import { SupportedTransformationType } from '@grafana/data';
|
||||
import { CorrelationData, CorrelationExternal, CorrelationQuery, getDataSourceSrv } from '@grafana/runtime';
|
||||
|
||||
import { toEnrichedCorrelationData } from './useCorrelations';
|
||||
|
||||
export const toEnrichedCorrelationDataK8s = (item: CorrelationK8s): CorrelationData | undefined => {
|
||||
const dsSrv = getDataSourceSrv();
|
||||
const sourceDS = dsSrv.getInstanceSettings({ type: item.spec.source.group, uid: item.spec.source.name });
|
||||
if (sourceDS !== undefined) {
|
||||
const baseCor = {
|
||||
uid: item.metadata.name!,
|
||||
sourceUID: sourceDS.uid,
|
||||
label: item.spec.label,
|
||||
description: item.spec.description,
|
||||
provisioned: false, // todo
|
||||
};
|
||||
|
||||
const transformationsFmt = item.spec.config.transformations?.map((trans) => {
|
||||
return {
|
||||
...trans,
|
||||
type: trans.type === 'regex' ? SupportedTransformationType.Regex : SupportedTransformationType.Logfmt,
|
||||
};
|
||||
});
|
||||
|
||||
if (item.spec.type === 'external') {
|
||||
const extCorr: CorrelationExternal = {
|
||||
...baseCor,
|
||||
type: 'external',
|
||||
config: {
|
||||
field: item.spec.config.field,
|
||||
target: {
|
||||
url: item.spec.config?.target?.url || '',
|
||||
},
|
||||
transformations: transformationsFmt,
|
||||
},
|
||||
};
|
||||
return toEnrichedCorrelationData(extCorr);
|
||||
} else {
|
||||
const targetDs = dsSrv.getInstanceSettings({ type: item.spec.target?.group, uid: item.spec.target?.name });
|
||||
if (targetDs !== undefined) {
|
||||
const queryCorr: CorrelationQuery = {
|
||||
...baseCor,
|
||||
type: 'query',
|
||||
targetUID: targetDs.uid,
|
||||
config: {
|
||||
field: item.spec.config.field,
|
||||
target: item.spec.config.target,
|
||||
transformations: transformationsFmt,
|
||||
},
|
||||
};
|
||||
return toEnrichedCorrelationData(queryCorr);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// we're faking traditional pagination here, realistically folks shouldnt have enough correlations to see a performance impact but if they do we can change the ui
|
||||
export const useCorrelationsK8s = (limit = 100, page: number) => {
|
||||
let pagedLimit = limit;
|
||||
if (page > 1) {
|
||||
pagedLimit = limit * page;
|
||||
}
|
||||
|
||||
const { currentData, isLoading, error } = useListCorrelationQuery({ limit: pagedLimit });
|
||||
const startIdx = limit * (page - 1);
|
||||
const pagedData = currentData?.items.slice(startIdx, startIdx + limit) ?? [];
|
||||
|
||||
const enrichedCorrelations =
|
||||
currentData !== undefined
|
||||
? pagedData
|
||||
.filter((i) => i.metadata.name !== undefined)
|
||||
.map((item) => toEnrichedCorrelationDataK8s(item))
|
||||
.filter((i) => i !== undefined)
|
||||
: [];
|
||||
|
||||
const fmtedError = error ? handleRequestError(error) : undefined;
|
||||
|
||||
return {
|
||||
currentData: enrichedCorrelations,
|
||||
isLoading,
|
||||
error: fmtedError,
|
||||
remainingItems: currentData?.metadata.remainingItemCount || 0,
|
||||
doesContinue: currentData?.metadata.continue !== undefined,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { VariableHide } from '@grafana/data';
|
||||
import { SceneGridLayout, SceneVariable, SceneVariableSet, ScopesVariable, TextBoxVariable } from '@grafana/scenes';
|
||||
|
||||
import { DashboardScene } from './DashboardScene';
|
||||
import { VariableControls } from './VariableControls';
|
||||
import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager';
|
||||
|
||||
jest.mock('@grafana/runtime', () => {
|
||||
const runtime = jest.requireActual('@grafana/runtime');
|
||||
return {
|
||||
...runtime,
|
||||
config: {
|
||||
...runtime.config,
|
||||
featureToggles: {
|
||||
dashboardNewLayouts: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('VariableControls', () => {
|
||||
it('should not render scopes variable', () => {
|
||||
const variables = [new ScopesVariable({})];
|
||||
const dashboard = buildScene(variables);
|
||||
dashboard.activate();
|
||||
|
||||
render(<VariableControls dashboard={dashboard} />);
|
||||
|
||||
expect(screen.queryByText('__scopes')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render regular hidden variables', () => {
|
||||
const hiddenVariable = new TextBoxVariable({
|
||||
name: 'HiddenVar',
|
||||
hide: VariableHide.hideVariable,
|
||||
});
|
||||
const variables = [hiddenVariable];
|
||||
const dashboard = buildScene(variables);
|
||||
dashboard.activate();
|
||||
|
||||
render(<VariableControls dashboard={dashboard} />);
|
||||
|
||||
expect(screen.queryByText('HiddenVar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render regular hidden variables in edit mode', async () => {
|
||||
const hiddenVariable = new TextBoxVariable({
|
||||
name: 'HiddenVar',
|
||||
hide: VariableHide.hideVariable,
|
||||
});
|
||||
const variables = [hiddenVariable];
|
||||
const dashboard = buildScene(variables);
|
||||
dashboard.activate();
|
||||
|
||||
dashboard.setState({ isEditing: true });
|
||||
render(<VariableControls dashboard={dashboard} />);
|
||||
|
||||
expect(await screen.findByText('HiddenVar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render variables hidden in controls menu in edit mode', async () => {
|
||||
const dashboard = buildScene([new TextBoxVariable({ name: 'TextVarControls', hide: VariableHide.inControlsMenu })]);
|
||||
dashboard.activate();
|
||||
|
||||
dashboard.setState({ isEditing: true });
|
||||
render(<VariableControls dashboard={dashboard} />);
|
||||
|
||||
expect(screen.queryByText('TextVarControls')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
function buildScene(variables: SceneVariable[] = []) {
|
||||
const dashboard = new DashboardScene({
|
||||
$variables: new SceneVariableSet({ variables }),
|
||||
body: new DefaultGridLayoutManager({
|
||||
grid: new SceneGridLayout({
|
||||
children: [],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
return dashboard;
|
||||
}
|
||||
@@ -39,8 +39,9 @@ export function VariableControls({ dashboard }: { dashboard: DashboardScene }) {
|
||||
? restVariables.filter((v) => v.state.hide !== VariableHide.inControlsMenu)
|
||||
: variables.filter(
|
||||
(v) =>
|
||||
// used for scopes variables, should always be hidden
|
||||
// if we're editing in dynamic dashboards, still shows hidden variable but greyed out
|
||||
(isEditingNewLayouts && v.state.hide === VariableHide.hideVariable) ||
|
||||
(!v.UNSAFE_renderAsHidden && isEditingNewLayouts && v.state.hide === VariableHide.hideVariable) ||
|
||||
v.state.hide !== VariableHide.inControlsMenu
|
||||
);
|
||||
|
||||
|
||||
@@ -140,7 +140,8 @@ export function getAppRoutes(): RouteDescriptor[] {
|
||||
{
|
||||
path: '/datasources/correlations',
|
||||
component: SafeDynamicImport(
|
||||
() => import(/* webpackChunkName: "CorrelationsPage" */ 'app/features/correlations/CorrelationsPage')
|
||||
() =>
|
||||
import(/* webpackChunkName: "CorrelationsPageWrapper" */ 'app/features/correlations/CorrelationsPageWrapper')
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
111
yarn.lock
111
yarn.lock
@@ -1572,65 +1572,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@codemirror/autocomplete@npm:^6.12.0":
|
||||
version: 6.20.0
|
||||
resolution: "@codemirror/autocomplete@npm:6.20.0"
|
||||
dependencies:
|
||||
"@codemirror/language": "npm:^6.0.0"
|
||||
"@codemirror/state": "npm:^6.0.0"
|
||||
"@codemirror/view": "npm:^6.17.0"
|
||||
"@lezer/common": "npm:^1.0.0"
|
||||
checksum: 10/ba3603b860c30dd4f8b7c20085680d2f491022db95fe1f3aa6a58363c64678efb3ba795d715755c8a02121631317cf7fbe44cfa3b4cdb01ebca2b4ed36ea5d8a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@codemirror/commands@npm:^6.3.3":
|
||||
version: 6.10.1
|
||||
resolution: "@codemirror/commands@npm:6.10.1"
|
||||
dependencies:
|
||||
"@codemirror/language": "npm:^6.0.0"
|
||||
"@codemirror/state": "npm:^6.4.0"
|
||||
"@codemirror/view": "npm:^6.27.0"
|
||||
"@lezer/common": "npm:^1.1.0"
|
||||
checksum: 10/9e305263dc457635fa1c7e5b47756958be5367e38f5bb07a3abfd5966591e2eafd57ea0c5c738b28bb3ab5de64c07a5302ebd49b129ff7e48b225841f66e647f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@codemirror/language@npm:^6.0.0, @codemirror/language@npm:^6.10.0":
|
||||
version: 6.12.1
|
||||
resolution: "@codemirror/language@npm:6.12.1"
|
||||
dependencies:
|
||||
"@codemirror/state": "npm:^6.0.0"
|
||||
"@codemirror/view": "npm:^6.23.0"
|
||||
"@lezer/common": "npm:^1.5.0"
|
||||
"@lezer/highlight": "npm:^1.0.0"
|
||||
"@lezer/lr": "npm:^1.0.0"
|
||||
style-mod: "npm:^4.0.0"
|
||||
checksum: 10/a24c3512d38cbb2a20cc3128da0eea074b4a6102b6a5a041b3dfd5e67638fb61dcdf4743ed87708db882df5d72a84d9f891aac6fa68447830989c8e2d9ffa2ba
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@codemirror/state@npm:^6.0.0, @codemirror/state@npm:^6.4.0, @codemirror/state@npm:^6.5.0":
|
||||
version: 6.5.3
|
||||
resolution: "@codemirror/state@npm:6.5.3"
|
||||
dependencies:
|
||||
"@marijn/find-cluster-break": "npm:^1.0.0"
|
||||
checksum: 10/07dc8e06aa3c78bde36fd584d1e1131a529d244474dd36bffc6ad1033701d6628a02259711692d099b2a482ede015930f20106aa8ebc7b251db6f303bc72caa2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.23.0, @codemirror/view@npm:^6.27.0":
|
||||
version: 6.39.9
|
||||
resolution: "@codemirror/view@npm:6.39.9"
|
||||
dependencies:
|
||||
"@codemirror/state": "npm:^6.5.0"
|
||||
crelt: "npm:^1.0.6"
|
||||
style-mod: "npm:^4.1.0"
|
||||
w3c-keyname: "npm:^2.2.4"
|
||||
checksum: 10/9e86b35f31fd4f8b4c2fe608fa6116ddc71261acd842c405de41de1f752268c47ea8e0c400818b4d0481a629e1f773dda9e6f0d24d38ed6a9f6b3d58b2dff669
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@colors/colors@npm:1.5.0":
|
||||
version: 1.5.0
|
||||
resolution: "@colors/colors@npm:1.5.0"
|
||||
@@ -3829,11 +3770,6 @@ __metadata:
|
||||
resolution: "@grafana/ui@workspace:packages/grafana-ui"
|
||||
dependencies:
|
||||
"@babel/core": "npm:7.28.0"
|
||||
"@codemirror/autocomplete": "npm:^6.12.0"
|
||||
"@codemirror/commands": "npm:^6.3.3"
|
||||
"@codemirror/language": "npm:^6.10.0"
|
||||
"@codemirror/state": "npm:^6.4.0"
|
||||
"@codemirror/view": "npm:^6.23.0"
|
||||
"@emotion/css": "npm:11.13.5"
|
||||
"@emotion/react": "npm:11.14.0"
|
||||
"@emotion/serialize": "npm:1.3.3"
|
||||
@@ -3845,7 +3781,6 @@ __metadata:
|
||||
"@grafana/i18n": "npm:12.4.0-pre"
|
||||
"@grafana/schema": "npm:12.4.0-pre"
|
||||
"@hello-pangea/dnd": "npm:18.0.1"
|
||||
"@lezer/highlight": "npm:^1.2.0"
|
||||
"@monaco-editor/react": "npm:4.7.0"
|
||||
"@popperjs/core": "npm:2.11.8"
|
||||
"@rc-component/drawer": "npm:1.3.0"
|
||||
@@ -5257,14 +5192,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@lezer/common@npm:^1.1.0, @lezer/common@npm:^1.5.0":
|
||||
version: 1.5.0
|
||||
resolution: "@lezer/common@npm:1.5.0"
|
||||
checksum: 10/d99a45947c5033476f7c16f475b364e5b276e89a351641d8d785ceac88e8175f7b7b7d43dda80c3d9097f5e3379f018404bbe59a41d15992df23a03bbef3519b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@lezer/highlight@npm:1.2.3, @lezer/highlight@npm:^1.0.0, @lezer/highlight@npm:^1.2.0":
|
||||
"@lezer/highlight@npm:1.2.3":
|
||||
version: 1.2.3
|
||||
resolution: "@lezer/highlight@npm:1.2.3"
|
||||
dependencies:
|
||||
@@ -5282,15 +5210,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@lezer/lr@npm:^1.0.0":
|
||||
version: 1.4.7
|
||||
resolution: "@lezer/lr@npm:1.4.7"
|
||||
dependencies:
|
||||
"@lezer/common": "npm:^1.0.0"
|
||||
checksum: 10/5407e10c8f983eedd8eaace9f2582aac39f7b280cdcf4e396d53ca6c1e654ce1bb2fdbddfbf9a63c8462046be37c8c4da180be7ffaf2d2aa24eb71622f624d85
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@linaria/core@npm:^4.5.4":
|
||||
version: 4.5.4
|
||||
resolution: "@linaria/core@npm:4.5.4"
|
||||
@@ -5468,13 +5387,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@marijn/find-cluster-break@npm:^1.0.0":
|
||||
version: 1.0.2
|
||||
resolution: "@marijn/find-cluster-break@npm:1.0.2"
|
||||
checksum: 10/92fe7ba43ce3d3314f593e4c2fd822d7089649baff47a474fe04b83e3119931d7cf58388747d429ff65fa2db14f5ca57e787268c482e868fc67759511f61f09b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mdx-js/react@npm:^3.0.0":
|
||||
version: 3.0.1
|
||||
resolution: "@mdx-js/react@npm:3.0.1"
|
||||
@@ -15018,13 +14930,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"crelt@npm:^1.0.6":
|
||||
version: 1.0.6
|
||||
resolution: "crelt@npm:1.0.6"
|
||||
checksum: 10/5ed326ca6bd243b1dba6b943f665b21c2c04be03271824bc48f20dba324b0f8233e221f8c67312526d24af2b1243c023dc05a41bd8bd05d1a479fd2c72fb39c3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"croact-css-styled@npm:^1.1.9":
|
||||
version: 1.1.9
|
||||
resolution: "croact-css-styled@npm:1.1.9"
|
||||
@@ -31893,13 +31798,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"style-mod@npm:^4.0.0, style-mod@npm:^4.1.0":
|
||||
version: 4.1.3
|
||||
resolution: "style-mod@npm:4.1.3"
|
||||
checksum: 10/b47465ea953c42e62682a2a366a0946a4aa973cbabb000619acbf5d1c162c94aa019caeb13804e38bed71c2b19b8c778f847542d7e82e9309154ccbb5ef9ca98
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"style-search@npm:^0.1.0":
|
||||
version: 0.1.0
|
||||
resolution: "style-search@npm:0.1.0"
|
||||
@@ -33941,13 +33839,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"w3c-keyname@npm:^2.2.4":
|
||||
version: 2.2.8
|
||||
resolution: "w3c-keyname@npm:2.2.8"
|
||||
checksum: 10/95bafa4c04fa2f685a86ca1000069c1ec43ace1f8776c10f226a73296caeddd83f893db885c2c220ebeb6c52d424e3b54d7c0c1e963bbf204038ff1a944fbb07
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"w3c-xmlserializer@npm:^3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "w3c-xmlserializer@npm:3.0.0"
|
||||
|
||||
Reference in New Issue
Block a user