diff --git a/pkg/registry/apis/provisioning/jobs/export/resources.go b/pkg/registry/apis/provisioning/jobs/export/resources.go index c824cafef94..85003e0b77b 100644 --- a/pkg/registry/apis/provisioning/jobs/export/resources.go +++ b/pkg/registry/apis/provisioning/jobs/export/resources.go @@ -119,6 +119,10 @@ func createDashboardConversionShimWithCache(ctx context.Context, clients resourc func ExportResources(ctx context.Context, options provisioning.ExportJobOptions, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder) error { progress.SetMessage(ctx, "start resource export") + + // Create a shared versionClients map for dashboard conversion caching + versionClients := make(map[string]dynamic.ResourceInterface) + for _, kind := range resources.SupportedProvisioningResources { // skip from folders as we do them first... so only dashboards if kind == resources.FolderResource { @@ -132,9 +136,10 @@ func ExportResources(ctx context.Context, options provisioning.ExportJobOptions, } // When requesting dashboards over the v1 api, we want to keep the original apiVersion if conversion fails + // Always use the cache version to share clients across all dashboard exports var shim conversionShim if kind.GroupResource() == resources.DashboardResource.GroupResource() { - shim, _ = createDashboardConversionShim(ctx, clients, kind) + shim = createDashboardConversionShimWithCache(ctx, clients, kind, versionClients) } if err := exportResource(ctx, kind.Resource, options, client, shim, repositoryResources, progress); err != nil { @@ -154,12 +159,32 @@ func ExportSpecificResources(ctx context.Context, options provisioning.ExportJob progress.SetMessage(ctx, "exporting specific resources") - // Load folder tree into memory so we can resolve folder paths for resources - // This is needed to replicate the folder structure when exporting + tree, err := loadUnmanagedFolderTree(ctx, clients, progress) + if err != nil { + return err + } + + // Create a shared dashboard conversion shim and cache for all dashboard resources + // Create the versionClients map once so it's shared across all dashboard conversion calls + var dashboardShim conversionShim + versionClients := make(map[string]dynamic.ResourceInterface) + + for _, resourceRef := range options.Resources { + if err := exportSingleResource(ctx, resourceRef, options, clients, repositoryResources, tree, &dashboardShim, versionClients, progress); err != nil { + return err + } + } + + return nil +} + +// loadUnmanagedFolderTree loads all unmanaged folders into a tree structure. +// This is needed to resolve folder paths for resources when exporting. +func loadUnmanagedFolderTree(ctx context.Context, clients resources.ResourceClients, progress jobs.JobProgressRecorder) (resources.FolderTree, error) { progress.SetMessage(ctx, "loading folder tree from API server") folderClient, err := clients.Folder(ctx) if err != nil { - return fmt.Errorf("get folder client: %w", err) + return nil, fmt.Errorf("get folder client: %w", err) } tree := resources.NewEmptyFolderTree() @@ -180,185 +205,256 @@ func ExportSpecificResources(ctx context.Context, options provisioning.ExportJob return tree.AddUnstructured(item) }); err != nil { - return fmt.Errorf("load folder tree: %w", err) + return nil, fmt.Errorf("load folder tree: %w", err) } - // Create a shared dashboard conversion shim and cache for all dashboard resources - // Create the versionClients map once so it's shared across all dashboard conversion calls - var dashboardShim conversionShim - versionClients := make(map[string]dynamic.ResourceInterface) + return tree, nil +} - for _, resourceRef := range options.Resources { - result := jobs.JobResourceResult{ - Name: resourceRef.Name, - Group: resourceRef.Group, - Kind: resourceRef.Kind, - Action: repository.FileActionCreated, - } +// exportSingleResource exports a single resource, handling validation, fetching, conversion, and writing. +func exportSingleResource( + ctx context.Context, + resourceRef provisioning.ResourceRef, + options provisioning.ExportJobOptions, + clients resources.ResourceClients, + repositoryResources resources.RepositoryResources, + tree resources.FolderTree, + dashboardShim *conversionShim, + versionClients map[string]dynamic.ResourceInterface, + progress jobs.JobProgressRecorder, +) error { + result := jobs.JobResourceResult{ + Name: resourceRef.Name, + Group: resourceRef.Group, + Kind: resourceRef.Kind, + Action: repository.FileActionCreated, + } - gvk := schema.GroupVersionKind{ - Group: resourceRef.Group, - Kind: resourceRef.Kind, - // Version is left empty so ForKind will use the preferred version - } + gvk := schema.GroupVersionKind{ + Group: resourceRef.Group, + Kind: resourceRef.Kind, + // Version is left empty so ForKind will use the preferred version + } - // Validate: reject folders - if gvk.Kind == resources.FolderKind.Kind || gvk.Group == resources.FolderResource.Group { - result.Action = repository.FileActionIgnored - result.Error = fmt.Errorf("folders are not supported for export") - progress.Record(ctx, result) - if err := progress.TooManyErrors(); err != nil { - return err - } - continue - } - - // Get client for this resource type - progress.SetMessage(ctx, fmt.Sprintf("Fetching resource %s/%s/%s", resourceRef.Group, resourceRef.Kind, resourceRef.Name)) - client, gvr, err := clients.ForKind(ctx, gvk) - if err != nil { - result.Error = fmt.Errorf("get client for %s/%s/%s: %w", resourceRef.Group, resourceRef.Kind, resourceRef.Name, err) - progress.Record(ctx, result) - if err := progress.TooManyErrors(); err != nil { - return err - } - continue - } - - // Validate: check if resource is supported - isSupported := false - for _, supported := range resources.SupportedProvisioningResources { - if supported.Group == gvr.Group && supported.Resource == gvr.Resource { - isSupported = true - break - } - } - if !isSupported { - result.Action = repository.FileActionIgnored - result.Error = fmt.Errorf("resource type %s/%s is not supported for export", gvr.Group, gvr.Resource) - progress.Record(ctx, result) - if err := progress.TooManyErrors(); err != nil { - return err - } - continue - } - - // Fetch the resource from the API server - item, err := client.Get(ctx, resourceRef.Name, metav1.GetOptions{}) - if err != nil { - result.Error = fmt.Errorf("get resource %s/%s/%s: %w", resourceRef.Group, resourceRef.Kind, resourceRef.Name, err) - progress.Record(ctx, result) - if err := progress.TooManyErrors(); err != nil { - return err - } - continue - } - - // Validate: check if resource is managed - 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) - if err := progress.TooManyErrors(); err != nil { - return err - } - continue - } - - manager, _ := meta.GetManagerProperties() - // Reject if already managed by any manager (repository, file provisioning, etc.) - if manager.Identity != "" { - result.Action = repository.FileActionIgnored - result.Error = fmt.Errorf("resource %s/%s/%s is managed and cannot be exported", resourceRef.Group, resourceRef.Kind, resourceRef.Name) - progress.Record(ctx, result) - if err := progress.TooManyErrors(); err != nil { - return err - } - continue - } - - // Handle dashboard version conversion using the shared shim logic - if gvr.GroupResource() == resources.DashboardResource.GroupResource() { - // Create or reuse the dashboard shim (shared across all dashboard resources) - // Pass the shared versionClients map to ensure client caching works correctly - if dashboardShim == nil { - dashboardShim = createDashboardConversionShimWithCache(ctx, clients, gvr, versionClients) - } - - item, err = dashboardShim(ctx, item) - if err != nil { - result.Error = fmt.Errorf("converting dashboard %s/%s/%s: %w", resourceRef.Group, resourceRef.Kind, resourceRef.Name, err) - progress.Record(ctx, result) - if err := progress.TooManyErrors(); err != nil { - return err - } - continue - } - // Re-extract meta after shim conversion in case the item changed - meta, err = utils.MetaAccessor(item) - if err != nil { - result.Action = repository.FileActionIgnored - result.Error = fmt.Errorf("extracting meta accessor after conversion for resource %s: %w", result.Name, err) - progress.Record(ctx, result) - if err := progress.TooManyErrors(); err != nil { - return err - } - continue - } - } - - // Get the folder path from the unmanaged folder tree and concatenate with Path - // This gives us the path in the unmanaged tree structure - exportPath := options.Path - resourceFolder := meta.GetFolder() - if resourceFolder != "" { - // Get the folder path from the unmanaged tree (rootFolder is empty string for unmanaged tree) - fid, ok := tree.DirPath(resourceFolder, "") - if ok && fid.Path != "" { - if exportPath != "" { - exportPath = safepath.Join(exportPath, fid.Path) - } else { - exportPath = fid.Path - } - } - } - - // Temporarily clear folder metadata so WriteResourceFileFromObject doesn't try to resolve - // folder paths from repository tree (we've already computed the path from unmanaged tree) - originalFolder := resourceFolder - if resourceFolder != "" { - meta.SetFolder("") - } - - // Export the resource - progress.SetMessage(ctx, fmt.Sprintf("Exporting resource %s/%s/%s", resourceRef.Group, resourceRef.Kind, resourceRef.Name)) - result.Path, err = repositoryResources.WriteResourceFileFromObject(ctx, item, resources.WriteOptions{ - Path: exportPath, // Path already includes folder structure from unmanaged tree - Ref: options.Branch, - }) - - // Restore original folder metadata - if originalFolder != "" { - meta.SetFolder(originalFolder) - } - - 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) - } + // Validate resource reference + if err := validateResourceRef(gvk, &result, progress, ctx); err != nil { + return err + } + if result.Error != nil { + // Validation failed, but we continue processing other resources + return nil + } + // Get client and fetch resource + progress.SetMessage(ctx, fmt.Sprintf("Fetching resource %s/%s/%s", resourceRef.Group, resourceRef.Kind, resourceRef.Name)) + client, gvr, err := clients.ForKind(ctx, gvk) + if err != nil { + result.Error = fmt.Errorf("get client for %s/%s/%s: %w", resourceRef.Group, resourceRef.Kind, resourceRef.Name, err) progress.Record(ctx, result) - if err := progress.TooManyErrors(); err != nil { - return err + return progress.TooManyErrors() + } + + // Validate resource type is supported + if err := validateResourceType(gvr, &result, progress, ctx); err != nil { + return err + } + if result.Error != nil { + return nil + } + + // Fetch and validate the resource + item, meta, err := fetchAndValidateResource(ctx, client, resourceRef, gvr, &result, progress) + if err != nil { + return err + } + if result.Error != nil { + return nil + } + + // Convert dashboard if needed + if err := convertDashboardIfNeeded(ctx, gvr, item, meta, clients, dashboardShim, versionClients, resourceRef, &result, progress); err != nil { + return err + } + if result.Error != nil { + return nil + } + + // Compute export path from folder tree + exportPath := computeExportPath(options.Path, meta, tree) + + // Export the resource + return writeResourceToRepository(ctx, item, meta, exportPath, options.Branch, repositoryResources, resourceRef, &result, progress) +} + +// validateResourceRef validates that a resource reference is not a folder. +func validateResourceRef(gvk schema.GroupVersionKind, result *jobs.JobResourceResult, progress jobs.JobProgressRecorder, ctx context.Context) error { + if gvk.Kind == resources.FolderKind.Kind || gvk.Group == resources.FolderResource.Group { + result.Action = repository.FileActionIgnored + result.Error = fmt.Errorf("folders are not supported for export") + progress.Record(ctx, *result) + return progress.TooManyErrors() + } + return nil +} + +// validateResourceType validates that a resource type is supported for export. +func validateResourceType(gvr schema.GroupVersionResource, result *jobs.JobResourceResult, progress jobs.JobProgressRecorder, ctx context.Context) error { + isSupported := false + for _, supported := range resources.SupportedProvisioningResources { + if supported.Group == gvr.Group && supported.Resource == gvr.Resource { + isSupported = true + break } } + if !isSupported { + result.Action = repository.FileActionIgnored + result.Error = fmt.Errorf("resource type %s/%s is not supported for export", gvr.Group, gvr.Resource) + progress.Record(ctx, *result) + return progress.TooManyErrors() + } + return nil +} + +// fetchAndValidateResource fetches a resource from the API server and validates it's unmanaged. +func fetchAndValidateResource( + ctx context.Context, + client dynamic.ResourceInterface, + resourceRef provisioning.ResourceRef, + gvr schema.GroupVersionResource, + result *jobs.JobResourceResult, + progress jobs.JobProgressRecorder, +) (*unstructured.Unstructured, utils.GrafanaMetaAccessor, error) { + item, err := client.Get(ctx, resourceRef.Name, metav1.GetOptions{}) + if err != nil { + result.Error = fmt.Errorf("get resource %s/%s/%s: %w", resourceRef.Group, resourceRef.Kind, resourceRef.Name, err) + progress.Record(ctx, *result) + return nil, nil, progress.TooManyErrors() + } + + 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, nil, progress.TooManyErrors() + } + + manager, _ := meta.GetManagerProperties() + // Reject if already managed by any manager (repository, file provisioning, etc.) + if manager.Identity != "" { + result.Action = repository.FileActionIgnored + result.Error = fmt.Errorf("resource %s/%s/%s is managed and cannot be exported", resourceRef.Group, resourceRef.Kind, resourceRef.Name) + progress.Record(ctx, *result) + return nil, nil, progress.TooManyErrors() + } + + return item, meta, nil +} + +// convertDashboardIfNeeded converts a dashboard to its original API version if needed. +func convertDashboardIfNeeded( + ctx context.Context, + gvr schema.GroupVersionResource, + item *unstructured.Unstructured, + meta utils.GrafanaMetaAccessor, + clients resources.ResourceClients, + dashboardShim *conversionShim, + versionClients map[string]dynamic.ResourceInterface, + resourceRef provisioning.ResourceRef, + result *jobs.JobResourceResult, + progress jobs.JobProgressRecorder, +) error { + if gvr.GroupResource() != resources.DashboardResource.GroupResource() { + return nil + } + + // Create or reuse the dashboard shim (shared across all dashboard resources) + // Pass the shared versionClients map to ensure client caching works correctly + if *dashboardShim == nil { + *dashboardShim = createDashboardConversionShimWithCache(ctx, clients, gvr, versionClients) + } + + var err error + item, err = (*dashboardShim)(ctx, item) + if err != nil { + result.Error = fmt.Errorf("converting dashboard %s/%s/%s: %w", resourceRef.Group, resourceRef.Kind, resourceRef.Name, err) + progress.Record(ctx, *result) + return progress.TooManyErrors() + } + + // Re-extract meta after shim conversion in case the item changed + meta, err = utils.MetaAccessor(item) + if err != nil { + result.Action = repository.FileActionIgnored + result.Error = fmt.Errorf("extracting meta accessor after conversion for resource %s: %w", result.Name, err) + progress.Record(ctx, *result) + return progress.TooManyErrors() + } return nil } +// computeExportPath computes the export path by combining the base path with the folder path from the tree. +func computeExportPath(basePath string, meta utils.GrafanaMetaAccessor, tree resources.FolderTree) string { + exportPath := basePath + resourceFolder := meta.GetFolder() + if resourceFolder != "" { + // Get the folder path from the unmanaged tree (rootFolder is empty string for unmanaged tree) + fid, ok := tree.DirPath(resourceFolder, "") + if ok && fid.Path != "" { + if exportPath != "" { + exportPath = safepath.Join(exportPath, fid.Path) + } else { + exportPath = fid.Path + } + } + } + return exportPath +} + +// writeResourceToRepository writes a resource to the repository. +func writeResourceToRepository( + ctx context.Context, + item *unstructured.Unstructured, + meta utils.GrafanaMetaAccessor, + exportPath string, + branch string, + repositoryResources resources.RepositoryResources, + resourceRef provisioning.ResourceRef, + result *jobs.JobResourceResult, + progress jobs.JobProgressRecorder, +) error { + // Temporarily clear folder metadata so WriteResourceFileFromObject doesn't try to resolve + // folder paths from repository tree (we've already computed the path from unmanaged tree) + originalFolder := meta.GetFolder() + if originalFolder != "" { + meta.SetFolder("") + } + defer func() { + if originalFolder != "" { + meta.SetFolder(originalFolder) + } + }() + + // Export the resource + progress.SetMessage(ctx, fmt.Sprintf("Exporting resource %s/%s/%s", resourceRef.Group, resourceRef.Kind, resourceRef.Name)) + var err error + result.Path, err = repositoryResources.WriteResourceFileFromObject(ctx, item, resources.WriteOptions{ + Path: exportPath, // Path already includes folder structure from unmanaged tree + Ref: 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) + return progress.TooManyErrors() +} + func exportResource(ctx context.Context, resource string, options provisioning.ExportJobOptions,