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 }