Versioning ISM policies with a GitOps workflow
This walkthrough makes a git repository the single source of truth for your OpenSearch ISM policies: every change lands through a reviewed pull request, each merge is tagged with a semantic version, a reconciler continuously drives the live cluster back to the committed state, and a bad release rolls back by reverting one commit. It extends the deploy job in Integrating ISM policy deployment into CI/CD pipelines from a push-on-merge model to a pull-based reconciliation loop, for teams that want the OpenSearch cluster’s lifecycle configuration to be as auditable and reversible as their application manifests.
Prerequisites
Confirm each of these before adopting the GitOps loop — a missing version tag or a reconciler running with an over-broad token is the usual reason a rollback either cannot find its target or reconciles the wrong cluster.
The reconciliation loop
GitOps inverts the push pipeline: instead of a merge pushing to the live cluster, a reconciler pulls the desired state from a git ref and continuously closes the gap between what git says and what the live cluster runs. The loop below is the whole model — a merged, tagged commit is the desired state; the reconciler reads it, diffs it against the live policy, applies the difference with a conditional PUT, and records the observed version. A failed release is undone by reverting the commit, which the same loop then reconciles back to the previous good version.
Step-by-step procedure
The five steps take a change from a proposed edit to a reconciled, versioned, and reversible release.
1. Propose the change as a pull request
Every edit is a branch and a pull request against the policy repository — never a console edit. The PR diff is the change review: a reviewer sees the exact state, action, or transition delta before it can reach any cluster.
git checkout -b shorten-warm-retention
# edit policies/logs_lifecycle.json — warm→delete min_index_age 30d -> 21d
git commit -am "logs_lifecycle: shorten warm retention to 21d"
git push -u origin shorten-warm-retention
Gotcha: the reviewer must scrutinise any change to a
deleteaction or amin_index_age, because those shorten retention and destroy data soonest. Encode that as aCODEOWNERSrule so retention edits require a data-owner approval, not just any teammate.
2. Tag the merge with a semantic version
On merge to main, tag the commit with a per-policy semantic version so the running configuration is always traceable to an immutable ref. The version is the anchor rollback and drift reporting both hang off.
git checkout main && git pull
git tag logs_lifecycle@2.3.0 -m "warm retention 21d"
git push origin logs_lifecycle@2.3.0
Embed that version inside the policy so the OpenSearch cluster can report it back:
{ "policy": { "description": "logs_lifecycle | version=2.3.0 | managed-by=gitops", "default_state": "hot", "states": [ "..." ] } }
Gotcha: bump the version on every functional change, even a one-line threshold edit. A reconciler that reports
observed=2.3.0is only trustworthy if that string uniquely identifies one committed document.
3. Reconcile the OpenSearch cluster to the tagged commit
The reconciler reads the desired document from the tagged ref, fetches the live policy for its concurrency tokens, and applies a conditional PUT only when they differ. Running it on a schedule (or triggered by a git webhook) is what makes the model continuous rather than merge-triggered — a manual console edit is reverted on the next pass.
import json, logging, subprocess
from opensearchpy import OpenSearch, exceptions
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger("gitops.reconcile")
def desired_at(ref: str, path: str) -> dict:
"""Read the committed policy document at a git ref."""
blob = subprocess.check_output(["git", "show", f"{ref}:{path}"])
return json.loads(blob)
def reconcile(client: OpenSearch, policy_id: str, desired: dict) -> str:
try:
live = client.transport.perform_request("GET", f"/_plugins/_ism/policies/{policy_id}")
seq_no, primary_term, current = live["_seq_no"], live["_primary_term"], live["policy"]
except exceptions.NotFoundError:
seq_no = primary_term = current = None
if current == desired["policy"]:
log.info("in-sync policy_id=%s", policy_id)
return "in-sync"
params = {} if current is None else {"if_seq_no": seq_no, "if_primary_term": primary_term}
try:
client.transport.perform_request(
"PUT", f"/_plugins/_ism/policies/{policy_id}", params=params, body=desired)
log.info("reconciled policy_id=%s -> desired", policy_id)
return "reconciled"
except exceptions.ConflictError:
log.warning("drift race policy_id=%s — retry next cycle", policy_id)
return "conflict"
if __name__ == "__main__":
client = OpenSearch(hosts=[{"host": "os-prod", "port": 9200}],
http_auth=("gitops-reconciler", "***"), use_ssl=True, verify_certs=True)
reconcile(client, "logs_lifecycle",
desired_at("logs_lifecycle@2.3.0", "policies/logs_lifecycle.json"))
The reconciler is idempotent by construction: once the OpenSearch cluster equals the tagged document it logs in-sync and writes nothing. This is the same conditional-PUT contract the push pipeline uses; the difference is that here the desired ref, not a merge event, drives the write.
4. Roll existing indices onto the new version
Reconciling the policy document does not move indices already managed under an older version. Advance them deliberately with change_policy, in a batch you can watch, so a retention change takes effect on a schedule you control.
POST _plugins/_ism/change_policy/logs-*
{ "policy_id": "logs_lifecycle", "state": { "name": "hot" } }
{ "updated_indices": 37, "failures": false }
Gotcha:
change_policyonly re-evaluates an index at its next transition, so a shortened retention does not retroactively delete already-aged indices in one sweep — it applies going forward. Verify withexplainbefore assuming an index moved.
5. Roll back a bad version by reverting the commit
Rollback in GitOps is not a special path — it is an ordinary change that happens to restore an earlier state. Revert the bad commit, tag the revert with a new version, and let the reconciler drive the OpenSearch cluster back. Because git holds every prior document, the previous good policy is always exactly recoverable.
git revert <bad-sha> --no-edit # restores the 2.2.0 document
git tag logs_lifecycle@2.3.1 -m "rollback: revert 21d retention change"
git push origin main logs_lifecycle@2.3.1
# reconciler's next pass applies the reverted document with a conditional PUT
Gotcha: reverting the commit fixes the desired state, but indices already advanced to the bad version in Step 4 stay there until you
change_policythem back. Roll the policy back and the indices back — the two are separate actions.
Verification
Confirm three things after a reconcile or rollback: the live policy matches the tagged commit, the OpenSearch cluster reports the version you expect, and no managed index is in a failed action.
# 1. The live description carries the version git just reconciled
GET _plugins/_ism/policies/logs_lifecycle
# "description": "logs_lifecycle | version=2.3.1 | managed-by=gitops", "_seq_no": 51
# 2. No managed index is stuck after the change
GET _plugins/_ism/explain/logs-*
# "logs-000014": { "state": {"name":"warm"}, "action": {"failed": false} }
# 3. The reconciler reports convergence, not a standing conflict
# in-sync policy_id=logs_lifecycle <- steady state
A healthy result shows the live version= string equal to the latest tag, every index at "failed": false, and the reconciler logging in-sync on its next pass. If the live version keeps disagreeing with the tag between reconciles, an out-of-band editor is fighting the loop — hand that off to the deeper remediation flow in Detecting and reconciling ISM policy drift.
Common failures
| Symptom | Root cause | Fix command |
|---|---|---|
Reconciler logs conflict every cycle |
someone edits the policy in the console between reconciles | disable console write for the reconciler’s account; escalate to drift remediation |
| Rollback tag applied but cluster unchanged | reconciler pinned to an old ref, not the newest tag | point the reconciler at the latest logs_lifecycle@* tag or a moving branch |
version= in cluster never matches the tag |
version string not bumped in the committed document | embed and increment the version on every functional change |
| Indices still on the bad version after revert | policy reverted but indices not moved | POST _plugins/_ism/change_policy/<pattern> back to the good version |
| Reconciler applies to the wrong cluster | shared token or host misconfig | scope one reconciler + token per cluster per Security & Access Boundaries |
| Revert reintroduces an even older bug | blind git revert of a squashed range |
revert the precise commit; verify the resulting document is the intended prior version |
Frequently asked questions
How is this different from the push pipeline in the sibling guide?
The push model in Integrating ISM policy deployment into CI/CD pipelines applies on merge and then stops — the OpenSearch cluster can drift until the next deploy. The GitOps model runs a reconciler continuously against a tagged git ref, so the OpenSearch cluster is always being pulled back toward the committed state. Push is event-driven; GitOps is convergence-driven. Many teams use the push pipeline for the reviewed apply and add the reconciler to guarantee the state holds between merges.
Do I tag every policy separately or the whole repo?
Tag per policy (logs_lifecycle@2.3.0, metrics_lifecycle@1.4.2) when policies change on independent cadences, so one policy’s rollback never drags another’s version with it. A single repo-wide tag is simpler but couples unrelated policies — a revert of one forces a version bump on all. Per-policy tags keep rollbacks surgical.
What stops the reconciler from fighting a legitimate emergency console fix?
Nothing should — that is the point. If an operator makes an emergency console edit, the reconciler will revert it on the next pass, which is correct: the fix must be committed to git to survive. For a genuine break-glass change, pause the reconciler, apply the fix, then immediately backport it to git and resume. A change that only lives in the OpenSearch cluster is a change that will be lost.
Can I reconcile more than one cluster from one repository?
Yes — run one reconciler instance per cluster, each with a token scoped to that cluster and each pointed at the ref that cluster should track (e.g. staging follows main, prod follows the latest release tag). Keeping the ref-per-cluster mapping explicit is what lets staging validate a version before prod’s reconciler ever sees it.
Related
- Integrating ISM policy deployment into CI/CD pipelines — the push-on-merge deploy job this GitOps loop builds on.
- CI/CD Pipeline Integration — the promotion model, guardrails, and conditional-PUT contract behind both approaches.
- Detecting and reconciling ISM policy drift — remediating out-of-band edits the reconciler keeps fighting.