mirror of https://github.com/grafana/grafana
Dashboards: Add k8s fallback client (#103404)
parent
b329b78ef6
commit
427715b070
@ -0,0 +1,157 @@ |
||||
package client |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
"sync" |
||||
|
||||
"go.opentelemetry.io/otel/attribute" |
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" |
||||
"k8s.io/apimachinery/pkg/runtime/schema" |
||||
|
||||
dashboardv1alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" |
||||
"github.com/grafana/grafana/pkg/infra/log" |
||||
"github.com/grafana/grafana/pkg/infra/tracing" |
||||
"github.com/grafana/grafana/pkg/services/apiserver" |
||||
"github.com/grafana/grafana/pkg/services/apiserver/client" |
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" |
||||
"github.com/grafana/grafana/pkg/services/dashboards" |
||||
"github.com/grafana/grafana/pkg/services/search/sort" |
||||
"github.com/grafana/grafana/pkg/services/user" |
||||
"github.com/grafana/grafana/pkg/setting" |
||||
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" |
||||
"github.com/grafana/grafana/pkg/storage/unified/resource" |
||||
"github.com/prometheus/client_golang/prometheus" |
||||
) |
||||
|
||||
type K8sClientFactory func(ctx context.Context, version string) client.K8sHandler |
||||
|
||||
type K8sClientWithFallback struct { |
||||
client.K8sHandler |
||||
|
||||
newClientFunc K8sClientFactory |
||||
metrics *k8sClientMetrics |
||||
log log.Logger |
||||
} |
||||
|
||||
func NewK8sClientWithFallback( |
||||
cfg *setting.Cfg, |
||||
restConfigProvider apiserver.RestConfigProvider, |
||||
dashboardStore dashboards.Store, |
||||
userService user.Service, |
||||
resourceClient resource.ResourceClient, |
||||
sorter sort.Service, |
||||
dual dualwrite.Service, |
||||
reg prometheus.Registerer, |
||||
) *K8sClientWithFallback { |
||||
newClientFunc := newK8sClientFactory(cfg, restConfigProvider, dashboardStore, userService, resourceClient, sorter, dual) |
||||
return &K8sClientWithFallback{ |
||||
K8sHandler: newClientFunc(context.Background(), dashboardv1alpha1.VERSION), |
||||
newClientFunc: newClientFunc, |
||||
metrics: newK8sClientMetrics(reg), |
||||
log: log.New("dashboards-k8s-client"), |
||||
} |
||||
} |
||||
|
||||
func (h *K8sClientWithFallback) Get(ctx context.Context, name string, orgID int64, options metav1.GetOptions, subresources ...string) (*unstructured.Unstructured, error) { |
||||
spanCtx, span := tracing.Start(ctx, "versionFallbackK8sHandler.Get") |
||||
defer span.End() |
||||
|
||||
span.SetAttributes( |
||||
attribute.String("dashboard.metadata.name", name), |
||||
attribute.Int64("org.id", orgID), |
||||
attribute.Bool("fallback", false), |
||||
) |
||||
|
||||
span.AddEvent("v1alpha1 Get") |
||||
result, err := h.K8sHandler.Get(spanCtx, name, orgID, options, subresources...) |
||||
if err != nil { |
||||
return nil, tracing.Error(span, err) |
||||
} |
||||
|
||||
failed, storedVersion, conversionErr := getConversionStatus(result) |
||||
if !failed { |
||||
// if the conversion did not fail, there is no need to fallback.
|
||||
return result, nil |
||||
} |
||||
|
||||
h.log.Info("falling back to stored version", "name", name, "storedVersion", storedVersion, "conversionErr", conversionErr) |
||||
h.metrics.fallbackCounter.WithLabelValues(storedVersion).Inc() |
||||
|
||||
span.SetAttributes( |
||||
attribute.Bool("fallback", true), |
||||
attribute.String("fallback.stored_version", storedVersion), |
||||
attribute.String("fallback.conversion_error", conversionErr), |
||||
) |
||||
|
||||
span.AddEvent(fmt.Sprintf("%s Get", storedVersion)) |
||||
return h.newClientFunc(spanCtx, storedVersion).Get(spanCtx, name, orgID, options, subresources...) |
||||
} |
||||
|
||||
func getConversionStatus(obj *unstructured.Unstructured) (failed bool, storedVersion string, conversionErr string) { |
||||
status, found, _ := unstructured.NestedMap(obj.Object, "status") |
||||
if !found { |
||||
return false, "", "" |
||||
} |
||||
conversionStatus, found, _ := unstructured.NestedMap(status, "conversion") |
||||
if !found { |
||||
return false, "", "" |
||||
} |
||||
failed, _, _ = unstructured.NestedBool(conversionStatus, "failed") |
||||
storedVersion, _, _ = unstructured.NestedString(conversionStatus, "storedVersion") |
||||
conversionErr, _, _ = unstructured.NestedString(conversionStatus, "error") |
||||
return failed, storedVersion, conversionErr |
||||
} |
||||
|
||||
func newK8sClientFactory( |
||||
cfg *setting.Cfg, |
||||
restConfigProvider apiserver.RestConfigProvider, |
||||
dashboardStore dashboards.Store, |
||||
userService user.Service, |
||||
resourceClient resource.ResourceClient, |
||||
sorter sort.Service, |
||||
dual dualwrite.Service, |
||||
) K8sClientFactory { |
||||
clientCache := make(map[string]client.K8sHandler) |
||||
cacheMutex := &sync.RWMutex{} |
||||
return func(ctx context.Context, version string) client.K8sHandler { |
||||
_, span := tracing.Start(ctx, "k8sClientFactory.GetClient", |
||||
attribute.String("group", dashboardv1alpha1.GROUP), |
||||
attribute.String("version", version), |
||||
attribute.String("resource", "dashboards"), |
||||
) |
||||
defer span.End() |
||||
|
||||
cacheMutex.RLock() |
||||
cachedClient, exists := clientCache[version] |
||||
cacheMutex.RUnlock() |
||||
|
||||
if exists { |
||||
span.AddEvent("Client found in cache") |
||||
return cachedClient |
||||
} |
||||
|
||||
cacheMutex.Lock() |
||||
defer cacheMutex.Unlock() |
||||
|
||||
// check again in case another goroutine created in between locks
|
||||
cachedClient, exists = clientCache[version] |
||||
if exists { |
||||
span.AddEvent("Client found in cache after lock") |
||||
return cachedClient |
||||
} |
||||
|
||||
gvr := schema.GroupVersionResource{ |
||||
Group: dashboardv1alpha1.GROUP, |
||||
Version: version, |
||||
Resource: "dashboards", |
||||
} |
||||
|
||||
span.AddEvent("Creating new client") |
||||
newClient := client.NewK8sHandler(dual, request.GetNamespaceMapper(cfg), gvr, restConfigProvider.GetRestConfig, dashboardStore, userService, resourceClient, sorter) |
||||
clientCache[version] = newClient |
||||
|
||||
return newClient |
||||
} |
||||
} |
@ -0,0 +1,357 @@ |
||||
package client |
||||
|
||||
import ( |
||||
"context" |
||||
"errors" |
||||
"testing" |
||||
|
||||
"github.com/prometheus/client_golang/prometheus" |
||||
"github.com/stretchr/testify/mock" |
||||
"github.com/stretchr/testify/require" |
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" |
||||
|
||||
dashboardv1alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" |
||||
"github.com/grafana/grafana/pkg/infra/log" |
||||
"github.com/grafana/grafana/pkg/services/apiserver/client" |
||||
) |
||||
|
||||
type testSetup struct { |
||||
handler *K8sClientWithFallback |
||||
mockClientV1Alpha1 *client.MockK8sHandler |
||||
mockClientV2Alpha1 *client.MockK8sHandler |
||||
mockMetrics *k8sClientMetrics |
||||
mockFactoryCalls map[string]int |
||||
t *testing.T |
||||
} |
||||
|
||||
func setupTest(t *testing.T) *testSetup { |
||||
mockClientV1Alpha1 := &client.MockK8sHandler{} |
||||
mockClientV2Alpha1 := &client.MockK8sHandler{} |
||||
mockMetrics := newK8sClientMetrics(prometheus.NewRegistry()) |
||||
mockFactoryCalls := make(map[string]int) |
||||
|
||||
handler := &K8sClientWithFallback{ |
||||
K8sHandler: mockClientV1Alpha1, |
||||
newClientFunc: func(ctx context.Context, version string) client.K8sHandler { |
||||
mockFactoryCalls[version]++ |
||||
if version == "v2alpha1" { |
||||
return mockClientV2Alpha1 |
||||
} |
||||
if version == dashboardv1alpha1.VERSION { |
||||
return mockClientV1Alpha1 |
||||
} |
||||
t.Fatalf("Unexpected call to newClientFunc with version %s", version) |
||||
return nil |
||||
}, |
||||
log: log.New("test"), |
||||
metrics: mockMetrics, |
||||
} |
||||
|
||||
return &testSetup{ |
||||
handler: handler, |
||||
mockClientV1Alpha1: mockClientV1Alpha1, |
||||
mockClientV2Alpha1: mockClientV2Alpha1, |
||||
mockMetrics: mockMetrics, |
||||
mockFactoryCalls: mockFactoryCalls, |
||||
t: t, |
||||
} |
||||
} |
||||
|
||||
func TestK8sHandlerWithFallback_Get(t *testing.T) { |
||||
t.Run("Get without fallback", func(t *testing.T) { |
||||
setup := setupTest(t) |
||||
|
||||
ctx := context.Background() |
||||
name := "test-dashboard" |
||||
orgID := int64(1) |
||||
options := metav1.GetOptions{} |
||||
|
||||
expectedResult := &unstructured.Unstructured{ |
||||
Object: map[string]interface{}{ |
||||
"metadata": map[string]interface{}{ |
||||
"name": name, |
||||
}, |
||||
"status": map[string]interface{}{ |
||||
"someOtherStatus": "ok", |
||||
}, |
||||
}, |
||||
} |
||||
|
||||
setup.mockClientV1Alpha1.On("Get", mock.Anything, name, orgID, options, mock.Anything).Return(expectedResult, nil).Once() |
||||
|
||||
result, err := setup.handler.Get(ctx, name, orgID, options) |
||||
require.NoError(t, err) |
||||
require.Equal(t, expectedResult, result) |
||||
require.Equal(t, 0, len(setup.mockFactoryCalls), "Factory should not be called for non-fallback case") |
||||
|
||||
setup.mockClientV1Alpha1.AssertExpectations(t) |
||||
setup.mockClientV2Alpha1.AssertExpectations(t) |
||||
}) |
||||
|
||||
t.Run("Get with fallback due to conversion error", func(t *testing.T) { |
||||
setup := setupTest(t) |
||||
|
||||
ctx := context.Background() |
||||
name := "test-dashboard-fallback" |
||||
orgID := int64(2) |
||||
options := metav1.GetOptions{ResourceVersion: "123"} |
||||
storedVersion := "v2alpha1" |
||||
conversionErr := "failed to convert" |
||||
|
||||
v1alpha1Result := &unstructured.Unstructured{ |
||||
Object: map[string]interface{}{ |
||||
"metadata": map[string]interface{}{ |
||||
"name": name, |
||||
}, |
||||
"status": map[string]interface{}{ |
||||
"conversion": map[string]interface{}{ |
||||
"failed": true, |
||||
"storedVersion": storedVersion, |
||||
"error": conversionErr, |
||||
}, |
||||
}, |
||||
}, |
||||
} |
||||
|
||||
expectedResultFallback := &unstructured.Unstructured{ |
||||
Object: map[string]interface{}{ |
||||
"apiVersion": "dashboard/v2alpha1", |
||||
"kind": "Dashboard", |
||||
"metadata": map[string]interface{}{ |
||||
"name": name, |
||||
}, |
||||
}, |
||||
} |
||||
|
||||
setup.mockClientV1Alpha1.On("Get", mock.Anything, name, orgID, options, mock.Anything).Return(v1alpha1Result, nil).Once() |
||||
setup.mockClientV2Alpha1.On("Get", mock.Anything, name, orgID, options, mock.Anything).Return(expectedResultFallback, nil).Once() |
||||
|
||||
result, err := setup.handler.Get(ctx, name, orgID, options) |
||||
require.NoError(t, err) |
||||
require.Equal(t, expectedResultFallback, result) |
||||
require.Equal(t, 1, setup.mockFactoryCalls["v2alpha1"], "Factory should be called once with v2alpha1") |
||||
|
||||
setup.mockClientV1Alpha1.AssertExpectations(t) |
||||
setup.mockClientV2Alpha1.AssertExpectations(t) |
||||
}) |
||||
|
||||
t.Run("Get initial error", func(t *testing.T) { |
||||
setup := setupTest(t) |
||||
|
||||
ctx := context.Background() |
||||
name := "test-dashboard-error" |
||||
orgID := int64(3) |
||||
options := metav1.GetOptions{} |
||||
expectedErr := errors.New("initial get failed") |
||||
|
||||
setup.mockClientV1Alpha1.On("Get", mock.Anything, name, orgID, options, mock.Anything).Return(nil, expectedErr).Once() |
||||
|
||||
_, err := setup.handler.Get(ctx, name, orgID, options) |
||||
require.Error(t, err) |
||||
require.Equal(t, expectedErr, err) |
||||
require.Equal(t, 0, len(setup.mockFactoryCalls), "Factory should not be called for error case") |
||||
|
||||
setup.mockClientV1Alpha1.AssertExpectations(t) |
||||
setup.mockClientV2Alpha1.AssertExpectations(t) |
||||
}) |
||||
|
||||
t.Run("Get with fallback fails", func(t *testing.T) { |
||||
setup := setupTest(t) |
||||
|
||||
ctx := context.Background() |
||||
name := "test-dashboard-fallback-error" |
||||
orgID := int64(4) |
||||
options := metav1.GetOptions{} |
||||
storedVersion := "v2alpha1" |
||||
conversionErr := "failed to convert again" |
||||
fallbackErr := errors.New("fallback get failed") |
||||
|
||||
v1alpha1Result := &unstructured.Unstructured{ |
||||
Object: map[string]interface{}{ |
||||
"metadata": map[string]interface{}{ |
||||
"name": name, |
||||
}, |
||||
"status": map[string]interface{}{ |
||||
"conversion": map[string]interface{}{ |
||||
"failed": true, |
||||
"storedVersion": storedVersion, |
||||
"error": conversionErr, |
||||
}, |
||||
}, |
||||
}, |
||||
} |
||||
|
||||
setup.mockClientV1Alpha1.On("Get", mock.Anything, name, orgID, options, mock.Anything).Return(v1alpha1Result, nil).Once() |
||||
setup.mockClientV2Alpha1.On("Get", mock.Anything, name, orgID, options, mock.Anything).Return(nil, fallbackErr).Once() |
||||
|
||||
_, err := setup.handler.Get(ctx, name, orgID, options) |
||||
require.Error(t, err) |
||||
require.Equal(t, fallbackErr, err) |
||||
require.Equal(t, 1, setup.mockFactoryCalls["v2alpha1"], "Factory should be called once with v2alpha1") |
||||
|
||||
setup.mockClientV1Alpha1.AssertExpectations(t) |
||||
setup.mockClientV2Alpha1.AssertExpectations(t) |
||||
}) |
||||
} |
||||
|
||||
func TestGetConversionStatus(t *testing.T) { |
||||
tests := []struct { |
||||
name string |
||||
obj *unstructured.Unstructured |
||||
expectedFailed bool |
||||
expectedStoredVersion string |
||||
expectedError string |
||||
}{ |
||||
{ |
||||
name: "No status field", |
||||
obj: &unstructured.Unstructured{Object: map[string]interface{}{ |
||||
"metadata": map[string]interface{}{"name": "test"}, |
||||
}}, |
||||
expectedFailed: false, |
||||
expectedStoredVersion: "", |
||||
expectedError: "", |
||||
}, |
||||
{ |
||||
name: "Status field, but no conversion field", |
||||
obj: &unstructured.Unstructured{Object: map[string]interface{}{ |
||||
"metadata": map[string]interface{}{"name": "test"}, |
||||
"status": map[string]interface{}{"someOtherStatus": "ok"}, |
||||
}}, |
||||
expectedFailed: false, |
||||
expectedStoredVersion: "", |
||||
expectedError: "", |
||||
}, |
||||
{ |
||||
name: "Conversion field, failed=true, with storedVersion and error", |
||||
obj: &unstructured.Unstructured{Object: map[string]interface{}{ |
||||
"metadata": map[string]interface{}{"name": "test"}, |
||||
"status": map[string]interface{}{ |
||||
"conversion": map[string]interface{}{ |
||||
"failed": true, |
||||
"storedVersion": "v2alpha1", |
||||
"error": "conversion failed", |
||||
}, |
||||
}, |
||||
}}, |
||||
expectedFailed: true, |
||||
expectedStoredVersion: "v2alpha1", |
||||
expectedError: "conversion failed", |
||||
}, |
||||
{ |
||||
name: "Conversion field, failed=false", |
||||
obj: &unstructured.Unstructured{Object: map[string]interface{}{ |
||||
"metadata": map[string]interface{}{"name": "test"}, |
||||
"status": map[string]interface{}{ |
||||
"conversion": map[string]interface{}{ |
||||
"failed": false, |
||||
"storedVersion": "v1alpha1", |
||||
"error": "", |
||||
}, |
||||
}, |
||||
}}, |
||||
expectedFailed: false, |
||||
expectedStoredVersion: "v1alpha1", |
||||
expectedError: "", |
||||
}, |
||||
{ |
||||
name: "Conversion field, missing failed (defaults to false)", |
||||
obj: &unstructured.Unstructured{Object: map[string]interface{}{ |
||||
"metadata": map[string]interface{}{"name": "test"}, |
||||
"status": map[string]interface{}{ |
||||
"conversion": map[string]interface{}{ |
||||
|
||||
"storedVersion": "v1alpha1", |
||||
"error": "", |
||||
}, |
||||
}, |
||||
}}, |
||||
expectedFailed: false, |
||||
expectedStoredVersion: "v1alpha1", |
||||
expectedError: "", |
||||
}, |
||||
{ |
||||
name: "Conversion field, failed=true, missing storedVersion", |
||||
obj: &unstructured.Unstructured{Object: map[string]interface{}{ |
||||
"metadata": map[string]interface{}{"name": "test"}, |
||||
"status": map[string]interface{}{ |
||||
"conversion": map[string]interface{}{ |
||||
"failed": true, |
||||
|
||||
"error": "conversion failed", |
||||
}, |
||||
}, |
||||
}}, |
||||
expectedFailed: true, |
||||
expectedStoredVersion: "", |
||||
expectedError: "conversion failed", |
||||
}, |
||||
{ |
||||
name: "Conversion field, failed=true, missing error", |
||||
obj: &unstructured.Unstructured{Object: map[string]interface{}{ |
||||
"metadata": map[string]interface{}{"name": "test"}, |
||||
"status": map[string]interface{}{ |
||||
"conversion": map[string]interface{}{ |
||||
"failed": true, |
||||
"storedVersion": "v2alpha1", |
||||
}, |
||||
}, |
||||
}}, |
||||
expectedFailed: true, |
||||
expectedStoredVersion: "v2alpha1", |
||||
expectedError: "", |
||||
}, |
||||
{ |
||||
name: "Empty object", |
||||
obj: &unstructured.Unstructured{Object: map[string]interface{}{}}, |
||||
expectedFailed: false, |
||||
expectedStoredVersion: "", |
||||
expectedError: "", |
||||
}, |
||||
{ |
||||
name: "Nil object", |
||||
obj: nil, |
||||
expectedFailed: false, |
||||
expectedStoredVersion: "", |
||||
expectedError: "", |
||||
}, |
||||
{ |
||||
name: "Status not a map", |
||||
obj: &unstructured.Unstructured{Object: map[string]interface{}{ |
||||
"metadata": map[string]interface{}{"name": "test"}, |
||||
"status": "not a map", |
||||
}}, |
||||
expectedFailed: false, |
||||
expectedStoredVersion: "", |
||||
expectedError: "", |
||||
}, |
||||
{ |
||||
name: "Conversion not a map", |
||||
obj: &unstructured.Unstructured{Object: map[string]interface{}{ |
||||
"metadata": map[string]interface{}{"name": "test"}, |
||||
"status": map[string]interface{}{ |
||||
"conversion": "not a map", |
||||
}, |
||||
}}, |
||||
expectedFailed: false, |
||||
expectedStoredVersion: "", |
||||
expectedError: "", |
||||
}, |
||||
} |
||||
|
||||
for _, tt := range tests { |
||||
t.Run(tt.name, func(t *testing.T) { |
||||
var input *unstructured.Unstructured |
||||
if tt.obj != nil { |
||||
input = tt.obj.DeepCopy() |
||||
} else { |
||||
input = &unstructured.Unstructured{Object: map[string]interface{}{}} |
||||
} |
||||
|
||||
failed, storedVersion, conversionErr := getConversionStatus(input) |
||||
require.Equal(t, tt.expectedFailed, failed, "failed mismatch") |
||||
require.Equal(t, tt.expectedStoredVersion, storedVersion, "storedVersion mismatch") |
||||
require.Equal(t, tt.expectedError, conversionErr, "conversionErr mismatch") |
||||
}) |
||||
} |
||||
} |
@ -0,0 +1,20 @@ |
||||
package client |
||||
|
||||
import ( |
||||
"github.com/prometheus/client_golang/prometheus" |
||||
"github.com/prometheus/client_golang/prometheus/promauto" |
||||
) |
||||
|
||||
type k8sClientMetrics struct { |
||||
fallbackCounter *prometheus.CounterVec |
||||
} |
||||
|
||||
func newK8sClientMetrics(reg prometheus.Registerer) *k8sClientMetrics { |
||||
return &k8sClientMetrics{ |
||||
fallbackCounter: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{ |
||||
Namespace: "grafana", |
||||
Name: "dashboard_stored_version_fallback_total", |
||||
Help: "Number of K8s dashboard client requests to storedVersion", |
||||
}, []string{"stored_version"}), |
||||
} |
||||
} |
Loading…
Reference in new issue