Schedule icon
Get icon
Script icon
Loop icon
Status icon
Docker icon
Log icon
If icon
SlackIncomingWebhook icon
Pause icon
Patch icon
LoopUntil icon

Restore an Argo CD sync policy switched off during an incident

Detect Argo CD Applications with a sync policy weaker than declared, restore it behind an approval gate then verify the stranded commit reaches the cluster.

Categories
Infrastructure

Safety

Switching spec.syncPolicy.automated off is the standard way to stop Argo CD fighting you during an incident. The problem is the state it leaves behind. The application looks calm, argocd app list shows no errors, yet every commit merged since then is sitting in Git with nothing moving it. Turning the policy back on is not a configuration tidy up: it hands the cluster back to Git the moment it lands, the first reconciliation overwrites anything changed by hand plus prune: true deletes any live resource that is no longer in the branch. So this flow refuses by default and each guard below was proven by its own run.

Guard What it stops
Opt out annotation policy-guard.kestra.io/exempt reading, planning or patching an application that is manual by design
Per application baseline_overrides rewriting a deliberate exception, such as an application that declares prune: false
Weaker-than-declared test only removing automation from an application that has more of it than the baseline asks for
restorable_projects allowlist on spec.project patching an application outside the projects you named, even in live mode
max_apps_per_run ceiling, worst drift first handing the whole fleet back to Git in one run when twenty applications are off
auto_remediate false, dry_run true, Pause approval three independent gates before one patch
Re-read of project, repository, path and branch after the pause a patch landing on an application that was handed to another team while the approval waited
LoopUntil on the live object plus assert_recovery a patch that returned success being reported as a fix when an ApplicationSet silently reverted it

How it works

  1. list_applications (io.kestra.plugin.kubernetes.kubectl.Get) reads the Application objects in argocd_namespace with their annotations. The Argo CD plugin fetches one named application per call, so this single Kubernetes read is where the names come from and it is also where the opt out annotation is seen.
  2. plan_scope (io.kestra.plugin.scripts.python.Script) splits that inventory into applications to read and applications excluded by exempt_annotation. Exclusion happens here, before any policy is fetched, so an exempt application never enters a plan.
  3. read_policies (io.kestra.plugin.core.flow.Loop, concurrencyLimit: 1) runs one read_policy (io.kestra.plugin.argocd.apps.Status, refresh: true) per application. The forced refresh is what makes the sync state current, which is how a stranded commit becomes visible instead of looking like a quiet healthy application.
  4. classify_policy compares the live automated, prune and selfHeal against the baseline that application declares, which is policy_baseline overridden by its own baseline_overrides entry. Only a weaker policy is drift. The verdict is one of restorable, matches_baseline, stronger_than_baseline, project_not_allowed or, after the ceiling is applied, over_ceiling. Every finding carries a ready to paste argocd app patch rollback string built from the policy as it was found.
  5. build_plan orders the restorable findings worst drift first, applies max_apps_per_run and records an approved snapshot per target: project, repository, path, branch, observed policy, declared policy and the exact merge patch to be sent.
  6. log_findings (io.kestra.plugin.core.log.Log) prints one line per audited application with the bucket it fell into. announce_findings (io.kestra.plugin.core.flow.If) then posts the same table to Slack through notify_findings whenever there is a target or a reported application outside the allowlist.
  7. route_remediation (io.kestra.plugin.core.flow.If) requires auto_remediate true, dry_run false and at least one target. Miss one and the run ends at log_report_only.
  8. approve_restore (io.kestra.plugin.core.flow.Pause) waits for a human. apply_if_approved reads onResume.approved ?? false, so a pause resumed without an answer is a refusal, not consent.
  9. restore_each (io.kestra.plugin.core.flow.Loop, concurrencyLimit: 1) handles one application at a time, because Argo CD starts reconciling the moment a policy lands and a parallel loop would mix one application's recovery window with the next one's patch. recheck_target reads the application again immediately before its write and guard_stale_plan compares it with the approved snapshot: already compliant is a skip, a moved project, repository, path or branch fails the run before any patch is sent.
  10. restore_policy (io.kestra.plugin.argocd.apps.Patch, patchType: MERGE) sets spec.syncPolicy.automated to the declared values. A merge patch leaves the rest of the policy alone, so syncOptions and any allowEmpty setting survive.
  11. await_recovery (io.kestra.plugin.core.flow.LoopUntil, failOnMaxReached: true) reads the application every 15 seconds for up to ten reads inside a three minute window. measure_recovery requires the policy to be on the object and, when the application was out of sync at plan time, Argo CD to report it reconciled. Only reads happen in the loop and no sync is ever issued, so if the commit arrives it arrived because automated sync is running again.
  12. assert_recovery fails that application when the last read did not prove recovery, then summarize_restores fails the run when any approved target is not compliant or never reported. log_restored states how many were restored and how many Argo CD reconciled on its own.

The outputs worth reading afterwards: {{ outputs.build_plan.vars.findings }} is the full table with a verdict per application, {{ outputs.build_plan.vars.targets }} is the approved snapshot set including the patch and the rollback string, {{ outputs.build_plan.vars.report }} is the Slack text block and {{ outputs.summarize_restores.vars.reconciled }} lists the applications Argo CD brought back to Git itself.

What you get

  • The applications that stopped following Git named with the field that is off, not a generic out of sync alert.
  • The commit the cluster is stuck on, printed next to the branch it should be following.
  • Three different consequences separated: a missing automated block means new commits never land, selfHeal: false means hand edits are never corrected, prune: false means resources deleted from Git stay forever.
  • A rollback string per application, captured from the policy as it was found, before anything is changed.
  • A run that fails when the patch was accepted and the policy still is not on the object, which is what happens when an ApplicationSet or a parent app of apps owns the spec.

Who it's for

Platform and GitOps teams running Argo CD where automated sync gets switched off during incidents and nobody owns switching it back on, so applications quietly stop following their branch.

Why orchestrate this with Kestra

The manual version is someone running argocd app list -o json, eyeballing which applications have no automated block, then pasting argocd app patch from shell history. There is no record of what the policy was before, no check that the application is still the one that was approved and no proof that the stranded commit actually landed. As a flow, the inventory, the classification, the approval and the verification are one audited unit. The Pause task is what makes a genuinely dangerous action safe to automate: a human reads the findings, knows prune is about to delete things and approves that specific list. The re-read after the pause and the LoopUntil after the patch are the parts that are tedious by hand and are exactly where a silent failure hides.

Tested end to end

Kestra 2.0.5, Argo CD 2.13.3 (v2.13.3+a25c8a0) on k3s v1.31.5+k3s1, Argo CD plugin apps.Status and apps.Patch, Kubernetes plugin kubectl.Get, Slack posted to a local HTTP sink. Flow validate on stock Kestra 2.0.5 was clean. The fixture is six Applications over two AppProjects against a local git HTTP server: two incident victims, one healthy control, one annotation exempt application, one application in a project off the allowlist and one that declares prune: false on purpose, plus a seventh generated by an ApplicationSet for the ineffective write case. Every state below was read with Argo CD's own CLI inside the argocd-server pod plus kubectl for the workload objects, never from a flow output.

Case Execution Result
Healthy 1O4iBR4Ozg5q1StmEFk3Gt SUCCESS. audited=5 targets=0 matches_baseline=5, the approval was never offered, no policy and no ConfigMap changed
Degraded dry run 3x2DMn6Z5gtLE81EKNvUjm SUCCESS. targets=2 project_not_allowed=1. Named the stranded commit and the exact patch, then wrote nothing: policies and ConfigMaps byte identical afterwards
Lookalike controls, all six verdicts in one run 5EZ3wMNNWZJplRfoONnn9B SUCCESS. restorable payments, over_ceiling checkout at a ceiling of 1, stronger_than_baseline reports with selfHeal on against a baseline that turns it off, matches_baseline batch against its own override, project_not_allowed sandbox in staging, exempt_annotation legacy. Nothing written
Declined approval 6ilkwGESbOt3GDeY5zpFPM SUCCESS. Resumed with approved: false. Both victims still had their policy off and the hand edited ConfigMap was still there
Remediation 6fGIB8GHwCUVFwXlBrYGli SUCCESS, planned=2 landed=2 reconciled=2. Measured payoff: payments-release went r4 to r5, the commit that had been stranded since the policy was switched off. On checkout, selfHeal reverted checkout-release from manual-hotfix back to r5 and prune deleted checkout-legacy-feature, the resource that had been removed from Git. Iterations 6lDTXibzqLIILmFY5dyMJn and 3T0nO0luQ7rD1XLEY9Dgvn. No sync was issued by the flow. batch, legacy, sandbox and reports were byte identical, while sandbox-release stayed at r4
Repeat 7TqKUTbtxcL3lUc2EXnMGb SUCCESS. targets=0, the approval was never offered, restore_each spawned no iteration at all and the restored ConfigMaps were untouched
Ineffective write XxlfT0mnz0Rs2RJzWdIjL FAILED as intended. Against the ApplicationSet owned application, restore_policy was accepted by Argo CD, then six reads over the three minute window kept seeing automated absent because the controller reverts it in under five seconds. Iteration 5ONgnPVE2drEvNYvCwd9ZG, Slack alert fired, nothing reported as restored
Project moved during the pause 4rpONdMh0QK1bkhDNYHNoI FAILED as intended. The approved application was moved from production to staging while the pause was open. Iteration 7mvrdN2eNQEjAXBAVZF9Z1 refused at guard_stale_plan naming the old and the new project, restore_policy never ran and no policy changed

All eight ran on the final revision, which is flow revision 4 in the packet. A second fixture built from scratch on its own k3s container repeated the three that matter: healthy 6CLXsfwPv2EoB9klE6plTV, the pause race 6ISGR4abLoxVaP1LDNimjE refused again before any write, then remediation 6roJ4keG1zvxo6qF5MmBZi with planned=2 landed=2 reconciled=2 and the same three effects measured independently, payments-release r1 to r2, checkout-release manual-hotfix to r2 and checkout-legacy-feature gone.

Prerequisites

  • Argo CD 2.13 or newer reachable from the Kestra worker, plus the Kubernetes API server that holds the Application objects.
  • Minimum permissions. The Argo CD account needs applications, get on everything you audit and applications, update only on the projects in restorable_projects. Scope the token to the same projects so the allowlist is enforced twice. The Kubernetes ServiceAccount needs get and list on applications.argoproj.io in the Argo CD namespace and no write verb at all. Neither credential needs cluster admin or any repository access.
  • Kestra 2.0 with the Argo CD plugin, the Kubernetes plugin plus the Docker task runner for the Python steps and the Argo CD CLI container.
  • A Slack incoming webhook.

Secrets

Secret What it is
ARGOCD_SERVER host:port of the Argo CD API server, reachable from the worker
ARGOCD_TOKEN Argo CD account token that can read every audited application and update the restorable projects
K8S_MASTER_URL Kubernetes API server URL
K8S_OAUTH_TOKEN ServiceAccount bearer token with get plus list on applications.argoproj.io
K8S_CA_CERT_DATA base64 encoded PEM CA bundle for the API server
SLACK_WEBHOOK_URL Slack incoming webhook for the findings plus the failure alert

Inputs

Input Type Default What it does
argocd_server STRING secret('ARGOCD_SERVER') Argo CD API server address
argocd_token STRING secret('ARGOCD_TOKEN') Argo CD API token
argocd_cli_version STRING 2.13.3 CLI version the plugin downloads, pin it to your server
cli_docker_network STRING bridge docker network the short lived CLI container joins
k8s_master_url STRING secret('K8S_MASTER_URL') API server holding the Application objects
k8s_oauth_token STRING secret('K8S_OAUTH_TOKEN') read only ServiceAccount token
k8s_ca_cert_data STRING secret('K8S_CA_CERT_DATA') base64 PEM CA bundle
argocd_namespace STRING argocd namespace the Application objects live in
policy_baseline JSON {"automated": true, "prune": true, "selfHeal": true} the policy every audited application is expected to carry
baseline_overrides JSON {} declared exceptions keyed by application name
restorable_projects ARRAY ["production"] write allowlist matched on spec.project
exempt_annotation STRING policy-guard.kestra.io/exempt annotation that skips an application before any read
max_apps_per_run INT 3 per run ceiling, worst drift first
dry_run BOOL true report without writing
auto_remediate BOOL false allow remediation to be considered
slack_channel STRING #platform-alerts channel for the findings plus the failure alert

Quick start

  1. Add the six secrets above to your Kestra instance.
  2. Set cli_docker_network to the docker network from which your Argo CD address resolves. Leave it on bridge when that address is routable from the worker host.
  3. Run it as it ships. dry_run true with auto_remediate false lists every application whose policy is weaker than declared, the commit its cluster is stuck on and the exact patch it would send. Nothing is written.
  4. Read the report. Add policy-guard.kestra.io/exempt to any application that is manual on purpose, then put its real policy in baseline_overrides when the difference is deliberate rather than accidental.
  5. Confirm the branch really holds the state you want, because restoring selfHeal overwrites hand edits and restoring prune deletes resources that are no longer in Git.
  6. Put the projects you are willing to write to in restorable_projects, then run with dry_run: false and auto_remediate: true. The execution pauses.
  7. Approve it. The flow re-reads each application, patches it, watches the restored policy reconcile and fails the run if it did not.
  8. Enable the schedule once you trust the report. It forces report only mode, so turning it on can never patch an application on its own.

How to extend

  • Give each AppProject its own schedule with its own restorable_projects and its own ceiling, so one team's incident never widens another team's blast radius.
  • Set policy_baseline per environment: many fleets want prune: false everywhere except production.
  • Keep {{ outputs.build_plan.vars.targets }} in the incident record. Each entry carries the policy as it was found plus the argocd app patch string that puts it back, which is what a rollback needs.
  • Send the finding to PagerDuty instead of Slack when a stranded commit means a security fix is not deployed.
  • Count how often the same application shows up. A repeat offender usually means the incident runbook switches the policy off and has no step that switches it back on.

Limits: the flow writes spec.syncPolicy.automated only. It never adds syncOptions, never changes the source, the project or the destination, never creates or deletes an Application and never issues a sync, so recovery is Argo CD's own reconciliation or it is a failure. Recovery is proven for an application that was out of sync at plan time; for one that was already in sync only the configuration is proven, so the run says so rather than claiming a fix. The three minute window suits the default reconciliation interval, so an application with a long running pre sync hook needs a wider checkFrequency. The re-read before each write closes the window from approval to patch but it is not a lock: Argo CD has no conditional write on the Application spec, so another administrator can still change it a moment later. Separate executions are not serialized against each other, only the iterations inside one run are. An application owned by an ApplicationSet or a parent app of apps cannot be fixed here at all, which the run proves by failing rather than by reporting success. Tested against Argo CD 2.13.3 on k3s with the Argo CD CLI pinned to the same version.

Links

This blueprint was created by zkasuran.

See How

New to Kestra?

Use blueprints to kickstart your first workflows.