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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user