K8s: Dashboards: Add deletion validation for provisioned dashboards (#98504)

This commit is contained in:
Stephanie Hingtgen
2025-01-08 05:58:21 -07:00
committed by GitHub
parent ce512862f7
commit 5ed2a4c624
8 changed files with 260 additions and 25 deletions
+49 -2
View File
@@ -1,18 +1,26 @@
package dashboard
import (
"context"
"errors"
"fmt"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/admission"
"k8s.io/apiserver/pkg/authorization/authorizer"
genericapiserver "k8s.io/apiserver/pkg/server"
"k8s.io/kube-openapi/pkg/common"
"k8s.io/kube-openapi/pkg/spec3"
"github.com/grafana/authlib/claims"
dashboardinternal "github.com/grafana/grafana/pkg/apis/dashboard"
dashboardv0alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"
dashboardv1alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v1alpha1"
dashboardv2alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v2alpha1"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
)
@@ -22,13 +30,18 @@ var (
)
// This is used just so wire has something unique to return
type DashboardsAPIBuilder struct{}
type DashboardsAPIBuilder struct {
ProvisioningDashboardService dashboards.DashboardProvisioningService
}
func RegisterAPIService(
features featuremgmt.FeatureToggles,
apiregistration builder.APIRegistrar,
provisioningDashboardService dashboards.DashboardProvisioningService,
) *DashboardsAPIBuilder {
builder := &DashboardsAPIBuilder{}
builder := &DashboardsAPIBuilder{
ProvisioningDashboardService: provisioningDashboardService,
}
apiregistration.RegisterAPI(builder)
return builder
}
@@ -63,3 +76,37 @@ func (b *DashboardsAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefiniti
func (b *DashboardsAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) {
return oas, nil
}
// Validate will prevent deletion of provisioned dashboards, unless the grace period is set to 0, indicating a force deletion
func (b *DashboardsAPIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
op := a.GetOperation()
if op == admission.Delete {
obj := a.GetOperationOptions()
deleteOptions, ok := obj.(*metav1.DeleteOptions)
if !ok {
return fmt.Errorf("expected v1.DeleteOptions")
}
if deleteOptions.GracePeriodSeconds == nil || *deleteOptions.GracePeriodSeconds != 0 {
nsInfo, err := claims.ParseNamespace(a.GetNamespace())
if err != nil {
return fmt.Errorf("%v: %w", "failed to parse namespace", err)
}
provisioningData, err := b.ProvisioningDashboardService.GetProvisionedDashboardDataByDashboardUID(ctx, nsInfo.OrgID, a.GetName())
if err != nil {
if errors.Is(err, dashboards.ErrProvisionedDashboardNotFound) {
return nil
}
return fmt.Errorf("%v: %w", "failed to check if dashboard is provisioned", err)
}
if provisioningData != nil {
return dashboards.ErrDashboardCannotDeleteProvisionedDashboard
}
}
}
return nil
}
@@ -0,0 +1,160 @@
package dashboard
import (
"context"
"fmt"
"testing"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/user"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apiserver/pkg/admission"
)
func TestDashboardAPIBuilder_Validate(t *testing.T) {
oneInt64 := int64(1)
zeroInt64 := int64(0)
tests := []struct {
name string
inputObj *v0alpha1.Dashboard
deletionOptions metav1.DeleteOptions
dashboardResponse *dashboards.DashboardProvisioning
dashboardErrorResponse error
checkRan bool
expectedError bool
}{
{
name: "should return an error if data is found",
inputObj: &v0alpha1.Dashboard{
Spec: common.Unstructured{},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
},
},
deletionOptions: metav1.DeleteOptions{
GracePeriodSeconds: nil,
},
dashboardResponse: &dashboards.DashboardProvisioning{ID: 1},
dashboardErrorResponse: nil,
checkRan: true,
expectedError: true,
},
{
name: "should return an error if unable to check",
inputObj: &v0alpha1.Dashboard{
Spec: common.Unstructured{},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
},
},
deletionOptions: metav1.DeleteOptions{
GracePeriodSeconds: nil,
},
dashboardResponse: nil,
dashboardErrorResponse: fmt.Errorf("generic error"),
checkRan: true,
expectedError: true,
},
{
name: "should be okay if error is provisioned dashboard not found",
inputObj: &v0alpha1.Dashboard{
Spec: common.Unstructured{},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
},
},
deletionOptions: metav1.DeleteOptions{
GracePeriodSeconds: nil,
},
dashboardResponse: nil,
dashboardErrorResponse: dashboards.ErrProvisionedDashboardNotFound,
checkRan: true,
expectedError: false,
},
{
name: "Should still run the check for delete if grace period is not 0",
inputObj: &v0alpha1.Dashboard{
Spec: common.Unstructured{},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
},
},
deletionOptions: metav1.DeleteOptions{
GracePeriodSeconds: &oneInt64,
},
dashboardResponse: nil,
dashboardErrorResponse: nil,
checkRan: true,
expectedError: false,
},
{
name: "should not run the check for delete if grace period is set to 0",
inputObj: &v0alpha1.Dashboard{
Spec: common.Unstructured{},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
},
},
deletionOptions: metav1.DeleteOptions{
GracePeriodSeconds: &zeroInt64,
},
dashboardResponse: nil,
dashboardErrorResponse: nil,
checkRan: false,
expectedError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fakeService := &dashboards.FakeDashboardProvisioning{}
fakeService.On("GetProvisionedDashboardDataByDashboardUID", mock.Anything, mock.Anything, mock.Anything).Return(tt.dashboardResponse, tt.dashboardErrorResponse).Once()
b := &DashboardsAPIBuilder{
ProvisioningDashboardService: fakeService,
}
err := b.Validate(context.Background(), admission.NewAttributesRecord(
tt.inputObj,
nil,
v0alpha1.DashboardResourceInfo.GroupVersionKind(),
"stacks-123",
tt.inputObj.Name,
v0alpha1.DashboardResourceInfo.GroupVersionResource(),
"",
admission.Operation("DELETE"),
&tt.deletionOptions,
true,
&user.SignedInUser{},
), nil)
if tt.expectedError {
require.Error(t, err)
} else {
require.NoError(t, err)
}
if tt.checkRan {
fakeService.AssertCalled(t, "GetProvisionedDashboardDataByDashboardUID", mock.Anything, mock.Anything, mock.Anything)
} else {
fakeService.AssertNotCalled(t, "GetProvisionedDashboardDataByDashboardUID", mock.Anything, mock.Anything, mock.Anything)
}
})
}
}
@@ -44,6 +44,7 @@ var (
// This is used just so wire has something unique to return
type DashboardsAPIBuilder struct {
dashboard.DashboardsAPIBuilder
dashboardService dashboards.DashboardService
features featuremgmt.FeatureToggles
@@ -59,6 +60,7 @@ type DashboardsAPIBuilder struct {
func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles,
apiregistration builder.APIRegistrar,
dashboardService dashboards.DashboardService,
provisioningDashboardService dashboards.DashboardProvisioningService,
accessControl accesscontrol.AccessControl,
provisioning provisioning.ProvisioningService,
dashStore dashboards.Store,
@@ -72,7 +74,9 @@ func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles,
namespacer := request.GetNamespaceMapper(cfg)
builder := &DashboardsAPIBuilder{
log: log.New("grafana-apiserver.dashboards.v0alpha1"),
DashboardsAPIBuilder: dashboard.DashboardsAPIBuilder{
ProvisioningDashboardService: provisioningDashboardService,
},
dashboardService: dashboardService,
features: features,
accessControl: accessControl,
@@ -42,6 +42,7 @@ var (
// This is used just so wire has something unique to return
type DashboardsAPIBuilder struct {
dashboard.DashboardsAPIBuilder
dashboardService dashboards.DashboardService
features featuremgmt.FeatureToggles
@@ -56,6 +57,7 @@ type DashboardsAPIBuilder struct {
func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles,
apiregistration builder.APIRegistrar,
dashboardService dashboards.DashboardService,
provisioningDashboardService dashboards.DashboardProvisioningService,
accessControl accesscontrol.AccessControl,
provisioning provisioning.ProvisioningService,
dashStore dashboards.Store,
@@ -69,7 +71,9 @@ func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles,
namespacer := request.GetNamespaceMapper(cfg)
builder := &DashboardsAPIBuilder{
log: log.New("grafana-apiserver.dashboards.v1alpha1"),
DashboardsAPIBuilder: dashboard.DashboardsAPIBuilder{
ProvisioningDashboardService: provisioningDashboardService,
},
dashboardService: dashboardService,
features: features,
accessControl: accessControl,
@@ -42,6 +42,7 @@ var (
// This is used just so wire has something unique to return
type DashboardsAPIBuilder struct {
dashboard.DashboardsAPIBuilder
dashboardService dashboards.DashboardService
features featuremgmt.FeatureToggles
@@ -56,6 +57,7 @@ type DashboardsAPIBuilder struct {
func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles,
apiregistration builder.APIRegistrar,
dashboardService dashboards.DashboardService,
provisioningDashboardService dashboards.DashboardProvisioningService,
accessControl accesscontrol.AccessControl,
provisioning provisioning.ProvisioningService,
dashStore dashboards.Store,
@@ -70,6 +72,9 @@ func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles,
builder := &DashboardsAPIBuilder{
log: log.New("grafana-apiserver.dashboards.v2alpha1"),
DashboardsAPIBuilder: dashboard.DashboardsAPIBuilder{
ProvisioningDashboardService: provisioningDashboardService,
},
dashboardService: dashboardService,
features: features,
accessControl: accessControl,