K8s: Remove restore functionality; can be done with list (#102560)

This commit is contained in:
Stephanie Hingtgen
2025-03-20 16:38:32 -05:00
committed by GitHub
parent 92cc10f983
commit c33a53a47a
32 changed files with 732 additions and 1873 deletions
+2 -4
View File
@@ -41,11 +41,9 @@ func GetAuthorizer(dashboardService dashboards.DashboardService, l log.Logger) a
}
// expensive path to lookup permissions for a single dashboard
// must include deleted to allow for restores
dto, err := dashboardService.GetDashboard(ctx, &dashboards.GetDashboardQuery{
UID: attr.GetName(),
OrgID: info.OrgID,
IncludeDeleted: true,
UID: attr.GetName(),
OrgID: info.OrgID,
})
if err != nil {
return authorizer.DecisionDeny, "error loading dashboard", err
-103
View File
@@ -1,103 +0,0 @@
package dashboard
import (
"context"
"fmt"
"net/http"
"strconv"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/registry/rest"
"k8s.io/apiserver/pkg/storage"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/storage/unified/resource"
)
// LatestConnector will return the latest version of the resource - even if it is deleted
type LatestConnector interface {
rest.Storage
rest.Connecter
rest.StorageMetadata
}
func NewLatestConnector(unified resource.ResourceClient, gr schema.GroupResource) LatestConnector {
return &latestREST{
unified: unified,
gr: gr,
}
}
type latestREST struct {
unified resource.ResourceClient
gr schema.GroupResource
}
func (l *latestREST) New() runtime.Object {
return &metav1.PartialObjectMetadataList{}
}
func (l *latestREST) Destroy() {
}
func (l *latestREST) ConnectMethods() []string {
return []string{"GET"}
}
func (l *latestREST) ProducesMIMETypes(verb string) []string {
return nil
}
func (l *latestREST) ProducesObject(verb string) interface{} {
return &metav1.PartialObjectMetadataList{}
}
func (l *latestREST) NewConnectOptions() (runtime.Object, bool, string) {
return nil, false, ""
}
func (l *latestREST) Connect(ctx context.Context, uid string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
info, err := request.NamespaceInfoFrom(ctx, true)
if err != nil {
return nil, err
}
key := &resource.ResourceKey{
Namespace: info.Value,
Group: l.gr.Group,
Resource: l.gr.Resource,
Name: uid,
}
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
rsp, err := l.unified.Read(ctx, &resource.ReadRequest{
Key: key,
ResourceVersion: 0, // 0 will return the latest version that was not a delete event
IncludeDeleted: true,
})
if err != nil {
responder.Error(err)
return
} else if rsp == nil || (rsp.Error != nil && rsp.Error.Code == http.StatusNotFound) {
responder.Error(storage.NewKeyNotFoundError(uid, 0))
return
} else if rsp.Error != nil {
responder.Error(fmt.Errorf("could not retrieve object: %s", rsp.Error.Message))
return
}
uncastObj, err := runtime.Decode(unstructured.UnstructuredJSONScheme, rsp.Value)
if err != nil {
responder.Error(fmt.Errorf("could not convert object: %s", err.Error()))
return
}
finalObj := uncastObj.(*unstructured.Unstructured)
finalObj.SetResourceVersion(strconv.FormatInt(rsp.ResourceVersion, 10))
responder.Object(http.StatusOK, finalObj)
}), nil
}
@@ -1,99 +0,0 @@
package dashboard
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"testing"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/stretchr/testify/assert"
"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"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/endpoints/request"
)
func TestLatest(t *testing.T) {
gr := schema.GroupResource{
Group: "group",
Resource: "resource",
}
ctx := context.Background()
mockResponder := &mockResponder{}
mockClient := &mockResourceClient{}
r := &latestREST{
unified: mockClient,
gr: gr,
}
t.Run("no namespace in context", func(t *testing.T) {
_, err := r.Connect(ctx, "test-uid", nil, mockResponder)
require.Error(t, err)
})
ctx = request.WithNamespace(context.Background(), "default")
t.Run("happy path", func(t *testing.T) {
req := httptest.NewRequest("GET", "/latest", nil)
w := httptest.NewRecorder()
readReq := &resource.ReadRequest{
Key: &resource.ResourceKey{
Namespace: "default",
Group: "group",
Resource: "resource",
Name: "uid",
},
ResourceVersion: 0,
IncludeDeleted: true,
}
expectedObject := &metav1.PartialObjectMetadata{
TypeMeta: metav1.TypeMeta{
Kind: "resource",
APIVersion: "v0alpha1",
},
ObjectMeta: metav1.ObjectMeta{
Name: "uid",
Namespace: "default",
ResourceVersion: strconv.FormatInt(123, 10),
},
}
val, err := json.Marshal(expectedObject)
require.NoError(t, err)
mockClient.On("Read", ctx, readReq).Return(&resource.ReadResponse{
ResourceVersion: 123,
Value: val,
}, nil).Once()
mockResponder.On("Object", http.StatusOK, mock.MatchedBy(func(obj interface{}) bool {
unstructuredObj, ok := obj.(*unstructured.Unstructured)
expectedMap := map[string]interface{}{
"apiVersion": expectedObject.APIVersion,
"kind": expectedObject.Kind,
"metadata": map[string]interface{}{
"name": expectedObject.Name,
"namespace": expectedObject.Namespace,
"resourceVersion": expectedObject.ResourceVersion,
"creationTimestamp": nil,
},
}
return ok && reflect.DeepEqual(unstructuredObj.Object, expectedMap)
}))
handler, err := r.Connect(ctx, "uid", nil, mockResponder)
require.NoError(t, err)
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
mockClient.AssertExpectations(t)
mockResponder.AssertExpectations(t)
})
}
@@ -70,11 +70,6 @@ func (d *directResourceClient) Read(ctx context.Context, in *resource.ReadReques
return d.server.Read(ctx, in)
}
// Restore implements ResourceClient.
func (d *directResourceClient) Restore(ctx context.Context, in *resource.RestoreRequest, opts ...grpc.CallOption) (*resource.RestoreResponse, error) {
return d.server.Restore(ctx, in)
}
// Search implements ResourceClient.
func (d *directResourceClient) Search(ctx context.Context, in *resource.ResourceSearchRequest, opts ...grpc.CallOption) (*resource.ResourceSearchResponse, error) {
return d.server.Search(ctx, in)
-5
View File
@@ -280,11 +280,6 @@ func (b *DashboardsAPIBuilder) storageForVersion(
return err
}
if b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesRestore) {
storage[dashboards.StoragePath("restore")] = NewRestoreConnector(b.unified, gr)
storage[dashboards.StoragePath("latest")] = NewLatestConnector(b.unified, gr)
}
// Register the DTO endpoint that will consolidate all dashboard bits
storage[dashboards.StoragePath("dto")], err = NewDTOConnector(
storage[dashboards.StoragePath()].(rest.Getter),
-122
View File
@@ -1,122 +0,0 @@
package dashboard
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/registry/rest"
"k8s.io/apiserver/pkg/storage"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/storage/unified/resource"
)
type RestoreConnector interface {
rest.Storage
rest.Connecter
rest.StorageMetadata
}
func NewRestoreConnector(unified resource.ResourceClient, gr schema.GroupResource) RestoreConnector {
return &restoreREST{
unified: unified,
gr: gr,
}
}
type restoreREST struct {
unified resource.ResourceClient
gr schema.GroupResource
}
func (r *restoreREST) New() runtime.Object {
return &metav1.PartialObjectMetadataList{}
}
func (r *restoreREST) Destroy() {
}
func (r *restoreREST) ConnectMethods() []string {
return []string{"POST"}
}
func (r *restoreREST) ProducesMIMETypes(verb string) []string {
return nil
}
func (r *restoreREST) ProducesObject(verb string) interface{} {
return &metav1.PartialObjectMetadataList{}
}
func (r *restoreREST) NewConnectOptions() (runtime.Object, bool, string) {
return nil, false, ""
}
type RestoreOptions struct {
ResourceVersion int64 `json:"resourceVersion"`
}
func (r *restoreREST) Connect(ctx context.Context, uid string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
info, err := request.NamespaceInfoFrom(ctx, true)
if err != nil {
return nil, err
}
key := &resource.ResourceKey{
Namespace: info.Value,
Group: r.gr.Group,
Resource: r.gr.Resource,
Name: uid,
}
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
body, err := io.ReadAll(req.Body)
if err != nil {
responder.Error(fmt.Errorf("unable to read request body: %s", err.Error()))
return
}
reqBody := &RestoreOptions{}
err = json.Unmarshal(body, &reqBody)
if err != nil {
responder.Error(fmt.Errorf("unable to unmarshal request body: %s", err.Error()))
return
}
if reqBody.ResourceVersion == 0 {
responder.Error(fmt.Errorf("resource version required"))
return
}
rsp, err := r.unified.Restore(ctx, &resource.RestoreRequest{
ResourceVersion: reqBody.ResourceVersion,
Key: key,
})
if err != nil {
responder.Error(err)
return
} else if rsp == nil || (rsp.Error != nil && rsp.Error.Code == http.StatusNotFound) {
responder.Error(storage.NewKeyNotFoundError(uid, reqBody.ResourceVersion))
return
} else if rsp.Error != nil {
responder.Error(fmt.Errorf("could not re-create object: %s", rsp.Error.Message))
return
}
obj := metav1.PartialObjectMetadata{
ObjectMeta: metav1.ObjectMeta{
Name: key.Name,
Namespace: key.Namespace,
ResourceVersion: strconv.FormatInt(rsp.ResourceVersion, 10),
},
}
responder.Object(http.StatusOK, &obj)
}), nil
}
-126
View File
@@ -1,126 +0,0 @@
package dashboard
import (
"bytes"
"context"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/endpoints/request"
)
type mockResourceClient struct {
mock.Mock
resource.ResourceClient
}
func (m *mockResourceClient) Restore(ctx context.Context, req *resource.RestoreRequest, opts ...grpc.CallOption) (*resource.RestoreResponse, error) {
args := m.Called(ctx, req)
return args.Get(0).(*resource.RestoreResponse), args.Error(1)
}
func (m *mockResourceClient) Read(ctx context.Context, req *resource.ReadRequest, opts ...grpc.CallOption) (*resource.ReadResponse, error) {
args := m.Called(ctx, req)
return args.Get(0).(*resource.ReadResponse), args.Error(1)
}
type mockResponder struct {
mock.Mock
}
func (m *mockResponder) Object(statusCode int, obj runtime.Object) {
m.Called(statusCode, obj)
}
func (m *mockResponder) Error(err error) {
m.Called(err)
}
func TestRestore(t *testing.T) {
gr := schema.GroupResource{
Group: "group",
Resource: "resource",
}
ctx := context.Background()
mockResponder := &mockResponder{}
mockClient := &mockResourceClient{}
r := &restoreREST{
unified: mockClient,
gr: gr,
}
t.Run("no namespace in context", func(t *testing.T) {
_, err := r.Connect(ctx, "test-uid", nil, mockResponder)
assert.Error(t, err)
})
ctx = request.WithNamespace(context.Background(), "default")
t.Run("invalid resourceVersion", func(t *testing.T) {
req := httptest.NewRequest("POST", "/restore", bytes.NewReader([]byte(`{"resourceVersion":0}`)))
w := httptest.NewRecorder()
expectedError := fmt.Errorf("resource version required")
mockResponder.On("Error", mock.MatchedBy(func(err error) bool {
return err.Error() == expectedError.Error()
}))
handler, err := r.Connect(ctx, "test-uid", nil, mockResponder)
assert.NoError(t, err)
handler.ServeHTTP(w, req)
mockResponder.AssertExpectations(t)
})
t.Run("happy path", func(t *testing.T) {
req := httptest.NewRequest("POST", "/restore", bytes.NewReader([]byte(`{"resourceVersion":123}`)))
w := httptest.NewRecorder()
restoreReq := &resource.RestoreRequest{
ResourceVersion: 123,
Key: &resource.ResourceKey{
Namespace: "default",
Group: "group",
Resource: "resource",
Name: "uid",
},
}
expectedObject := &metav1.PartialObjectMetadata{
ObjectMeta: metav1.ObjectMeta{
Name: "uid",
Namespace: "default",
ResourceVersion: strconv.FormatInt(123, 10),
},
}
mockClient.On("Restore", ctx, restoreReq).Return(&resource.RestoreResponse{
ResourceVersion: 123,
}, nil).Once()
mockResponder.On("Object", http.StatusOK, mock.MatchedBy(func(obj interface{}) bool {
metadata, ok := obj.(*metav1.PartialObjectMetadata)
return ok &&
metadata.ObjectMeta.Name == "uid" &&
metadata.ObjectMeta.Namespace == "default" &&
metadata.ObjectMeta.ResourceVersion == "123"
})).Return(expectedObject)
handler, err := r.Connect(ctx, "uid", nil, mockResponder)
assert.NoError(t, err)
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
mockClient.AssertExpectations(t)
mockResponder.AssertExpectations(t)
})
}
+1 -1
View File
@@ -459,7 +459,7 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
}
if folderUidIdx == -1 {
return sharedDashboards, fmt.Errorf("Error retrieving folder information")
return sharedDashboards, fmt.Errorf("error retrieving folder information")
}
// populate list of unique folder UIDs in the list of dashboards user has read permissions
@@ -692,9 +692,6 @@ func (m *MockClient) Update(ctx context.Context, in *resource.UpdateRequest, opt
func (m *MockClient) Read(ctx context.Context, in *resource.ReadRequest, opts ...grpc.CallOption) (*resource.ReadResponse, error) {
return nil, nil
}
func (m *MockClient) Restore(ctx context.Context, in *resource.RestoreRequest, opts ...grpc.CallOption) (*resource.RestoreResponse, error) {
return nil, nil
}
func (m *MockClient) GetBlob(ctx context.Context, in *resource.GetBlobRequest, opts ...grpc.CallOption) (*resource.GetBlobResponse, error) {
return nil, nil
}