mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
633 lines
18 KiB
Go
633 lines
18 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only
|
|
// Provenance-includes-location: https://github.com/kubernetes-sigs/apiserver-runtime/blob/main/pkg/experimental/storage/filepath/jsonfile_rest.go
|
|
// Provenance-includes-license: Apache-2.0
|
|
// Provenance-includes-copyright: The Kubernetes Authors.
|
|
|
|
package apistore
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"reflect"
|
|
"strconv"
|
|
"time"
|
|
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
"k8s.io/apimachinery/pkg/api/meta"
|
|
metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/conversion"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
|
"k8s.io/apimachinery/pkg/watch"
|
|
"k8s.io/apiserver/pkg/storage"
|
|
"k8s.io/apiserver/pkg/storage/storagebackend"
|
|
"k8s.io/apiserver/pkg/storage/storagebackend/factory"
|
|
"k8s.io/client-go/tools/cache"
|
|
|
|
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
|
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
|
|
"github.com/grafana/grafana/pkg/apiserver/rest"
|
|
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
|
)
|
|
|
|
const (
|
|
MaxUpdateAttempts = 30
|
|
LargeObjectSupportEnabled = true
|
|
LargeObjectSupportDisabled = false
|
|
)
|
|
|
|
var _ storage.Interface = (*Storage)(nil)
|
|
|
|
// Optional settings that apply to a single resource
|
|
type StorageOptions struct {
|
|
LargeObjectSupport LargeObjectSupport
|
|
InternalConversion func([]byte, runtime.Object) (runtime.Object, error)
|
|
}
|
|
|
|
// Storage implements storage.Interface and storage resources as JSON files on disk.
|
|
type Storage struct {
|
|
gr schema.GroupResource
|
|
codec runtime.Codec
|
|
keyFunc func(obj runtime.Object) (string, error)
|
|
newFunc func() runtime.Object
|
|
newListFunc func() runtime.Object
|
|
getAttrsFunc storage.AttrFunc
|
|
trigger storage.IndexerFuncs
|
|
indexers *cache.Indexers
|
|
|
|
store resource.ResourceClient
|
|
getKey func(string) (*resource.ResourceKey, error)
|
|
|
|
versioner storage.Versioner
|
|
|
|
// Resource options like large object support
|
|
opts StorageOptions
|
|
}
|
|
|
|
// ErrFileNotExists means the file doesn't actually exist.
|
|
var ErrFileNotExists = fmt.Errorf("file doesn't exist")
|
|
|
|
// ErrNamespaceNotExists means the directory for the namespace doesn't actually exist.
|
|
var ErrNamespaceNotExists = errors.New("namespace does not exist")
|
|
|
|
// NewStorage instantiates a new Storage.
|
|
func NewStorage(
|
|
config *storagebackend.ConfigForResource,
|
|
store resource.ResourceClient,
|
|
keyFunc func(obj runtime.Object) (string, error),
|
|
keyParser func(key string) (*resource.ResourceKey, error),
|
|
newFunc func() runtime.Object,
|
|
newListFunc func() runtime.Object,
|
|
getAttrsFunc storage.AttrFunc,
|
|
trigger storage.IndexerFuncs,
|
|
indexers *cache.Indexers,
|
|
opts StorageOptions,
|
|
) (storage.Interface, factory.DestroyFunc, error) {
|
|
s := &Storage{
|
|
store: store,
|
|
gr: config.GroupResource,
|
|
codec: config.Codec,
|
|
keyFunc: keyFunc,
|
|
newFunc: newFunc,
|
|
newListFunc: newListFunc,
|
|
getAttrsFunc: getAttrsFunc,
|
|
trigger: trigger,
|
|
indexers: indexers,
|
|
|
|
getKey: keyParser,
|
|
|
|
versioner: &storage.APIObjectVersioner{},
|
|
|
|
opts: opts,
|
|
}
|
|
|
|
// The key parsing callback allows us to support the hardcoded paths from upstream tests
|
|
if s.getKey == nil {
|
|
s.getKey = func(key string) (*resource.ResourceKey, error) {
|
|
k, err := grafanaregistry.ParseKey(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if k.Group == "" {
|
|
return nil, apierrors.NewInternalError(fmt.Errorf("missing group in request"))
|
|
}
|
|
if k.Resource == "" {
|
|
return nil, apierrors.NewInternalError(fmt.Errorf("missing resource in request"))
|
|
}
|
|
return &resource.ResourceKey{
|
|
Namespace: k.Namespace,
|
|
Group: k.Group,
|
|
Resource: k.Resource,
|
|
Name: k.Name,
|
|
}, err
|
|
}
|
|
}
|
|
|
|
return s, func() {}, nil
|
|
}
|
|
|
|
func (s *Storage) Versioner() storage.Versioner {
|
|
return s.versioner
|
|
}
|
|
|
|
func (s *Storage) convertToObject(data []byte, obj runtime.Object) (runtime.Object, error) {
|
|
if s.opts.InternalConversion != nil {
|
|
return s.opts.InternalConversion(data, obj)
|
|
}
|
|
obj, _, err := s.codec.Decode(data, nil, obj)
|
|
return obj, err
|
|
}
|
|
|
|
// Create adds a new object at a key unless it already exists. 'ttl' is time-to-live
|
|
// in seconds (0 means forever). If no error is returned and out is not nil, out will be
|
|
// set to the read value from database.
|
|
func (s *Storage) Create(ctx context.Context, key string, obj runtime.Object, out runtime.Object, ttl uint64) error {
|
|
var err error
|
|
req := &resource.CreateRequest{}
|
|
req.Value, err = s.prepareObjectForStorage(ctx, obj)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Key, err = s.getKey(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rsp, err := s.store.Create(ctx, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rsp.Error != nil {
|
|
if rsp.Error.Code == http.StatusConflict {
|
|
return storage.NewKeyExistsError(key, 0)
|
|
}
|
|
return resource.GetError(rsp.Error)
|
|
}
|
|
|
|
if err := copyModifiedObjectToDestination(obj, out); err != nil {
|
|
return err
|
|
}
|
|
|
|
meta, err := utils.MetaAccessor(out)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
meta.SetResourceVersionInt64(rsp.ResourceVersion)
|
|
|
|
// set a timer to delete the file after ttl seconds
|
|
if ttl > 0 {
|
|
time.AfterFunc(time.Second*time.Duration(ttl), func() {
|
|
if err := s.Delete(ctx, key, s.newFunc(), &storage.Preconditions{}, func(ctx context.Context, obj runtime.Object) error { return nil }, obj); err != nil {
|
|
panic(err)
|
|
}
|
|
})
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Delete removes the specified key and returns the value that existed at that spot.
|
|
// If key didn't exist, it will return NotFound storage error.
|
|
// If 'cachedExistingObject' is non-nil, it can be used as a suggestion about the
|
|
// current version of the object to avoid read operation from storage to get it.
|
|
// However, the implementations have to retry in case suggestion is stale.
|
|
func (s *Storage) Delete(
|
|
ctx context.Context,
|
|
key string,
|
|
out runtime.Object,
|
|
preconditions *storage.Preconditions,
|
|
validateDeletion storage.ValidateObjectFunc,
|
|
_ runtime.Object,
|
|
) error {
|
|
if err := s.Get(ctx, key, storage.GetOptions{}, out); err != nil {
|
|
return err
|
|
}
|
|
|
|
k, err := s.getKey(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cmd := &resource.DeleteRequest{Key: k}
|
|
|
|
if preconditions != nil {
|
|
if err := preconditions.Check(key, out); err != nil {
|
|
return err
|
|
}
|
|
|
|
if preconditions.ResourceVersion != nil {
|
|
cmd.ResourceVersion, err = strconv.ParseInt(*preconditions.ResourceVersion, 10, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if preconditions.UID != nil {
|
|
cmd.Uid = string(*preconditions.UID)
|
|
}
|
|
}
|
|
|
|
// ?? this was after delete before
|
|
if err := validateDeletion(ctx, out); err != nil {
|
|
return err
|
|
}
|
|
rsp, err := s.store.Delete(ctx, cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rsp.Error != nil {
|
|
return resource.GetError(rsp.Error)
|
|
}
|
|
if err := s.versioner.UpdateObject(out, uint64(rsp.ResourceVersion)); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// This version is not yet passing the watch tests
|
|
func (s *Storage) Watch(ctx context.Context, key string, opts storage.ListOptions) (watch.Interface, error) {
|
|
k, err := s.getKey(key)
|
|
if err != nil {
|
|
return watch.NewEmptyWatch(), nil
|
|
}
|
|
|
|
req, predicate, err := toListRequest(k, opts)
|
|
if err != nil {
|
|
return watch.NewEmptyWatch(), nil
|
|
}
|
|
|
|
cmd := &resource.WatchRequest{
|
|
Since: req.ResourceVersion,
|
|
Options: req.Options,
|
|
SendInitialEvents: false,
|
|
AllowWatchBookmarks: opts.Predicate.AllowWatchBookmarks,
|
|
}
|
|
if opts.SendInitialEvents != nil {
|
|
cmd.SendInitialEvents = *opts.SendInitialEvents
|
|
}
|
|
ctx, cancelWatch := context.WithCancel(ctx)
|
|
client, err := s.store.Watch(ctx, cmd)
|
|
if err != nil {
|
|
// if the context was canceled, just return a new empty watch
|
|
cancelWatch()
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, io.EOF) {
|
|
return watch.NewEmptyWatch(), nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
reporter := apierrors.NewClientErrorReporter(500, "WATCH", "")
|
|
decoder := newStreamDecoder(client, s.newFunc, predicate, s.codec, cancelWatch)
|
|
|
|
return watch.NewStreamWatcher(decoder, reporter), nil
|
|
}
|
|
|
|
// Get unmarshals object found at key into objPtr. On a not found error, will either
|
|
// return a zero object of the requested type, or an error, depending on 'opts.ignoreNotFound'.
|
|
// Treats empty responses and nil response nodes exactly like a not found error.
|
|
// The returned contents may be delayed, but it is guaranteed that they will
|
|
// match 'opts.ResourceVersion' according 'opts.ResourceVersionMatch'.
|
|
func (s *Storage) Get(ctx context.Context, key string, opts storage.GetOptions, objPtr runtime.Object) error {
|
|
var err error
|
|
req := &resource.ReadRequest{}
|
|
req.Key, err = s.getKey(key)
|
|
if err != nil {
|
|
if opts.IgnoreNotFound {
|
|
return runtime.SetZeroValue(objPtr)
|
|
}
|
|
return storage.NewKeyNotFoundError(key, 0)
|
|
}
|
|
|
|
if opts.ResourceVersion != "" {
|
|
req.ResourceVersion, err = strconv.ParseInt(opts.ResourceVersion, 10, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
rsp, err := s.store.Read(ctx, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rsp.Error != nil {
|
|
if rsp.Error.Code == http.StatusNotFound {
|
|
if opts.IgnoreNotFound {
|
|
return runtime.SetZeroValue(objPtr)
|
|
}
|
|
return storage.NewKeyNotFoundError(key, req.ResourceVersion)
|
|
}
|
|
return resource.GetError(rsp.Error)
|
|
}
|
|
|
|
_, err = s.convertToObject(rsp.Value, objPtr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.versioner.UpdateObject(objPtr, uint64(rsp.ResourceVersion))
|
|
}
|
|
|
|
// GetList unmarshalls objects found at key into a *List api object (an object
|
|
// that satisfies runtime.IsList definition).
|
|
// If 'opts.Recursive' is false, 'key' is used as an exact match. If `opts.Recursive'
|
|
// is true, 'key' is used as a prefix.
|
|
// The returned contents may be delayed, but it is guaranteed that they will
|
|
// match 'opts.ResourceVersion' according 'opts.ResourceVersionMatch'.
|
|
func (s *Storage) GetList(ctx context.Context, key string, opts storage.ListOptions, listObj runtime.Object) error {
|
|
k, err := s.getKey(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req, predicate, err := toListRequest(k, opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
rsp, err := s.store.List(ctx, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rsp.Error != nil {
|
|
return resource.GetError(rsp.Error)
|
|
}
|
|
|
|
if err := s.validateMinimumResourceVersion(opts.ResourceVersion, uint64(rsp.ResourceVersion)); err != nil {
|
|
return err
|
|
}
|
|
|
|
listPtr, err := meta.GetItemsPtr(listObj)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
v, err := conversion.EnforcePtr(listPtr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if v.IsNil() {
|
|
v.Set(reflect.MakeSlice(v.Type(), 0, 0))
|
|
}
|
|
|
|
for _, item := range rsp.Items {
|
|
obj, err := s.convertToObject(item.Value, s.newFunc())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.versioner.UpdateObject(obj, uint64(item.ResourceVersion)); err != nil {
|
|
return err
|
|
}
|
|
|
|
if opts.ResourceVersionMatch == metaV1.ResourceVersionMatchExact {
|
|
currentVersion, err := s.versioner.ObjectResourceVersion(obj)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
expectedRV, err := s.versioner.ParseResourceVersion(opts.ResourceVersion)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if currentVersion != expectedRV {
|
|
continue
|
|
}
|
|
}
|
|
|
|
ok, err := predicate.Matches(obj)
|
|
if err == nil && ok {
|
|
v.Set(reflect.Append(v, reflect.ValueOf(obj).Elem()))
|
|
}
|
|
}
|
|
|
|
var remainingItems *int64
|
|
if rsp.RemainingItemCount > 0 {
|
|
remainingItems = &rsp.RemainingItemCount
|
|
}
|
|
if err := s.versioner.UpdateList(listObj, uint64(rsp.ResourceVersion), rsp.NextPageToken, remainingItems); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GuaranteedUpdate keeps calling 'tryUpdate()' to update key 'key' (of type 'destination')
|
|
// retrying the update until success if there is index conflict.
|
|
// Note that object passed to tryUpdate may change across invocations of tryUpdate() if
|
|
// other writers are simultaneously updating it, so tryUpdate() needs to take into account
|
|
// the current contents of the object when deciding how the update object should look.
|
|
// If the key doesn't exist, it will return NotFound storage error if ignoreNotFound=false
|
|
// else `destination` will be set to the zero value of it's type.
|
|
// If the eventual successful invocation of `tryUpdate` returns an output with the same serialized
|
|
// contents as the input, it won't perform any update, but instead set `destination` to an object with those
|
|
// contents.
|
|
// If 'cachedExistingObject' is non-nil, it can be used as a suggestion about the
|
|
// current version of the object to avoid read operation from storage to get it.
|
|
// However, the implementations have to retry in case suggestion is stale.
|
|
// nolint:gocyclo
|
|
func (s *Storage) GuaranteedUpdate(
|
|
ctx context.Context,
|
|
key string,
|
|
destination runtime.Object,
|
|
ignoreNotFound bool,
|
|
preconditions *storage.Preconditions,
|
|
tryUpdate storage.UpdateFunc,
|
|
cachedExistingObject runtime.Object,
|
|
) error {
|
|
var (
|
|
res storage.ResponseMeta
|
|
updatedObj runtime.Object
|
|
existingObj runtime.Object
|
|
created bool
|
|
err error
|
|
)
|
|
req := &resource.UpdateRequest{}
|
|
req.Key, err = s.getKey(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if preconditions != nil && preconditions.ResourceVersion != nil {
|
|
req.ResourceVersion, err = strconv.ParseInt(*preconditions.ResourceVersion, 10, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
for attempt := 1; attempt <= MaxUpdateAttempts; attempt = attempt + 1 {
|
|
// Read the latest value
|
|
rsp, err := s.store.Read(ctx, &resource.ReadRequest{Key: req.Key})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if rsp.Error != nil {
|
|
if rsp.Error.Code == http.StatusNotFound {
|
|
if !ignoreNotFound {
|
|
return apierrors.NewNotFound(s.gr, req.Key.Name)
|
|
}
|
|
} else {
|
|
return resource.GetError(rsp.Error)
|
|
}
|
|
}
|
|
|
|
created = true
|
|
existingObj = s.newFunc()
|
|
if len(rsp.Value) > 0 {
|
|
created = false
|
|
_, err = s.convertToObject(rsp.Value, existingObj)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
mmm, err := utils.MetaAccessor(existingObj)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
mmm.SetResourceVersionInt64(rsp.ResourceVersion)
|
|
res.ResourceVersion = uint64(rsp.ResourceVersion)
|
|
|
|
if rest.IsDualWriteUpdate(ctx) {
|
|
// Ignore the RV when updating legacy values
|
|
mmm.SetResourceVersion("")
|
|
} else {
|
|
if err := preconditions.Check(key, existingObj); err != nil {
|
|
if attempt >= MaxUpdateAttempts {
|
|
return fmt.Errorf("precondition failed: %w", err)
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
|
|
// restore the full original object before tryUpdate
|
|
if s.opts.LargeObjectSupport != nil && mmm.GetBlob() != nil {
|
|
err = s.opts.LargeObjectSupport.Reconstruct(ctx, req.Key, s.store, mmm)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
} else if !ignoreNotFound {
|
|
return apierrors.NewNotFound(s.gr, req.Key.Name)
|
|
}
|
|
|
|
updatedObj, _, err = tryUpdate(existingObj.DeepCopyObject(), res)
|
|
if err != nil {
|
|
if attempt >= MaxUpdateAttempts {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
|
|
unchanged, err := isUnchanged(s.codec, existingObj, updatedObj)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if unchanged {
|
|
if err := copyModifiedObjectToDestination(updatedObj, destination); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
rv := int64(0)
|
|
if created {
|
|
value, err := s.prepareObjectForStorage(ctx, updatedObj)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rsp2, err := s.store.Create(ctx, &resource.CreateRequest{
|
|
Key: req.Key,
|
|
Value: value,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rsp2.Error != nil {
|
|
return resource.GetError(rsp2.Error)
|
|
}
|
|
rv = rsp2.ResourceVersion
|
|
} else {
|
|
req.Value, err = s.prepareObjectForUpdate(ctx, updatedObj, existingObj)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rsp2, err := s.store.Update(ctx, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rsp2.Error != nil {
|
|
return resource.GetError(rsp2.Error)
|
|
}
|
|
rv = rsp2.ResourceVersion
|
|
}
|
|
|
|
if err := s.versioner.UpdateObject(updatedObj, uint64(rv)); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := copyModifiedObjectToDestination(updatedObj, destination); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Count returns number of different entries under the key (generally being path prefix).
|
|
// TODO: Implement count.
|
|
func (s *Storage) Count(key string) (int64, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
// RequestWatchProgress requests the a watch stream progress status be sent in the
|
|
// watch response stream as soon as possible.
|
|
// Used for monitor watch progress even if watching resources with no changes.
|
|
//
|
|
// If watch is lagging, progress status might:
|
|
// * be pointing to stale resource version. Use etcd KV request to get linearizable resource version.
|
|
// * not be delivered at all. It's recommended to poll request progress periodically.
|
|
//
|
|
// Note: Only watches with matching context grpc metadata will be notified.
|
|
// https://github.com/kubernetes/kubernetes/blob/9325a57125e8502941d1b0c7379c4bb80a678d5c/vendor/go.etcd.io/etcd/client/v3/watch.go#L1037-L1042
|
|
//
|
|
// TODO: Remove when storage.Interface will be separate from etc3.store.
|
|
// Deprecated: Added temporarily to simplify exposing RequestProgress for watch cache.
|
|
func (s *Storage) RequestWatchProgress(_ context.Context) error {
|
|
return nil
|
|
}
|
|
|
|
// ReadinessCheck checks if the storage is ready for accepting requests.
|
|
// TODO: Implement readiness check.
|
|
func (s *Storage) ReadinessCheck() error {
|
|
return nil
|
|
}
|
|
|
|
// validateMinimumResourceVersion returns a 'too large resource' version error when the provided minimumResourceVersion is
|
|
// greater than the most recent actualRevision available from storage.
|
|
func (s *Storage) validateMinimumResourceVersion(minimumResourceVersion string, actualRevision uint64) error {
|
|
if minimumResourceVersion == "" {
|
|
return nil
|
|
}
|
|
minimumRV, err := s.versioner.ParseResourceVersion(minimumResourceVersion)
|
|
if err != nil {
|
|
return apierrors.NewBadRequest(fmt.Sprintf("invalid resource version: %v", err))
|
|
}
|
|
|
|
// Enforce the storage.Interface guarantee that the resource version of the returned data
|
|
// "will be at least 'resourceVersion'".
|
|
if minimumRV > actualRevision {
|
|
return storage.NewTooLargeResourceVersionError(minimumRV, actualRevision, 0)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func copyModifiedObjectToDestination(updatedObj runtime.Object, destination runtime.Object) error {
|
|
u, err := conversion.EnforcePtr(updatedObj)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to enforce updated object pointer: %w", err)
|
|
}
|
|
d, err := conversion.EnforcePtr(destination)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to enforce destination pointer: %w", err)
|
|
}
|
|
d.Set(u)
|
|
return nil
|
|
}
|