Files
grafana/pkg/registry/apis/provisioning/jobs/export/resources.go
Roberto Jiménez Sánchez f2694ce72f Provisioning: add generic version handling for dashboard export (#114691)
* feat(provisioning): add generic version handling for dashboard export

- Update export job to handle any dashboard version generically (v0, v1, v2, v3, etc.)
- Dynamically construct GroupVersionResource for any stored version
- Cache version-specific clients for efficiency
- Add comprehensive table-driven unit tests for multiple versions
- Add integration test to verify version handling end-to-end
- Remove unnecessary version shim from clean operation (deletion works by name)

* test: add unit test for v2 dashboard version (no suffix)
2025-12-02 16:44:24 +01:00

157 lines
5.8 KiB
Go

package export
import (
"context"
"errors"
"fmt"
"strings"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
)
// FIXME: This is used to make sure we save dashboards in the apiVersion they were original saved in
// When requesting v0 or v2 dashboards over the v1 api -- the backend tries (and fails!) to convert values
// The response status indicates the original stored version, so we can then request it in an un-converted form
type conversionShim = func(ctx context.Context, item *unstructured.Unstructured) (*unstructured.Unstructured, error)
func ExportResources(ctx context.Context, options provisioning.ExportJobOptions, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder) error {
progress.SetMessage(ctx, "start resource export")
for _, kind := range resources.SupportedProvisioningResources {
// skip from folders as we do them first... so only dashboards
if kind == resources.FolderResource {
continue
}
progress.SetMessage(ctx, fmt.Sprintf("export %s", kind.Resource))
client, _, err := clients.ForResource(ctx, kind)
if err != nil {
return fmt.Errorf("get client for %s: %w", kind.Resource, err)
}
// When requesting dashboards over the v1 api, we want to keep the original apiVersion if conversion fails
var shim conversionShim
if kind.GroupResource() == resources.DashboardResource.GroupResource() {
// Cache clients for different versions
versionClients := make(map[string]dynamic.ResourceInterface)
shim = func(ctx context.Context, item *unstructured.Unstructured) (*unstructured.Unstructured, error) {
// Check if there's a stored version in the conversion status.
// This indicates the original API version the dashboard was created with,
// which should be preserved during export regardless of whether conversion succeeded or failed.
storedVersion, _, _ := unstructured.NestedString(item.Object, "status", "conversion", "storedVersion")
if storedVersion != "" {
// For v0 we can simply fallback -- the full model is saved
if strings.HasPrefix(storedVersion, "v0") {
item.SetAPIVersion(fmt.Sprintf("%s/%s", kind.Group, storedVersion))
return item, nil
}
// For any other version (v1, v2, v3, etc.), fetch the original version via client
// Check if we already have a client cached for this version
versionClient, ok := versionClients[storedVersion]
if !ok {
// Dynamically construct the GroupVersionResource for any version
versionGVR := schema.GroupVersionResource{
Group: kind.Group,
Version: storedVersion,
Resource: kind.Resource,
}
var err error
versionClient, _, err = clients.ForResource(ctx, versionGVR)
if err != nil {
return nil, fmt.Errorf("get client for version %s: %w", storedVersion, err)
}
versionClients[storedVersion] = versionClient
}
return versionClient.Get(ctx, item.GetName(), metav1.GetOptions{})
}
// If conversion failed but there's no storedVersion, this is an error condition
failed, _, _ := unstructured.NestedBool(item.Object, "status", "conversion", "failed")
if failed {
return nil, fmt.Errorf("conversion failed but no storedVersion available")
}
return item, nil
}
}
if err := exportResource(ctx, kind.Resource, options, client, shim, repositoryResources, progress); err != nil {
return fmt.Errorf("export %s: %w", kind.Resource, err)
}
}
return nil
}
func exportResource(ctx context.Context,
resource string,
options provisioning.ExportJobOptions,
client dynamic.ResourceInterface,
shim conversionShim,
repositoryResources resources.RepositoryResources,
progress jobs.JobProgressRecorder,
) error {
// FIXME: using k8s list will force evrything into one version -- we really want the original saved version
// this will work well enough for now, but needs to be revisted as we have a bigger mix of active versions
return resources.ForEach(ctx, client, func(item *unstructured.Unstructured) (err error) {
gvk := item.GroupVersionKind()
result := jobs.JobResourceResult{
Name: item.GetName(),
Group: gvk.Group,
Kind: gvk.Kind,
Action: repository.FileActionCreated,
}
// Check if resource is already managed by a repository
meta, err := utils.MetaAccessor(item)
if err != nil {
result.Action = repository.FileActionIgnored
result.Error = fmt.Errorf("extracting meta accessor for resource %s: %w", result.Name, err)
progress.Record(ctx, result)
return nil
}
manager, _ := meta.GetManagerProperties()
// Skip if already managed by any manager (repository, file provisioning, etc.)
if manager.Identity != "" {
result.Action = repository.FileActionIgnored
progress.Record(ctx, result)
return nil
}
if shim != nil {
item, err = shim(ctx, item)
}
if err == nil {
result.Path, err = repositoryResources.WriteResourceFileFromObject(ctx, item, resources.WriteOptions{
Path: options.Path,
Ref: options.Branch,
})
}
if errors.Is(err, resources.ErrAlreadyInRepository) {
result.Action = repository.FileActionIgnored
} else if err != nil {
result.Action = repository.FileActionIgnored
result.Error = fmt.Errorf("writing resource file for %s: %w", result.Name, err)
}
progress.Record(ctx, result)
if err := progress.TooManyErrors(); err != nil {
return err
}
return nil
})
}