Integrating ISM policy deployment into CI/CD pipelines

This walkthrough builds one concrete GitLab CI pipeline job that validates an OpenSearch ISM policy, computes a plan/diff against the target cluster, applies it with an idempotent conditional PUT, and gates the merge on a post-deploy verification that reads the policy back. It is the hands-on job behind the promotion model in CI/CD Pipeline Integration, aimed at teams who already keep policies in git but still apply them by hand and want the deploy to become a reviewable, replayable pipeline stage instead.

Prerequisites

Confirm each of these before wiring the job — a missing scoped token or an un-stripped _seq_no in the committed file is the usual reason the first pipeline run either fails to authenticate or reports an endless spurious diff.

How the job flows

The pipeline is three sequential stages with one hard gate. Validation runs with no cluster access; the diff runs read-only and prints the change set into the merge request; apply runs only after the diff is approved, guards its write with the concurrency tokens read during the diff, and immediately verifies. A failure anywhere before apply means the OpenSearch cluster is never touched.

GitLab CI ISM deployment job flow Four stages left to right. Validate has no cluster access. Diff fetches the live policy with a read-only token, computes the change set, and posts it to the merge request. A manual approval gate sits between diff and apply. Apply issues a conditional PUT guarded by if_seq_no and if_primary_term captured during diff, then verify reads the policy back with explain. A dashed fail edge from validate and diff routes to a no-write stop node. validate no cluster access diff read-only · to MR approval manual gate apply conditional PUT verify explain read-back tokens carried diff → apply fail → stop, cluster untouched

Step-by-step procedure

The four stages map one-to-one to jobs in a single .gitlab-ci.yml. Build them in order; each stage assumes the previous one passed.

1. Validate the policy with no cluster access

The first job proves the committed file is a structurally valid ISM policy before any credential is loaded. It parses the JSON (a lint failure raises here) and checks that default_state names a real state and every transition targets a defined state.

YAML
stages: [validate, diff, apply]

validate:
  stage: validate
  image: python:3.11-slim
  script:
    - python ci/validate_policy.py policies/logs_lifecycle.json
  rules:
    - changes: [policies/**/*]
Text
$ python ci/validate_policy.py policies/logs_lifecycle.json
policy 'logs_lifecycle' OK — 3 states, default_state=hot, 2 transitions resolved

Gotcha: run validation on a slim image with no network egress at all. If this stage can reach the OpenSearch cluster, a contributor’s typo can eventually reach it too — keep the read/write tokens out of the job entirely.

2. Diff the desired policy against the live cluster

The diff job fetches the currently attached policy read-only, strips the OpenSearch cluster-owned concurrency metadata, and compares the remaining policy object to the committed file. It writes the diff into the merge request and stashes the _seq_no/_primary_term as a job artifact so the apply job can reuse them.

YAML
diff:
  stage: diff
  image: python:3.11-slim
  before_script: [pip install --quiet opensearch-py]
  script:
    - python ci/deploy.py --plan --emit-tokens tokens.env policies/logs_lifecycle.json
  artifacts:
    reports: { dotenv: tokens.env }     # carries IF_SEQ_NO / IF_PRIMARY_TERM forward
  variables:
    OS_HOST: $OS_PROD_HOST
    OS_TOKEN: $OS_READ_TOKEN            # read-only
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
Text
$ python ci/deploy.py --plan ...
plan logs_lifecycle: UPDATE (live _seq_no=42 _primary_term=6)
  states.warm.transitions[0].conditions.min_index_age: "30d" -> "21d"
1 change — review required before apply

Gotcha: strip _id, _seq_no, _primary_term, and _version from the fetched document before diffing. Leave any of them in and the diff is never empty, because those tokens change on every write — you would review the same phantom change forever. This is the single most common reason a “working” pipeline reports noise; the plan/diff contract is spelled out in the parent CI/CD Pipeline Integration guide.

3. Apply with a conditional PUT behind a manual gate

The apply job is when: manual so it will not run until a reviewer clicks it, and it only exists on the default branch. It consumes the tokens from the diff artifact and passes them as if_seq_no/if_primary_term, so if the live policy changed since the diff, the write is rejected rather than clobbering the newer state.

YAML
apply:
  stage: apply
  image: python:3.11-slim
  before_script: [pip install --quiet opensearch-py]
  script:
    - python ci/deploy.py --apply --verify
        --if-seq-no "$IF_SEQ_NO" --if-primary-term "$IF_PRIMARY_TERM"
        policies/logs_lifecycle.json
  environment: { name: production }     # protected env injects the write token
  variables:
    OS_HOST: $OS_PROD_HOST
    OS_TOKEN: $OS_WRITE_TOKEN
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
      when: manual                       # human approval gate

The deploy.py core is a single conditional request. On update it sends the tokens; a mismatch returns 409 and the job fails loudly:

Python
def apply(client, policy_id, desired, seq_no, primary_term):
    params = {"if_seq_no": seq_no, "if_primary_term": primary_term} if seq_no else {}
    try:
        client.transport.perform_request(
            "PUT", f"/_plugins/_ism/policies/{policy_id}", params=params, body=desired)
        logging.info("applied policy_id=%s guarded=%s", policy_id, bool(seq_no))
    except exceptions.ConflictError:
        logging.error("stale tokens for %s — re-run diff and retry", policy_id)
        raise SystemExit(1)

Gotcha: the 409 is the guardrail doing its job. Do not “fix” it by dropping the tokens — that reintroduces the clobber it prevents. Re-run the diff to read fresh tokens, review the new change set, then re-apply.

4. Verify inside the same job and fail closed

Verification runs in the apply job immediately after the PUT, so an apply that attaches a broken policy turns red before the pipeline reports success. It reads GET _plugins/_ism/explain/<pattern> and fails the job if any managed index reports action.failed.

Python
explain = client.transport.perform_request("GET", "/_plugins/_ism/explain/logs-*")
failed = [i for i, s in explain.items()
          if isinstance(s, dict) and s.get("action", {}).get("failed")]
if failed:
    raise SystemExit(f"verify failed for {failed}")
logging.info("verify-ok managed=%d", len(explain))

Verification

After the first gated apply, confirm three things: the policy version advanced, the intended indices are managed with no failed action, and — if you moved existing indices — they landed on the new version.

Shell
# 1. The stored policy reflects the committed change and a bumped _seq_no
GET _plugins/_ism/policies/logs_lifecycle
# "_seq_no": 43, "_primary_term": 6, "policy": { ... "min_index_age": "21d" ... }
Shell
# 2. Managed indices show no failed action
GET _plugins/_ism/explain/logs-*
# "logs-000012": { "state": {"name":"hot"}, "action": {"failed": false} }
Shell
# 3. If you rolled existing indices forward, they run the new policy version
POST _plugins/_ism/change_policy/logs-000009
# { "updated_indices": 1, "failures": false }

A healthy result shows the _seq_no incremented by exactly one per apply, every managed index at "failed": false, and no index still pinned to the pre-change policy version. A pipeline that keeps drifting back should hand off to the continuous audit loop in Policy Drift Detection, which catches out-of-band edits between deploys.

Common failures

Symptom Root cause Fix command
Diff never empties, same change every run committed file still carries _seq_no/_primary_term strip cluster metadata from the repo file; diff only the policy object
Apply fails 409 Conflict live policy changed after the diff read its tokens re-run the diff job, review, then re-run apply with fresh tokens
401/403 in the diff job read token unscoped or expired mint a scoped read token per Security & Access Boundaries
Apply green but indices unchanged existing indices pinned to old policy version POST _plugins/_ism/change_policy/<index> in a watched batch
Verify fails on action.failed applied policy references an invalid action target read action.cause in explain, fix the repo, redeploy; POST _plugins/_ism/retry/<index>
Job applies to prod without approval when: manual missing or branch rule too broad gate apply on main + when: manual + a protected environment

Frequently asked questions

GitHub Actions or GitLab CI — does the approach change?

The mechanics are identical; only the syntax differs. In GitLab CI a protected environment plus when: manual provides the approval gate and injects the write token; in GitHub Actions an environment: with a required reviewer does the same. Both carry the diff’s concurrency tokens forward as job artifacts or outputs. The parent CI/CD Pipeline Integration guide shows the GitHub Actions form of the same three stages.

Why put verify in the apply job instead of a separate stage?

Because a policy that attaches but immediately errors should fail the job that applied it, not a later one. Running explain inside the apply job means a red build maps exactly to the deploy that caused it, and the write token is already loaded. A separate verify stage would report the failure one job late and blur which apply introduced it.

Can I skip the diff stage and just apply on every merge?

You can, but you lose the interlock that makes the deploy safe. The diff is what surfaces a shortened retention window or an added delete action to a reviewer before it runs, and it is what reads the concurrency tokens the conditional apply needs. Skipping it means applying blind and unconditionally — the exact console-editing habit the pipeline exists to replace.

How do I roll back a bad apply?

Revert the commit and let the pipeline redeploy the previous document — the last-known-good policies/logs_lifecycle.json is one conditional PUT away. Because git holds every version, rollback is just deploying an earlier commit; the full revert-and-reconcile flow is covered in Versioning ISM policies with a GitOps workflow.

Up: CI/CD Pipeline Integration