Repository: argoproj/argo-rollouts
Pull request #2319 (2022-10-12 19:54:53)

ci: add auto close to issues and prs
Signed-off-by: zachaller <zachaller@users.noreply.github.com>

sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=argoproj_argo-rollouts&pullRequest=2319)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=CODE_SMELL)

[![No Coverage information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo-16px.png 'No Coverage information')](https://sonarcloud.io/component_measures?id=argoproj_argo-rollouts&pullRequest=2319) No Coverage information  
[![2.9%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '2.9%')](https://sonarcloud.io/component_measures?id=argoproj_argo-rollouts&pullRequest=2319&metric=new_duplicated_lines_density&view=list) [2.9% Duplication](https://sonarcloud.io/component_measures?id=argoproj_argo-rollouts&pullRequest=2319&metric=new_duplicated_lines_density&view=list)


github-actions[bot]: ## Go Published Test Results
1 772 tests   1 772 [:heavy_check_mark:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "passed tests")  2m 34s [:stopwatch:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "duration of all tests")
   101 suites         0 [:zzz:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "skipped / disabled tests")
       1 files           0 [:x:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "failed tests")

Results for commit d1b19b46.

[test-results]:data:application/gzip;base64,H4sIADseR2MC/2XMSw7CIBSF4a00jB1wLRTrZgyXR0Jsi6Ewaty7QH2gDv/vJGcj1k1mJecODh1Zk4t70JI6BRmdXwpwliGPsc5CHF95WZNSf3Z1t2z0DVa66QtMCD48JaSleS31c7rT57N2c1m7fVR+nl3MQTQgjMgGRIbixDWVXECP0oygJADr+YBUaEvuDwTsyI0LAQAA

codecov[bot]: # [Codecov](https://codecov.io/gh/argoproj/argo-rollouts/pull/2319?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=argoproj) Report
Base: **82.38**% // Head: **82.39**% // Increases project coverage by **`+0.01%`** :tada:
> Coverage data is based on head [(`d1b19b4`)](https://codecov.io/gh/argoproj/argo-rollouts/pull/2319?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=argoproj) compared to base [(`1394f4e`)](https://codecov.io/gh/argoproj/argo-rollouts/commit/1394f4e8bbcdd248ea955454b608ba129d267d95?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=argoproj).
> Patch has no changes to coverable lines.

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


```diff
@@            Coverage Diff             @@
##           master    #2319      +/-   ##
==========================================
+ Coverage   82.38%   82.39%   +0.01%     
==========================================
  Files         121      121              
  Lines       18476    18476              
==========================================
+ Hits        15221    15224       +3     
+ Misses       2468     2466       -2     
+ Partials      787      786       -1     
```


| [Impacted Files](https://codecov.io/gh/argoproj/argo-rollouts/pull/2319?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=argoproj) | Coverage Δ | |
|---|---|---|
| [rollout/trafficrouting/istio/controller.go](https://codecov.io/gh/argoproj/argo-rollouts/pull/2319/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=argoproj#diff-cm9sbG91dC90cmFmZmljcm91dGluZy9pc3Rpby9jb250cm9sbGVyLmdv) | `52.43% <0.00%> (+1.62%)` | :arrow_up: |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=argoproj). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=argoproj)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/argoproj/argo-rollouts/pull/2319?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=argoproj).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=argoproj).

github-actions[bot]: ## E2E Tests Published Test Results
  1 files    1 suites   44m 54s [:stopwatch:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "duration of all tests")
89 tests 82 [:heavy_check_mark:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "passed tests") 3 [:zzz:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "skipped / disabled tests") 4 [:x:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "failed tests")
93 runs  86 [:heavy_check_mark:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "passed tests") 3 [:zzz:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "skipped / disabled tests") 4 [:x:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "failed tests")

For more details on these failures, see [this check](https://github.com/argoproj/argo-rollouts/runs/8855259919).

Results for commit d1b19b46.

[test-results]:data:application/gzip;base64,H4sIAHgiR2MC/02Myw6DIBBFf8Ww7oKRh9KfMQxoQuqjQViZ/nsH06K7e85NzsGmMI87ezbwaNieQ6rgc7QpbCthq40kQ18qb2/+MOzZuWLay7zCm4yoYrJhJlEDwxjjFslwMjGvpWjEb9egruLqnXzLnXyvuW1ZQiJgHhAMSo0oseuV51Z1INCOBpwFkEJp5J2f2OcL0ycLSgIBAAA=

sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=argoproj_argo-rollouts&pullRequest=2319)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=CODE_SMELL)

[![No Coverage information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo-16px.png 'No Coverage information')](https://sonarcloud.io/component_measures?id=argoproj_argo-rollouts&pullRequest=2319) No Coverage information  
[![2.9%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '2.9%')](https://sonarcloud.io/component_measures?id=argoproj_argo-rollouts&pullRequest=2319&metric=new_duplicated_lines_density&view=list) [2.9% Duplication](https://sonarcloud.io/component_measures?id=argoproj_argo-rollouts&pullRequest=2319&metric=new_duplicated_lines_density&view=list)


sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=argoproj_argo-rollouts&pullRequest=2319)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=CODE_SMELL)

[![No Coverage information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo-16px.png 'No Coverage information')](https://sonarcloud.io/component_measures?id=argoproj_argo-rollouts&pullRequest=2319) No Coverage information  
[![2.9%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '2.9%')](https://sonarcloud.io/component_measures?id=argoproj_argo-rollouts&pullRequest=2319&metric=new_duplicated_lines_density&view=list) [2.9% Duplication](https://sonarcloud.io/component_measures?id=argoproj_argo-rollouts&pullRequest=2319&metric=new_duplicated_lines_density&view=list)


sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=argoproj_argo-rollouts&pullRequest=2319)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=argoproj_argo-rollouts&pullRequest=2319&resolved=false&types=CODE_SMELL)

[![No Coverage information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo-16px.png 'No Coverage information')](https://sonarcloud.io/component_measures?id=argoproj_argo-rollouts&pullRequest=2319) No Coverage information  
[![2.9%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '2.9%')](https://sonarcloud.io/component_measures?id=argoproj_argo-rollouts&pullRequest=2319&metric=new_duplicated_lines_density&view=list) [2.9% Duplication](https://sonarcloud.io/component_measures?id=argoproj_argo-rollouts&pullRequest=2319&metric=new_duplicated_lines_density&view=list)



Patch:
diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml
new file mode 100644
index 000000000..5f11c118a
--- /dev/null
+++ b/.github/workflows/pr-title-check.yml
@@ -0,0 +1,47 @@
+name: "Lint PR"
+
+on:
+  pull_request_target:
+    types:
+      - opened
+      - edited
+      - synchronize
+
+jobs:
+  main:
+    name: Validate PR title
+    runs-on: ubuntu-latest
+    steps:
+      - uses: amannn/action-semantic-pull-request@v5
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+    with:
+      # Configure which types are allowed (newline delimited).
+      # Default: https://github.com/commitizen/conventional-commit-types
+      types: |
+        feat
+        fix
+        docs
+        style
+        refactor
+        perf
+        test
+        build
+        ci
+        chore
+        revert
+
+      # Configure which scopes are allowed (newline delimited).
+      scopes: |
+        controller
+        dashboard
+        trafficrouting
+        analysis
+        metricprovider
+        experiments
+        deps
+        example
+        cli
+
+      # Configure that a scope must always be provided.
+      requireScope: false
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index 7bb0c1dfb..43080842a 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -155,6 +155,7 @@ jobs:
             dist/kubectl-argo-rollouts-darwin-amd64
             dist/kubectl-argo-rollouts-darwin-arm64
             dist/kubectl-argo-rollouts-windows-amd64
+            dist/argo-rollouts-checksums.txt
             manifests/dashboard-install.yaml
             manifests/install.yaml
             manifests/namespace-install.yaml
diff --git a/.github/workflows/stale-issues-pr.yml b/.github/workflows/stale-issues-pr.yml
new file mode 100644
index 000000000..cbf65e7e7
--- /dev/null
+++ b/.github/workflows/stale-issues-pr.yml
@@ -0,0 +1,27 @@
+name: 'Close stale issues and PRs'
+on:
+  schedule:
+    - cron: '30 1 * * *'
+
+jobs:
+  stale:
+    runs-on: ubuntu-latest
+    permissions:
+      issues: write
+      pull-requests: write
+    steps:
+      - uses: actions/stale@v6
+        with:
+          stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
+          stale-pr-message: 'This PR is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 14 days.'
+          close-issue-message: 'This issue was closed because it has been stalled for 7 days with no activity.'
+          close-pr-message: 'This PR was closed because it has been stalled for 14 days with no activity.'
+          days-before-issue-stale: 60
+          days-before-pr-stale: 90
+          days-before-issue-close: 7
+          days-before-pr-close: 14
+          stale-issue-label: 'no-issue-activity'
+          exempt-issue-labels: 'awaiting-approval,work-in-progress'
+          stale-pr-label: 'no-pr-activity'
+          exempt-pr-labels: 'awaiting-approval,work-in-progress'
+          exempt-all-milestones: true
\ No newline at end of file
diff --git a/.github/workflows/waiting-issues.yml b/.github/workflows/waiting-issues.yml
new file mode 100644
index 000000000..7e1814e20
--- /dev/null
+++ b/.github/workflows/waiting-issues.yml
@@ -0,0 +1,28 @@
+name: 'Close stale issues and PRs waiting for response'
+on:
+  schedule:
+    - cron: '30 1 * * *'
+
+jobs:
+  stale:
+    runs-on: ubuntu-latest
+    permissions:
+      issues: write
+      pull-requests: write
+    steps:
+      - uses: actions/stale@v6
+        with:
+          stale-issue-message: 'This issue is stale because it has awaiting-response label for 5 days with no activity. Remove stale label or comment or this will be closed in 5 days.'
+          stale-pr-message: 'This PR is stale because it has awaiting-response label for 10 days with no activity. Remove stale label or comment or this will be closed in 10 days.'
+          close-issue-message: 'This issue was closed because it has been stalled for 5 days with no activity and has the awaiting-response label.'
+          close-pr-message: 'This PR was closed because it has been stalled for 10 days with no activity and has the awaiting-response label.'
+          days-before-issue-stale: 5
+          days-before-pr-stale: 10
+          days-before-issue-close: 5
+          days-before-pr-close: 10
+          stale-issue-label: 'no-issue-activity'
+          exempt-issue-labels: 'awaiting-approval,work-in-progress'
+          stale-pr-label: 'no-pr-activity'
+          exempt-pr-labels: 'awaiting-approval,work-in-progress'
+          exempt-all-milestones: true
+          any-of-labels: 'awaiting-response'
\ No newline at end of file
diff --git a/README.md b/README.md
index f044877ab..0a3ba824d 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,7 @@
 [![codecov](https://codecov.io/gh/argoproj/argo-rollouts/branch/master/graph/badge.svg)](https://codecov.io/gh/argoproj/argo-rollouts)
 [![slack](https://img.shields.io/badge/slack-argoproj-brightgreen.svg?logo=slack)](https://argoproj.github.io/community/join-slack)
 [![CII Best Practices](https://bestpractices.coreinfrastructure.org/projects/3834/badge)](https://bestpractices.coreinfrastructure.org/projects/3834)
+[![Artifact HUB](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/argo-rollouts)](https://artifacthub.io/packages/helm/argo/argo-rollouts)
 
 ## What is Argo Rollouts?
 Argo Rollouts is a Kubernetes controller and set of CRDs which provide advanced deployment capabilities such as blue-green, canary, canary analysis, experimentation, and progressive delivery features to Kubernetes. 
diff --git a/analysis/controller.go b/analysis/controller.go
index 285cd1ff1..f05c955bb 100644
--- a/analysis/controller.go
+++ b/analysis/controller.go
@@ -1,6 +1,8 @@
 package analysis
 
 import (
+	"context"
+	"sync"
 	"time"
 
 	unstructuredutil "github.com/argoproj/argo-rollouts/utils/unstructured"
@@ -131,21 +133,26 @@ func NewController(cfg ControllerConfig) *Controller {
 	return controller
 }
 
-func (c *Controller) Run(threadiness int, stopCh <-chan struct{}) error {
+func (c *Controller) Run(ctx context.Context, threadiness int) error {
 	log.Info("Starting analysis workers")
+	wg := sync.WaitGroup{}
 	for i := 0; i < threadiness; i++ {
+		wg.Add(1)
 		go wait.Until(func() {
-			controllerutil.RunWorker(c.analysisRunWorkQueue, logutil.AnalysisRunKey, c.syncHandler, c.metricsServer)
-		}, time.Second, stopCh)
+			controllerutil.RunWorker(ctx, c.analysisRunWorkQueue, logutil.AnalysisRunKey, c.syncHandler, c.metricsServer)
+			log.Debug("Analysis worker has stopped")
+			wg.Done()
+		}, time.Second, ctx.Done())
 	}
 	log.Infof("Started %d analysis workers", threadiness)
-	<-stopCh
-	log.Info("Shutting down analysis workers")
+	<-ctx.Done()
+	wg.Wait()
+	log.Info("All analysis workers have stopped")
 
 	return nil
 }
 
-func (c *Controller) syncHandler(key string) error {
+func (c *Controller) syncHandler(ctx context.Context, key string) error {
 	startTime := timeutil.Now()
 	namespace, name, err := cache.SplitMetaNamespaceKey(key)
 	if err != nil {
diff --git a/cmd/rollouts-controller/main.go b/cmd/rollouts-controller/main.go
index f669a7115..4aadf9ad8 100644
--- a/cmd/rollouts-controller/main.go
+++ b/cmd/rollouts-controller/main.go
@@ -88,7 +88,7 @@ func newCommand() *cobra.Command {
 			log.WithField("version", version.GetVersion()).Info("Argo Rollouts starting")
 
 			// set up signals so we handle the first shutdown signal gracefully
-			stopCh := signals.SetupSignalHandler()
+			ctx := signals.SetupSignalHandlerContext()
 
 			defaults.SetVerifyTargetGroup(awsVerifyTargetGroup)
 			defaults.SetIstioAPIVersion(istioVersion)
@@ -190,23 +190,16 @@ func newCommand() *cobra.Command {
 				healthzPort,
 				k8sRequestProvider,
 				nginxIngressClasses,
-				albIngressClasses)
-			// notice that there is no need to run Start methods in a separate goroutine. (i.e. go kubeInformerFactory.Start(stopCh)
-			// Start method is non-blocking and runs all registered informers in a dedicated goroutine.
-			dynamicInformerFactory.Start(stopCh)
-			if !namespaced {
-				clusterDynamicInformerFactory.Start(stopCh)
-			}
-			kubeInformerFactory.Start(stopCh)
-			controllerNamespaceInformerFactory.Start(stopCh)
-			jobInformerFactory.Start(stopCh)
-
-			// Check if Istio installed on cluster before starting dynamicInformerFactory
-			if istioutil.DoesIstioExist(istioPrimaryDynamicClient, namespace) {
-				istioDynamicInformerFactory.Start(stopCh)
-			}
+				albIngressClasses,
+				dynamicInformerFactory,
+				clusterDynamicInformerFactory,
+				istioDynamicInformerFactory,
+				namespaced,
+				kubeInformerFactory,
+				controllerNamespaceInformerFactory,
+				jobInformerFactory)
 
-			if err = cm.Run(rolloutThreads, serviceThreads, ingressThreads, experimentThreads, analysisThreads, electOpts, stopCh); err != nil {
+			if err = cm.Run(ctx, rolloutThreads, serviceThreads, ingressThreads, experimentThreads, analysisThreads, electOpts); err != nil {
 				log.Fatalf("Error running controller: %s", err.Error())
 			}
 			return nil
diff --git a/controller/controller.go b/controller/controller.go
index a0efcbe55..55ef4a45f 100644
--- a/controller/controller.go
+++ b/controller/controller.go
@@ -6,8 +6,15 @@ import (
 	"fmt"
 	"net/http"
 	"os"
+	"sync"
 	"time"
 
+	"k8s.io/apimachinery/pkg/util/wait"
+
+	istioutil "github.com/argoproj/argo-rollouts/utils/istio"
+	"k8s.io/client-go/dynamic/dynamicinformer"
+	kubeinformers "k8s.io/client-go/informers"
+
 	notificationapi "github.com/argoproj/notifications-engine/pkg/api"
 	notificationcontroller "github.com/argoproj/notifications-engine/pkg/controller"
 
@@ -18,7 +25,6 @@ import (
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/util/runtime"
 	"k8s.io/apimachinery/pkg/util/uuid"
-	"k8s.io/apimachinery/pkg/util/wait"
 	"k8s.io/client-go/discovery"
 	"k8s.io/client-go/dynamic"
 	appsinformers "k8s.io/client-go/informers/apps/v1"
@@ -108,8 +114,8 @@ func NewLeaderElectionOptions() *LeaderElectionOptions {
 
 // Manager is the controller implementation for Argo-Rollout resources
 type Manager struct {
+	wg                      *sync.WaitGroup
 	metricsServer           *metrics.MetricsServer
-	secondaryMetricsServer  *metrics.MetricsServer
 	healthzServer           *http.Server
 	rolloutController       *rollout.Controller
 	experimentController    *experiments.Controller
@@ -141,6 +147,15 @@ type Manager struct {
 	kubeClientSet kubernetes.Interface
 
 	namespace string
+
+	dynamicInformerFactory             dynamicinformer.DynamicSharedInformerFactory
+	clusterDynamicInformerFactory      dynamicinformer.DynamicSharedInformerFactory
+	istioDynamicInformerFactory        dynamicinformer.DynamicSharedInformerFactory
+	namespaced                         bool
+	kubeInformerFactory                kubeinformers.SharedInformerFactory
+	controllerNamespaceInformerFactory kubeinformers.SharedInformerFactory
+	jobInformerFactory                 kubeinformers.SharedInformerFactory
+	istioPrimaryDynamicClient          dynamic.Interface
 }
 
 // NewManager returns a new manager to manage all the controllers
@@ -172,6 +187,13 @@ func NewManager(
 	k8sRequestProvider *metrics.K8sRequestsCountProvider,
 	nginxIngressClasses []string,
 	albIngressClasses []string,
+	dynamicInformerFactory dynamicinformer.DynamicSharedInformerFactory,
+	clusterDynamicInformerFactory dynamicinformer.DynamicSharedInformerFactory,
+	istioDynamicInformerFactory dynamicinformer.DynamicSharedInformerFactory,
+	namespaced bool,
+	kubeInformerFactory kubeinformers.SharedInformerFactory,
+	controllerNamespaceInformerFactory kubeinformers.SharedInformerFactory,
+	jobInformerFactory kubeinformers.SharedInformerFactory,
 ) *Manager {
 
 	runtime.Must(rolloutscheme.AddToScheme(scheme.Scheme))
@@ -186,10 +208,9 @@ func NewManager(
 		ClusterAnalysisTemplateLister: clusterAnalysisTemplateInformer.Lister(),
 		ExperimentLister:              experimentsInformer.Lister(),
 		K8SRequestProvider:            k8sRequestProvider,
-	}, true)
+	})
 
 	healthzServer := NewHealthzServer(fmt.Sprintf(listenAddr, healthzPort))
-
 	rolloutWorkqueue := workqueue.NewNamedRateLimitingQueue(queue.DefaultArgoRolloutsRateLimiter(), "Rollouts")
 	experimentWorkqueue := workqueue.NewNamedRateLimitingQueue(queue.DefaultArgoRolloutsRateLimiter(), "Experiments")
 	analysisRunWorkqueue := workqueue.NewNamedRateLimitingQueue(queue.DefaultArgoRolloutsRateLimiter(), "AnalysisRuns")
@@ -293,33 +314,42 @@ func NewManager(
 	})
 
 	cm := &Manager{
-		metricsServer:                 metricsServer,
-		healthzServer:                 healthzServer,
-		rolloutSynced:                 rolloutsInformer.Informer().HasSynced,
-		serviceSynced:                 servicesInformer.Informer().HasSynced,
-		ingressSynced:                 ingressWrap.HasSynced,
-		jobSynced:                     jobInformer.Informer().HasSynced,
-		experimentSynced:              experimentsInformer.Informer().HasSynced,
-		analysisRunSynced:             analysisRunInformer.Informer().HasSynced,
-		analysisTemplateSynced:        analysisTemplateInformer.Informer().HasSynced,
-		clusterAnalysisTemplateSynced: clusterAnalysisTemplateInformer.Informer().HasSynced,
-		replicasSetSynced:             replicaSetInformer.Informer().HasSynced,
-		configMapSynced:               configMapInformer.Informer().HasSynced,
-		secretSynced:                  secretInformer.Informer().HasSynced,
-		rolloutWorkqueue:              rolloutWorkqueue,
-		experimentWorkqueue:           experimentWorkqueue,
-		analysisRunWorkqueue:          analysisRunWorkqueue,
-		serviceWorkqueue:              serviceWorkqueue,
-		ingressWorkqueue:              ingressWorkqueue,
-		rolloutController:             rolloutController,
-		serviceController:             serviceController,
-		ingressController:             ingressController,
-		experimentController:          experimentController,
-		analysisController:            analysisController,
-		notificationsController:       notificationsController,
-		refResolver:                   refResolver,
-		namespace:                     namespace,
-		kubeClientSet:                 kubeclientset,
+		wg:                                 &sync.WaitGroup{},
+		metricsServer:                      metricsServer,
+		healthzServer:                      healthzServer,
+		rolloutSynced:                      rolloutsInformer.Informer().HasSynced,
+		serviceSynced:                      servicesInformer.Informer().HasSynced,
+		ingressSynced:                      ingressWrap.HasSynced,
+		jobSynced:                          jobInformer.Informer().HasSynced,
+		experimentSynced:                   experimentsInformer.Informer().HasSynced,
+		analysisRunSynced:                  analysisRunInformer.Informer().HasSynced,
+		analysisTemplateSynced:             analysisTemplateInformer.Informer().HasSynced,
+		clusterAnalysisTemplateSynced:      clusterAnalysisTemplateInformer.Informer().HasSynced,
+		replicasSetSynced:                  replicaSetInformer.Informer().HasSynced,
+		configMapSynced:                    configMapInformer.Informer().HasSynced,
+		secretSynced:                       secretInformer.Informer().HasSynced,
+		rolloutWorkqueue:                   rolloutWorkqueue,
+		experimentWorkqueue:                experimentWorkqueue,
+		analysisRunWorkqueue:               analysisRunWorkqueue,
+		serviceWorkqueue:                   serviceWorkqueue,
+		ingressWorkqueue:                   ingressWorkqueue,
+		rolloutController:                  rolloutController,
+		serviceController:                  serviceController,
+		ingressController:                  ingressController,
+		experimentController:               experimentController,
+		analysisController:                 analysisController,
+		notificationsController:            notificationsController,
+		refResolver:                        refResolver,
+		namespace:                          namespace,
+		kubeClientSet:                      kubeclientset,
+		dynamicInformerFactory:             dynamicInformerFactory,
+		clusterDynamicInformerFactory:      clusterDynamicInformerFactory,
+		istioDynamicInformerFactory:        istioDynamicInformerFactory,
+		namespaced:                         namespaced,
+		kubeInformerFactory:                kubeInformerFactory,
+		controllerNamespaceInformerFactory: controllerNamespaceInformerFactory,
+		jobInformerFactory:                 jobInformerFactory,
+		istioPrimaryDynamicClient:          istioPrimaryDynamicClient,
 	}
 
 	return cm
@@ -328,32 +358,32 @@ func NewManager(
 // Run will sync informer caches and start controllers. It will block until stopCh
 // is closed, at which point it will shutdown the workqueue and wait for
 // controllers to finish processing their current work items.
-func (c *Manager) Run(rolloutThreadiness, serviceThreadiness, ingressThreadiness, experimentThreadiness, analysisThreadiness int, electOpts *LeaderElectionOptions, stopCh <-chan struct{}) error {
+func (c *Manager) Run(ctx context.Context, rolloutThreadiness, serviceThreadiness, ingressThreadiness, experimentThreadiness, analysisThreadiness int, electOpts *LeaderElectionOptions) error {
 	defer runtime.HandleCrash()
-	defer c.serviceWorkqueue.ShutDown()
-	defer c.ingressWorkqueue.ShutDown()
-	defer c.rolloutWorkqueue.ShutDown()
-	defer c.experimentWorkqueue.ShutDown()
-	defer c.analysisRunWorkqueue.ShutDown()
+	defer func() {
+		log.Infof("Exiting Main Run function")
+	}()
 
-	// Wait for the caches to be synced before starting workers
-	log.Info("Waiting for controller's informer caches to sync")
-	if ok := cache.WaitForCacheSync(stopCh, c.serviceSynced, c.ingressSynced, c.jobSynced, c.rolloutSynced, c.experimentSynced, c.analysisRunSynced, c.analysisTemplateSynced, c.replicasSetSynced, c.configMapSynced, c.secretSynced); !ok {
-		return fmt.Errorf("failed to wait for caches to sync")
-	}
-	// only wait for cluster scoped informers to sync if we are running in cluster-wide mode
-	if c.namespace == metav1.NamespaceAll {
-		if ok := cache.WaitForCacheSync(stopCh, c.clusterAnalysisTemplateSynced); !ok {
-			return fmt.Errorf("failed to wait for cluster-scoped caches to sync")
+	go func() {
+		log.Infof("Starting Healthz Server at %s", c.healthzServer.Addr)
+		err := c.healthzServer.ListenAndServe()
+		if err != nil {
+			err = errors.Wrap(err, "Healthz Server Error")
+			log.Error(err)
 		}
-	}
+	}()
 
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
+	go func() {
+		log.Infof("Starting Metric Server at %s", c.metricsServer.Addr)
+		if err := c.metricsServer.ListenAndServe(); err != nil {
+			log.Error(errors.Wrap(err, "Metric Server Error"))
+		}
+	}()
 
 	if !electOpts.LeaderElect {
 		log.Info("Leader election is turned off. Running in single-instance mode")
 		go c.startLeading(ctx, rolloutThreadiness, serviceThreadiness, ingressThreadiness, experimentThreadiness, analysisThreadiness)
+		<-ctx.Done()
 	} else {
 		// id used to distinguish between multiple controller manager instances
 		id, err := os.Hostname()
@@ -368,63 +398,45 @@ func (c *Manager) Run(rolloutThreadiness, serviceThreadiness, ingressThreadiness
 		// add a uniquifier so that two processes on the same host don't accidentally both become active
 		id = id + "_" + string(uuid.NewUUID())
 		log.Infof("Leaderelection get id %s", id)
-		go leaderelection.RunOrDie(ctx, leaderelection.LeaderElectionConfig{
+		leaderelection.RunOrDie(ctx, leaderelection.LeaderElectionConfig{
 			Lock: &resourcelock.LeaseLock{
 				LeaseMeta: metav1.ObjectMeta{Name: defaultLeaderElectionLeaseLockName, Namespace: electOpts.LeaderElectionNamespace}, Client: c.kubeClientSet.CoordinationV1(),
 				LockConfig: resourcelock.ResourceLockConfig{Identity: id},
 			},
-			ReleaseOnCancel: true,
-			LeaseDuration:   electOpts.LeaderElectionLeaseDuration,
-			RenewDeadline:   electOpts.LeaderElectionRenewDeadline,
-			RetryPeriod:     electOpts.LeaderElectionRetryPeriod,
+			ReleaseOnCancel: false, // We can not set this to true because our context is sent on sig which means our code
+			// is still running prior to calling cancel. We would need to shut down and then call cancel in order to set this to true.
+			LeaseDuration: electOpts.LeaderElectionLeaseDuration,
+			RenewDeadline: electOpts.LeaderElectionRenewDeadline,
+			RetryPeriod:   electOpts.LeaderElectionRetryPeriod,
 			Callbacks: leaderelection.LeaderCallbacks{
 				OnStartedLeading: func(ctx context.Context) {
-					if c.secondaryMetricsServer != nil {
-						log.Warnln("Shutdown Secondary Metrics Server")
-						c.secondaryMetricsServer.Shutdown(ctx)
-					}
+					log.Infof("I am the new leader: %s", id)
 					c.startLeading(ctx, rolloutThreadiness, serviceThreadiness, ingressThreadiness, experimentThreadiness, analysisThreadiness)
 				},
 				OnStoppedLeading: func() {
-					log.Infof("Stopped leading controller: %s", id)
-					return
+					log.Infof("OnStoppedLeading called, shutting down: %s, context err: %s", id, ctx.Err())
 				},
 				OnNewLeader: func(identity string) {
-					if identity == id {
-						return
-					}
 					log.Infof("New leader elected: %s", identity)
-
-					if c.secondaryMetricsServer != nil {
-						log.Warn("Secondary metrics server already started")
-						return
-					}
-
-					log.Infof("Starting Secondary Metric Server at %s", c.metricsServer.Addr)
-					c.secondaryMetricsServer = metrics.NewMetricsServer(metrics.ServerConfig{
-						Addr: c.metricsServer.Addr,
-					}, false)
-					err = c.secondaryMetricsServer.ListenAndServe()
-					if err != nil {
-						err = errors.Wrap(err, "Starting Secondary Metric Server")
-						log.Warn(err)
-					}
 				},
 			},
 		})
 	}
+	log.Info("Shutting down workers")
 
-	go func() {
-		log.Infof("Starting Healthz Server at %s", c.healthzServer.Addr)
-		err := c.healthzServer.ListenAndServe()
-		if err != nil {
-			err = errors.Wrap(err, "Starting Healthz Server")
-			log.Error(err)
-		}
-	}()
+	c.serviceWorkqueue.ShutDownWithDrain()
+	c.ingressWorkqueue.ShutDownWithDrain()
+	c.rolloutWorkqueue.ShutDownWithDrain()
+	c.experimentWorkqueue.ShutDownWithDrain()
+	c.analysisRunWorkqueue.ShutDownWithDrain()
+	c.analysisRunWorkqueue.ShutDownWithDrain()
 
-	<-stopCh
-	log.Info("Shutting down workers")
+	ctxWithTimeout, cancel := context.WithTimeout(ctx, 5*time.Second) // give max of 10 seconds for http servers to shut down
+	defer cancel()
+	c.healthzServer.Shutdown(ctxWithTimeout)
+	c.metricsServer.Shutdown(ctxWithTimeout)
+
+	c.wg.Wait()
 
 	return nil
 }
@@ -433,19 +445,40 @@ func (c *Manager) startLeading(ctx context.Context, rolloutThreadiness, serviceT
 	defer runtime.HandleCrash()
 	// Start the informer factories to begin populating the informer caches
 	log.Info("Starting Controllers")
-	go wait.Until(func() { c.rolloutController.Run(rolloutThreadiness, ctx.Done()) }, time.Second, ctx.Done())
-	go wait.Until(func() { c.serviceController.Run(serviceThreadiness, ctx.Done()) }, time.Second, ctx.Done())
-	go wait.Until(func() { c.ingressController.Run(ingressThreadiness, ctx.Done()) }, time.Second, ctx.Done())
-	go wait.Until(func() { c.experimentController.Run(experimentThreadiness, ctx.Done()) }, time.Second, ctx.Done())
-	go wait.Until(func() { c.analysisController.Run(analysisThreadiness, ctx.Done()) }, time.Second, ctx.Done())
-	go wait.Until(func() { c.notificationsController.Run(rolloutThreadiness, ctx.Done()) }, time.Second, ctx.Done())
 
-	go func() {
-		log.Infof("Starting Metric Server at %s", c.metricsServer.Addr)
-		if err := c.metricsServer.ListenAndServe(); err != nil {
-			log.Error(errors.Wrap(err, "Starting Metric Server"))
+	// notice that there is no need to run Start methods in a separate goroutine. (i.e. go kubeInformerFactory.Start(stopCh)
+	// Start method is non-blocking and runs all registered informers in a dedicated goroutine.
+	c.dynamicInformerFactory.Start(ctx.Done())
+	if !c.namespaced {
+		c.clusterDynamicInformerFactory.Start(ctx.Done())
+	}
+	c.kubeInformerFactory.Start(ctx.Done())
+	c.controllerNamespaceInformerFactory.Start(ctx.Done())
+	c.jobInformerFactory.Start(ctx.Done())
+
+	// Check if Istio installed on cluster before starting dynamicInformerFactory
+	if istioutil.DoesIstioExist(c.istioPrimaryDynamicClient, c.namespace) {
+		c.istioDynamicInformerFactory.Start(ctx.Done())
+	}
+
+	// Wait for the caches to be synced before starting workers
+	log.Info("Waiting for controller's informer caches to sync")
+	if ok := cache.WaitForCacheSync(ctx.Done(), c.serviceSynced, c.ingressSynced, c.jobSynced, c.rolloutSynced, c.experimentSynced, c.analysisRunSynced, c.analysisTemplateSynced, c.replicasSetSynced, c.configMapSynced, c.secretSynced); !ok {
+		log.Fatalf("failed to wait for caches to sync, exiting")
+	}
+	// only wait for cluster scoped informers to sync if we are running in cluster-wide mode
+	if c.namespace == metav1.NamespaceAll {
+		if ok := cache.WaitForCacheSync(ctx.Done(), c.clusterAnalysisTemplateSynced); !ok {
+			log.Fatalf("failed to wait for cluster-scoped caches to sync, exiting")
 		}
-	}()
+	}
+
+	go wait.Until(func() { c.wg.Add(1); c.rolloutController.Run(ctx, rolloutThreadiness); c.wg.Done() }, time.Second, ctx.Done())
+	go wait.Until(func() { c.wg.Add(1); c.serviceController.Run(ctx, serviceThreadiness); c.wg.Done() }, time.Second, ctx.Done())
+	go wait.Until(func() { c.wg.Add(1); c.ingressController.Run(ctx, ingressThreadiness); c.wg.Done() }, time.Second, ctx.Done())
+	go wait.Until(func() { c.wg.Add(1); c.experimentController.Run(ctx, experimentThreadiness); c.wg.Done() }, time.Second, ctx.Done())
+	go wait.Until(func() { c.wg.Add(1); c.analysisController.Run(ctx, analysisThreadiness); c.wg.Done() }, time.Second, ctx.Done())
+	go wait.Until(func() { c.wg.Add(1); c.notificationsController.Run(rolloutThreadiness, ctx.Done()); c.wg.Done() }, time.Second, ctx.Done())
 
 	log.Info("Started controller")
 }
diff --git a/controller/metrics/metrics.go b/controller/metrics/metrics.go
index 3c3bbfe24..bad18a1eb 100644
--- a/controller/metrics/metrics.go
+++ b/controller/metrics/metrics.go
@@ -50,24 +50,11 @@ type ServerConfig struct {
 }
 
 // NewMetricsServer returns a new prometheus server which collects rollout metrics
-func NewMetricsServer(cfg ServerConfig, isPrimary bool) *MetricsServer {
+func NewMetricsServer(cfg ServerConfig) *MetricsServer {
 	mux := http.NewServeMux()
 
 	reg := prometheus.NewRegistry()
 
-	// secondary controller doesn't expose any metrics
-	if !isPrimary {
-		mux.Handle(MetricsPath, promhttp.HandlerFor(prometheus.Gatherers{
-			reg,
-		}, promhttp.HandlerOpts{}))
-		return &MetricsServer{
-			Server: &http.Server{
-				Addr:    cfg.Addr,
-				Handler: mux,
-			},
-		}
-	}
-
 	reg.MustRegister(NewRolloutCollector(cfg.RolloutLister))
 	reg.MustRegister(NewAnalysisRunCollector(cfg.AnalysisRunLister, cfg.AnalysisTemplateLister, cfg.ClusterAnalysisTemplateLister))
 	reg.MustRegister(NewExperimentCollector(cfg.ExperimentLister))
diff --git a/experiments/controller.go b/experiments/controller.go
index 2b2600597..a42821aab 100644
--- a/experiments/controller.go
+++ b/experiments/controller.go
@@ -2,6 +2,7 @@ package experiments
 
 import (
 	"context"
+	"sync"
 	"time"
 
 	log "github.com/sirupsen/logrus"
@@ -223,21 +224,26 @@ func NewController(cfg ControllerConfig) *Controller {
 }
 
 // Run starts the controller threads
-func (ec *Controller) Run(threadiness int, stopCh <-chan struct{}) error {
+func (ec *Controller) Run(ctx context.Context, threadiness int) error {
 	log.Info("Starting Experiment workers")
+	wg := sync.WaitGroup{}
 	for i := 0; i < threadiness; i++ {
+		wg.Add(1)
 		go wait.Until(func() {
-			controllerutil.RunWorker(ec.experimentWorkqueue, logutil.ExperimentKey, ec.syncHandler, ec.metricsServer)
-		}, time.Second, stopCh)
+			controllerutil.RunWorker(ctx, ec.experimentWorkqueue, logutil.ExperimentKey, ec.syncHandler, ec.metricsServer)
+			log.Debug("Experiment worker has stopped")
+			wg.Done()
+		}, time.Second, ctx.Done())
 	}
 	log.Info("Started Experiment workers")
-	<-stopCh
-	log.Info("Shutting down experiment workers")
+	<-ctx.Done()
+	wg.Wait()
+	log.Info("All experiment workers have stopped")
 
 	return nil
 }
 
-func (ec *Controller) syncHandler(key string) error {
+func (ec *Controller) syncHandler(ctx context.Context, key string) error {
 	startTime := timeutil.Now()
 	namespace, name, err := cache.SplitMetaNamespaceKey(key)
 	if err != nil {
diff --git a/hack/build-release-plugins.sh b/hack/build-release-plugins.sh
index db5bcdbea..390fbdadb 100755
--- a/hack/build-release-plugins.sh
+++ b/hack/build-release-plugins.sh
@@ -14,5 +14,9 @@ container_id=$(docker create ${rollout_iid})
 for plat in linux-amd64 linux-arm64 darwin-amd64 darwin-arm64 windows-amd64; do
     docker cp ${container_id}:/go/src/github.com/argoproj/argo-rollouts/dist/kubectl-argo-rollouts-${plat} ${SRCROOT}/dist
 done
+
 docker rm -v ${container_id}
 rm -f ${rollout_iid_file}
+
+cd ${SRCROOT}/dist/
+shasum -a 256 kubectl-argo-rollouts-* > argo-rollouts-checksums.txt
diff --git a/ingress/ingress.go b/ingress/ingress.go
index 7bbf856b8..6c4059e47 100644
--- a/ingress/ingress.go
+++ b/ingress/ingress.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"fmt"
 	"strings"
+	"sync"
 	"time"
 
 	log "github.com/sirupsen/logrus"
@@ -102,23 +103,28 @@ func NewController(cfg ControllerConfig) *Controller {
 }
 
 // Run starts the controller threads
-func (c *Controller) Run(threadiness int, stopCh <-chan struct{}) error {
+func (c *Controller) Run(ctx context.Context, threadiness int) error {
 	log.Info("Starting Ingress workers")
+	wg := sync.WaitGroup{}
 	for i := 0; i < threadiness; i++ {
+		wg.Add(1)
 		go wait.Until(func() {
-			controllerutil.RunWorker(c.ingressWorkqueue, logutil.IngressKey, c.syncIngress, c.metricServer)
-		}, time.Second, stopCh)
+			controllerutil.RunWorker(ctx, c.ingressWorkqueue, logutil.IngressKey, c.syncIngress, c.metricServer)
+			wg.Done()
+			log.Debug("Ingress worker has stopped")
+		}, time.Second, ctx.Done())
 	}
 
 	log.Info("Started Ingress workers")
-	<-stopCh
-	log.Info("Shutting down Ingress workers")
+	<-ctx.Done()
+	wg.Wait()
+	log.Info("All ingress workers have stopped")
 
 	return nil
 }
 
 // syncIngress queues all rollouts referencing the Ingress for reconciliation
-func (c *Controller) syncIngress(key string) error {
+func (c *Controller) syncIngress(ctx context.Context, key string) error {
 	namespace, name, err := cache.SplitMetaNamespaceKey(key)
 	if err != nil {
 		return err
diff --git a/pkg/apis/rollouts/validation/validation_references.go b/pkg/apis/rollouts/validation/validation_references.go
index 545b87e7a..8e3578675 100644
--- a/pkg/apis/rollouts/validation/validation_references.go
+++ b/pkg/apis/rollouts/validation/validation_references.go
@@ -273,6 +273,15 @@ func ValidateVirtualService(rollout *v1alpha1.Rollout, obj unstructured.Unstruct
 	allErrs := field.ErrorList{}
 	newObj := obj.DeepCopy()
 
+	if rollout.Spec.Strategy.Canary == nil ||
+		rollout.Spec.Strategy.Canary.TrafficRouting == nil ||
+		rollout.Spec.Strategy.Canary.TrafficRouting.Istio == nil {
+
+		msg := "Rollout object is not configured with Istio traffic routing"
+		allErrs = append(allErrs, field.Invalid(field.NewPath("spec", "strategy", "canary", "trafficRouting", "istio"), rollout.Name, msg))
+		return allErrs
+	}
+
 	if istioutil.MultipleVirtualServiceConfigured(rollout) {
 		fldPath = field.NewPath("spec", "strategy", "canary", "trafficRouting", "istio", "virtualServices", "name")
 		virtualServices = rollout.Spec.Strategy.Canary.TrafficRouting.Istio.VirtualServices
diff --git a/pkg/kubectl-argo-rollouts/cmd/lint/lint.go b/pkg/kubectl-argo-rollouts/cmd/lint/lint.go
index 66e28318e..c7cb091a0 100644
--- a/pkg/kubectl-argo-rollouts/cmd/lint/lint.go
+++ b/pkg/kubectl-argo-rollouts/cmd/lint/lint.go
@@ -294,6 +294,9 @@ func setIngressManagedAnnotation(rollouts []v1alpha1.Rollout, refResource valida
 func setVirtualServiceManagedAnnotation(ro []v1alpha1.Rollout, refResource validation.ReferencedResources) {
 	for _, rollout := range ro {
 		for i := range refResource.VirtualServices {
+			if rollout.Spec.Strategy.Canary == nil || rollout.Spec.Strategy.Canary.TrafficRouting == nil || rollout.Spec.Strategy.Canary.TrafficRouting.Istio == nil {
+				return
+			}
 			if rollout.Spec.Strategy.Canary.TrafficRouting.Istio.VirtualService != nil && rollout.Spec.Strategy.Canary.TrafficRouting.Istio.VirtualService.Name == refResource.VirtualServices[i].GetName() {
 				annotations := refResource.VirtualServices[i].GetAnnotations()
 				if annotations == nil {
diff --git a/pkg/signals/signal.go b/pkg/signals/signal.go
index f82aafbbd..b5121ad88 100644
--- a/pkg/signals/signal.go
+++ b/pkg/signals/signal.go
@@ -1,27 +1,28 @@
 package signals
 
 import (
+	"context"
 	"os"
 	"os/signal"
 )
 
 var onlyOneSignalHandler = make(chan struct{})
 
-// SetupSignalHandler registered for SIGTERM and SIGINT. A stop channel is returned
-// which is closed on one of these signals. If a second signal is caught, the program
+// SetupSignalHandlerContext registered for SIGTERM and SIGINT. A context is returned
+// which is canceled on one of these signals. If a second signal is caught, the program
 // is terminated with exit code 1.
-func SetupSignalHandler() (stopCh <-chan struct{}) {
+func SetupSignalHandlerContext() (ctx context.Context) {
 	close(onlyOneSignalHandler) // panics when called twice
 
-	stop := make(chan struct{})
+	ctx, cancel := context.WithCancel(context.Background())
 	c := make(chan os.Signal, 2)
 	signal.Notify(c, shutdownSignals...)
 	go func() {
 		<-c
-		close(stop)
+		cancel()
 		<-c
 		os.Exit(1) // second signal. Exit directly.
 	}()
 
-	return stop
+	return ctx
 }
diff --git a/rollout/controller.go b/rollout/controller.go
index e314adf4a..5b2a4eb91 100644
--- a/rollout/controller.go
+++ b/rollout/controller.go
@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"reflect"
 	"strconv"
+	"sync"
 	"time"
 
 	"k8s.io/apimachinery/pkg/runtime/schema"
@@ -326,19 +327,28 @@ func removedKeys(name string, old, new *v1alpha1.Rollout, keyFunc func(ro *v1alp
 // as syncing informer caches and starting workers. It will block until stopCh
 // is closed, at which point it will shutdown the workqueue and wait for
 // workers to finish processing their current work items.
-func (c *Controller) Run(threadiness int, stopCh <-chan struct{}) error {
+func (c *Controller) Run(ctx context.Context, threadiness int) error {
 	log.Info("Starting Rollout workers")
+	wg := sync.WaitGroup{}
 	for i := 0; i < threadiness; i++ {
+		wg.Add(1)
 		go wait.Until(func() {
-			controllerutil.RunWorker(c.rolloutWorkqueue, logutil.RolloutKey, c.syncHandler, c.metricsServer)
-		}, time.Second, stopCh)
+			controllerutil.RunWorker(ctx, c.rolloutWorkqueue, logutil.RolloutKey, c.syncHandler, c.metricsServer)
+			log.Debug("Rollout worker has stopped")
+			wg.Done()
+		}, time.Second, ctx.Done())
 	}
-	log.Info("Started Rollout workers")
+	log.Info("Started rollout workers")
 
-	go c.IstioController.Run(stopCh)
+	wg.Add(1)
+	go c.IstioController.Run(ctx)
 
-	<-stopCh
-	log.Info("Shutting down workers")
+	<-ctx.Done()
+	c.IstioController.ShutDownWithDrain()
+	wg.Done()
+
+	wg.Wait()
+	log.Info("All rollout workers have stopped")
 
 	return nil
 }
@@ -346,8 +356,7 @@ func (c *Controller) Run(threadiness int, stopCh <-chan struct{}) error {
 // syncHandler compares the actual state with the desired, and attempts to
 // converge the two. It then updates the Phase block of the Rollout resource
 // with the current status of the resource.
-func (c *Controller) syncHandler(key string) error {
-	ctx := context.TODO()
+func (c *Controller) syncHandler(ctx context.Context, key string) error {
 	startTime := timeutil.Now()
 	namespace, name, err := cache.SplitMetaNamespaceKey(key)
 	if err != nil {
diff --git a/rollout/trafficrouting/istio/controller.go b/rollout/trafficrouting/istio/controller.go
index 6b76304d7..847ae6595 100644
--- a/rollout/trafficrouting/istio/controller.go
+++ b/rollout/trafficrouting/istio/controller.go
@@ -3,6 +3,7 @@ package istio
 import (
 	"context"
 	"fmt"
+	"sync"
 	"time"
 
 	log "github.com/sirupsen/logrus"
@@ -114,7 +115,7 @@ func NewIstioController(cfg IstioControllerConfig) *IstioController {
 // Run starts the Istio informers. If Istio is not installed, will periodically check for presence
 // of Istio, then start informers once detected. This allows Argo Rollouts to be installed in any
 // order during cluster bootstrapping.
-func (c *IstioController) Run(stopCh <-chan struct{}) {
+func (c *IstioController) Run(ctx context.Context) {
 	ns := defaults.Namespace()
 	waitForIstioInstall := !istioutil.DoesIstioExist(c.DynamicClientSet, ns)
 	if waitForIstioInstall {
@@ -122,7 +123,7 @@ func (c *IstioController) Run(stopCh <-chan struct{}) {
 		for !istioutil.DoesIstioExist(c.DynamicClientSet, ns) {
 			// Should only execute if Istio is not installed on cluster
 			select {
-			case <-stopCh:
+			case <-ctx.Done():
 				ticker.Stop()
 				return
 			case <-ticker.C:
@@ -130,23 +131,29 @@ func (c *IstioController) Run(stopCh <-chan struct{}) {
 		}
 		ticker.Stop()
 		log.Info("Istio install detected. Starting informers")
-		go c.VirtualServiceInformer.Run(stopCh)
-		go c.DestinationRuleInformer.Run(stopCh)
+		go c.VirtualServiceInformer.Run(ctx.Done())
+		go c.DestinationRuleInformer.Run(ctx.Done())
 	} else {
 		log.Info("Istio detected")
 	}
 
-	cache.WaitForCacheSync(stopCh, c.VirtualServiceInformer.HasSynced, c.DestinationRuleInformer.HasSynced)
+	cache.WaitForCacheSync(ctx.Done(), c.VirtualServiceInformer.HasSynced, c.DestinationRuleInformer.HasSynced)
 
+	log.Info("Starting istio workers")
+	wg := sync.WaitGroup{}
 	for i := 0; i < destinationRuleWorkers; i++ {
+		wg.Add(1)
 		go wait.Until(func() {
-			controllerutil.RunWorker(c.destinationRuleWorkqueue, "destinationrule", c.syncDestinationRule, nil)
-		}, time.Second, stopCh)
+			controllerutil.RunWorker(ctx, c.destinationRuleWorkqueue, "destinationrule", c.syncDestinationRule, nil)
+			wg.Done()
+			log.Debug("Istio worker has stopped")
+		}, time.Second, ctx.Done())
 	}
 	log.Infof("Istio workers (%d) started", destinationRuleWorkers)
 
-	<-stopCh
-	log.Info("Istio controller stopped")
+	<-ctx.Done()
+	wg.Wait()
+	log.Info("All istio workers have stopped")
 }
 
 // EnqueueDestinationRule examines a VirtualService, finds the Rollout referencing
@@ -223,7 +230,7 @@ func (c *IstioController) GetReferencedVirtualServices(ro *v1alpha1.Rollout) (*[
 // `rollouts-pod-template-hash` label and the managed-by annotation. This handles the case when a
 // Rollout has either been deleted, or modified such that it is longer referencing the
 // DestinationRule.
-func (c *IstioController) syncDestinationRule(key string) error {
+func (c *IstioController) syncDestinationRule(ctx context.Context, key string) error {
 	namespace, name, err := cache.SplitMetaNamespaceKey(key)
 	if err != nil {
 		return err
@@ -297,3 +304,7 @@ func getManagingRolloutName(un *unstructured.Unstructured) string {
 	}
 	return annots[v1alpha1.ManagedByRolloutsKey]
 }
+
+func (c *IstioController) ShutDownWithDrain() {
+	c.destinationRuleWorkqueue.ShutDownWithDrain()
+}
diff --git a/service/service.go b/service/service.go
index 47d0ac40d..f81c82c77 100644
--- a/service/service.go
+++ b/service/service.go
@@ -3,6 +3,7 @@ package service
 import (
 	"context"
 	"fmt"
+	"sync"
 	"time"
 
 	log "github.com/sirupsen/logrus"
@@ -129,17 +130,22 @@ func NewController(cfg ControllerConfig) *Controller {
 }
 
 // Run starts the controller threads
-func (c *Controller) Run(threadiness int, stopCh <-chan struct{}) error {
+func (c *Controller) Run(ctx context.Context, threadiness int) error {
 	log.Info("Starting Service workers")
+	wg := sync.WaitGroup{}
 	for i := 0; i < threadiness; i++ {
+		wg.Add(1)
 		go wait.Until(func() {
-			controllerutil.RunWorker(c.serviceWorkqueue, logutil.ServiceKey, c.syncService, c.metricServer)
-		}, time.Second, stopCh)
+			controllerutil.RunWorker(ctx, c.serviceWorkqueue, logutil.ServiceKey, c.syncService, c.metricServer)
+			log.Debug("Service worker has stopped")
+			wg.Done()
+		}, time.Second, ctx.Done())
 	}
 
 	log.Info("Started Service workers")
-	<-stopCh
-	log.Info("Shutting down workers")
+	<-ctx.Done()
+	wg.Wait()
+	log.Info("All service workers have stopped")
 
 	return nil
 }
@@ -147,8 +153,7 @@ func (c *Controller) Run(threadiness int, stopCh <-chan struct{}) error {
 // syncService detects a change to a Service which is managed by a Rollout, and enqueues the
 // related rollout for reconciliation. If no rollout is referencing the Service, then removes
 // any injected fields in the service (e.g. rollouts-pod-template-hash and managed-by annotation)
-func (c *Controller) syncService(key string) error {
-	ctx := context.TODO()
+func (c *Controller) syncService(ctx context.Context, key string) error {
 	namespace, name, err := cache.SplitMetaNamespaceKey(key)
 	if err != nil {
 		return err
diff --git a/utils/controller/controller.go b/utils/controller/controller.go
index e3123d0f1..63aa6f11c 100644
--- a/utils/controller/controller.go
+++ b/utils/controller/controller.go
@@ -100,14 +100,14 @@ func WatchResourceWithExponentialBackoff(stopCh <-chan struct{}, client dynamic.
 // RunWorker is a long-running function that will continually call the
 // processNextWorkItem function in order to read and process a message on the
 // workqueue.
-func RunWorker(workqueue workqueue.RateLimitingInterface, objType string, syncHandler func(string) error, metricServer *metrics.MetricsServer) {
-	for processNextWorkItem(workqueue, objType, syncHandler, metricServer) {
+func RunWorker(ctx context.Context, workqueue workqueue.RateLimitingInterface, objType string, syncHandler func(context.Context, string) error, metricServer *metrics.MetricsServer) {
+	for processNextWorkItem(ctx, workqueue, objType, syncHandler, metricServer) {
 	}
 }
 
 // processNextWorkItem will read a single work item off the workqueue and
 // attempt to process it, by calling the syncHandler.
-func processNextWorkItem(workqueue workqueue.RateLimitingInterface, objType string, syncHandler func(string) error, metricsServer *metrics.MetricsServer) bool {
+func processNextWorkItem(ctx context.Context, workqueue workqueue.RateLimitingInterface, objType string, syncHandler func(context.Context, string) error, metricsServer *metrics.MetricsServer) bool {
 	obj, shutdown := workqueue.Get()
 
 	if shutdown {
@@ -150,7 +150,7 @@ func processNextWorkItem(workqueue workqueue.RateLimitingInterface, objType stri
 					err = fmt.Errorf("Recovered from Panic")
 				}
 			}()
-			return syncHandler(key)
+			return syncHandler(ctx, key)
 		}
 		// Run the syncHandler, passing it the namespace/name string of the
 		// Rollout resource to be synced.


Test patch:
diff --git a/analysis/controller_test.go b/analysis/controller_test.go
index 289992ad1..a0af638c9 100644
--- a/analysis/controller_test.go
+++ b/analysis/controller_test.go
@@ -1,6 +1,7 @@
 package analysis
 
 import (
+	"context"
 	"encoding/json"
 	"reflect"
 	"testing"
@@ -97,7 +98,7 @@ func (f *fixture) newController(resync resyncFunc) (*Controller, informers.Share
 	metricsServer := metrics.NewMetricsServer(metrics.ServerConfig{
 		Addr:               "localhost:8080",
 		K8SRequestProvider: &metrics.K8sRequestsCountProvider{},
-	}, true)
+	})
 
 	c := NewController(ControllerConfig{
 		KubeClientSet:        f.kubeclient,
@@ -159,7 +160,7 @@ func (f *fixture) runController(analysisRunName string, startInformers bool, exp
 		assert.True(f.t, cache.WaitForCacheSync(stopCh, c.analysisRunSynced))
 	}
 
-	err := c.syncHandler(analysisRunName)
+	err := c.syncHandler(context.Background(), analysisRunName)
 	if !expectError && err != nil {
 		f.t.Errorf("error syncing experiment: %v", err)
 	} else if expectError && err == nil {
@@ -314,3 +315,19 @@ func TestNoReconcileForAnalysisRunWithDeletionTimestamp(t *testing.T) {
 
 	f.run(getKey(ar, t))
 }
+
+func TestRun(t *testing.T) {
+	f := newFixture(t)
+	defer f.Close()
+
+	// make sure we can start and top the controller
+	c, _, _ := f.newController(noResyncPeriodFunc)
+	ctx, cancel := context.WithCancel(context.TODO())
+	defer cancel()
+	go func() {
+		time.Sleep(1000 * time.Millisecond)
+		c.analysisRunWorkQueue.ShutDownWithDrain()
+		cancel()
+	}()
+	c.Run(ctx, 1)
+}
diff --git a/controller/controller_test.go b/controller/controller_test.go
index da7c22f7b..1a601c924 100644
--- a/controller/controller_test.go
+++ b/controller/controller_test.go
@@ -3,15 +3,13 @@ package controller
 import (
 	"context"
 	"fmt"
-	"net/http"
-	"net/http/httptest"
+	"sync"
 	"testing"
 	"time"
 
 	notificationapi "github.com/argoproj/notifications-engine/pkg/api"
 	notificationcontroller "github.com/argoproj/notifications-engine/pkg/controller"
 	smifake "github.com/servicemeshinterface/smi-sdk-go/pkg/gen/client/split/clientset/versioned/fake"
-	log "github.com/sirupsen/logrus"
 	"github.com/stretchr/testify/assert"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@@ -21,11 +19,8 @@ import (
 	"k8s.io/client-go/dynamic/dynamicinformer"
 	dynamicfake "k8s.io/client-go/dynamic/fake"
 	kubeinformers "k8s.io/client-go/informers"
-	"k8s.io/client-go/kubernetes"
 	k8sfake "k8s.io/client-go/kubernetes/fake"
 
-	"k8s.io/client-go/tools/leaderelection"
-	"k8s.io/client-go/tools/leaderelection/resourcelock"
 	"k8s.io/client-go/util/workqueue"
 
 	"github.com/argoproj/argo-rollouts/analysis"
@@ -72,34 +67,34 @@ func (f *fixture) newManager(t *testing.T) *Manager {
 	analysisRunWorkqueue := workqueue.NewNamedRateLimitingQueue(queue.DefaultArgoRolloutsRateLimiter(), "AnalysisRuns")
 
 	cm := &Manager{
-
-		kubeClientSet: f.kubeclient,
-
-		ingressWorkqueue:     ingressWorkqueue,
-		serviceWorkqueue:     serviceWorkqueue,
-		rolloutWorkqueue:     rolloutWorkqueue,
-		experimentWorkqueue:  experimentWorkqueue,
-		analysisRunWorkqueue: analysisRunWorkqueue,
-
+		wg:                            &sync.WaitGroup{},
+		healthzServer:                 NewHealthzServer(fmt.Sprintf(listenAddr, 8080)),
 		rolloutSynced:                 alwaysReady,
-		serviceSynced:                 alwaysReady,
-		ingressSynced:                 alwaysReady,
-		jobSynced:                     alwaysReady,
 		experimentSynced:              alwaysReady,
 		analysisRunSynced:             alwaysReady,
 		analysisTemplateSynced:        alwaysReady,
+		clusterAnalysisTemplateSynced: alwaysReady,
+		serviceSynced:                 alwaysReady,
+		ingressSynced:                 alwaysReady,
+		jobSynced:                     alwaysReady,
 		replicasSetSynced:             alwaysReady,
 		configMapSynced:               alwaysReady,
 		secretSynced:                  alwaysReady,
-		clusterAnalysisTemplateSynced: alwaysReady,
-
-		healthzServer: NewHealthzServer(fmt.Sprintf(listenAddr, 8080)),
+		rolloutWorkqueue:              rolloutWorkqueue,
+		serviceWorkqueue:              serviceWorkqueue,
+		ingressWorkqueue:              ingressWorkqueue,
+		experimentWorkqueue:           experimentWorkqueue,
+		analysisRunWorkqueue:          analysisRunWorkqueue,
+		kubeClientSet:                 f.kubeclient,
+		namespace:                     "",
+		namespaced:                    false,
 	}
 
 	metricsAddr := fmt.Sprintf(listenAddr, 8090)
 	cm.metricsServer = metrics.NewMetricsServer(metrics.ServerConfig{
-		Addr: metricsAddr,
-	}, false)
+		Addr:               metricsAddr,
+		K8SRequestProvider: &metrics.K8sRequestsCountProvider{},
+	})
 
 	i := informers.NewSharedInformerFactory(f.client, noResyncPeriodFunc())
 	k8sI := kubeinformers.NewSharedInformerFactory(f.kubeclient, noResyncPeriodFunc())
@@ -109,16 +104,27 @@ func (f *fixture) newManager(t *testing.T) *Manager {
 		Resource: "targetgroupbindings",
 	}
 	vsvcGVR := istioutil.GetIstioVirtualServiceGVR()
+	destGVR := istioutil.GetIstioDestinationRuleGVR()
 	scheme := runtime.NewScheme()
 	listMapping := map[schema.GroupVersionResource]string{
 		tgbGVR:  "TargetGroupBindingList",
 		vsvcGVR: vsvcGVR.Resource + "List",
+		destGVR: destGVR.Resource + "List",
 	}
+
 	dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listMapping)
 	dynamicInformerFactory := dynamicinformer.NewDynamicSharedInformerFactory(dynamicClient, 0)
 	istioVirtualServiceInformer := dynamicInformerFactory.ForResource(istioutil.GetIstioVirtualServiceGVR()).Informer()
 	istioDestinationRuleInformer := dynamicInformerFactory.ForResource(istioutil.GetIstioDestinationRuleGVR()).Informer()
 
+	cm.dynamicInformerFactory = dynamicInformerFactory
+	cm.clusterDynamicInformerFactory = dynamicInformerFactory
+	cm.kubeInformerFactory = k8sI
+	cm.controllerNamespaceInformerFactory = k8sI
+	cm.jobInformerFactory = k8sI
+	cm.istioPrimaryDynamicClient = dynamicClient
+	cm.istioDynamicInformerFactory = dynamicInformerFactory
+
 	mode, err := ingressutil.DetermineIngressMode("extensions/v1beta1", &discoveryfake.FakeDiscovery{})
 	assert.NoError(t, err)
 	ingressWrapper, err := ingressutil.NewIngressWrapper(mode, f.kubeclient, k8sI)
@@ -209,35 +215,6 @@ func (f *fixture) newManager(t *testing.T) *Manager {
 	return cm
 }
 
-func newElectorConfig(kubeclientset kubernetes.Interface, id string, electOpts LeaderElectionOptions) *leaderelection.LeaderElectionConfig {
-	lec := leaderelection.LeaderElectionConfig{
-		Lock: &resourcelock.LeaseLock{
-			LeaseMeta: metav1.ObjectMeta{Name: defaultLeaderElectionLeaseLockName, Namespace: electOpts.LeaderElectionNamespace}, Client: kubeclientset.CoordinationV1(),
-			LockConfig: resourcelock.ResourceLockConfig{Identity: id},
-		},
-		ReleaseOnCancel: true,
-		LeaseDuration:   electOpts.LeaderElectionLeaseDuration,
-		RenewDeadline:   electOpts.LeaderElectionRenewDeadline,
-		RetryPeriod:     electOpts.LeaderElectionRetryPeriod,
-		Callbacks: leaderelection.LeaderCallbacks{
-			OnStartedLeading: func(ctx context.Context) {
-				log.Info("Starting leading")
-			},
-			OnStoppedLeading: func() {
-				log.Infof("Stopped leading controller: %s", id)
-				return
-			},
-			OnNewLeader: func(identity string) {
-				if identity == id {
-					return
-				}
-				log.Infof("New leader elected: %s", identity)
-			},
-		},
-	}
-	return &lec
-}
-
 func TestNewManager(t *testing.T) {
 	f := newFixture(t)
 
@@ -285,6 +262,13 @@ func TestNewManager(t *testing.T) {
 		k8sRequestProvider,
 		nil,
 		nil,
+		dynamicInformerFactory,
+		nil,
+		nil,
+		false,
+		nil,
+		nil,
+		nil,
 	)
 
 	assert.NotNil(t, cm)
@@ -293,97 +277,26 @@ func TestNewManager(t *testing.T) {
 func TestPrimaryController(t *testing.T) {
 	f := newFixture(t)
 
-	stopCh := make(chan struct{})
-
 	cm := f.newManager(t)
 	electOpts := NewLeaderElectionOptions()
-	go cm.Run(1, 1, 1, 1, 1, electOpts, stopCh)
-	time.Sleep(2 * time.Second)
-	close(stopCh)
-
-	// Test primary controller shutdown secondary metrics server
-	cm.secondaryMetricsServer = metrics.NewMetricsServer(metrics.ServerConfig{}, false)
-	time.Sleep(2 * time.Second)
-	stopCh = make(chan struct{})
-	go cm.Run(1, 1, 1, 1, 1, electOpts, stopCh)
-	time.Sleep(2 * time.Second)
-	close(stopCh)
-}
-
-func TestTwoControllers(t *testing.T) {
-	f := newFixture(t)
-
-	stopCh := make(chan struct{})
-	primary := f.newManager(t)
-	electOpts := NewLeaderElectionOptions()
-	go primary.Run(1, 1, 1, 1, 1, electOpts, stopCh)
-	time.Sleep(1 * time.Second)
-
-	secondary := f.newManager(t)
-	secondary.healthzServer = NewHealthzServer(fmt.Sprintf(listenAddr, 8081))
-	secondary.metricsServer.Addr = (fmt.Sprintf(listenAddr, 8091))
-	go secondary.Run(1, 1, 1, 1, 1, electOpts, stopCh)
-	time.Sleep(1 * time.Second)
-
-	var verifyEndpoints func(url string)
-	verifyEndpoints = func(url string) {
-		_, err := http.Get(url)
-		assert.NoErrorf(t, err, "error connecting to %s", url)
-		rr := httptest.NewRecorder()
-		assert.Equal(t, rr.Code, http.StatusOK)
-	}
-
-	verifyEndpoints("http://localhost:8080/healthz")
-	verifyEndpoints("http://localhost:8090/metrics")
-	verifyEndpoints("http://localhost:8081/healthz")
-	verifyEndpoints("http://localhost:8091/metrics")
-
-	// stop all controllers
-	time.Sleep(1 * time.Second)
-	close(stopCh)
+	ctx, cancel := context.WithCancel(context.Background())
+	go func() {
+		time.Sleep(5 * time.Second)
+		cancel()
+	}()
+	cm.Run(ctx, 1, 1, 1, 1, 1, electOpts)
 }
 
-func TestSecondaryController(t *testing.T) {
+func TestPrimaryControllerSingleInstanceWithShutdown(t *testing.T) {
 	f := newFixture(t)
 
-	stopCh := make(chan struct{})
-
 	cm := f.newManager(t)
-
 	electOpts := NewLeaderElectionOptions()
-	lec := newElectorConfig(f.kubeclient, "holder-123-456", *electOpts)
-	le, err := leaderelection.NewLeaderElector(*lec)
-	assert.NoError(t, err)
-
+	electOpts.LeaderElect = false
 	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
-	go le.Run(ctx)
-	time.Sleep(1 * time.Second)
-	assert.True(t, le.IsLeader())
-
-	go cm.Run(1, 1, 1, 1, 1, electOpts, stopCh)
-	time.Sleep(2 * time.Second)
-	assert.True(t, le.IsLeader())
-	close(stopCh)
-	time.Sleep(1 * time.Second)
-
-	// Test secondary metrics server has been started
-	stopCh = make(chan struct{})
-	go cm.Run(1, 1, 1, 1, 1, electOpts, stopCh)
-	time.Sleep(2 * time.Second)
-	assert.True(t, le.IsLeader())
-	close(stopCh)
-	time.Sleep(1 * time.Second)
-
-	// Test secondary metrics server listen port is taken
-	metricsServer := metrics.NewMetricsServer(metrics.ServerConfig{
-		Addr: fmt.Sprintf(listenAddr, DefaultMetricsPort),
-	}, false)
-	go metricsServer.ListenAndServe()
-	time.Sleep(1 * time.Second)
-	cm.secondaryMetricsServer = nil
-	stopCh = make(chan struct{})
-	go cm.Run(1, 1, 1, 1, 1, electOpts, stopCh)
-	time.Sleep(2 * time.Second)
-	close(stopCh)
+	go func() {
+		time.Sleep(5 * time.Second)
+		cancel()
+	}()
+	cm.Run(ctx, 1, 1, 1, 1, 1, electOpts)
 }
diff --git a/controller/metrics/analysis_test.go b/controller/metrics/analysis_test.go
index 61ca87a80..942c16520 100644
--- a/controller/metrics/analysis_test.go
+++ b/controller/metrics/analysis_test.go
@@ -178,7 +178,7 @@ analysis_run_reconcile_bucket{name="ar-test",namespace="ar-namespace",le="1"} 1
 analysis_run_reconcile_bucket{name="ar-test",namespace="ar-namespace",le="+Inf"} 1
 analysis_run_reconcile_sum{name="ar-test",namespace="ar-namespace"} 0.001
 analysis_run_reconcile_count{name="ar-test",namespace="ar-namespace"} 1`
-	metricsServ := NewMetricsServer(newFakeServerConfig(), true)
+	metricsServ := NewMetricsServer(newFakeServerConfig())
 	ar := &v1alpha1.AnalysisRun{
 		ObjectMeta: metav1.ObjectMeta{
 			Name:      "ar-test",
diff --git a/controller/metrics/client_test.go b/controller/metrics/client_test.go
index 0ebf470a6..b09fc6b88 100644
--- a/controller/metrics/client_test.go
+++ b/controller/metrics/client_test.go
@@ -14,7 +14,7 @@ controller_clientset_k8s_request_total{kind="replicasets",name="N/A",namespace="
 
 func TestIncKubernetesRequest(t *testing.T) {
 	config := newFakeServerConfig()
-	metricsServ := NewMetricsServer(config, true)
+	metricsServ := NewMetricsServer(config)
 	config.K8SRequestProvider.IncKubernetesRequest(kubeclientmetrics.ResourceInfo{
 		Kind:       "replicasets",
 		Namespace:  "default",
diff --git a/controller/metrics/experiment_test.go b/controller/metrics/experiment_test.go
index da050e937..4335af6fe 100644
--- a/controller/metrics/experiment_test.go
+++ b/controller/metrics/experiment_test.go
@@ -150,7 +150,7 @@ experiment_reconcile_bucket{name="ex-test",namespace="ex-namespace",le="+Inf"} 1
 experiment_reconcile_sum{name="ex-test",namespace="ex-namespace"} 0.001
 experiment_reconcile_count{name="ex-test",namespace="ex-namespace"} 1`
 
-	metricsServ := NewMetricsServer(newFakeServerConfig(), true)
+	metricsServ := NewMetricsServer(newFakeServerConfig())
 	ex := &v1alpha1.Experiment{
 		ObjectMeta: metav1.ObjectMeta{
 			Name:      "ex-test",
diff --git a/controller/metrics/metrics_test.go b/controller/metrics/metrics_test.go
index 22d671cee..00700321f 100644
--- a/controller/metrics/metrics_test.go
+++ b/controller/metrics/metrics_test.go
@@ -84,7 +84,7 @@ experiment_reconcile_error{name="name",namespace="ns"} 1
 # TYPE rollout_reconcile_error counter
 rollout_reconcile_error{name="name",namespace="ns"} 1`
 
-	metricsServ := NewMetricsServer(newFakeServerConfig(), true)
+	metricsServ := NewMetricsServer(newFakeServerConfig())
 
 	metricsServ.IncError("ns", "name", logutil.AnalysisRunKey)
 	metricsServ.IncError("ns", "name", logutil.ExperimentKey)
@@ -95,14 +95,7 @@ rollout_reconcile_error{name="name",namespace="ns"} 1`
 func TestVersionInfo(t *testing.T) {
 	expectedResponse := `# HELP argo_rollouts_controller_info Running Argo-rollouts version
 # TYPE argo_rollouts_controller_info gauge`
-	metricsServ := NewMetricsServer(newFakeServerConfig(), true)
-	testHttpResponse(t, metricsServ.Handler, expectedResponse, assert.Contains)
-}
-
-func TestSecondaryMetricsServer(t *testing.T) {
-	expectedResponse := ``
-
-	metricsServ := NewMetricsServer(newFakeServerConfig(), false)
+	metricsServ := NewMetricsServer(newFakeServerConfig())
 	testHttpResponse(t, metricsServ.Handler, expectedResponse, assert.Contains)
 }
 
@@ -113,7 +106,7 @@ func TestRemove(t *testing.T) {
 experiment_reconcile_error{name="name1",namespace="ns"} 1
 rollout_reconcile_error{name="name1",namespace="ns"} 1`
 
-	metricsServ := NewMetricsServer(newFakeServerConfig(), true)
+	metricsServ := NewMetricsServer(newFakeServerConfig())
 
 	metricsServ.IncError("ns", "name1", logutil.RolloutKey)
 	metricsServ.IncError("ns", "name1", logutil.AnalysisRunKey)
diff --git a/controller/metrics/rollout_test.go b/controller/metrics/rollout_test.go
index b10af8b18..7c889ee09 100644
--- a/controller/metrics/rollout_test.go
+++ b/controller/metrics/rollout_test.go
@@ -180,7 +180,7 @@ rollout_reconcile_sum{name="ro-test",namespace="ro-namespace"} 0.001
 rollout_reconcile_count{name="ro-test",namespace="ro-namespace"} 1
 `
 
-	metricsServ := NewMetricsServer(newFakeServerConfig(), true)
+	metricsServ := NewMetricsServer(newFakeServerConfig())
 	ro := &v1alpha1.Rollout{
 		ObjectMeta: metav1.ObjectMeta{
 			Name:      "ro-test",
@@ -305,7 +305,7 @@ rollout_events_total{name="ro-test-1",namespace="ro-namespace",reason="FooEvent"
 rollout_events_total{name="ro-test-2",namespace="ro-namespace",reason="BazEvent",type="Warning"} 2
 `
 
-	metricsServ := NewMetricsServer(newFakeServerConfig(), true)
+	metricsServ := NewMetricsServer(newFakeServerConfig())
 	MetricRolloutEventsTotal.WithLabelValues("ro-namespace", "ro-test-1", corev1.EventTypeNormal, "FooEvent").Inc()
 	MetricRolloutEventsTotal.WithLabelValues("ro-namespace", "ro-test-1", corev1.EventTypeNormal, "BarEvent").Inc()
 	MetricRolloutEventsTotal.WithLabelValues("ro-namespace", "ro-test-2", corev1.EventTypeWarning, "BazEvent").Inc()
diff --git a/experiments/controller_test.go b/experiments/controller_test.go
index 70fa70ca1..1e7eb14f1 100644
--- a/experiments/controller_test.go
+++ b/experiments/controller_test.go
@@ -1,6 +1,7 @@
 package experiments
 
 import (
+	"context"
 	"encoding/json"
 	"fmt"
 	"reflect"
@@ -360,7 +361,7 @@ func (f *fixture) newController(resync resyncFunc) (*Controller, informers.Share
 	metricsServer := metrics.NewMetricsServer(metrics.ServerConfig{
 		Addr:               "localhost:8080",
 		K8SRequestProvider: &metrics.K8sRequestsCountProvider{},
-	}, true)
+	})
 
 	c := NewController(ControllerConfig{
 		KubeClientSet:                   f.kubeclient,
@@ -445,7 +446,7 @@ func (f *fixture) runController(experimentName string, startInformers bool, expe
 		assert.True(f.t, cache.WaitForCacheSync(stopCh, c.replicaSetSynced, c.experimentSynced, c.analysisRunSynced, c.analysisTemplateSynced, c.clusterAnalysisTemplateSynced))
 	}
 
-	err := c.syncHandler(experimentName)
+	err := c.syncHandler(context.Background(), experimentName)
 	if !expectError && err != nil {
 		f.t.Errorf("error syncing experiment: %v", err)
 	} else if expectError && err == nil {
@@ -893,3 +894,18 @@ func TestRemoveInvalidSpec(t *testing.T) {
 	}`, templateStatus, cond)
 	assert.Equal(t, expectedPatch, patch)
 }
+
+func TestRun(t *testing.T) {
+	f := newFixture(t, nil)
+	defer f.Close()
+	// make sure we can start and top the controller
+	c, _, _ := f.newController(noResyncPeriodFunc)
+	ctx, cancel := context.WithCancel(context.TODO())
+	defer cancel()
+	go func() {
+		time.Sleep(1000 * time.Millisecond)
+		c.experimentWorkqueue.ShutDownWithDrain()
+		cancel()
+	}()
+	c.Run(ctx, 1)
+}
diff --git a/ingress/alb_test.go b/ingress/alb_test.go
index 79a1c915a..295f130da 100644
--- a/ingress/alb_test.go
+++ b/ingress/alb_test.go
@@ -1,6 +1,7 @@
 package ingress
 
 import (
+	"context"
 	"fmt"
 	"testing"
 
@@ -130,7 +131,7 @@ func TestInvalidManagedALBActions(t *testing.T) {
 
 	ctrl, kubeclient, enqueuedObjects := newFakeIngressController(t, ing, rollout)
 
-	err := ctrl.syncIngress("default/test-ingress")
+	err := ctrl.syncIngress(context.Background(), "default/test-ingress")
 	assert.Nil(t, err)
 	assert.Len(t, kubeclient.Actions(), 0)
 	assert.Len(t, enqueuedObjects, 0)
@@ -142,7 +143,7 @@ func TestInvalidPreviousALBActionAnnotationValue(t *testing.T) {
 
 	ctrl, kubeclient, enqueuedObjects := newFakeIngressController(t, ing, nil)
 
-	err := ctrl.syncIngress("default/test-ingress")
+	err := ctrl.syncIngress(context.Background(), "default/test-ingress")
 	assert.Nil(t, err)
 	assert.Len(t, kubeclient.Actions(), 0)
 	assert.Len(t, enqueuedObjects, 0)
@@ -153,7 +154,7 @@ func TestInvalidPreviousALBActionAnnotationKey(t *testing.T) {
 	ing.Annotations[ingressutil.ManagedAnnotations] = "invalid-action-key"
 	ctrl, kubeclient, enqueuedObjects := newFakeIngressController(t, ing, nil)
 
-	err := ctrl.syncIngress("default/test-ingress")
+	err := ctrl.syncIngress(context.Background(), "default/test-ingress")
 	assert.Nil(t, err)
 	assert.Len(t, kubeclient.Actions(), 0)
 	assert.Len(t, enqueuedObjects, 0)
@@ -165,7 +166,7 @@ func TestResetActionFailureFindNoPort(t *testing.T) {
 
 	ctrl, kubeclient, enqueuedObjects := newFakeIngressController(t, ing, nil)
 
-	err := ctrl.syncIngress("default/test-ingress")
+	err := ctrl.syncIngress(context.Background(), "default/test-ingress")
 	assert.Nil(t, err)
 	assert.Len(t, kubeclient.Actions(), 0)
 	assert.Len(t, enqueuedObjects, 0)
@@ -177,7 +178,7 @@ func TestALBIngressNoModifications(t *testing.T) {
 
 	ctrl, kubeclient, enqueuedObjects := newFakeIngressController(t, ing, rollout)
 
-	err := ctrl.syncIngress("default/test-ingress")
+	err := ctrl.syncIngress(context.Background(), "default/test-ingress")
 	assert.Nil(t, err)
 	assert.Len(t, kubeclient.Actions(), 0)
 	assert.Len(t, enqueuedObjects, 1)
@@ -187,7 +188,7 @@ func TestALBIngressResetAction(t *testing.T) {
 	ing := newALBIngress("test-ingress", 80, "stable-service", "non-existing-rollout", false)
 
 	ctrl, kubeclient, enqueuedObjects := newFakeIngressController(t, ing, nil)
-	err := ctrl.syncIngress("default/test-ingress")
+	err := ctrl.syncIngress(context.Background(), "default/test-ingress")
 	assert.Nil(t, err)
 	assert.Len(t, enqueuedObjects, 0)
 	actions := kubeclient.Actions()
@@ -211,7 +212,7 @@ func TestALBIngressResetActionWithStickyConfig(t *testing.T) {
 	ing := newALBIngress("test-ingress", 80, "stable-service", "non-existing-rollout", true)
 
 	ctrl, kubeclient, enqueuedObjects := newFakeIngressController(t, ing, nil)
-	err := ctrl.syncIngress("default/test-ingress")
+	err := ctrl.syncIngress(context.Background(), "default/test-ingress")
 	assert.Nil(t, err)
 	assert.Len(t, enqueuedObjects, 0)
 	actions := kubeclient.Actions()
diff --git a/ingress/ingress_test.go b/ingress/ingress_test.go
index 3db4f1bcc..e33ee29c3 100644
--- a/ingress/ingress_test.go
+++ b/ingress/ingress_test.go
@@ -1,8 +1,10 @@
 package ingress
 
 import (
+	"context"
 	"sync"
 	"testing"
+	"time"
 
 	"github.com/argoproj/argo-rollouts/utils/queue"
 
@@ -117,7 +119,7 @@ func newFakeIngressController(t *testing.T, ing *extensionsv1beta1.Ingress, roll
 		MetricsServer: metrics.NewMetricsServer(metrics.ServerConfig{
 			Addr:               "localhost:8080",
 			K8SRequestProvider: &metrics.K8sRequestsCountProvider{},
-		}, true),
+		}),
 	})
 	enqueuedObjects := map[string]int{}
 	var enqueuedObjectsLock sync.Mutex
@@ -149,7 +151,7 @@ func newFakeIngressController(t *testing.T, ing *extensionsv1beta1.Ingress, roll
 func TestSyncMissingIngress(t *testing.T) {
 	ctrl, _, _ := newFakeIngressController(t, nil, nil)
 
-	err := ctrl.syncIngress("default/test-ingress")
+	err := ctrl.syncIngress(context.Background(), "default/test-ingress")
 	assert.NoError(t, err)
 }
 
@@ -158,7 +160,7 @@ func TestSyncIngressNotReferencedByRollout(t *testing.T) {
 
 	ctrl, kubeclient, _ := newFakeIngressController(t, ing, nil)
 
-	err := ctrl.syncIngress("default/test-stable-ingress")
+	err := ctrl.syncIngress(context.Background(), "default/test-stable-ingress")
 	assert.NoError(t, err)
 	actions := kubeclient.Actions()
 	assert.Len(t, actions, 0)
@@ -189,7 +191,7 @@ func TestSyncIngressReferencedByRollout(t *testing.T) {
 
 	ctrl, kubeclient, enqueuedObjects := newFakeIngressController(t, ing, rollout)
 
-	err := ctrl.syncIngress("default/test-stable-ingress")
+	err := ctrl.syncIngress(context.Background(), "default/test-stable-ingress")
 	assert.NoError(t, err)
 	actions := kubeclient.Actions()
 	assert.Len(t, actions, 0)
@@ -221,9 +223,22 @@ func TestSkipIngressWithNoClass(t *testing.T) {
 
 	ctrl, kubeclient, enqueuedObjects := newFakeIngressController(t, ing, rollout)
 
-	err := ctrl.syncIngress("default/test-stable-ingress")
+	err := ctrl.syncIngress(context.Background(), "default/test-stable-ingress")
 	assert.NoError(t, err)
 	actions := kubeclient.Actions()
 	assert.Len(t, actions, 0)
 	assert.Len(t, enqueuedObjects, 0)
 }
+
+func TestRun(t *testing.T) {
+	// make sure we can start and top the controller
+	c, _, _ := newFakeIngressController(t, nil, nil)
+	ctx, cancel := context.WithCancel(context.TODO())
+	defer cancel()
+	go func() {
+		time.Sleep(1000 * time.Millisecond)
+		c.ingressWorkqueue.ShutDownWithDrain()
+		cancel()
+	}()
+	c.Run(ctx, 1)
+}
diff --git a/pkg/apis/rollouts/validation/validation_references_test.go b/pkg/apis/rollouts/validation/validation_references_test.go
index ac8e962cf..75e7a59c9 100644
--- a/pkg/apis/rollouts/validation/validation_references_test.go
+++ b/pkg/apis/rollouts/validation/validation_references_test.go
@@ -558,6 +558,18 @@ func TestValidateVirtualService(t *testing.T) {
 		},
 	}
 
+	roWithoutIstio := &v1alpha1.Rollout{
+		Spec: v1alpha1.RolloutSpec{
+			Strategy: v1alpha1.RolloutStrategy{
+				Canary: &v1alpha1.CanaryStrategy{
+					StableService:  "stable",
+					CanaryService:  "canary",
+					TrafficRouting: &v1alpha1.RolloutTrafficRouting{},
+				},
+			},
+		},
+	}
+
 	t.Run("validate virtualService HTTP routes - success", func(t *testing.T) {
 		vsvc := unstructured.StrToUnstructuredUnsafe(successCaseVsvc)
 		allErrs := ValidateVirtualService(ro, *vsvc)
@@ -624,13 +636,21 @@ func TestValidateVirtualService(t *testing.T) {
 		assert.Equal(t, expectedErr.Error(), allErrs[0].Error())
 	})
 
-	t.Run("validate virtualService invalid tcp routes - failure", func(t *testing.T) {
+	t.Run("validate virtualService invalid rollout missing istio", func(t *testing.T) {
 		vsvc := unstructured.StrToUnstructuredUnsafe(failCaseInvalidTcpRoutesVsvc)
 		allErrs := ValidateVirtualService(ro, *vsvc)
 		assert.Len(t, allErrs, 1)
 		expectedErr := field.Invalid(field.NewPath("spec", "strategy", "canary", "trafficRouting", "istio", "virtualService", "name"), "istio-vsvc", "Unable to get TCP routes for Istio VirtualService")
 		assert.Equal(t, expectedErr.Error(), allErrs[0].Error())
 	})
+
+	t.Run("validate virtualService invalid tcp routes - failure", func(t *testing.T) {
+		vsvc := unstructured.StrToUnstructuredUnsafe(successCaseTcpVsvc)
+		allErrs := ValidateVirtualService(roWithoutIstio, *vsvc)
+		assert.Len(t, allErrs, 1)
+		expectedErr := field.Invalid(field.NewPath("spec", "strategy", "canary", "trafficRouting", "istio"), roWithoutIstio.Name, "Rollout object is not configured with Istio traffic routing")
+		assert.Equal(t, expectedErr.Error(), allErrs[0].Error())
+	})
 }
 
 func TestValidateVirtualServices(t *testing.T) {
diff --git a/pkg/kubectl-argo-rollouts/cmd/lint/lint_test.go b/pkg/kubectl-argo-rollouts/cmd/lint/lint_test.go
index 326015bbb..e9b66fd2e 100644
--- a/pkg/kubectl-argo-rollouts/cmd/lint/lint_test.go
+++ b/pkg/kubectl-argo-rollouts/cmd/lint/lint_test.go
@@ -29,6 +29,7 @@ func TestLintValidRollout(t *testing.T) {
 		"testdata/valid-nginx-canary.yml",
 		"testdata/valid-nginx-basic-canary.yml",
 		"testdata/valid-istio-v1beta1-mulitiple-virtualsvcs.yml",
+		"testdata/valid-nginx-smi-with-vsvc.yaml",
 	}
 
 	for _, filename := range tests {
@@ -54,6 +55,10 @@ func TestLintInvalidRollout(t *testing.T) {
 			"testdata/invalid.yml",
 			"Error: spec.strategy.maxSurge: Invalid value: intstr.IntOrString{Type:0, IntVal:0, StrVal:\"\"}: MaxSurge and MaxUnavailable both can not be zero\n",
 		},
+		{
+			"testdata/invalid-empty-rollout-vsvc.yml",
+			"Error: spec.selector: Required value: Rollout has missing field '.spec.selector'\n",
+		},
 		{
 			"testdata/invalid.json",
 			"Error: spec.strategy.maxSurge: Invalid value: intstr.IntOrString{Type:0, IntVal:0, StrVal:\"\"}: MaxSurge and MaxUnavailable both can not be zero\n",
diff --git a/pkg/kubectl-argo-rollouts/cmd/lint/testdata/invalid-empty-rollout-vsvc.yml b/pkg/kubectl-argo-rollouts/cmd/lint/testdata/invalid-empty-rollout-vsvc.yml
new file mode 100644
index 000000000..bf184253b
--- /dev/null
+++ b/pkg/kubectl-argo-rollouts/cmd/lint/testdata/invalid-empty-rollout-vsvc.yml
@@ -0,0 +1,5 @@
+apiVersion: argoproj.io/v1alpha1
+kind: Rollout
+---
+apiVersion: networking.istio.io/v1alpha3
+kind: VirtualService
\ No newline at end of file
diff --git a/pkg/kubectl-argo-rollouts/cmd/lint/testdata/valid-nginx-smi-with-vsvc.yaml b/pkg/kubectl-argo-rollouts/cmd/lint/testdata/valid-nginx-smi-with-vsvc.yaml
new file mode 100644
index 000000000..0acb23d62
--- /dev/null
+++ b/pkg/kubectl-argo-rollouts/cmd/lint/testdata/valid-nginx-smi-with-vsvc.yaml
@@ -0,0 +1,74 @@
+apiVersion: networking.istio.io/v1beta1
+kind: VirtualService
+metadata:
+  name: istio-host-split-vsvc
+spec:
+  hosts:
+    - istio-host-split.com
+  gateways:
+    - istio-host-split-gateway
+  http:
+    - name: primary
+      route:
+        - destination:
+            host: istio-host-split-stable
+          weight: 100
+        - destination:
+            host: istio-host-split-canary
+          weight: 0
+---
+apiVersion: networking.istio.io/v1beta1
+kind: VirtualService
+metadata:
+  name: istio-host-split-vsvc-1
+spec:
+  hosts:
+    - istio-host-split.com
+  gateways:
+    - istio-host-split-gateway
+  http:
+    - name: primary
+      route:
+        - destination:
+            host: istio-host-split-stable
+          weight: 100
+        - destination:
+            host: istio-host-split-canary
+          weight: 0
+---
+apiVersion: argoproj.io/v1alpha1
+kind: Rollout
+metadata:
+  name: nginx-rollout
+spec:
+  selector:
+    matchLabels:
+      app: nginx-rollout
+  template:
+    metadata:
+      labels:
+        app: nginx-rollout
+    spec:
+      containers:
+        - name: nginx-rollout
+          image: argoproj/rollouts-demo:blue
+          ports:
+            - name: http
+              containerPort: 80
+              protocol: TCP
+          resources:
+            requests:
+              memory: 16Mi
+              cpu: 5m
+  strategy:
+    canary:
+      canaryService: nginx-rollout-canary
+      stableService: nginx-rollout-stable
+      trafficRouting:
+        smi:
+          trafficSplitName: rollout-smi-experiment-split
+          rootService: rollout-smi-experiment-root
+        nginx:
+          stableIngress: nginx-rollout-ingress
+      steps:
+        - setWeight: 10
diff --git a/rollout/controller_test.go b/rollout/controller_test.go
index 673890085..d7683fdde 100644
--- a/rollout/controller_test.go
+++ b/rollout/controller_test.go
@@ -1,6 +1,7 @@
 package rollout
 
 import (
+	"context"
 	"encoding/json"
 	"fmt"
 	"reflect"
@@ -545,9 +546,14 @@ func (f *fixture) newController(resync resyncFunc) (*Controller, informers.Share
 		Version:  "v1beta1",
 		Resource: "targetgroupbindings",
 	}
+	vsvcGVR := istioutil.GetIstioVirtualServiceGVR()
+	destGVR := istioutil.GetIstioDestinationRuleGVR()
 	listMapping := map[schema.GroupVersionResource]string{
-		tgbGVR: "TargetGroupBindingList",
+		tgbGVR:  "TargetGroupBindingList",
+		vsvcGVR: vsvcGVR.Resource + "List",
+		destGVR: destGVR.Resource + "List",
 	}
+
 	dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listMapping, f.objects...)
 	dynamicInformerFactory := dynamicinformer.NewDynamicSharedInformerFactory(dynamicClient, 0)
 	istioVirtualServiceInformer := dynamicInformerFactory.ForResource(istioutil.GetIstioVirtualServiceGVR()).Informer()
@@ -560,7 +566,7 @@ func (f *fixture) newController(resync resyncFunc) (*Controller, informers.Share
 	metricsServer := metrics.NewMetricsServer(metrics.ServerConfig{
 		Addr:               "localhost:8080",
 		K8SRequestProvider: &metrics.K8sRequestsCountProvider{},
-	}, true)
+	})
 
 	ingressWrapper, err := ingressutil.NewIngressWrapper(ingressutil.IngressModeExtensions, f.kubeclient, k8sI)
 	if err != nil {
@@ -675,7 +681,7 @@ func (f *fixture) runController(rolloutName string, startInformers bool, expectE
 		assert.True(f.t, cache.WaitForCacheSync(stopCh, c.replicaSetSynced, c.rolloutsSynced))
 	}
 
-	err := c.syncHandler(rolloutName)
+	err := c.syncHandler(context.Background(), rolloutName)
 	if !expectError && err != nil {
 		f.t.Errorf("error syncing rollout: %v", err)
 	} else if expectError && err == nil {
@@ -1939,9 +1945,27 @@ func TestWriteBackToInformer(t *testing.T) {
 	f.runController(roKey, true, false, c, i, k8sI)
 
 	// Verify the informer was updated with the new unstructured object after reconciliation
-	obj, _, _ := c.rolloutsIndexer.GetByKey(roKey)
-	un := obj.(*unstructured.Unstructured)
+	obj, exists, err := c.rolloutsIndexer.GetByKey(roKey)
+	assert.NoError(t, err)
+	assert.True(t, exists)
+	un, ok := obj.(*unstructured.Unstructured)
+	assert.True(t, ok)
 	stableRS, _, _ := unstructured.NestedString(un.Object, "status", "stableRS")
 	assert.NotEmpty(t, stableRS)
 	assert.Equal(t, rs1.Labels[v1alpha1.DefaultRolloutUniqueLabelKey], stableRS)
 }
+
+func TestRun(t *testing.T) {
+	f := newFixture(t)
+	defer f.Close()
+	// make sure we can start and top the controller
+	c, _, _ := f.newController(noResyncPeriodFunc)
+	ctx, cancel := context.WithCancel(context.TODO())
+	defer cancel()
+	go func() {
+		time.Sleep(1000 * time.Millisecond)
+		c.rolloutWorkqueue.ShutDownWithDrain()
+		cancel()
+	}()
+	c.Run(ctx, 1)
+}
diff --git a/rollout/trafficrouting/istio/controller_test.go b/rollout/trafficrouting/istio/controller_test.go
index 443786c01..af37f1105 100644
--- a/rollout/trafficrouting/istio/controller_test.go
+++ b/rollout/trafficrouting/istio/controller_test.go
@@ -182,7 +182,7 @@ spec:
 			enqueueCalled = true
 		}
 
-		err = c.syncDestinationRule(key)
+		err = c.syncDestinationRule(context.Background(), key)
 		assert.NoError(t, err)
 		actions := c.DynamicClientSet.(*dynamicfake.FakeDynamicClient).Actions()
 		assert.Len(t, actions, 0)
@@ -203,7 +203,7 @@ spec:
 			enqueueCalled = true
 		}
 
-		err = c.syncDestinationRule(key)
+		err = c.syncDestinationRule(context.Background(), key)
 		assert.NoError(t, err)
 		actions := c.DynamicClientSet.(*dynamicfake.FakeDynamicClient).Actions()
 		assert.Len(t, actions, 1)
@@ -223,7 +223,7 @@ spec:
 			enqueueCalled = true
 		}
 
-		err = c.syncDestinationRule(key)
+		err = c.syncDestinationRule(context.Background(), key)
 		assert.NoError(t, err)
 		actions := c.DynamicClientSet.(*dynamicfake.FakeDynamicClient).Actions()
 		assert.Len(t, actions, 1)
@@ -238,10 +238,11 @@ func TestRun(t *testing.T) {
 	ctx, cancel := context.WithCancel(context.TODO())
 	defer cancel()
 	go func() {
-		time.Sleep(100 * time.Millisecond)
+		time.Sleep(1000 * time.Millisecond)
+		c.destinationRuleWorkqueue.ShutDownWithDrain()
 		cancel()
 	}()
 	go c.DestinationRuleInformer.Run(ctx.Done())
 	go c.VirtualServiceInformer.Run(ctx.Done())
-	c.Run(ctx.Done())
+	c.Run(ctx)
 }
diff --git a/service/service_test.go b/service/service_test.go
index 4bcd6e8da..69dea462d 100644
--- a/service/service_test.go
+++ b/service/service_test.go
@@ -1,7 +1,9 @@
 package service
 
 import (
+	"context"
 	"testing"
+	"time"
 
 	"github.com/argoproj/argo-rollouts/utils/queue"
 
@@ -72,7 +74,7 @@ func newFakeServiceController(svc *corev1.Service, rollout *v1alpha1.Rollout) (*
 	metricsServer := metrics.NewMetricsServer(metrics.ServerConfig{
 		Addr:               "localhost:8080",
 		K8SRequestProvider: &metrics.K8sRequestsCountProvider{},
-	}, true)
+	})
 	c := NewController(ControllerConfig{
 		Kubeclientset:     kubeclient,
 		Argoprojclientset: client,
@@ -110,7 +112,7 @@ func newFakeServiceController(svc *corev1.Service, rollout *v1alpha1.Rollout) (*
 func TestSyncMissingService(t *testing.T) {
 	ctrl, _, _, _ := newFakeServiceController(nil, nil)
 
-	err := ctrl.syncService("default/test-service")
+	err := ctrl.syncService(context.Background(), "default/test-service")
 	assert.NoError(t, err)
 }
 
@@ -122,7 +124,7 @@ func TestSyncMissingServiceInCache(t *testing.T) {
 	})
 	ctrl, _, _, _ := newFakeServiceController(svc, nil)
 	ctrl.kubeclientset = k8sfake.NewSimpleClientset()
-	err := ctrl.syncService("default/test-service")
+	err := ctrl.syncService(context.Background(), "default/test-service")
 	assert.NoError(t, err)
 }
 
@@ -133,7 +135,7 @@ func TestSyncServiceNotReferencedByRollout(t *testing.T) {
 
 	ctrl, kubeclient, _, _ := newFakeServiceController(svc, nil)
 
-	err := ctrl.syncService("default/test-service")
+	err := ctrl.syncService(context.Background(), "default/test-service")
 	assert.NoError(t, err)
 	actions := kubeclient.Actions()
 	assert.Len(t, actions, 1)
@@ -164,7 +166,7 @@ func TestSyncServiceWithNoManagedBy(t *testing.T) {
 
 	ctrl, kubeclient, client, _ := newFakeServiceController(svc, ro)
 
-	err := ctrl.syncService("default/test-service")
+	err := ctrl.syncService(context.Background(), "default/test-service")
 	assert.NoError(t, err)
 	actions := kubeclient.Actions()
 	assert.Len(t, actions, 0)
@@ -192,7 +194,7 @@ func TestSyncServiceWithManagedByWithNoRolloutReference(t *testing.T) {
 
 	ctrl, kubeclient, client, _ := newFakeServiceController(svc, ro)
 
-	err := ctrl.syncService("default/test-service")
+	err := ctrl.syncService(context.Background(), "default/test-service")
 	assert.NoError(t, err)
 	actions := kubeclient.Actions()
 	patch, ok := actions[0].(k8stesting.PatchAction)
@@ -227,9 +229,22 @@ func TestSyncServiceReferencedByRollout(t *testing.T) {
 
 	ctrl, kubeclient, _, enqueuedObjects := newFakeServiceController(svc, rollout)
 
-	err := ctrl.syncService("default/test-service")
+	err := ctrl.syncService(context.Background(), "default/test-service")
 	assert.NoError(t, err)
 	actions := kubeclient.Actions()
 	assert.Len(t, actions, 0)
 	assert.Equal(t, 1, enqueuedObjects["default/rollout"])
 }
+
+func TestRun(t *testing.T) {
+	// make sure we can start and top the controller
+	c, _, _, _ := newFakeServiceController(nil, nil)
+	ctx, cancel := context.WithCancel(context.TODO())
+	defer cancel()
+	go func() {
+		time.Sleep(1000 * time.Millisecond)
+		c.serviceWorkqueue.ShutDownWithDrain()
+		cancel()
+	}()
+	c.Run(ctx, 1)
+}
diff --git a/utils/controller/controller_test.go b/utils/controller/controller_test.go
index 601a52628..9fa54b551 100644
--- a/utils/controller/controller_test.go
+++ b/utils/controller/controller_test.go
@@ -1,6 +1,7 @@
 package controller
 
 import (
+	"context"
 	"fmt"
 	"testing"
 	"time"
@@ -38,47 +39,47 @@ func TestProcessNextWorkItemHandlePanic(t *testing.T) {
 	metricServer := metrics.NewMetricsServer(metrics.ServerConfig{
 		Addr:               "localhost:8080",
 		K8SRequestProvider: &metrics.K8sRequestsCountProvider{},
-	}, true)
-	syncHandler := func(key string) error {
+	})
+	syncHandler := func(ctx context.Context, key string) error {
 		panic("Bad big panic :(")
 	}
-	assert.True(t, processNextWorkItem(q, log.RolloutKey, syncHandler, metricServer))
+	assert.True(t, processNextWorkItem(context.Background(), q, log.RolloutKey, syncHandler, metricServer))
 }
 
 func TestProcessNextWorkItemShutDownQueue(t *testing.T) {
 	q := workqueue.NewNamedRateLimitingQueue(queue.DefaultArgoRolloutsRateLimiter(), "Rollouts")
-	syncHandler := func(key string) error {
+	syncHandler := func(ctx context.Context, key string) error {
 		return nil
 	}
 	q.ShutDown()
-	assert.False(t, processNextWorkItem(q, log.RolloutKey, syncHandler, nil))
+	assert.False(t, processNextWorkItem(context.Background(), q, log.RolloutKey, syncHandler, nil))
 }
 
 func TestProcessNextWorkItemNoTStringKey(t *testing.T) {
 	q := workqueue.NewNamedRateLimitingQueue(queue.DefaultArgoRolloutsRateLimiter(), "Rollouts")
 	q.Add(1)
-	syncHandler := func(key string) error {
+	syncHandler := func(ctx context.Context, key string) error {
 		return nil
 	}
-	assert.True(t, processNextWorkItem(q, log.RolloutKey, syncHandler, nil))
+	assert.True(t, processNextWorkItem(context.Background(), q, log.RolloutKey, syncHandler, nil))
 }
 
 func TestProcessNextWorkItemNoValidKey(t *testing.T) {
 	q := workqueue.NewNamedRateLimitingQueue(queue.DefaultArgoRolloutsRateLimiter(), "Rollouts")
 	q.Add("invalid.key")
-	syncHandler := func(key string) error {
+	syncHandler := func(ctx context.Context, key string) error {
 		return nil
 	}
-	assert.True(t, processNextWorkItem(q, log.RolloutKey, syncHandler, nil))
+	assert.True(t, processNextWorkItem(context.Background(), q, log.RolloutKey, syncHandler, nil))
 }
 
 func TestProcessNextWorkItemNormalSync(t *testing.T) {
 	q := workqueue.NewNamedRateLimitingQueue(queue.DefaultArgoRolloutsRateLimiter(), "Rollouts")
 	q.Add("valid/key")
-	syncHandler := func(key string) error {
+	syncHandler := func(ctx context.Context, key string) error {
 		return nil
 	}
-	assert.True(t, processNextWorkItem(q, log.RolloutKey, syncHandler, nil))
+	assert.True(t, processNextWorkItem(context.Background(), q, log.RolloutKey, syncHandler, nil))
 }
 
 func TestProcessNextWorkItemSyncHandlerReturnError(t *testing.T) {
@@ -87,11 +88,11 @@ func TestProcessNextWorkItemSyncHandlerReturnError(t *testing.T) {
 	metricServer := metrics.NewMetricsServer(metrics.ServerConfig{
 		Addr:               "localhost:8080",
 		K8SRequestProvider: &metrics.K8sRequestsCountProvider{},
-	}, true)
-	syncHandler := func(key string) error {
+	})
+	syncHandler := func(ctx context.Context, key string) error {
 		return fmt.Errorf("error message")
 	}
-	assert.True(t, processNextWorkItem(q, log.RolloutKey, syncHandler, metricServer))
+	assert.True(t, processNextWorkItem(context.Background(), q, log.RolloutKey, syncHandler, metricServer))
 }
 
 func TestEnqueue(t *testing.T) {