github.com/argoproj/argo-cd/v3@v3.2.1/controller/hydrator/hydrator.go (about)

     1  package hydrator
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"fmt"
     7  	"maps"
     8  	"path/filepath"
     9  	"slices"
    10  	"sync"
    11  	"time"
    12  
    13  	"golang.org/x/sync/errgroup"
    14  
    15  	log "github.com/sirupsen/logrus"
    16  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    17  	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    18  
    19  	commitclient "github.com/argoproj/argo-cd/v3/commitserver/apiclient"
    20  	"github.com/argoproj/argo-cd/v3/controller/hydrator/types"
    21  	appv1 "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1"
    22  	"github.com/argoproj/argo-cd/v3/reposerver/apiclient"
    23  	applog "github.com/argoproj/argo-cd/v3/util/app/log"
    24  	"github.com/argoproj/argo-cd/v3/util/git"
    25  	"github.com/argoproj/argo-cd/v3/util/hydrator"
    26  	utilio "github.com/argoproj/argo-cd/v3/util/io"
    27  )
    28  
    29  // RepoGetter is an interface that defines methods for getting repository objects. It's a subset of the DB interface to
    30  // avoid granting access to things we don't need.
    31  type RepoGetter interface {
    32  	// GetRepository returns a repository by its URL and project name.
    33  	GetRepository(ctx context.Context, repoURL, project string) (*appv1.Repository, error)
    34  }
    35  
    36  // Dependencies is the interface for the dependencies of the Hydrator. It serves two purposes: 1) it prevents the
    37  // hydrator from having direct access to the app controller, and 2) it allows for easy mocking of dependencies in tests.
    38  // If you add something here, be sure that it is something the app controller needs to provide to the hydrator.
    39  type Dependencies interface {
    40  	// TODO: determine if we actually need to get the app, or if all the stuff we need the app for is done already on
    41  	//       the app controller side.
    42  
    43  	// GetProcessableAppProj returns the AppProject for the given application. It should only return projects that are
    44  	// processable by the controller, meaning that the project is not deleted and the application is in a namespace
    45  	// permitted by the project.
    46  	GetProcessableAppProj(app *appv1.Application) (*appv1.AppProject, error)
    47  
    48  	// GetProcessableApps returns a list of applications that are processable by the controller.
    49  	GetProcessableApps() (*appv1.ApplicationList, error)
    50  
    51  	// GetRepoObjs returns the repository objects for the given application, source, and revision. It calls the repo-
    52  	// server and gets the manifests (objects).
    53  	GetRepoObjs(ctx context.Context, app *appv1.Application, source appv1.ApplicationSource, revision string, project *appv1.AppProject) ([]*unstructured.Unstructured, *apiclient.ManifestResponse, error)
    54  
    55  	// GetWriteCredentials returns the repository credentials for the given repository URL and project. These are to be
    56  	// sent to the commit server to write the hydrated manifests.
    57  	GetWriteCredentials(ctx context.Context, repoURL string, project string) (*appv1.Repository, error)
    58  
    59  	// RequestAppRefresh requests a refresh of the application with the given name and namespace. This is used to
    60  	// trigger a refresh after the application has been hydrated and a new commit has been pushed.
    61  	RequestAppRefresh(appName string, appNamespace string) error
    62  
    63  	// PersistAppHydratorStatus persists the application status for the source hydrator.
    64  	PersistAppHydratorStatus(orig *appv1.Application, newStatus *appv1.SourceHydratorStatus)
    65  
    66  	// AddHydrationQueueItem adds a hydration queue item to the queue. This is used to trigger the hydration process for
    67  	// a group of applications which are hydrating to the same repo and target branch.
    68  	AddHydrationQueueItem(key types.HydrationQueueKey)
    69  
    70  	// GetHydratorCommitMessageTemplate gets the configured template for rendering commit messages.
    71  	GetHydratorCommitMessageTemplate() (string, error)
    72  }
    73  
    74  // Hydrator is the main struct that implements the hydration logic. It uses the Dependencies interface to access the
    75  // app controller's functionality without directly depending on it.
    76  type Hydrator struct {
    77  	dependencies         Dependencies
    78  	statusRefreshTimeout time.Duration
    79  	commitClientset      commitclient.Clientset
    80  	repoClientset        apiclient.Clientset
    81  	repoGetter           RepoGetter
    82  }
    83  
    84  // NewHydrator creates a new Hydrator instance with the given dependencies, status refresh timeout, commit clientset,
    85  // repo clientset, and repo getter. The refresh timeout determines how often the hydrator checks if an application
    86  // needs to be hydrated.
    87  func NewHydrator(dependencies Dependencies, statusRefreshTimeout time.Duration, commitClientset commitclient.Clientset, repoClientset apiclient.Clientset, repoGetter RepoGetter) *Hydrator {
    88  	return &Hydrator{
    89  		dependencies:         dependencies,
    90  		statusRefreshTimeout: statusRefreshTimeout,
    91  		commitClientset:      commitClientset,
    92  		repoClientset:        repoClientset,
    93  		repoGetter:           repoGetter,
    94  	}
    95  }
    96  
    97  // ProcessAppHydrateQueueItem processes an application hydrate queue item. It checks if the application needs hydration
    98  // and if so, it updates the application's status to indicate that hydration is in progress. It then adds the
    99  // hydration queue item to the queue for further processing.
   100  //
   101  // It's likely that multiple applications will trigger hydration at the same time. The hydration queue key is meant to
   102  // dedupe these requests.
   103  func (h *Hydrator) ProcessAppHydrateQueueItem(origApp *appv1.Application) {
   104  	app := origApp.DeepCopy()
   105  	if app.Spec.SourceHydrator == nil {
   106  		return
   107  	}
   108  
   109  	logCtx := log.WithFields(applog.GetAppLogFields(app))
   110  	logCtx.Debug("Processing app hydrate queue item")
   111  
   112  	needsHydration, reason := appNeedsHydration(app)
   113  	if needsHydration {
   114  		app.Status.SourceHydrator.CurrentOperation = &appv1.HydrateOperation{
   115  			StartedAt:      metav1.Now(),
   116  			FinishedAt:     nil,
   117  			Phase:          appv1.HydrateOperationPhaseHydrating,
   118  			SourceHydrator: *app.Spec.SourceHydrator,
   119  		}
   120  		h.dependencies.PersistAppHydratorStatus(origApp, &app.Status.SourceHydrator)
   121  	}
   122  
   123  	needsRefresh := app.Status.SourceHydrator.CurrentOperation.Phase == appv1.HydrateOperationPhaseHydrating && metav1.Now().Sub(app.Status.SourceHydrator.CurrentOperation.StartedAt.Time) > h.statusRefreshTimeout
   124  	if needsHydration || needsRefresh {
   125  		logCtx.WithField("reason", reason).Info("Hydrating app")
   126  		h.dependencies.AddHydrationQueueItem(getHydrationQueueKey(app))
   127  	} else {
   128  		logCtx.WithField("reason", reason).Debug("Skipping hydration")
   129  	}
   130  
   131  	logCtx.Debug("Successfully processed app hydrate queue item")
   132  }
   133  
   134  func getHydrationQueueKey(app *appv1.Application) types.HydrationQueueKey {
   135  	key := types.HydrationQueueKey{
   136  		SourceRepoURL:        git.NormalizeGitURLAllowInvalid(app.Spec.SourceHydrator.DrySource.RepoURL),
   137  		SourceTargetRevision: app.Spec.SourceHydrator.DrySource.TargetRevision,
   138  		DestinationBranch:    app.Spec.GetHydrateToSource().TargetRevision,
   139  	}
   140  	return key
   141  }
   142  
   143  // ProcessHydrationQueueItem processes a hydration queue item. It retrieves the relevant applications for the given
   144  // hydration key, hydrates their latest commit, and updates their status accordingly. If the hydration fails, it marks
   145  // the operation as failed and logs the error. If successful, it updates the operation to indicate that hydration was
   146  // successful and requests a refresh of the applications to pick up the new hydrated commit.
   147  func (h *Hydrator) ProcessHydrationQueueItem(hydrationKey types.HydrationQueueKey) {
   148  	logCtx := log.WithFields(log.Fields{
   149  		"sourceRepoURL":        hydrationKey.SourceRepoURL,
   150  		"sourceTargetRevision": hydrationKey.SourceTargetRevision,
   151  		"destinationBranch":    hydrationKey.DestinationBranch,
   152  	})
   153  
   154  	// Get all applications sharing the same hydration key
   155  	apps, err := h.getAppsForHydrationKey(hydrationKey)
   156  	if err != nil {
   157  		// If we get an error here, we cannot proceed with hydration and we do not know
   158  		// which apps to update with the failure. The best we can do is log an error in
   159  		// the controller and wait for statusRefreshTimeout to retry
   160  		logCtx.WithError(err).Error("failed to get apps for hydration")
   161  		return
   162  	}
   163  	logCtx.WithField("appCount", len(apps))
   164  
   165  	// FIXME: we might end up in a race condition here where an HydrationQueueItem is processed
   166  	// before all applications had their CurrentOperation set by ProcessAppHydrateQueueItem.
   167  	// This would cause this method to update "old" CurrentOperation.
   168  	// It should only start hydration if all apps are in the HydrateOperationPhaseHydrating phase.
   169  	raceDetected := false
   170  	for _, app := range apps {
   171  		if app.Status.SourceHydrator.CurrentOperation == nil || app.Status.SourceHydrator.CurrentOperation.Phase != appv1.HydrateOperationPhaseHydrating {
   172  			raceDetected = true
   173  			break
   174  		}
   175  	}
   176  	if raceDetected {
   177  		logCtx.Warn("race condition detected: not all apps are in HydrateOperationPhaseHydrating phase")
   178  	}
   179  
   180  	// validate all the applications to make sure they are all correctly configured.
   181  	// All applications sharing the same hydration key must succeed for the hydration to be processed.
   182  	projects, validationErrors := h.validateApplications(apps)
   183  	if len(validationErrors) > 0 {
   184  		// For the applications that have an error, set the specific error in their status.
   185  		// Applications without error will still fail with a generic error since the hydration cannot be partial
   186  		genericError := genericHydrationError(validationErrors)
   187  		for _, app := range apps {
   188  			if err, ok := validationErrors[app.QualifiedName()]; ok {
   189  				logCtx = logCtx.WithFields(applog.GetAppLogFields(app))
   190  				logCtx.Errorf("failed to validate hydration app: %v", err)
   191  				h.setAppHydratorError(app, err)
   192  			} else {
   193  				h.setAppHydratorError(app, genericError)
   194  			}
   195  		}
   196  		return
   197  	}
   198  
   199  	// Hydrate all the apps
   200  	drySHA, hydratedSHA, appErrors, err := h.hydrate(logCtx, apps, projects)
   201  	if err != nil {
   202  		// If there is a single error, it affects each applications
   203  		for i := range apps {
   204  			appErrors[apps[i].QualifiedName()] = err
   205  		}
   206  	}
   207  	if drySHA != "" {
   208  		logCtx = logCtx.WithField("drySHA", drySHA)
   209  	}
   210  	if len(appErrors) > 0 {
   211  		// For the applications that have an error, set the specific error in their status.
   212  		// Applications without error will still fail with a generic error since the hydration cannot be partial
   213  		genericError := genericHydrationError(appErrors)
   214  		for _, app := range apps {
   215  			if drySHA != "" {
   216  				// If we have a drySHA, we can set it on the app status
   217  				app.Status.SourceHydrator.CurrentOperation.DrySHA = drySHA
   218  			}
   219  			if err, ok := appErrors[app.QualifiedName()]; ok {
   220  				logCtx = logCtx.WithFields(applog.GetAppLogFields(app))
   221  				logCtx.Errorf("failed to hydrate app: %v", err)
   222  				h.setAppHydratorError(app, err)
   223  			} else {
   224  				h.setAppHydratorError(app, genericError)
   225  			}
   226  		}
   227  		return
   228  	}
   229  
   230  	logCtx.Debug("Successfully hydrated apps")
   231  	finishedAt := metav1.Now()
   232  	for _, app := range apps {
   233  		origApp := app.DeepCopy()
   234  		operation := &appv1.HydrateOperation{
   235  			StartedAt:      app.Status.SourceHydrator.CurrentOperation.StartedAt,
   236  			FinishedAt:     &finishedAt,
   237  			Phase:          appv1.HydrateOperationPhaseHydrated,
   238  			Message:        "",
   239  			DrySHA:         drySHA,
   240  			HydratedSHA:    hydratedSHA,
   241  			SourceHydrator: app.Status.SourceHydrator.CurrentOperation.SourceHydrator,
   242  		}
   243  		app.Status.SourceHydrator.CurrentOperation = operation
   244  		app.Status.SourceHydrator.LastSuccessfulOperation = &appv1.SuccessfulHydrateOperation{
   245  			DrySHA:         drySHA,
   246  			HydratedSHA:    hydratedSHA,
   247  			SourceHydrator: app.Status.SourceHydrator.CurrentOperation.SourceHydrator,
   248  		}
   249  		h.dependencies.PersistAppHydratorStatus(origApp, &app.Status.SourceHydrator)
   250  
   251  		// Request a refresh since we pushed a new commit.
   252  		err := h.dependencies.RequestAppRefresh(app.Name, app.Namespace)
   253  		if err != nil {
   254  			logCtx.WithFields(applog.GetAppLogFields(app)).WithError(err).Error("Failed to request app refresh after hydration")
   255  		}
   256  	}
   257  }
   258  
   259  // setAppHydratorError updates the CurrentOperation with the error information.
   260  func (h *Hydrator) setAppHydratorError(app *appv1.Application, err error) {
   261  	// if the operation is not in progress, we do not update the status
   262  	if app.Status.SourceHydrator.CurrentOperation.Phase != appv1.HydrateOperationPhaseHydrating {
   263  		return
   264  	}
   265  
   266  	origApp := app.DeepCopy()
   267  	app.Status.SourceHydrator.CurrentOperation.Phase = appv1.HydrateOperationPhaseFailed
   268  	failedAt := metav1.Now()
   269  	app.Status.SourceHydrator.CurrentOperation.FinishedAt = &failedAt
   270  	app.Status.SourceHydrator.CurrentOperation.Message = fmt.Sprintf("Failed to hydrate: %v", err.Error())
   271  	h.dependencies.PersistAppHydratorStatus(origApp, &app.Status.SourceHydrator)
   272  }
   273  
   274  // getAppsForHydrationKey returns the applications matching the hydration key.
   275  func (h *Hydrator) getAppsForHydrationKey(hydrationKey types.HydrationQueueKey) ([]*appv1.Application, error) {
   276  	// Get all apps
   277  	apps, err := h.dependencies.GetProcessableApps()
   278  	if err != nil {
   279  		return nil, fmt.Errorf("failed to list apps: %w", err)
   280  	}
   281  
   282  	var relevantApps []*appv1.Application
   283  	for _, app := range apps.Items {
   284  		if app.Spec.SourceHydrator == nil {
   285  			continue
   286  		}
   287  		appKey := getHydrationQueueKey(&app)
   288  		if appKey != hydrationKey {
   289  			continue
   290  		}
   291  		relevantApps = append(relevantApps, &app)
   292  	}
   293  	return relevantApps, nil
   294  }
   295  
   296  // validateApplications checks that all applications are valid for hydration.
   297  func (h *Hydrator) validateApplications(apps []*appv1.Application) (map[string]*appv1.AppProject, map[string]error) {
   298  	projects := make(map[string]*appv1.AppProject)
   299  	errors := make(map[string]error)
   300  	uniquePaths := make(map[string]string, len(apps))
   301  
   302  	for _, app := range apps {
   303  		// Get the project for the app and validate if the app is allowed to use the source.
   304  		// We can't short-circuit this even if we have seen this project before, because we need to verify that this
   305  		// particular app is allowed to use this project.
   306  		proj, err := h.dependencies.GetProcessableAppProj(app)
   307  		if err != nil {
   308  			errors[app.QualifiedName()] = fmt.Errorf("failed to get project %q: %w", app.Spec.Project, err)
   309  			continue
   310  		}
   311  		permitted := proj.IsSourcePermitted(app.Spec.GetSource())
   312  		if !permitted {
   313  			errors[app.QualifiedName()] = fmt.Errorf("application repo %s is not permitted in project '%s'", app.Spec.GetSource().RepoURL, proj.Name)
   314  			continue
   315  		}
   316  		projects[app.Spec.Project] = proj
   317  
   318  		// Disallow hydrating to the repository root.
   319  		// Hydrating to root would overwrite or delete files at the top level of the repo,
   320  		// which can break other applications or shared configuration.
   321  		// Every hydrated app must write into a subdirectory instead.
   322  		destPath := app.Spec.SourceHydrator.SyncSource.Path
   323  		if IsRootPath(destPath) {
   324  			errors[app.QualifiedName()] = fmt.Errorf("app is configured to hydrate to the repository root (branch %q, path %q) which is not allowed", app.Spec.GetHydrateToSource().TargetRevision, destPath)
   325  			continue
   326  		}
   327  
   328  		// TODO: test the dupe detection
   329  		// TODO: normalize the path to avoid "path/.." from being treated as different from "."
   330  		if appName, ok := uniquePaths[destPath]; ok {
   331  			errors[app.QualifiedName()] = fmt.Errorf("app %s hydrator use the same destination: %v", appName, app.Spec.SourceHydrator.SyncSource.Path)
   332  			errors[appName] = fmt.Errorf("app %s hydrator use the same destination: %v", app.QualifiedName(), app.Spec.SourceHydrator.SyncSource.Path)
   333  			continue
   334  		}
   335  		uniquePaths[destPath] = app.QualifiedName()
   336  	}
   337  
   338  	// If there are any errors, return nil for projects to avoid possible partial processing.
   339  	if len(errors) > 0 {
   340  		projects = nil
   341  	}
   342  
   343  	return projects, errors
   344  }
   345  
   346  func (h *Hydrator) hydrate(logCtx *log.Entry, apps []*appv1.Application, projects map[string]*appv1.AppProject) (string, string, map[string]error, error) {
   347  	errors := make(map[string]error)
   348  	if len(apps) == 0 {
   349  		return "", "", nil, nil
   350  	}
   351  
   352  	// These values are the same for all apps being hydrated together, so just get them from the first app.
   353  	repoURL := apps[0].Spec.GetHydrateToSource().RepoURL
   354  	targetBranch := apps[0].Spec.GetHydrateToSource().TargetRevision
   355  	// FIXME: As a convenience, the commit server will create the syncBranch if it does not exist. If the
   356  	// targetBranch does not exist, it will create it based on the syncBranch. On the next line, we take
   357  	// the `syncBranch` from the first app and assume that they're all configured the same. Instead, if any
   358  	// app has a different syncBranch, we should send the commit server an empty string and allow it to
   359  	// create the targetBranch as an orphan since we can't reliable determine a reasonable base.
   360  	syncBranch := apps[0].Spec.SourceHydrator.SyncSource.TargetBranch
   361  
   362  	// Get a static SHA revision from the first app so that all apps are hydrated from the same revision.
   363  	targetRevision, pathDetails, err := h.getManifests(context.Background(), apps[0], "", projects[apps[0].Spec.Project])
   364  	if err != nil {
   365  		errors[apps[0].QualifiedName()] = fmt.Errorf("failed to get manifests: %w", err)
   366  		return "", "", errors, nil
   367  	}
   368  	paths := []*commitclient.PathDetails{pathDetails}
   369  
   370  	eg, ctx := errgroup.WithContext(context.Background())
   371  	var mu sync.Mutex
   372  
   373  	for _, app := range apps[1:] {
   374  		app := app
   375  		eg.Go(func() error {
   376  			_, pathDetails, err = h.getManifests(ctx, app, targetRevision, projects[app.Spec.Project])
   377  			mu.Lock()
   378  			defer mu.Unlock()
   379  			if err != nil {
   380  				errors[app.QualifiedName()] = fmt.Errorf("failed to get manifests: %w", err)
   381  				return errors[app.QualifiedName()]
   382  			}
   383  			paths = append(paths, pathDetails)
   384  			return nil
   385  		})
   386  	}
   387  	if err := eg.Wait(); err != nil {
   388  		return targetRevision, "", errors, nil
   389  	}
   390  
   391  	// If all the apps are under the same project, use that project. Otherwise, use an empty string to indicate that we
   392  	// need global creds.
   393  	project := ""
   394  	if len(projects) == 1 {
   395  		for p := range projects {
   396  			project = p
   397  			break
   398  		}
   399  	}
   400  
   401  	// Get the commit metadata for the target revision.
   402  	revisionMetadata, err := h.getRevisionMetadata(context.Background(), repoURL, project, targetRevision)
   403  	if err != nil {
   404  		return targetRevision, "", errors, fmt.Errorf("failed to get revision metadata for %q: %w", targetRevision, err)
   405  	}
   406  
   407  	repo, err := h.dependencies.GetWriteCredentials(context.Background(), repoURL, project)
   408  	if err != nil {
   409  		return targetRevision, "", errors, fmt.Errorf("failed to get hydrator credentials: %w", err)
   410  	}
   411  	if repo == nil {
   412  		// Try without credentials.
   413  		repo = &appv1.Repository{
   414  			Repo: repoURL,
   415  		}
   416  		logCtx.Warn("no credentials found for repo, continuing without credentials")
   417  	}
   418  	// get the commit message template
   419  	commitMessageTemplate, err := h.dependencies.GetHydratorCommitMessageTemplate()
   420  	if err != nil {
   421  		return targetRevision, "", errors, fmt.Errorf("failed to get hydrated commit message template: %w", err)
   422  	}
   423  	commitMessage, errMsg := getTemplatedCommitMessage(repoURL, targetRevision, commitMessageTemplate, revisionMetadata)
   424  	if errMsg != nil {
   425  		return targetRevision, "", errors, fmt.Errorf("failed to get hydrator commit templated message: %w", errMsg)
   426  	}
   427  
   428  	manifestsRequest := commitclient.CommitHydratedManifestsRequest{
   429  		Repo:              repo,
   430  		SyncBranch:        syncBranch,
   431  		TargetBranch:      targetBranch,
   432  		DrySha:            targetRevision,
   433  		CommitMessage:     commitMessage,
   434  		Paths:             paths,
   435  		DryCommitMetadata: revisionMetadata,
   436  	}
   437  
   438  	closer, commitService, err := h.commitClientset.NewCommitServerClient()
   439  	if err != nil {
   440  		return targetRevision, "", errors, fmt.Errorf("failed to create commit service: %w", err)
   441  	}
   442  	defer utilio.Close(closer)
   443  	resp, err := commitService.CommitHydratedManifests(context.Background(), &manifestsRequest)
   444  	if err != nil {
   445  		return targetRevision, "", errors, fmt.Errorf("failed to commit hydrated manifests: %w", err)
   446  	}
   447  	return targetRevision, resp.HydratedSha, errors, nil
   448  }
   449  
   450  // getManifests gets the manifests for the given application and target revision. It returns the resolved revision
   451  // (a git SHA), and path details for the commit server.
   452  //
   453  // If the given target revision is empty, it uses the target revision from the app dry source spec.
   454  func (h *Hydrator) getManifests(ctx context.Context, app *appv1.Application, targetRevision string, project *appv1.AppProject) (revision string, pathDetails *commitclient.PathDetails, err error) {
   455  	drySource := appv1.ApplicationSource{
   456  		RepoURL:        app.Spec.SourceHydrator.DrySource.RepoURL,
   457  		Path:           app.Spec.SourceHydrator.DrySource.Path,
   458  		TargetRevision: app.Spec.SourceHydrator.DrySource.TargetRevision,
   459  	}
   460  	if targetRevision == "" {
   461  		targetRevision = app.Spec.SourceHydrator.DrySource.TargetRevision
   462  	}
   463  
   464  	// TODO: enable signature verification
   465  	objs, resp, err := h.dependencies.GetRepoObjs(ctx, app, drySource, targetRevision, project)
   466  	if err != nil {
   467  		return "", nil, fmt.Errorf("failed to get repo objects for app %q: %w", app.QualifiedName(), err)
   468  	}
   469  
   470  	// Set up a ManifestsRequest
   471  	manifestDetails := make([]*commitclient.HydratedManifestDetails, len(objs))
   472  	for i, obj := range objs {
   473  		objJSON, err := json.Marshal(obj)
   474  		if err != nil {
   475  			return "", nil, fmt.Errorf("failed to marshal object: %w", err)
   476  		}
   477  		manifestDetails[i] = &commitclient.HydratedManifestDetails{ManifestJSON: string(objJSON)}
   478  	}
   479  
   480  	return resp.Revision, &commitclient.PathDetails{
   481  		Path:      app.Spec.SourceHydrator.SyncSource.Path,
   482  		Manifests: manifestDetails,
   483  		Commands:  resp.Commands,
   484  	}, nil
   485  }
   486  
   487  func (h *Hydrator) getRevisionMetadata(ctx context.Context, repoURL, project, revision string) (*appv1.RevisionMetadata, error) {
   488  	repo, err := h.repoGetter.GetRepository(ctx, repoURL, project)
   489  	if err != nil {
   490  		return nil, fmt.Errorf("failed to get repository %q: %w", repoURL, err)
   491  	}
   492  
   493  	closer, repoService, err := h.repoClientset.NewRepoServerClient()
   494  	if err != nil {
   495  		return nil, fmt.Errorf("failed to create commit service: %w", err)
   496  	}
   497  	defer utilio.Close(closer)
   498  
   499  	resp, err := repoService.GetRevisionMetadata(context.Background(), &apiclient.RepoServerRevisionMetadataRequest{
   500  		Repo:     repo,
   501  		Revision: revision,
   502  	})
   503  	if err != nil {
   504  		return nil, fmt.Errorf("failed to get revision metadata: %w", err)
   505  	}
   506  	return resp, nil
   507  }
   508  
   509  // appNeedsHydration answers if application needs manifests hydrated.
   510  func appNeedsHydration(app *appv1.Application) (needsHydration bool, reason string) {
   511  	switch {
   512  	case app.Spec.SourceHydrator == nil:
   513  		return false, "source hydrator not configured"
   514  	case app.Status.SourceHydrator.CurrentOperation == nil:
   515  		return true, "no previous hydrate operation"
   516  	case app.Status.SourceHydrator.CurrentOperation.Phase == appv1.HydrateOperationPhaseHydrating:
   517  		return false, "hydration operation already in progress"
   518  	case app.IsHydrateRequested():
   519  		return true, "hydrate requested"
   520  	case !app.Spec.SourceHydrator.DeepEquals(app.Status.SourceHydrator.CurrentOperation.SourceHydrator):
   521  		return true, "spec.sourceHydrator differs"
   522  	case app.Status.SourceHydrator.CurrentOperation.Phase == appv1.HydrateOperationPhaseFailed && metav1.Now().Sub(app.Status.SourceHydrator.CurrentOperation.FinishedAt.Time) > 2*time.Minute:
   523  		return true, "previous hydrate operation failed more than 2 minutes ago"
   524  	}
   525  
   526  	return false, "hydration not needed"
   527  }
   528  
   529  // getTemplatedCommitMessage gets the multi-line commit message based on the template defined in the configmap. It is a two step process:
   530  // 1. Get the metadata template engine would use to render the template
   531  // 2. Pass the output of Step 1 and Step 2 to template Render
   532  func getTemplatedCommitMessage(repoURL, revision, commitMessageTemplate string, dryCommitMetadata *appv1.RevisionMetadata) (string, error) {
   533  	hydratorCommitMetadata, err := hydrator.GetCommitMetadata(repoURL, revision, dryCommitMetadata)
   534  	if err != nil {
   535  		return "", fmt.Errorf("failed to get hydrated commit message: %w", err)
   536  	}
   537  	templatedCommitMsg, err := hydrator.Render(commitMessageTemplate, hydratorCommitMetadata)
   538  	if err != nil {
   539  		return "", fmt.Errorf("failed to parse template %s: %w", commitMessageTemplate, err)
   540  	}
   541  	return templatedCommitMsg, nil
   542  }
   543  
   544  // genericHydrationError returns an error that summarizes the hydration errors for all applications.
   545  func genericHydrationError(validationErrors map[string]error) error {
   546  	if len(validationErrors) == 0 {
   547  		return nil
   548  	}
   549  
   550  	keys := slices.Sorted(maps.Keys(validationErrors))
   551  	remainder := "has an error"
   552  	if len(keys) > 1 {
   553  		remainder = fmt.Sprintf("and %d more have errors", len(keys)-1)
   554  	}
   555  	return fmt.Errorf("cannot hydrate because application %s %s", keys[0], remainder)
   556  }
   557  
   558  // IsRootPath returns whether the path references a root path
   559  func IsRootPath(path string) bool {
   560  	clean := filepath.Clean(path)
   561  	return clean == "" || clean == "." || clean == string(filepath.Separator)
   562  }