Automating ISM drift remediation with scheduled jobs
This walkthrough wraps the drift detector in a Kubernetes CronJob that runs on a fixed cadence, emits an alert-grade metric the moment the live ISM policy diverges from its git source of truth, and — only when a dry-run guard is explicitly lifted — auto-reconciles the policy and re-applies it to stuck indices with change_policy. It operationalizes the single-run detector from Detecting and reconciling ISM policy drift into an unattended control loop, following the model laid out in Policy Drift Detection.
Prerequisites
Confirm each of these before scheduling the job — an unset dry-run guard or a credential baked into the image is the usual reason a first deployment either does nothing or does far too much.
What the scheduled job does each cycle
Every scheduled run performs the same sequence: check each policy for drift, push a gauge that alerting watches, and then consult the dry-run guard. With the guard on (the default) it stops at alerting; with the guard lifted it reconciles the document and re-applies the current version to any index still pinned to an old policy_seq_no.
Step-by-step procedure
Build the runner that iterates policies and emits metrics, then package it as a CronJob with the dry-run guard exposed as an environment variable.
1. Iterate policies and emit a per-policy drift gauge
Wrap the detector in a loop over every policy id you manage, and record a 0/1 drift value per id. The gauge — not a log line — is what alerting keys on, so a silent job that stops running still fires a staleness alert.
import json, logging, os
from opensearchpy import OpenSearch, exceptions
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("ism.remediate")
SERVER_FIELDS = {"policy_id", "last_updated_time", "last_updated_user", "schema_version"}
APPLY = os.environ.get("DRIFT_APPLY", "false").lower() == "true"
def canonical(body: dict) -> str:
cleaned = {k: v for k, v in body.items() if k not in SERVER_FIELDS}
return json.dumps(cleaned, sort_keys=True, separators=(",", ":"))
def check_policy(client, policy_id, desired_path) -> int:
"""Return 1 if drifted, 0 if in sync; reconcile when APPLY is set."""
with open(desired_path) as fh:
desired = json.load(fh)
try:
live = client.transport.perform_request(
"GET", f"/_plugins/_ism/policies/{policy_id}")
except exceptions.NotFoundError:
logger.error("policy_missing id=%s", policy_id)
return 1
if canonical(live["policy"]) == canonical(desired["policy"]):
logger.info("in_sync id=%s", policy_id)
return 0
logger.warning("drift id=%s apply=%s", policy_id, APPLY)
if APPLY:
reconcile(client, policy_id, desired, live["_seq_no"], live["_primary_term"])
return 1
2026-07-18 02:10:03 [INFO] in_sync id=logs-hot-warm-delete
2026-07-18 02:10:03 [WARNING] drift id=metrics-rollup apply=false
Gotcha: return the drift value even after reconciling — the gauge should reflect that drift was seen this cycle, so a dashboard shows the event and the subsequent return to
0. Suppressing the gauge on the reconcile path hides the very events you deployed the job to catch.
2. Reconcile and re-apply to stuck indices
When APPLY is set, the reconcile step does two things the alert-only path never does: a version-guarded PUT to fix the document, then a change_policy for each index whose policy_seq_no trails the current version.
def reconcile(client, policy_id, desired, seq_no, primary_term) -> None:
try:
client.transport.perform_request(
"PUT", f"/_plugins/_ism/policies/{policy_id}",
params={"if_seq_no": seq_no, "if_primary_term": primary_term},
body=desired)
logger.info("reconciled id=%s from_seq_no=%s", policy_id, seq_no)
except exceptions.ConflictError:
logger.error("conflict id=%s; retry next cycle", policy_id)
return
move_stuck_indices(client, policy_id)
def move_stuck_indices(client, policy_id) -> None:
explain = client.transport.perform_request(
"GET", f"/_plugins/_ism/explain/{policy_id}-*")
current = client.transport.perform_request(
"GET", f"/_plugins/_ism/policies/{policy_id}")["_seq_no"]
for index, meta in explain.items():
if not isinstance(meta, dict) or "policy_seq_no" not in meta:
continue
if meta["policy_seq_no"] < current:
state = meta.get("state", {}).get("name")
client.transport.perform_request(
"POST", f"/_plugins/_ism/change_policy/{index}",
body={"policy_id": policy_id, "state": state})
logger.info("moved index=%s to seq_no=%s at state=%s", index, current, state)
Gotcha: pass the index’s current
stateintochange_policyso a reconcile does not rewind an index that is mid-warmback tohot. Re-applying without a state can re-run actions the index already completed. The state semantics are the ones described in Policy Drift Detection.
3. Publish the gauge to the metrics sink
Push the per-policy drift value with the policy id as a label. A max by (policy_id) alert over a short window then pages on any policy that is drifted for more than one cycle.
from prometheus_client import CollectorRegistry, Gauge, push_to_gateway
def publish(results: dict[str, int], gateway: str) -> None:
registry = CollectorRegistry()
g = Gauge("ism_policy_drift", "1 if live policy differs from git",
["policy_id"], registry=registry)
for policy_id, drifted in results.items():
g.labels(policy_id=policy_id).set(drifted)
push_to_gateway(gateway, job="ism-drift-remediation", registry=registry)
ism_policy_drift{policy_id="logs-hot-warm-delete"} 0.0
ism_policy_drift{policy_id="metrics-rollup"} 1.0
Gotcha: use
push_to_gateway(replace), notpushadd_to_gateway, so a policy that returns to sync overwrites its stale1with a0. Withpushaddan old drift value lingers in the gateway and the alert never clears.
4. Package as a CronJob with the dry-run guard
Run the job on a cadence aligned with the ISM sweep, inject credentials from a Secret, and expose the guard as DRIFT_APPLY. Keeping it "false" in the committed spec means auto-reconcile can only be turned on by an explicit, reviewable edit.
apiVersion: batch/v1
kind: CronJob
metadata:
name: ism-drift-remediation
spec:
schedule: "*/10 * * * *" # align with the 5m ISM sweep, one cycle of slack
concurrencyPolicy: Forbid # never overlap remediation cycles
jobTemplate:
spec:
backoffLimit: 1
template:
spec:
restartPolicy: Never
containers:
- name: remediate
image: registry.internal/ism-drift:2.1.0
envFrom:
- secretRef: { name: opensearch-drift-creds }
env:
- { name: OPENSEARCH_HOST, value: "https://opensearch.data.svc:9200" }
- { name: PUSHGATEWAY, value: "http://pushgateway.monitoring.svc:9091" }
- { name: DRIFT_APPLY, value: "false" } # detect + alert only; flip to enable repair
volumeMounts:
- { name: desired, mountPath: /policies, readOnly: true }
volumes:
- name: desired
configMap: { name: ism-desired-policies }
Gotcha:
concurrencyPolicy: Forbidmatters more here than for a read-only job — two overlapping reconcile cycles can each readseq_no: 42, and the secondPUTwill409against the first. Serializing cycles keeps the optimistic guard from tripping on your own job. For building and shipping this image, see CI/CD Pipeline Integration.
Verification
After the first scheduled runs, confirm the gauge reflects reality and that a flip of the guard actually repairs drift.
# 1. The gauge should show 0 for in-sync policies, 1 for drifted ones.
curl -s "http://pushgateway.monitoring.svc:9091/metrics" | grep ism_policy_drift
# ism_policy_drift{policy_id="logs-hot-warm-delete"} 0
# 2. With DRIFT_APPLY=true, a drifted policy returns to sync and stuck indices move up.
GET _plugins/_ism/explain/metrics-rollup-*
# "metrics-rollup-000004": { "policy_seq_no": 51, "state": { "name": "warm" } }
# policy_seq_no now equals the current policy _seq_no -> no longer pinned
A healthy result shows every managed policy’s gauge at 0 within one cycle of a deploy, and — after enabling the guard on a genuinely drifted policy — a policy_seq_no on each index that matches the policy’s current _seq_no. If the gauge flips between 0 and 1 every cycle, a writer outside git is fighting the reconciler; freeze DRIFT_APPLY and find the other actor first.
Common failures
| Symptom | Root cause | Fix command |
|---|---|---|
| Alert never clears after a policy is fixed | Gauge pushed with pushadd, stale 1 retained |
Switch to push_to_gateway (replace) so a 0 overwrites the old value |
Job 409s against itself every cycle |
Overlapping runs both read the same seq_no |
Set concurrencyPolicy: Forbid; ensure the prior job finished |
| Drifted policy reconciled but indices still stale | change_policy skipped or missing state |
Re-run with the per-index state from _plugins/_ism/explain |
Gauge flaps 0/1 every run |
A non-git actor keeps editing the policy | Freeze DRIFT_APPLY=false; audit last_updated_user in GET _plugins/_ism/policies/<id> |
| CronJob runs but pushes nothing | Pushgateway unreachable or wrong label set | curl the gateway from the pod; confirm PUSHGATEWAY env and network policy |
Frequently asked questions
Should the scheduled job auto-reconcile by default?
No. Ship it with DRIFT_APPLY=false so every deployment starts in detect-and-alert mode. Auto-reconcile writes to a live cluster and, on a bad desired document, can rewrite every policy in one cycle. Turn the guard off only for policies whose git source is itself gated behind review, and treat flipping it as a change worth its own pull request.
How often should the job run relative to the ISM sweep?
Run it at roughly the ISM job_interval cadence or a little slower — every 10 minutes against a 5-minute sweep is a good default. Running much faster just re-observes indices that ISM has not had a chance to advance yet, producing noisy transient “stuck” readings. One sweep of slack lets a legitimate transition settle before the detector judges it.
Why alert on a gauge instead of parsing the job's logs?
A gauge tells you two things logs cannot cheaply express: which policies are currently drifted, and whether the job ran at all. A max by (policy_id) (ism_policy_drift) alert fires on real drift, and an absent()-style alert on the same metric fires when the CronJob silently stops scheduling. Log scraping catches neither the “job died” case nor the clean per-policy dimensionality.
Related
- Detecting and reconciling ISM policy drift — the single-run detector this job schedules and wraps.
- Policy Drift Detection — the control loop, guardrails, and state semantics behind auto-reconcile.
- CI/CD Pipeline Integration — building the job image and mounting the same desired policies your pipeline deploys.