Repository: argoproj/argo-cd
Pull request #23094 (2025-05-22 03:28:18)

chore(deps): bump library/golang from `86b4cff` to `1bcf884` in /test/remote
Bumps library/golang from `86b4cff` to `1bcf884`.

<details>
<summary>Most Recent Ignore Conditions Applied to This Pull Request</summary>

| Dependency Name | Ignore Conditions |
| --- | --- |
| library/golang | [< 1.23, > 1.22.0] |
</details>


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=library/golang&package-manager=docker&previous-version=1.24.3&new-version=1.24.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)


</details>

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/argoproj/argo-cd/pull/23094?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=argoproj) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 60.06%. Comparing base [(`4febded`)](https://app.codecov.io/gh/argoproj/argo-cd/commit/4febded784fd5c0639fff9acbfb240cfe58c344a?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=argoproj) to head [(`16d9e36`)](https://app.codecov.io/gh/argoproj/argo-cd/commit/16d9e36c42babd7ca9da977563a4d7316837395c?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=argoproj).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master   #23094      +/-   ##
==========================================
+ Coverage   59.99%   60.06%   +0.06%     
==========================================
  Files         343      343              
  Lines       57822    57822              
==========================================
+ Hits        34690    34729      +39     
+ Misses      20354    20324      -30     
+ Partials     2778     2769       -9     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/argoproj/argo-cd/pull/23094?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=argoproj).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=argoproj).


Patch:
diff --git a/cmd/argocd-application-controller/commands/argocd_application_controller.go b/cmd/argocd-application-controller/commands/argocd_application_controller.go
index 49a71f746..d9f3e2d9c 100644
--- a/cmd/argocd-application-controller/commands/argocd_application_controller.go
+++ b/cmd/argocd-application-controller/commands/argocd_application_controller.go
@@ -68,6 +68,7 @@ func NewCommand() *cobra.Command {
 		selfHealBackoffTimeoutSeconds    int
 		selfHealBackoffFactor            int
 		selfHealBackoffCapSeconds        int
+		selfHealBackoffCooldownSeconds   int
 		syncTimeout                      int
 		statusProcessors                 int
 		operationProcessors              int
@@ -201,6 +202,7 @@ func NewCommand() *cobra.Command {
 				time.Duration(appResyncJitter)*time.Second,
 				time.Duration(selfHealTimeoutSeconds)*time.Second,
 				selfHealBackoff,
+				time.Duration(selfHealBackoffCooldownSeconds)*time.Second,
 				time.Duration(syncTimeout)*time.Second,
 				time.Duration(repoErrorGracePeriod)*time.Second,
 				metricsPort,
@@ -272,6 +274,7 @@ func NewCommand() *cobra.Command {
 	command.Flags().IntVar(&selfHealBackoffTimeoutSeconds, "self-heal-backoff-timeout-seconds", env.ParseNumFromEnv("ARGOCD_APPLICATION_CONTROLLER_SELF_HEAL_BACKOFF_TIMEOUT_SECONDS", 2, 0, math.MaxInt32), "Specifies initial timeout of exponential backoff between self heal attempts")
 	command.Flags().IntVar(&selfHealBackoffFactor, "self-heal-backoff-factor", env.ParseNumFromEnv("ARGOCD_APPLICATION_CONTROLLER_SELF_HEAL_BACKOFF_FACTOR", 3, 0, math.MaxInt32), "Specifies factor of exponential timeout between application self heal attempts")
 	command.Flags().IntVar(&selfHealBackoffCapSeconds, "self-heal-backoff-cap-seconds", env.ParseNumFromEnv("ARGOCD_APPLICATION_CONTROLLER_SELF_HEAL_BACKOFF_CAP_SECONDS", 300, 0, math.MaxInt32), "Specifies max timeout of exponential backoff between application self heal attempts")
+	command.Flags().IntVar(&selfHealBackoffCooldownSeconds, "self-heal-backoff-cooldown-seconds", env.ParseNumFromEnv("ARGOCD_APPLICATION_CONTROLLER_SELF_HEAL_BACKOFF_COOLDOWN_SECONDS", 330, 0, math.MaxInt32), "Specifies period of time the app needs to stay synced before the self heal backoff can reset")
 	command.Flags().IntVar(&syncTimeout, "sync-timeout", env.ParseNumFromEnv("ARGOCD_APPLICATION_CONTROLLER_SYNC_TIMEOUT", 0, 0, math.MaxInt32), "Specifies the timeout after which a sync would be terminated. 0 means no timeout (default 0).")
 	command.Flags().Int64Var(&kubectlParallelismLimit, "kubectl-parallelism-limit", env.ParseInt64FromEnv("ARGOCD_APPLICATION_CONTROLLER_KUBECTL_PARALLELISM_LIMIT", 20, 0, math.MaxInt64), "Number of allowed concurrent kubectl fork/execs. Any value less than 1 means no limit.")
 	command.Flags().BoolVar(&repoServerPlaintext, "repo-server-plaintext", env.ParseBoolFromEnv("ARGOCD_APPLICATION_CONTROLLER_REPO_SERVER_PLAINTEXT", false), "Disable TLS on connections to repo server")
diff --git a/controller/appcontroller.go b/controller/appcontroller.go
index 8a020f20c..a82ecac76 100644
--- a/controller/appcontroller.go
+++ b/controller/appcontroller.go
@@ -41,6 +41,7 @@ import (
 	"k8s.io/client-go/kubernetes"
 	"k8s.io/client-go/tools/cache"
 	"k8s.io/client-go/util/workqueue"
+	"k8s.io/utils/ptr"
 
 	commitclient "github.com/argoproj/argo-cd/v3/commitserver/apiclient"
 	"github.com/argoproj/argo-cd/v3/common"
@@ -125,6 +126,7 @@ type ApplicationController struct {
 	statusRefreshJitter           time.Duration
 	selfHealTimeout               time.Duration
 	selfHealBackOff               *wait.Backoff
+	selfHealBackoffCooldown       time.Duration
 	syncTimeout                   time.Duration
 	db                            db.ArgoDB
 	settingsMgr                   *settings_util.SettingsManager
@@ -160,6 +162,7 @@ func NewApplicationController(
 	appResyncJitter time.Duration,
 	selfHealTimeout time.Duration,
 	selfHealBackoff *wait.Backoff,
+	selfHealBackoffCooldown time.Duration,
 	syncTimeout time.Duration,
 	repoErrorGracePeriod time.Duration,
 	metricsPort int,
@@ -206,6 +209,7 @@ func NewApplicationController(
 		settingsMgr:                       settingsMgr,
 		selfHealTimeout:                   selfHealTimeout,
 		selfHealBackOff:                   selfHealBackoff,
+		selfHealBackoffCooldown:           selfHealBackoffCooldown,
 		syncTimeout:                       syncTimeout,
 		clusterSharding:                   clusterSharding,
 		projByNameCache:                   sync.Map{},
@@ -2240,17 +2244,22 @@ func (ctrl *ApplicationController) shouldSelfHeal(app *appv1.Application, alread
 		return true, time.Duration(0)
 	}
 
-	// Reset counter if the prior sync was successful OR if the revision has changed
-	if !alreadyAttempted || app.Status.Sync.Status == appv1.SyncStatusCodeSynced {
+	var timeSinceOperation *time.Duration
+	if app.Status.OperationState.FinishedAt != nil {
+		timeSinceOperation = ptr.To(time.Since(app.Status.OperationState.FinishedAt.Time))
+	}
+
+	// Reset counter if the prior sync was successful and the cooldown period is over OR if the revision has changed
+	if !alreadyAttempted || (timeSinceOperation != nil && *timeSinceOperation >= ctrl.selfHealBackoffCooldown && app.Status.Sync.Status == appv1.SyncStatusCodeSynced) {
 		app.Status.OperationState.Operation.Sync.SelfHealAttemptsCount = 0
 	}
 
 	var retryAfter time.Duration
 	if ctrl.selfHealBackOff == nil {
-		if app.Status.OperationState.FinishedAt == nil {
+		if timeSinceOperation == nil {
 			retryAfter = ctrl.selfHealTimeout
 		} else {
-			retryAfter = ctrl.selfHealTimeout - time.Since(app.Status.OperationState.FinishedAt.Time)
+			retryAfter = ctrl.selfHealTimeout - *timeSinceOperation
 		}
 	} else {
 		backOff := *ctrl.selfHealBackOff
@@ -2260,10 +2269,11 @@ func (ctrl *ApplicationController) shouldSelfHeal(app *appv1.Application, alread
 		for i := 0; i < steps; i++ {
 			delay = backOff.Step()
 		}
-		if app.Status.OperationState.FinishedAt == nil {
+
+		if timeSinceOperation == nil {
 			retryAfter = delay
 		} else {
-			retryAfter = delay - time.Since(app.Status.OperationState.FinishedAt.Time)
+			retryAfter = delay - *timeSinceOperation
 		}
 	}
 	return retryAfter <= 0, retryAfter
diff --git a/docs/operator-manual/health.md b/docs/operator-manual/health.md
index 468f13407..cdaf053ea 100644
--- a/docs/operator-manual/health.md
+++ b/docs/operator-manual/health.md
@@ -168,7 +168,33 @@ To test the implemented custom health checks, run `go test -v ./util/lua/`.
 
 The [PR#1139](https://github.com/argoproj/argo-cd/pull/1139) is an example of Cert Manager CRDs custom health check.
 
-Please note that bundled health checks with wildcards are not supported.
+#### Wildcard Support for Built-in Health Checks
+
+You can use a single health check for multiple resources by using a wildcard in the group or kind directory names.
+
+The `_` character behaves like a `*` wildcard. For example, consider the following directory structure:
+
+```
+argo-cd
+|-- resource_customizations
+|    |-- _.group.io               # CRD group
+|    |    |-- _                   # Resource kind
+|    |    |    |-- health.lua     # Health check
+```
+
+Any resource with a group that ends with `.group.io` will use the health check in `health.lua`.
+
+Wildcard checks are only evaluated if there is no specific check for the resource.
+
+If multiple wildcard checks match, the first one in the directory structure is used.
+
+We use the [doublestar](https://github.com/bmatcuk/doublestar) glob library to match the wildcard checks. We currently
+only treat a path as a wildcard if it contains a `_` character, but this may change in the future.
+
+!!!important "Avoid Massive Scripts"
+
+    Avoid writing massive scripts to handle multiple resources. They'll get hard to read and maintain. Instead, just
+    duplicate the relevant parts in resource-specific scripts.
 
 ## Overriding Go-Based Health Checks
 
diff --git a/docs/operator-manual/server-commands/argocd-application-controller.md b/docs/operator-manual/server-commands/argocd-application-controller.md
index 852c62456..29c6b95d7 100644
--- a/docs/operator-manual/server-commands/argocd-application-controller.md
+++ b/docs/operator-manual/server-commands/argocd-application-controller.md
@@ -71,6 +71,7 @@ argocd-application-controller [flags]
       --repo-server-timeout-seconds int                           Repo server RPC call timeout seconds. (default 60)
       --request-timeout string                                    The length of time to wait before giving up on a single server request. Non-zero values should contain a corresponding time unit (e.g. 1s, 2m, 3h). A value of zero means don't timeout requests. (default "0")
       --self-heal-backoff-cap-seconds int                         Specifies max timeout of exponential backoff between application self heal attempts (default 300)
+      --self-heal-backoff-cooldown-seconds int                    Specifies period of time the app needs to stay synced before the self heal backoff can reset (default 330)
       --self-heal-backoff-factor int                              Specifies factor of exponential timeout between application self heal attempts (default 3)
       --self-heal-backoff-timeout-seconds int                     Specifies initial timeout of exponential backoff between self heal attempts (default 2)
       --self-heal-timeout-seconds int                             Specifies timeout between application self heal attempts
diff --git a/manifests/base/application-controller-deployment/argocd-application-controller-deployment.yaml b/manifests/base/application-controller-deployment/argocd-application-controller-deployment.yaml
index ea40f9c9a..bf65aed35 100644
--- a/manifests/base/application-controller-deployment/argocd-application-controller-deployment.yaml
+++ b/manifests/base/application-controller-deployment/argocd-application-controller-deployment.yaml
@@ -121,6 +121,12 @@ spec:
               name: argocd-cmd-params-cm
               key: controller.self.heal.backoff.cap.seconds
               optional: true
+        - name: ARGOCD_APPLICATION_CONTROLLER_SELF_HEAL_BACKOFF_COOLDOWN_SECONDS
+          valueFrom:
+            configMapKeyRef:
+              name: argocd-cmd-params-cm
+              key: controller.self.heal.backoff.cooldown.seconds
+              optional: true
         - name: ARGOCD_APPLICATION_CONTROLLER_SYNC_TIMEOUT
           valueFrom:
             configMapKeyRef:
diff --git a/manifests/base/application-controller/argocd-application-controller-statefulset.yaml b/manifests/base/application-controller/argocd-application-controller-statefulset.yaml
index a595b75f8..e33d3917f 100644
--- a/manifests/base/application-controller/argocd-application-controller-statefulset.yaml
+++ b/manifests/base/application-controller/argocd-application-controller-statefulset.yaml
@@ -124,6 +124,12 @@ spec:
               name: argocd-cmd-params-cm
               key: controller.self.heal.backoff.cap.seconds
               optional: true
+        - name: ARGOCD_APPLICATION_CONTROLLER_SELF_HEAL_BACKOFF_COOLDOWN_SECONDS
+          valueFrom:
+            configMapKeyRef:
+              name: argocd-cmd-params-cm
+              key: controller.self.heal.backoff.cooldown.seconds
+              optional: true
         - name: ARGOCD_APPLICATION_CONTROLLER_SYNC_TIMEOUT
           valueFrom:
             configMapKeyRef:
diff --git a/manifests/core-install-with-hydrator.yaml b/manifests/core-install-with-hydrator.yaml
index cc4cdef63..1feb53fcf 100644
--- a/manifests/core-install-with-hydrator.yaml
+++ b/manifests/core-install-with-hydrator.yaml
@@ -25456,6 +25456,12 @@ spec:
               key: controller.self.heal.backoff.cap.seconds
               name: argocd-cmd-params-cm
               optional: true
+        - name: ARGOCD_APPLICATION_CONTROLLER_SELF_HEAL_BACKOFF_COOLDOWN_SECONDS
+          valueFrom:
+            configMapKeyRef:
+              key: controller.self.heal.backoff.cooldown.seconds
+              name: argocd-cmd-params-cm
+              optional: true
         - name: ARGOCD_APPLICATION_CONTROLLER_SYNC_TIMEOUT
           valueFrom:
             configMapKeyRef:
diff --git a/manifests/core-install.yaml b/manifests/core-install.yaml
index 9dea88c51..b38872875 100644
--- a/manifests/core-install.yaml
+++ b/manifests/core-install.yaml
@@ -25290,6 +25290,12 @@ spec:
               key: controller.self.heal.backoff.cap.seconds
               name: argocd-cmd-params-cm
               optional: true
+        - name: ARGOCD_APPLICATION_CONTROLLER_SELF_HEAL_BACKOFF_COOLDOWN_SECONDS
+          valueFrom:
+            configMapKeyRef:
+              key: controller.self.heal.backoff.cooldown.seconds
+              name: argocd-cmd-params-cm
+              optional: true
         - name: ARGOCD_APPLICATION_CONTROLLER_SYNC_TIMEOUT
           valueFrom:
             configMapKeyRef:
diff --git a/manifests/ha/install-with-hydrator.yaml b/manifests/ha/install-with-hydrator.yaml
index 468841fa5..36ee83081 100644
--- a/manifests/ha/install-with-hydrator.yaml
+++ b/manifests/ha/install-with-hydrator.yaml
@@ -27501,6 +27501,12 @@ spec:
               key: controller.self.heal.backoff.cap.seconds
               name: argocd-cmd-params-cm
               optional: true
+        - name: ARGOCD_APPLICATION_CONTROLLER_SELF_HEAL_BACKOFF_COOLDOWN_SECONDS
+          valueFrom:
+            configMapKeyRef:
+              key: controller.self.heal.backoff.cooldown.seconds
+              name: argocd-cmd-params-cm
+              optional: true
         - name: ARGOCD_APPLICATION_CONTROLLER_SYNC_TIMEOUT
           valueFrom:
             configMapKeyRef:
diff --git a/manifests/ha/install.yaml b/manifests/ha/install.yaml
index f7880bb48..8000e128f 100644
--- a/manifests/ha/install.yaml
+++ b/manifests/ha/install.yaml
@@ -27337,6 +27337,12 @@ spec:
               key: controller.self.heal.backoff.cap.seconds
               name: argocd-cmd-params-cm
               optional: true
+        - name: ARGOCD_APPLICATION_CONTROLLER_SELF_HEAL_BACKOFF_COOLDOWN_SECONDS
+          valueFrom:
+            configMapKeyRef:
+              key: controller.self.heal.backoff.cooldown.seconds
+              name: argocd-cmd-params-cm
+              optional: true
         - name: ARGOCD_APPLICATION_CONTROLLER_SYNC_TIMEOUT
           valueFrom:
             configMapKeyRef:
diff --git a/manifests/ha/namespace-install-with-hydrator.yaml b/manifests/ha/namespace-install-with-hydrator.yaml
index 5ce09327c..251a8e6ad 100644
--- a/manifests/ha/namespace-install-with-hydrator.yaml
+++ b/manifests/ha/namespace-install-with-hydrator.yaml
@@ -3310,6 +3310,12 @@ spec:
               key: controller.self.heal.backoff.cap.seconds
               name: argocd-cmd-params-cm
               optional: true
+        - name: ARGOCD_APPLICATION_CONTROLLER_SELF_HEAL_BACKOFF_COOLDOWN_SECONDS
+          valueFrom:
+            configMapKeyRef:
+              key: controller.self.heal.backoff.cooldown.seconds
+              name: argocd-cmd-params-cm
+              optional: true
         - name: ARGOCD_APPLICATION_CONTROLLER_SYNC_TIMEOUT
           valueFrom:
             configMapKeyRef:
diff --git a/manifests/ha/namespace-install.yaml b/manifests/ha/namespace-install.yaml
index 6cdf81e20..db6d2f97e 100644
--- a/manifests/ha/namespace-install.yaml
+++ b/manifests/ha/namespace-install.yaml
@@ -3146,6 +3146,12 @@ spec:
               key: controller.self.heal.backoff.cap.seconds
               name: argocd-cmd-params-cm
               optional: true
+        - name: ARGOCD_APPLICATION_CONTROLLER_SELF_HEAL_BACKOFF_COOLDOWN_SECONDS
+          valueFrom:
+            configMapKeyRef:
+              key: controller.self.heal.backoff.cooldown.seconds
+              name: argocd-cmd-params-cm
+              optional: true
         - name: ARGOCD_APPLICATION_CONTROLLER_SYNC_TIMEOUT
           valueFrom:
             configMapKeyRef:
diff --git a/manifests/install-with-hydrator.yaml b/manifests/install-with-hydrator.yaml
index 90ffd7004..183b6fbf7 100644
--- a/manifests/install-with-hydrator.yaml
+++ b/manifests/install-with-hydrator.yaml
@@ -26545,6 +26545,12 @@ spec:
               key: controller.self.heal.backoff.cap.seconds
               name: argocd-cmd-params-cm
               optional: true
+        - name: ARGOCD_APPLICATION_CONTROLLER_SELF_HEAL_BACKOFF_COOLDOWN_SECONDS
+          valueFrom:
+            configMapKeyRef:
+              key: controller.self.heal.backoff.cooldown.seconds
+              name: argocd-cmd-params-cm
+              optional: true
         - name: ARGOCD_APPLICATION_CONTROLLER_SYNC_TIMEOUT
           valueFrom:
             configMapKeyRef:
diff --git a/manifests/install.yaml b/manifests/install.yaml
index a2e82b3a5..1a1c9b103 100644
--- a/manifests/install.yaml
+++ b/manifests/install.yaml
@@ -26379,6 +26379,12 @@ spec:
               key: controller.self.heal.backoff.cap.seconds
               name: argocd-cmd-params-cm
               optional: true
+        - name: ARGOCD_APPLICATION_CONTROLLER_SELF_HEAL_BACKOFF_COOLDOWN_SECONDS
+          valueFrom:
+            configMapKeyRef:
+              key: controller.self.heal.backoff.cooldown.seconds
+              name: argocd-cmd-params-cm
+              optional: true
         - name: ARGOCD_APPLICATION_CONTROLLER_SYNC_TIMEOUT
           valueFrom:
             configMapKeyRef:
diff --git a/manifests/namespace-install-with-hydrator.yaml b/manifests/namespace-install-with-hydrator.yaml
index 0dd6a1147..fb86245be 100644
--- a/manifests/namespace-install-with-hydrator.yaml
+++ b/manifests/namespace-install-with-hydrator.yaml
@@ -2354,6 +2354,12 @@ spec:
               key: controller.self.heal.backoff.cap.seconds
               name: argocd-cmd-params-cm
               optional: true
+        - name: ARGOCD_APPLICATION_CONTROLLER_SELF_HEAL_BACKOFF_COOLDOWN_SECONDS
+          valueFrom:
+            configMapKeyRef:
+              key: controller.self.heal.backoff.cooldown.seconds
+              name: argocd-cmd-params-cm
+              optional: true
         - name: ARGOCD_APPLICATION_CONTROLLER_SYNC_TIMEOUT
           valueFrom:
             configMapKeyRef:
diff --git a/manifests/namespace-install.yaml b/manifests/namespace-install.yaml
index 85aee13ab..31c6c7e72 100644
--- a/manifests/namespace-install.yaml
+++ b/manifests/namespace-install.yaml
@@ -2188,6 +2188,12 @@ spec:
               key: controller.self.heal.backoff.cap.seconds
               name: argocd-cmd-params-cm
               optional: true
+        - name: ARGOCD_APPLICATION_CONTROLLER_SELF_HEAL_BACKOFF_COOLDOWN_SECONDS
+          valueFrom:
+            configMapKeyRef:
+              key: controller.self.heal.backoff.cooldown.seconds
+              name: argocd-cmd-params-cm
+              optional: true
         - name: ARGOCD_APPLICATION_CONTROLLER_SYNC_TIMEOUT
           valueFrom:
             configMapKeyRef:
diff --git a/resource_customizations/_.crossplane.io/_/health.lua b/resource_customizations/_.crossplane.io/_/health.lua
new file mode 100644
index 000000000..c4cd585cf
--- /dev/null
+++ b/resource_customizations/_.crossplane.io/_/health.lua
@@ -0,0 +1,66 @@
+-- Health check copied from here: https://github.com/crossplane/docs/blob/bd701357e9d5eecf529a0b42f23a78850a6d1d87/content/master/guides/crossplane-with-argo-cd.md
+
+health_status = {
+  status = "Progressing",
+  message = "Provisioning ..."
+}
+
+local function contains (table, val)
+  for i, v in ipairs(table) do
+    if v == val then
+      return true
+    end
+  end
+  return false
+end
+
+local has_no_status = {
+  "Composition",
+  "CompositionRevision",
+  "DeploymentRuntimeConfig",
+  "ControllerConfig",
+  "ProviderConfig",
+  "ProviderConfigUsage"
+}
+if obj.status == nil or next(obj.status) == nil and contains(has_no_status, obj.kind) then
+    health_status.status = "Healthy"
+    health_status.message = "Resource is up-to-date."
+  return health_status
+end
+
+if obj.status == nil or next(obj.status) == nil or obj.status.conditions == nil then
+  if obj.kind == "ProviderConfig" and obj.status.users ~= nil then
+    health_status.status = "Healthy"
+    health_status.message = "Resource is in use."
+    return health_status
+  end
+  return health_status
+end
+
+for i, condition in ipairs(obj.status.conditions) do
+  if condition.type == "LastAsyncOperation" then
+    if condition.status == "False" then
+      health_status.status = "Degraded"
+      health_status.message = condition.message
+      return health_status
+    end
+  end
+
+  if condition.type == "Synced" then
+    if condition.status == "False" then
+      health_status.status = "Degraded"
+      health_status.message = condition.message
+      return health_status
+    end
+  end
+
+  if contains({"Ready", "Healthy", "Offered", "Established"}, condition.type) then
+    if condition.status == "True" then
+      health_status.status = "Healthy"
+      health_status.message = "Resource is up-to-date."
+      return health_status
+    end
+  end
+end
+
+return health_status
diff --git a/resource_customizations/_.upbound.io/_/health.lua b/resource_customizations/_.upbound.io/_/health.lua
new file mode 100644
index 000000000..57869c81f
--- /dev/null
+++ b/resource_customizations/_.upbound.io/_/health.lua
@@ -0,0 +1,63 @@
+-- Health check copied from here: https://github.com/crossplane/docs/blob/bd701357e9d5eecf529a0b42f23a78850a6d1d87/content/master/guides/crossplane-with-argo-cd.md
+
+health_status = {
+  status = "Progressing",
+  message = "Provisioning ..."
+}
+
+local function contains (table, val)
+  for i, v in ipairs(table) do
+    if v == val then
+      return true
+    end
+  end
+  return false
+end
+
+local has_no_status = {
+  "ProviderConfig",
+  "ProviderConfigUsage"
+}
+
+if obj.status == nil or next(obj.status) == nil and contains(has_no_status, obj.kind) then
+  health_status.status = "Healthy"
+  health_status.message = "Resource is up-to-date."
+  return health_status
+end
+
+if obj.status == nil or next(obj.status) == nil or obj.status.conditions == nil then
+  if obj.kind == "ProviderConfig" and obj.status.users ~= nil then
+    health_status.status = "Healthy"
+    health_status.message = "Resource is in use."
+    return health_status
+  end
+  return health_status
+end
+
+for i, condition in ipairs(obj.status.conditions) do
+  if condition.type == "LastAsyncOperation" then
+    if condition.status == "False" then
+      health_status.status = "Degraded"
+      health_status.message = condition.message
+      return health_status
+    end
+  end
+
+  if condition.type == "Synced" then
+    if condition.status == "False" then
+      health_status.status = "Degraded"
+      health_status.message = condition.message
+      return health_status
+    end
+  end
+
+  if condition.type == "Ready" then
+    if condition.status == "True" then
+      health_status.status = "Healthy"
+      health_status.message = "Resource is up-to-date."
+      return health_status
+    end
+  end
+end
+
+return health_status
diff --git a/resource_customizations/embed.go b/resource_customizations/embed.go
index 8a4d5316c..8b2157b82 100644
--- a/resource_customizations/embed.go
+++ b/resource_customizations/embed.go
@@ -6,5 +6,5 @@ import (
 
 // Embedded contains embedded resource customization
 //
-//go:embed *
+//go:embed all:*
 var Embedded embed.FS
diff --git a/util/lua/lua.go b/util/lua/lua.go
index df47709b7..abdc88f84 100644
--- a/util/lua/lua.go
+++ b/util/lua/lua.go
@@ -6,12 +6,17 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"io/fs"
 	"os"
 	"path/filepath"
 	"reflect"
+	"slices"
+	"strings"
+	"sync"
 	"time"
 
 	"github.com/argoproj/gitops-engine/pkg/health"
+	glob "github.com/bmatcuk/doublestar/v4"
 	lua "github.com/yuin/gopher-lua"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/runtime/schema"
@@ -20,7 +25,7 @@ import (
 	applicationpkg "github.com/argoproj/argo-cd/v3/pkg/apiclient/application"
 	appv1 "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1"
 	"github.com/argoproj/argo-cd/v3/resource_customizations"
-	"github.com/argoproj/argo-cd/v3/util/glob"
+	argoglob "github.com/argoproj/argo-cd/v3/util/glob"
 )
 
 const (
@@ -31,15 +36,8 @@ const (
 	actionDiscoveryScriptFile = "discovery.lua"
 )
 
-// ScriptDoesNotExistError is an error type for when a built-in script does not exist.
-type ScriptDoesNotExistError struct {
-	// ScriptName is the name of the script that does not exist.
-	ScriptName string
-}
-
-func (e ScriptDoesNotExistError) Error() string {
-	return fmt.Sprintf("built-in script %q does not exist", e.ScriptName)
-}
+// errScriptDoesNotExist is an error type for when a built-in script does not exist.
+var errScriptDoesNotExist = errors.New("built-in script does not exist")
 
 type ResourceHealthOverrides map[string]appv1.ResourceOverride
 
@@ -187,8 +185,16 @@ func (vm VM) GetHealthScript(obj *unstructured.Unstructured) (script string, use
 	// (as built-in scripts are files in folders, named after the GVK, currently there is no wildcard support for them)
 	builtInScript, err := vm.getPredefinedLuaScripts(key, healthScriptFile)
 	if err != nil {
-		var doesNotExist *ScriptDoesNotExistError
-		if errors.As(err, &doesNotExist) {
+		if errors.Is(err, errScriptDoesNotExist) {
+			// Try to find a wildcard built-in health script
+			builtInScript, err = getWildcardBuiltInHealthOverrideLua(key)
+			if err != nil {
+				return "", false, fmt.Errorf("error while fetching built-in health script: %w", err)
+			}
+			if builtInScript != "" {
+				return builtInScript, true, nil
+			}
+
 			// It's okay if no built-in health script exists. Just return an empty string and let the caller handle it.
 			return "", false, nil
 		}
@@ -422,8 +428,7 @@ func (vm VM) GetResourceActionDiscovery(obj *unstructured.Unstructured) ([]strin
 	discoveryKey := key + "/actions/"
 	discoveryScript, err := vm.getPredefinedLuaScripts(discoveryKey, actionDiscoveryScriptFile)
 	if err != nil {
-		var doesNotExistErr *ScriptDoesNotExistError
-		if errors.As(err, &doesNotExistErr) {
+		if errors.Is(err, errScriptDoesNotExist) {
 			// No worries, just return what we have.
 			return discoveryScripts, nil
 		}
@@ -477,7 +482,7 @@ func getWildcardHealthOverrideLua(overrides map[string]appv1.ResourceOverride, g
 	gvkKeyToMatch := GetConfigMapKey(gvk)
 
 	for key, override := range overrides {
-		if glob.Match(key, gvkKeyToMatch) && override.HealthLua != "" {
+		if argoglob.Match(key, gvkKeyToMatch) && override.HealthLua != "" {
 			return override.HealthLua, override.UseOpenLibs
 		}
 	}
@@ -488,13 +493,95 @@ func (vm VM) getPredefinedLuaScripts(objKey string, scriptFile string) (string,
 	data, err := resource_customizations.Embedded.ReadFile(filepath.Join(objKey, scriptFile))
 	if err != nil {
 		if os.IsNotExist(err) {
-			return "", &ScriptDoesNotExistError{ScriptName: objKey}
+			return "", errScriptDoesNotExist
 		}
 		return "", err
 	}
 	return string(data), nil
 }
 
+// globHealthScriptPathsOnce is a sync.Once instance to ensure that the globHealthScriptPaths are only initialized once.
+// The globs come from an embedded filesystem, so it won't change at runtime.
+var globHealthScriptPathsOnce sync.Once
+
+// globHealthScriptPaths is a cache for the glob patterns of directories containing health.lua files. Don't use this
+// directly, use getGlobHealthScriptPaths() instead.
+var globHealthScriptPaths []string
+
+// getGlobHealthScriptPaths returns the paths of the directories containing health.lua files where the path contains a
+// glob pattern. It uses a sync.Once to ensure that the paths are only initialized once.
+func getGlobHealthScriptPaths() ([]string, error) {
+	var err error
+	globHealthScriptPathsOnce.Do(func() {
+		// Walk through the embedded filesystem and get the directory names of all directories containing a health.lua.
+		var patterns []string
+		err = fs.WalkDir(resource_customizations.Embedded, ".", func(path string, d fs.DirEntry, err error) error {
+			if err != nil {
+				return fmt.Errorf("error walking path %q: %w", path, err)
+			}
+
+			// Skip non-directories at the top level
+			if d.IsDir() && filepath.Dir(path) == "." {
+				return nil
+			}
+
+			// Check if the directory contains a health.lua file
+			if filepath.Base(path) != healthScriptFile {
+				return nil
+			}
+
+			groupKindPath := filepath.Dir(path)
+			// Check if the path contains a wildcard. If it doesn't, skip it.
+			if !strings.Contains(groupKindPath, "_") {
+				return nil
+			}
+
+			pattern := strings.ReplaceAll(groupKindPath, "_", "*")
+			// Check that the pattern is valid.
+			if !glob.ValidatePattern(pattern) {
+				return fmt.Errorf("invalid glob pattern %q: %w", pattern, err)
+			}
+
+			patterns = append(patterns, groupKindPath)
+			return nil
+		})
+		if err != nil {
+			return
+		}
+
+		// Sort the patterns to ensure deterministic choice of wildcard directory for a given GK.
+		slices.Sort(patterns)
+
+		globHealthScriptPaths = patterns
+	})
+	if err != nil {
+		return nil, fmt.Errorf("error getting health script glob directories: %w", err)
+	}
+	return globHealthScriptPaths, nil
+}
+
+func getWildcardBuiltInHealthOverrideLua(objKey string) (string, error) {
+	// Check if the GVK matches any of the wildcard directories
+	globs, err := getGlobHealthScriptPaths()
+	if err != nil {
+		return "", fmt.Errorf("error getting health script globs: %w", err)
+	}
+	for _, g := range globs {
+		pattern := strings.ReplaceAll(g, "_", "*")
+		if !glob.PathMatchUnvalidated(pattern, objKey) {
+			continue
+		}
+
+		var script []byte
+		script, err = resource_customizations.Embedded.ReadFile(filepath.Join(g, healthScriptFile))
+		if err != nil {
+			return "", fmt.Errorf("error reading %q file in embedded filesystem: %w", filepath.Join(objKey, healthScriptFile), err)
+		}
+		return string(script), nil
+	}
+	return "", nil
+}
+
 func isValidHealthStatusCode(statusCode health.HealthStatusCode) bool {
 	switch statusCode {
 	case health.HealthStatusUnknown, health.HealthStatusProgressing, health.HealthStatusSuspended, health.HealthStatusHealthy, health.HealthStatusDegraded, health.HealthStatusMissing:


Test patch:
diff --git a/controller/appcontroller_test.go b/controller/appcontroller_test.go
index 12be8228f..8185b5462 100644
--- a/controller/appcontroller_test.go
+++ b/controller/appcontroller_test.go
@@ -172,6 +172,7 @@ func newFakeControllerWithResync(data *fakeData, appResyncPeriod time.Duration,
 		time.Second,
 		time.Minute,
 		nil,
+		time.Minute,
 		0,
 		time.Second*10,
 		common.DefaultPortArgoCDMetrics,
@@ -2642,10 +2643,18 @@ func TestSelfHealExponentialBackoff(t *testing.T) {
 		alreadyAttempted: false,
 		expectedAttempts: 0,
 		syncStatus:       v1alpha1.SyncStatusCodeOutOfSync,
-	}, {
+	}, { // backoff will not reset as finished tme isn't >= cooldown
 		attempts:         6,
-		finishedAt:       nil,
-		expectedDuration: 0,
+		finishedAt:       ptr.To(metav1.Now()),
+		expectedDuration: 120 * time.Second,
+		shouldSelfHeal:   false,
+		alreadyAttempted: true,
+		expectedAttempts: 6,
+		syncStatus:       v1alpha1.SyncStatusCodeSynced,
+	}, { // backoff will reset as finished time is >= cooldown
+		attempts:         40,
+		finishedAt:       &metav1.Time{Time: time.Now().Add(-(1 * time.Minute))},
+		expectedDuration: -60 * time.Second,
 		shouldSelfHeal:   true,
 		alreadyAttempted: true,
 		expectedAttempts: 0,
diff --git a/resource_customizations/_.crossplane.io/_/health_test.yaml b/resource_customizations/_.crossplane.io/_/health_test.yaml
new file mode 100644
index 000000000..7ef0da991
--- /dev/null
+++ b/resource_customizations/_.crossplane.io/_/health_test.yaml
@@ -0,0 +1,5 @@
+tests:
+  - healthStatus:
+      status: Healthy
+      message: "Resource is up-to-date."
+    inputPath: testdata/composition_healthy.yaml
diff --git a/resource_customizations/_.crossplane.io/_/testdata/composition_healthy.yaml b/resource_customizations/_.crossplane.io/_/testdata/composition_healthy.yaml
new file mode 100644
index 000000000..56ae5ac89
--- /dev/null
+++ b/resource_customizations/_.crossplane.io/_/testdata/composition_healthy.yaml
@@ -0,0 +1,25 @@
+# Taken from here May 9, 2025: https://docs.crossplane.io/latest/concepts/compositions/
+apiVersion: apiextensions.crossplane.io/v1
+kind: Composition
+metadata:
+  name: example
+spec:
+  compositeTypeRef:
+    apiVersion: custom-api.example.org/v1alpha1
+    kind: AcmeBucket
+  mode: Pipeline
+  pipeline:
+    - step: patch-and-transform
+      functionRef:
+        name: function-patch-and-transform
+      input:
+        apiVersion: pt.fn.crossplane.io/v1beta1
+        kind: Resources
+        resources:
+          - name: storage-bucket
+            base:
+              apiVersion: s3.aws.upbound.io/v1beta1
+              kind: Bucket
+              spec:
+                forProvider:
+                  region: "us-east-2"
diff --git a/resource_customizations/_.upbound.io/_/health_test.yaml b/resource_customizations/_.upbound.io/_/health_test.yaml
new file mode 100644
index 000000000..a11541d26
--- /dev/null
+++ b/resource_customizations/_.upbound.io/_/health_test.yaml
@@ -0,0 +1,5 @@
+tests:
+  - healthStatus:
+      status: Healthy
+      message: "Resource is up-to-date."
+    inputPath: testdata/providerconfig_healthy.yaml
diff --git a/resource_customizations/_.upbound.io/_/testdata/providerconfig_healthy.yaml b/resource_customizations/_.upbound.io/_/testdata/providerconfig_healthy.yaml
new file mode 100644
index 000000000..5429ac065
--- /dev/null
+++ b/resource_customizations/_.upbound.io/_/testdata/providerconfig_healthy.yaml
@@ -0,0 +1,10 @@
+apiVersion: aws.upbound.io/v1beta1
+kind: ProviderConfig
+metadata:
+  name: irsa-with-role-chaining
+spec:
+  credentials:
+    source: IRSA
+  assumeRoleChain:
+    - roleARN: <roleARN-1>
+    - roleARN: <roleARN-2>
diff --git a/test/remote/Dockerfile b/test/remote/Dockerfile
index a220616b0..6da0a30c9 100644
--- a/test/remote/Dockerfile
+++ b/test/remote/Dockerfile
@@ -1,6 +1,6 @@
 ARG BASE_IMAGE=docker.io/library/ubuntu:24.04@sha256:80dd3c3b9c6cecb9f1667e9290b3bc61b78c2678c02cbdae5f0fea92cc6734ab
 
-FROM docker.io/library/golang:1.24.3@sha256:86b4cff66e04d41821a17cea30c1031ed53e2635e2be99ae0b4a7d69336b5063 AS go
+FROM docker.io/library/golang:1.24.3@sha256:1bcf8844d2464a6485c87646f9da684610758eb1a2df63c8a6e7ca47c64f8655 AS go
 
 RUN go install github.com/mattn/goreman@latest && \
     go install github.com/kisielk/godepgraph@latest
diff --git a/util/lua/lua_test.go b/util/lua/lua_test.go
index aa7842410..9274d279d 100644
--- a/util/lua/lua_test.go
+++ b/util/lua/lua_test.go
@@ -284,8 +284,7 @@ func TestGetResourceActionNoPredefined(t *testing.T) {
 	testObj := StrToUnstructured(objWithNoScriptJSON)
 	vm := VM{}
 	action, err := vm.GetResourceAction(testObj, "test")
-	var expectedErr *ScriptDoesNotExistError
-	require.ErrorAs(t, err, &expectedErr)
+	require.ErrorIs(t, err, errScriptDoesNotExist)
 	assert.Empty(t, action.ActionLua)
 }
 
@@ -868,7 +867,7 @@ return hs`
 	})
 
 	t.Run("Get resource health for */* override with empty health.lua", func(t *testing.T) {
-		testObj := StrToUnstructured(ec2AWSCrossplaneObjJSON)
+		testObj := StrToUnstructured(objWithNoScriptJSON)
 		overrides := getBaseWildcardHealthOverrides
 		status, err := overrides.GetResourceHealth(testObj)
 		require.NoError(t, err)
@@ -954,3 +953,12 @@ func createMockResource(kind string, name string, replicas int) *unstructured.Un
             image: nginx
     `, kind, name, replicas))
 }
+
+func Test_getHealthScriptPaths(t *testing.T) {
+	paths, err := getGlobHealthScriptPaths()
+	require.NoError(t, err)
+
+	// This test will fail any time a glob pattern is added to the health script paths. We don't expect that to happen
+	// often.
+	assert.Equal(t, []string{"_.crossplane.io/_", "_.upbound.io/_"}, paths)
+}