Frog Wizard

Sign AWS OpenSearch requests from a custom Ansible module

The AWS Ansible collections manage the OpenSearch domain, not what runs inside it, and a domain with IAM auth rejects unsigned requests to its REST API. botocore already ships the SigV4 signer, so a custom module that signs its own requests is short. ISM policies are the payload here, but the same wrapper works for any OpenSearch API call.

#!/usr/bin/python
import json

import requests
from ansible.module_utils.basic import AnsibleModule
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
from botocore.session import Session


def signed(method, url, region, body=None):
    request = AWSRequest(method=method, url=url, data=body,
                         headers={"Content-Type": "application/json"})
    SigV4Auth(Session().get_credentials(), "es", region).add_auth(request)
    return requests.request(method, url, headers=dict(request.headers),
                            data=body, timeout=30)


def main():
    module = AnsibleModule(
        argument_spec=dict(
            endpoint=dict(required=True),
            region=dict(required=True),
            name=dict(required=True),
            policy=dict(type="dict", required=True),
        ),
        supports_check_mode=True,
    )
    p = module.params
    url = f"https://{p['endpoint']}/_plugins/_ism/policies/{p['name']}"

    current = signed("GET", url, p["region"])
    if current.status_code == 200:
        doc = current.json()
        desired = p["policy"]["policy"]
        live = {k: v for k, v in doc["policy"].items() if k in desired}
        if live == desired:
            module.exit_json(changed=False)
        url += f"?if_seq_no={doc['_seq_no']}&if_primary_term={doc['_primary_term']}"

    if module.check_mode:
        module.exit_json(changed=True)
    resp = signed("PUT", url, p["region"], json.dumps(p["policy"]))
    if resp.status_code not in (200, 201):
        module.fail_json(msg=resp.text)
    module.exit_json(changed=True)


if __name__ == "__main__":
    main()

Two quirks. Updating an existing policy requires the current _seq_no and _primary_term as query params or OpenSearch rejects the PUT. And the GET response includes server-added fields like policy_id and last_updated_time, so compare only the keys you supplied or the module reports changed on every run.

- name: delete logs after 30 days
  ism_policy:
    endpoint: search-logs-abc123.us-west-2.es.amazonaws.com
    region: us-west-2
    name: delete-after-30d
    policy:
      policy:
        description: delete indices after 30 days
        default_state: hot
        ism_template:
          - index_patterns: ["logs-*"]
        states:
          - name: hot
            actions: []
            transitions:
              - state_name: delete
                conditions:
                  min_index_age: 30d
          - name: delete
            actions:
              - delete: {}

The module runs wherever the play targets, so credentials come from the usual boto chain, an instance profile, assumed role, or environment. Nothing OpenSearch-specific in the signing, service="es" is the only tell.

#ansible #aws #opensearch