From 3e32563878a18c44b3830969fb8cd8dcd1015b25 Mon Sep 17 00:00:00 2001 From: Sam Crauwels Date: Mon, 9 Mar 2026 12:31:56 +0100 Subject: [PATCH] Add idempotent cluster settings management via elasticsearch_cluster_settings Adds a new elasticsearch_cluster_settings dictionary variable that applies persistent cluster settings via the PUT _cluster/settings API after the cluster is healthy. Reads current settings first and only sends the PUT when values differ. Runs once per play on the CA host. This provides a general mechanism for any cluster-level setting, replacing the need for one-off variables for individual settings. LogsDB, watermarks, recovery throttling, and any other cluster setting can now be expressed as a dict entry. Closes #21 --- docs/roles/elasticsearch.md | 18 ++++++++ molecule/elasticsearch_default/converge.yml | 2 + molecule/elasticsearch_default/verify.yml | 20 ++++++++ roles/elasticsearch/defaults/main.yml | 10 ++++ roles/elasticsearch/tasks/main.yml | 51 +++++++++++++++++++++ 5 files changed, 101 insertions(+) diff --git a/docs/roles/elasticsearch.md b/docs/roles/elasticsearch.md index b759c2ab..d2317f5b 100644 --- a/docs/roles/elasticsearch.md +++ b/docs/roles/elasticsearch.md @@ -220,6 +220,24 @@ elasticsearch_recovery_max_bytes_per_sec: "" `elasticsearch_recovery_max_bytes_per_sec` throttles shard recovery bandwidth (e.g., `"100mb"`). When empty (the default), Elasticsearch uses its internal default (40 MB/s). Increase this on fast networks to speed up recovery after node restarts, or decrease it on shared networks to prevent saturation. +### Persistent Cluster Settings + +```yaml +elasticsearch_cluster_settings: {} +``` + +`elasticsearch_cluster_settings` applies persistent cluster settings via the `PUT _cluster/settings` API after the cluster is healthy. Unlike `elasticsearch_extra_config` (which writes to `elasticsearch.yml` and requires a restart), cluster settings take effect immediately at runtime and apply cluster-wide. The task reads current settings first and only sends the PUT when values differ. + +```yaml +elasticsearch_cluster_settings: + cluster.logsdb.enabled: true + indices.recovery.max_bytes_per_sec: "100mb" + cluster.routing.allocation.disk.watermark.low: "90%" + cluster.routing.allocation.disk.watermark.high: "95%" +``` + +Any setting supported by the [cluster settings API](https://www.elastic.co/docs/reference/elasticsearch/rest-api/cluster/update-cluster-settings) can be used. Values can be strings, numbers, booleans, or nested objects — the YAML dict is serialized to JSON directly. + ### Temperature Attribute ```yaml diff --git a/molecule/elasticsearch_default/converge.yml b/molecule/elasticsearch_default/converge.yml index d2d74abf..ee6dce45 100644 --- a/molecule/elasticsearch_default/converge.yml +++ b/molecule/elasticsearch_default/converge.yml @@ -9,6 +9,8 @@ elasticstack_release: "{{ lookup('env', 'ELASTIC_RELEASE') | default('9', true) | int }}" elasticsearch_heap: "1" elasticstack_no_log: false + elasticsearch_cluster_settings: + action.destructive_requires_name: "true" tasks: - name: Include Elastics repos role ansible.builtin.include_role: diff --git a/molecule/elasticsearch_default/verify.yml b/molecule/elasticsearch_default/verify.yml index 66ac551a..9cd684ee 100644 --- a/molecule/elasticsearch_default/verify.yml +++ b/molecule/elasticsearch_default/verify.yml @@ -16,3 +16,23 @@ - health.json.number_of_nodes == groups['elasticsearch'] | length fail_msg: "Expected {{ groups['elasticsearch'] | length }} nodes, got {{ health.json.number_of_nodes }}" run_once: true # noqa: run-once[task] + + - name: Read cluster settings # noqa: run-once[task] + ansible.builtin.uri: + url: "https://localhost:9200/_cluster/settings?flat_settings=true" + method: GET + user: elastic + password: "{{ elastic_pass.stdout }}" + force_basic_auth: true + validate_certs: false + register: cluster_settings + run_once: true + + - name: Verify cluster settings were applied # noqa: run-once[task] + ansible.builtin.assert: + that: + - cluster_settings.json.persistent['action.destructive_requires_name'] == 'true' + fail_msg: >- + elasticsearch_cluster_settings not applied. + Got: {{ cluster_settings.json.persistent }} + run_once: true diff --git a/roles/elasticsearch/defaults/main.yml b/roles/elasticsearch/defaults/main.yml index b753e575..75fcd50b 100644 --- a/roles/elasticsearch/defaults/main.yml +++ b/roles/elasticsearch/defaults/main.yml @@ -93,6 +93,16 @@ elasticsearch_http_cors_allow_headers: "X-Requested-With, Content-Type, Content- # @var elasticsearch_http_cors_allow_credentials:description: Whether to send CORS credentials (cookies, auth headers) elasticsearch_http_cors_allow_credentials: false +# @var elasticsearch_cluster_settings:description: > +# Persistent cluster settings applied via PUT _cluster/settings after the +# cluster is healthy. Accepts any setting the cluster settings API supports. +# Only applied when non-empty. Runs once per play on the CA host. +# Example: +# elasticsearch_cluster_settings: +# cluster.logsdb.enabled: true +# indices.recovery.max_bytes_per_sec: "100mb" +elasticsearch_cluster_settings: {} + # @var elasticsearch_initialized_file:description: Marker file path that indicates the cluster has been initialized elasticsearch_initialized_file: "{{ elasticstack_initial_passwords | dirname }}/cluster_initialized" # @var elasticsearch_tls_key_passphrase:description: Passphrase for the Elasticsearch node TLS private key diff --git a/roles/elasticsearch/tasks/main.yml b/roles/elasticsearch/tasks/main.yml index f0a5a21c..46416a76 100644 --- a/roles/elasticsearch/tasks/main.yml +++ b/roles/elasticsearch/tasks/main.yml @@ -582,3 +582,54 @@ when: - elasticsearch_security | bool - inventory_hostname == elasticstack_ca_host + +# -- Persistent cluster settings via _cluster/settings API -- + +- name: Apply persistent cluster settings # noqa: run-once[task] + when: + - elasticsearch_cluster_settings | default({}) | length > 0 + - not ansible_check_mode + run_once: true + delegate_to: "{{ elasticstack_ca_host | default(inventory_hostname) }}" + block: + - name: Read current persistent cluster settings + ansible.builtin.uri: + url: "{{ elasticsearch_http_protocol }}://{{ elasticsearch_api_host }}:{{ elasticstack_elasticsearch_http_port }}/_cluster/settings?flat_settings=true" + method: GET + user: "{{ 'elastic' if elasticsearch_security | bool else omit }}" + password: "{{ elasticstack_password.stdout if elasticsearch_security | bool else omit }}" + force_basic_auth: "{{ elasticsearch_security | bool }}" + validate_certs: "{{ elasticsearch_validate_api_certs }}" + return_content: true + register: _es_current_cluster_settings + no_log: "{{ elasticstack_no_log }}" + + - name: Check if settings already match + ansible.builtin.set_fact: + _es_cluster_settings_changed: "{{ _needs_update }}" + vars: + _current: "{{ _es_current_cluster_settings.json.persistent }}" + _needs_update: >- + {% set ns = namespace(changed=false) %} + {% for key, value in elasticsearch_cluster_settings.items() %} + {% if _current.get(key) is none or _current[key] | string != value | string %} + {% set ns.changed = true %} + {% endif %} + {% endfor %} + {{ ns.changed }} + + - name: Apply cluster settings + ansible.builtin.uri: + url: "{{ elasticsearch_http_protocol }}://{{ elasticsearch_api_host }}:{{ elasticstack_elasticsearch_http_port }}/_cluster/settings" + method: PUT + body_format: json + body: + persistent: "{{ elasticsearch_cluster_settings }}" + user: "{{ 'elastic' if elasticsearch_security | bool else omit }}" + password: "{{ elasticstack_password.stdout if elasticsearch_security | bool else omit }}" + force_basic_auth: "{{ elasticsearch_security | bool }}" + validate_certs: "{{ elasticsearch_validate_api_certs }}" + status_code: 200 + no_log: "{{ elasticstack_no_log }}" + when: _es_cluster_settings_changed | bool + changed_when: true