package dualwrite import ( "context" "fmt" "time" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apiserver/pkg/registry/rest" "github.com/grafana/grafana-app-sdk/logging" "github.com/grafana/grafana/pkg/apimachinery/utils" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" ) var ( _ grafanarest.Storage = (*dualWriter)(nil) tracer = otel.Tracer("github.com/grafana/grafana/pkg/storage/legacysql/dualwrite") ) const ( // Let's give the background queries a bit more time to complete // as we also run them as part of load tests that might need longer // to complete. Those run in the background and won't impact the // user experience in any way. backgroundReqTimeout = time.Minute ) // dualWriter will write first to legacy, then to unified keeping the same internal ID type dualWriter struct { legacy grafanarest.Storage unified grafanarest.Storage readUnified bool errorIsOK bool // in "mode1" we try writing both -- but don't block on unified write errors } func (d *dualWriter) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { ctx, span := tracer.Start(ctx, "dualwrite.dualWriter.Get", trace.WithAttributes( attribute.Bool("errorIsOK", d.errorIsOK), attribute.Bool("readUnified", d.readUnified))) defer span.End() log := logging.FromContext(ctx).With("method", "Get", "name", name) // If we read from unified, we can just do that and return. if d.readUnified { return d.unified.Get(ctx, name, options) } // If legacy is still our main store, lets first read from it. legacyGet, err := d.legacy.Get(ctx, name, options) if err != nil { log.Error("failed to GET object from legacy storage", "err", err) return nil, err } // Once we have successfully read from legacy, we can check if we want to fail on a unified read. // If we allow the unified read to fail, we can do it in the background. if d.errorIsOK { go func(ctxBg context.Context, cancel context.CancelFunc) { defer cancel() if _, err := d.unified.Get(ctxBg, name, options); err != nil { log.Error("failed background GET to unified", "err", err) } }(context.WithTimeout(context.WithoutCancel(ctx), backgroundReqTimeout)) return legacyGet, nil } // If it's not okay to fail, we have to check it in the foreground. _, unifiedErr := d.unified.Get(ctx, name, options) if unifiedErr != nil && !apierrors.IsNotFound(unifiedErr) { log.Error("failed to GET object from unified storage", "err", unifiedErr) return nil, unifiedErr } return legacyGet, nil } func (d *dualWriter) List(ctx context.Context, options *metainternalversion.ListOptions) (runtime.Object, error) { ctx, span := tracer.Start(ctx, "dualwrite.dualWriter.List", trace.WithAttributes( attribute.Bool("errorIsOK", d.errorIsOK), attribute.Bool("readUnified", d.readUnified))) defer span.End() // Always work on *copies* so we never mutate the caller's ListOptions. var ( legacyOptions = options.DeepCopy() unifiedOptions = options.DeepCopy() log = logging.FromContext(ctx).With("method", "List", "options", options) ) legacyToken, unifiedToken, err := parseContinueTokens(options.Continue) if err != nil { return nil, err } legacyOptions.Continue = legacyToken unifiedOptions.Continue = unifiedToken // If we read from unified, we can just do that and return. if d.readUnified { unifiedList, err := d.unified.List(ctx, unifiedOptions) if err != nil { log.Error("failed to list objects from unified storage", "err", err) return nil, err } unifiedMeta, err := meta.ListAccessor(unifiedList) if err != nil { return nil, fmt.Errorf("failed to access legacy List MetaData: %w", err) } unifiedMeta.SetContinue(buildContinueToken("", unifiedMeta.GetContinue())) return unifiedList, nil } // In some cases, the unified token might be there but legacy token is empty (i.e. finished iteration). // This can happen, as unified storage iteration is doing paging not only based on the provided limit, // but also based on the response size. This check prevents starting the new iteration again. if options.Continue != "" && legacyToken == "" { return d.NewList(), nil } // In some cases, where the stores are not in sync yet, the unified storage continue token might already // be empty, while the legacy one is not, as it has more data. In that case we don't want to issue a new // request with an empty continue token, resulting in getting the first page again. // nolint:staticcheck shouldDoUnifiedRequest := true if options.Continue != "" && unifiedToken == "" { shouldDoUnifiedRequest = false } // If legacy is still the main store, lets first read from it. legacyList, err := d.legacy.List(ctx, legacyOptions) if err != nil { log.Error("failed to list objects from legacy storage", "err", err) return nil, err } legacyMeta, err := meta.ListAccessor(legacyList) if err != nil { return nil, fmt.Errorf("failed to access legacy List MetaData: %w", err) } legacyToken = legacyMeta.GetContinue() // Once we have successfully listed from legacy, we can check if we want to fail on a unified list. // If we allow the unified list to fail, we can do it in the background and return. if d.errorIsOK && shouldDoUnifiedRequest { // We would like to get continue token from unified storage, but // don't want to wait for unified storage too long, since we're calling // unified-storage asynchronously. out := make(chan string, 1) go func(ctxBg context.Context, cancel context.CancelFunc) { defer cancel() defer close(out) unifiedList, err := d.unified.List(ctxBg, unifiedOptions) if err != nil { log.Error("failed background LIST to unified", "err", err) return } unifiedMeta, err := meta.ListAccessor(unifiedList) if err != nil { log.Error("failed background LIST to unified", "err", fmt.Errorf("failed to access unified List MetaData: %w", err)) } out <- unifiedMeta.GetContinue() }(context.WithTimeout(context.WithoutCancel(ctx), backgroundReqTimeout)) select { case unifiedToken = <-out: case <-time.After(300 * time.Millisecond): log.Warn("timeout while waiting on the unified storage continue token") break } legacyMeta.SetContinue(buildContinueToken(legacyToken, unifiedToken)) return legacyList, nil } if shouldDoUnifiedRequest { // If it's not okay to fail, we have to check it in the foreground. unifiedList, err := d.unified.List(ctx, unifiedOptions) if err != nil { log.Error("failed to list objects from unified storage", "err", err) return nil, err } unifiedMeta, err := meta.ListAccessor(unifiedList) if err != nil { return nil, fmt.Errorf("failed to access unified List MetaData: %w", err) } unifiedToken = unifiedMeta.GetContinue() } legacyMeta.SetContinue(buildContinueToken(legacyToken, unifiedToken)) return legacyList, nil } // Create overrides the behavior of the generic DualWriter and writes to LegacyStorage and Storage. func (d *dualWriter) Create(ctx context.Context, in runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) { ctx, span := tracer.Start(ctx, "dualwrite.dualWriter.Create", trace.WithAttributes( attribute.Bool("errorIsOK", d.errorIsOK), attribute.Bool("readUnified", d.readUnified))) defer span.End() log := logging.FromContext(ctx).With("method", "Create") accIn, err := meta.Accessor(in) if err != nil { return nil, err } if accIn.GetUID() != "" { return nil, fmt.Errorf("UID should not be: %v", accIn.GetUID()) } if accIn.GetName() == "" && accIn.GetGenerateName() == "" { return nil, fmt.Errorf("name or generatename have to be set") } readFromUnifiedWriteToBothStorages := d.readUnified && d.legacy != nil && d.unified != nil permissions := "" if readFromUnifiedWriteToBothStorages { objIn, err := utils.MetaAccessor(in) if err != nil { return nil, err } // keep permissions, we will set it back after the object is created permissions = objIn.GetAnnotation(utils.AnnoKeyGrantPermissions) if permissions != "" { objIn.SetAnnotation(utils.AnnoKeyGrantPermissions, "") // remove the annotation for now } } // create in legacy first, and then unistore. if unistore fails, but legacy succeeds, // will try to cleanup the object in legacy. createdFromLegacy, err := d.legacy.Create(ctx, in, createValidation, options) if err != nil { log.With("objectInfo", objectInfo(in)).Error("failed to CREATE object in legacy storage", "err", err) return nil, err } createdCopy := createdFromLegacy.DeepCopyObject() accCreated, err := meta.Accessor(createdCopy) if err != nil { return nil, err } accCreated.SetResourceVersion("") accCreated.SetUID("") if readFromUnifiedWriteToBothStorages { objCopy, err := utils.MetaAccessor(createdCopy) if err != nil { return nil, err } // restore the permissions annotation, as we removed it before creating in legacy if permissions != "" { objCopy.SetAnnotation(utils.AnnoKeyGrantPermissions, permissions) } // Propagate annotations and labels to the object saved in // unified storage, making sure the `deprecatedID` is saved // as well as provisioning metadata, when present. for name, val := range accIn.GetAnnotations() { objCopy.SetAnnotation(name, val) } legacyAcc, err := meta.Accessor(createdFromLegacy) if err != nil { return nil, err } objCopy.SetLabels(legacyAcc.GetLabels()) } // If unified storage is the primary storage, let's just create it in the foreground and return it. if d.readUnified { storageObj, errObjectSt := d.unified.Create(ctx, createdCopy, createValidation, options) if errObjectSt != nil { log.With("objectInfo", objectInfo(createdCopy)).Error("failed to CREATE object in unified storage", "err", errObjectSt) // If we cannot create in unified storage, attempt to clean up legacy. go func(ctxBg context.Context, cancel context.CancelFunc) { defer cancel() if _, asyncDelete, err := d.legacy.Delete(ctxBg, accCreated.GetName(), nil, &metav1.DeleteOptions{}); err != nil { log.With("name", accCreated.GetName()).Error("failed to CLEANUP object in legacy storage", "err", err, "asyncDelete", asyncDelete) } }(context.WithTimeout(context.WithoutCancel(ctx), backgroundReqTimeout)) return nil, errObjectSt } return storageObj, nil } else if d.errorIsOK { // If we don't use unified as the primary store and errors are okay, let's create it in the background. go func(ctxBg context.Context, cancel context.CancelFunc) { defer cancel() if _, err := d.unified.Create(ctxBg, createdCopy, createValidation, options); err != nil { log.With("objectInfo", objectInfo(createdCopy)).Error("failed to CREATE object in unified storage", "err", err) } }(context.WithTimeout(context.WithoutCancel(ctx), backgroundReqTimeout)) } else { // Otherwise let's create it in the foreground and return any error. if _, err := d.unified.Create(ctx, createdCopy, createValidation, options); err != nil { log.With("objectInfo", objectInfo(createdCopy)).Error("failed to CREATE object in unified storage", "err", err) if d.errorIsOK { return createdFromLegacy, nil } // If we cannot create in unified storage, attempt to clean up legacy. go func(ctxBg context.Context, cancel context.CancelFunc) { defer cancel() if _, asyncDelete, err := d.legacy.Delete(ctxBg, accCreated.GetName(), nil, &metav1.DeleteOptions{}); err != nil { log.With("name", accCreated.GetName()).Error("failed to CLEANUP object in legacy storage", "err", err, "asyncDelete", asyncDelete) } }(context.WithTimeout(context.WithoutCancel(ctx), backgroundReqTimeout)) return nil, err } } return createdFromLegacy, nil } func (d *dualWriter) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) { // delete from legacy first, and then unistore. Will return a failure if either fails, // unless its a 404. // // we want to delete from legacy first, otherwise if the delete from unistore was successful, // but legacy failed, the user would get a failure, but not be able to retry the delete // as they would not be able to see the object in unistore anymore. // By setting RemovePermissions to false in the context, we will skip the deletion of permissions // in the legacy store. This is needed as otherwise the permissions would be missing when executing // the delete operation in the unified storage store. ctx, span := tracer.Start(ctx, "dualwrite.dualWriter.Delete", trace.WithAttributes( attribute.Bool("errorIsOK", d.errorIsOK), attribute.Bool("readUnified", d.readUnified))) defer span.End() log := logging.FromContext(ctx).With("method", "Delete", "name", name) ctx = utils.SetFolderRemovePermissions(ctx, false) objFromLegacy, asyncLegacy, err := d.legacy.Delete(ctx, name, deleteValidation, options) if err != nil && (!d.readUnified || !d.errorIsOK && !apierrors.IsNotFound(err)) { log.Error("failed to DELETE object in legacy storage", "err", err) return nil, false, err } // We can now flip it again. ctx = utils.SetFolderRemovePermissions(ctx, true) // If unified storage is our primary store, just delete it and return if d.readUnified { objFromStorage, asyncStorage, err := d.unified.Delete(ctx, name, deleteValidation, options) if err != nil && !apierrors.IsNotFound(err) && !d.errorIsOK { log.Error("failed to DELETE object in unified storage", "err", err) return nil, false, err } return objFromStorage, asyncStorage, nil } else if d.errorIsOK { // If errors are okay and unified is not primary, we can just run it as background operation. go func(ctxBg context.Context, cancel context.CancelFunc) { defer cancel() _, _, err := d.unified.Delete(ctxBg, name, deleteValidation, options) if err != nil && !apierrors.IsNotFound(err) && !d.errorIsOK { log.Error("failed background DELETE in unified storage", "err", err) } }(context.WithTimeout(context.WithoutCancel(ctx), backgroundReqTimeout)) } // Otherwise we just run it in the foreground and return an error if any might happen. _, _, err = d.unified.Delete(ctx, name, deleteValidation, options) if err != nil && !apierrors.IsNotFound(err) && !d.errorIsOK { log.Error("failed to DELETE object in unified storage", "err", err) return nil, false, err } return objFromLegacy, asyncLegacy, nil } // Update overrides the behavior of the generic DualWriter and writes first to Storage and then to LegacyStorage. func (d *dualWriter) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) { ctx, span := tracer.Start(ctx, "dualwrite.dualWriter.Update", trace.WithAttributes( attribute.Bool("errorIsOK", d.errorIsOK), attribute.Bool("readUnified", d.readUnified))) defer span.End() log := logging.FromContext(ctx).With("method", "Update", "name", name) // update in legacy first, and then unistore. Will return a failure if either fails. // // we want to update in legacy first, otherwise if the update from unistore was successful, // but legacy failed, the user would get a failure, but see the update did apply to the source // of truth, and be less likely to retry to save (and get the stores in sync again) legacyInfo := objInfo legacyForceCreate := forceAllowCreate unifiedInfo := objInfo unifiedForceCreate := forceAllowCreate if d.readUnified { legacyInfo = &wrappedUpdateInfo{objInfo: objInfo} legacyForceCreate = true } else { unifiedInfo = &wrappedUpdateInfo{objInfo: objInfo} unifiedForceCreate = true } objFromLegacy, createdLegacy, err := d.legacy.Update(ctx, name, legacyInfo, createValidation, updateValidation, legacyForceCreate, options) if err != nil { log.Error("failed to UPDATE in legacy storage", "err", err) return nil, false, err } // add any metadata returned from legacy to what is saved in unified storage when forceCreate is used. // this is especially needed for legacy internal IDs if createdLegacy { legacyMeta, err := utils.MetaAccessor(objFromLegacy) if err != nil { log.Error("failed to get meta accessor for legacy object", "err", err) return nil, false, err } unifiedInfo = &wrappedUpdateInfo{ objInfo: objInfo, legacyLabels: legacyMeta.GetLabels(), legacyAnnotations: legacyMeta.GetAnnotations(), } } if d.readUnified { return d.unified.Update(ctx, name, unifiedInfo, createValidation, updateValidation, unifiedForceCreate, options) } else if d.errorIsOK { // If unified is not primary, but errors are okay, we can just run in the background. go func(ctxBg context.Context, cancel context.CancelFunc) { defer cancel() if _, _, err := d.unified.Update(ctxBg, name, unifiedInfo, createValidation, updateValidation, unifiedForceCreate, options); err != nil { log.With("objectInfo", objectInfo(objFromLegacy)).Error("failed background UPDATE to unified storage", "err", err) } }(context.WithTimeout(context.WithoutCancel(ctx), backgroundReqTimeout)) return objFromLegacy, createdLegacy, nil } // If we want to check unified errors just run it in foreground. if _, _, err := d.unified.Update(ctx, name, unifiedInfo, createValidation, updateValidation, unifiedForceCreate, options); err != nil { log.With("objectInfo", objectInfo(objFromLegacy)).Error("failed to UPDATE in unified storage", "err", err) // cleanup the legacy object if we created it there if createdLegacy { go func(ctxBg context.Context, cancel context.CancelFunc) { defer cancel() if _, asyncDelete, err := d.legacy.Delete(ctxBg, name, nil, &metav1.DeleteOptions{}); err != nil { log.With("name", name).Error("failed to CLEANUP object in legacy storage after unified storage update failure", "err", err, "asyncDelete", asyncDelete) } }(context.WithTimeout(context.WithoutCancel(ctx), backgroundReqTimeout)) } return nil, false, err } return objFromLegacy, createdLegacy, nil } // DeleteCollection overrides the behavior of the generic DualWriter and deletes from both LegacyStorage and Storage. func (d *dualWriter) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *metainternalversion.ListOptions) (runtime.Object, error) { ctx, span := tracer.Start(ctx, "dualwrite.dualWriter.DeleteCollection", trace.WithAttributes( attribute.Bool("errorIsOK", d.errorIsOK), attribute.Bool("readUnified", d.readUnified))) defer span.End() log := logging.FromContext(ctx).With("method", "DeleteCollection", "resourceVersion", listOptions.ResourceVersion) // delete from legacy first, and anything that is successful can be deleted in unistore too. // // we want to delete from legacy first, otherwise if the delete from unistore was successful, // but legacy failed, the user would get a failure, but not be able to retry the delete // as they would not be able to see the object in unistore anymore. deletedLegacy, err := d.legacy.DeleteCollection(ctx, deleteValidation, options, listOptions) if err != nil { log.With("options", options).Error("failed to DELETE collection successfully from legacy storage", "err", err) return nil, err } // If unified is the primary store, we can just delete it there and return. if d.readUnified { return d.unified.DeleteCollection(ctx, deleteValidation, options, listOptions) } else if d.errorIsOK { // If unified storage is not the primary store and errors are okay, we can just run it in the background. go func(ctxBg context.Context, cancel context.CancelFunc) { defer cancel() if _, err := d.unified.DeleteCollection(ctxBg, deleteValidation, options, listOptions); err != nil { log.With("objectInfo", objectInfo(deletedLegacy)).Error("failed background DELETE collection to unified storage", "err", err) } }(context.WithTimeout(context.WithoutCancel(ctx), backgroundReqTimeout)) return deletedLegacy, nil } // Otherwise we have to check the error and run it in the foreground. if _, err := d.unified.DeleteCollection(ctx, deleteValidation, options, listOptions); err != nil { log.With("objectInfo", objectInfo(deletedLegacy)).Error("failed to DELETE collection successfully from Storage", "err", err) return nil, err } return deletedLegacy, nil } func (d *dualWriter) Destroy() { d.legacy.Destroy() d.unified.Destroy() } func (d *dualWriter) GetSingularName() string { return d.unified.GetSingularName() } func (d *dualWriter) NamespaceScoped() bool { return d.unified.NamespaceScoped() } func (d *dualWriter) New() runtime.Object { return d.unified.New() } func (d *dualWriter) NewList() runtime.Object { return d.unified.NewList() } func (d *dualWriter) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { return d.unified.ConvertToTable(ctx, object, tableOptions) } type wrappedUpdateInfo struct { objInfo rest.UpdatedObjectInfo legacyLabels map[string]string legacyAnnotations map[string]string } // Preconditions implements rest.UpdatedObjectInfo. func (w *wrappedUpdateInfo) Preconditions() *metav1.Preconditions { return nil } // UpdatedObject implements rest.UpdatedObjectInfo. func (w *wrappedUpdateInfo) UpdatedObject(ctx context.Context, oldObj runtime.Object) (newObj runtime.Object, err error) { obj, err := w.objInfo.UpdatedObject(ctx, oldObj) if err != nil { return nil, err } meta, err := utils.MetaAccessor(obj) if err != nil { return nil, err } // add any labels or annotations set by legacy storage if len(w.legacyLabels) > 0 { existingLabels := meta.GetLabels() if existingLabels == nil { existingLabels = make(map[string]string) } for key, value := range w.legacyLabels { existingLabels[key] = value } meta.SetLabels(existingLabels) } if len(w.legacyAnnotations) > 0 { existingAnnotations := meta.GetAnnotations() if existingAnnotations == nil { existingAnnotations = make(map[string]string) } for key, value := range w.legacyAnnotations { existingAnnotations[key] = value } meta.SetAnnotations(existingAnnotations) } meta.SetResourceVersion("") meta.SetUID("") return obj, err } func objectInfo(obj runtime.Object) map[string]interface{} { if obj == nil { return map[string]interface{}{"object": "nil"} } acc, err := meta.Accessor(obj) if err != nil { return map[string]interface{}{"object": fmt.Sprintf("%T", obj), "error": err.Error()} } info := map[string]interface{}{ "name": acc.GetName(), } if ns := acc.GetNamespace(); ns != "" { info["namespace"] = ns } if uid := acc.GetUID(); uid != "" { info["uid"] = string(uid) } if rv := acc.GetResourceVersion(); rv != "" { info["resourceVersion"] = rv } return info }