refactor(provisioning): improve code quality by breaking down ExportSpecificResources

- Extract loadUnmanagedFolderTree function for loading folder tree
- Extract exportSingleResource function for processing individual resources
- Extract validateResourceRef, validateResourceType functions for validation
- Extract fetchAndValidateResource function for fetching and validation
- Extract convertDashboardIfNeeded function for dashboard conversion
- Extract computeExportPath function for path computation
- Extract writeResourceToRepository function for writing resources
- Always use createDashboardConversionShimWithCache in both ExportResources and ExportSpecificResources
- Share versionClients map across all dashboard exports for better caching
This commit is contained in:
Roberto Jimenez Sanchez
2025-12-02 19:39:53 +01:00
parent 326cf170ec
commit 26bddcee2f
@@ -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,