243 lines
7.7 KiB
Go
243 lines
7.7 KiB
Go
package move
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
|
|
|
"github.com/grafana/grafana-app-sdk/logging"
|
|
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
|
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
|
|
"github.com/grafana/grafana/apps/provisioning/pkg/safepath"
|
|
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
|
|
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
|
"github.com/grafana/grafana/pkg/registry/apis/provisioning/utils"
|
|
)
|
|
|
|
type Worker struct {
|
|
syncWorker jobs.Worker
|
|
wrapFn repository.WrapWithStageFn
|
|
resourcesFactory resources.RepositoryResourcesFactory
|
|
metrics jobs.JobMetrics
|
|
}
|
|
|
|
func NewWorker(syncWorker jobs.Worker, wrapFn repository.WrapWithStageFn, resourcesFactory resources.RepositoryResourcesFactory, metrics jobs.JobMetrics) *Worker {
|
|
return &Worker{
|
|
syncWorker: syncWorker,
|
|
wrapFn: wrapFn,
|
|
resourcesFactory: resourcesFactory,
|
|
metrics: metrics,
|
|
}
|
|
}
|
|
|
|
func (w *Worker) IsSupported(ctx context.Context, job provisioning.Job) bool {
|
|
return job.Spec.Action == provisioning.JobActionMove
|
|
}
|
|
|
|
func (w *Worker) Process(ctx context.Context, repo repository.Repository, job provisioning.Job, progress jobs.JobProgressRecorder) error {
|
|
if job.Spec.Move == nil {
|
|
return errors.New("missing move settings")
|
|
}
|
|
opts := *job.Spec.Move
|
|
logger := logging.FromContext(ctx).With("job", job.GetName(), "namespace", job.GetNamespace())
|
|
outcome := utils.ErrorOutcome
|
|
start := time.Now()
|
|
resourcesMoved := 0
|
|
defer func() {
|
|
w.metrics.RecordJob(string(provisioning.JobActionMove), outcome, resourcesMoved, time.Since(start).Seconds())
|
|
}()
|
|
|
|
if opts.TargetPath == "" {
|
|
return errors.New("target path is required for move operation")
|
|
}
|
|
|
|
// Validate that target path is a directory (ends with slash)
|
|
if !safepath.IsDir(opts.TargetPath) {
|
|
return errors.New("target path must be a directory (should end with '/')")
|
|
}
|
|
|
|
paths := opts.Paths
|
|
|
|
progress.SetTotal(ctx, len(paths)+len(opts.Resources))
|
|
progress.StrictMaxErrors(1) // Fail fast on any error during move
|
|
|
|
fn := func(repo repository.Repository, _ bool) error {
|
|
rw, ok := repo.(repository.ReaderWriter)
|
|
if !ok {
|
|
logger.Error("move job submitted targeting repository that is not a ReaderWriter")
|
|
return errors.New("move job submitted targeting repository that is not a ReaderWriter")
|
|
}
|
|
|
|
// Resolve ResourceRef entries to file paths using RepositoryResources
|
|
if len(opts.Resources) > 0 {
|
|
resolvedPaths, err := w.resolveResourcesToPaths(ctx, rw, progress, opts.Resources)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
paths = append(paths, resolvedPaths...)
|
|
}
|
|
|
|
// Deduplicate paths to avoid attempting to move the same file multiple times
|
|
paths = deduplicatePaths(paths)
|
|
|
|
return w.moveFiles(ctx, rw, progress, opts, paths...)
|
|
}
|
|
|
|
msg := fmt.Sprintf("Move files from Grafana %s", job.Name)
|
|
stageOptions := repository.StageOptions{
|
|
Mode: repository.StageModeCommitOnlyOnce,
|
|
CommitOnlyOnceMessage: msg,
|
|
PushOnWrites: false,
|
|
Timeout: 10 * time.Minute,
|
|
Ref: opts.Ref,
|
|
}
|
|
|
|
err := w.wrapFn(ctx, repo, stageOptions, fn)
|
|
if err != nil {
|
|
logger.Error("failed to move files in repository", "error", err)
|
|
return fmt.Errorf("move files in repository: %w", err)
|
|
}
|
|
|
|
// Set RefURLs if the repository supports it and we have a target ref
|
|
if opts.Ref != "" {
|
|
if repoWithURLs, ok := repo.(repository.RepositoryWithURLs); ok {
|
|
if refURLs, urlErr := repoWithURLs.RefURLs(ctx, opts.Ref); urlErr == nil && refURLs != nil {
|
|
progress.SetRefURLs(ctx, refURLs)
|
|
}
|
|
}
|
|
}
|
|
|
|
if opts.Ref == "" {
|
|
progress.ResetResults()
|
|
progress.SetMessage(ctx, "pull resources")
|
|
|
|
syncJob := provisioning.Job{
|
|
Spec: provisioning.JobSpec{
|
|
Pull: &provisioning.SyncJobOptions{
|
|
// Full sync because it's the only one that supports empty folder deletion
|
|
Incremental: false,
|
|
},
|
|
},
|
|
}
|
|
|
|
if err := w.syncWorker.Process(ctx, repo, syncJob, progress); err != nil {
|
|
logger.Error("failed to pull resources", "error", err)
|
|
return fmt.Errorf("pull resources: %w", err)
|
|
}
|
|
}
|
|
|
|
outcome = utils.SuccessOutcome
|
|
jobStatus := progress.Complete(ctx, nil)
|
|
for _, summary := range jobStatus.Summary {
|
|
// FileActionRenamed increments both delete & create, use create here
|
|
resourcesMoved += int(summary.Create)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (w *Worker) moveFiles(ctx context.Context, rw repository.ReaderWriter, progress jobs.JobProgressRecorder, opts provisioning.MoveJobOptions, paths ...string) error {
|
|
for _, path := range paths {
|
|
result := jobs.JobResourceResult{
|
|
Path: path,
|
|
Action: repository.FileActionRenamed,
|
|
}
|
|
|
|
// Construct the target path by combining the job's target path with the file/folder name
|
|
targetPath := w.constructTargetPath(opts.TargetPath, path)
|
|
|
|
progress.SetMessage(ctx, "Moving "+path+" to "+targetPath)
|
|
if err := rw.Move(ctx, path, targetPath, opts.Ref, "Move "+path+" to "+targetPath); err != nil {
|
|
result.Error = fmt.Errorf("moving file %s to %s: %w", path, targetPath, err)
|
|
}
|
|
progress.Record(ctx, result)
|
|
if err := progress.TooManyErrors(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// constructTargetPath combines the job's target path with the file/folder name from the source path
|
|
func (w *Worker) constructTargetPath(jobTargetPath, sourcePath string) string {
|
|
// Extract the file/folder name from the source path
|
|
fileName := filepath.Base(sourcePath)
|
|
|
|
// If the source path is a directory (ends with slash), preserve the trailing slash in target
|
|
if safepath.IsDir(sourcePath) {
|
|
return jobTargetPath + fileName + "/"
|
|
}
|
|
|
|
// For files, just append the filename
|
|
return jobTargetPath + fileName
|
|
}
|
|
|
|
// resolveResourcesToPaths converts ResourceRef entries to file paths, recording errors for individual resources
|
|
func (w *Worker) resolveResourcesToPaths(ctx context.Context, rw repository.ReaderWriter, progress jobs.JobProgressRecorder, resources []provisioning.ResourceRef) ([]string, error) {
|
|
if len(resources) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
progress.SetMessage(ctx, "Resolving resource paths")
|
|
repositoryResources, err := w.resourcesFactory.Client(ctx, rw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create repository resources client: %w", err)
|
|
}
|
|
|
|
resolvedPaths := make([]string, 0, len(resources))
|
|
for _, resource := range resources {
|
|
result := jobs.JobResourceResult{
|
|
Name: resource.Name,
|
|
Group: resource.Group,
|
|
Action: repository.FileActionRenamed, // Will be used for move later
|
|
}
|
|
|
|
gvk := schema.GroupVersionKind{
|
|
Group: resource.Group,
|
|
Kind: resource.Kind,
|
|
// Version is left empty so ForKind will use the preferred version
|
|
}
|
|
|
|
progress.SetMessage(ctx, fmt.Sprintf("Finding path for resource %s/%s/%s", resource.Group, resource.Kind, resource.Name))
|
|
resourcePath, err := repositoryResources.FindResourcePath(ctx, resource.Name, gvk)
|
|
if err != nil {
|
|
result.Error = fmt.Errorf("find path for resource %s/%s/%s: %w", resource.Group, resource.Kind, resource.Name, err)
|
|
progress.Record(ctx, result)
|
|
// Continue with next resource instead of failing fast
|
|
if err := progress.TooManyErrors(); err != nil {
|
|
return resolvedPaths, err
|
|
}
|
|
continue
|
|
}
|
|
|
|
result.Path = resourcePath
|
|
resolvedPaths = append(resolvedPaths, resourcePath)
|
|
}
|
|
|
|
return resolvedPaths, nil
|
|
}
|
|
|
|
// deduplicatePaths removes duplicate file paths from the slice while preserving order
|
|
func deduplicatePaths(paths []string) []string {
|
|
if len(paths) <= 1 {
|
|
return paths
|
|
}
|
|
|
|
seen := make(map[string]struct{}, len(paths))
|
|
result := make([]string, 0, len(paths))
|
|
|
|
for _, path := range paths {
|
|
if _, exists := seen[path]; !exists {
|
|
seen[path] = struct{}{}
|
|
result = append(result, path)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|