From 1157448d59a3bfe296618f861942dff22c27c24a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 14:03:58 +0200 Subject: [PATCH] fix(garage): make bootstrap & provision idempotent across reruns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * bootstrap: `garage layout show` truncates node IDs to 16 chars, but the membership check compared against the full hex. After the first successful join, subsequent runs no longer found the short ID in `layout show` and re-issued `layout assign`, marking the task changed every time. Compare against both the truncated and the full form so a configured node stays detected. Also tag the read-only `garage node id` / `layout show` probes with `changed_when: false`. * provision keys: the old parser sliced `stdout_lines[1:]` to drop the header but missed that INFO log lines and ANSI escapes can interleave with table rows. Replace with an explicit `^GK[0-9a-fA-F]+` filter after stripping ANSI, so probe-output noise no longer corrupts the existing-keys set and triggers spurious `key new` calls. * provision buckets: same class of fix — match `^[0-9a-f]{16}\s` data rows instead of slicing `[2:]`, which broke when the table header wasn't exactly two lines. * provision permissions: pre-read `bucket info` for each (key, bucket) pair and only run `bucket allow` when the current `RWO` flag set for that key ID doesn't already match the desired permissions. Previously `bucket allow` ran unconditionally and reported changed every play. * `changed_when: false` on all read-only probes (`key list`, `key info`, `bucket list`). --- roles/garage/tasks/bootstrap.yml | 8 ++++- roles/garage/tasks/provision.yml | 53 +++++++++++++++++++++++++++++--- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/roles/garage/tasks/bootstrap.yml b/roles/garage/tasks/bootstrap.yml index 6cab2cf..5dc7e6e 100644 --- a/roles/garage/tasks/bootstrap.yml +++ b/roles/garage/tasks/bootstrap.yml @@ -7,21 +7,27 @@ container: "{{ garage_service_name }}" command: /garage node id -q register: _garage_node_id + changed_when: false - name: Extract short node ID ansible.builtin.set_fact: _garage_node_id_short: "{{ _garage_node_id.stdout.split('@')[0] }}" +- name: Extract truncated node ID (first 16 chars, matches `garage layout show` output) + ansible.builtin.set_fact: + _garage_node_id_truncated: "{{ _garage_node_id_short[:16] }}" + - name: Check if node layout is configured community.docker.docker_container_exec: container: "{{ garage_service_name }}" command: /garage layout show register: _garage_layout_show failed_when: false + changed_when: false - name: Check if node is in layout ansible.builtin.set_fact: - _node_in_layout: "{{ _garage_node_id_short in _garage_layout_show.stdout }}" + _node_in_layout: "{{ (_garage_node_id_truncated in _garage_layout_show.stdout) or (_garage_node_id_short in _garage_layout_show.stdout) }}" - name: Configure garage node layout community.docker.docker_container_exec: diff --git a/roles/garage/tasks/provision.yml b/roles/garage/tasks/provision.yml index 1c2628e..dacf2c0 100644 --- a/roles/garage/tasks/provision.yml +++ b/roles/garage/tasks/provision.yml @@ -4,11 +4,17 @@ container: "{{ garage_service_name }}" command: /garage key list register: _existing_keys_output + changed_when: false when: garage_s3_keys | length > 0 - name: Parse existing key names ansible.builtin.set_fact: - _existing_keys: "{{ _existing_keys_output.stdout_lines[1:] | select('match', '^GK') | map('regex_replace', '^\\S+\\s+\\S+\\s+(\\S+)\\s+.*$', '\\1') | list }}" + # `garage key list` columns: ID Created Name Expiration. + # Data rows begin with a GK key ID; header is "ID Created ..." + # and INFO log lines may interleave on stderr (kept separate by + # docker_container_exec). Strip ANSI escapes defensively, filter to + # GK-prefixed rows, then take the 3rd whitespace-separated field. + _existing_keys: "{{ _existing_keys_output.stdout_lines | map('regex_replace', '\\x1b\\[[0-9;]*m', '') | select('match', '^GK[0-9a-fA-F]+') | map('regex_replace', '^\\S+\\s+\\S+\\s+(\\S+).*$', '\\1') | list }}" when: garage_s3_keys | length > 0 - name: Create S3 keys @@ -27,6 +33,7 @@ command: /garage key info {{ item.name }} loop: "{{ garage_s3_keys }}" register: _key_info_results + changed_when: false when: garage_s3_keys | length > 0 - name: Extract key IDs from info @@ -42,11 +49,21 @@ container: "{{ garage_service_name }}" command: /garage bucket list register: _existing_buckets_output + changed_when: false when: garage_s3_keys | length > 0 - name: Parse existing bucket names ansible.builtin.set_fact: - _existing_buckets: "{{ _existing_buckets_output.stdout_lines[2:] | map('split') | map('first') | list }}" + # `garage bucket list` columns: ID Created Global aliases Local aliases + # Data rows start with a hex bucket ID; filter to those and take the + # third whitespace-separated field (the global alias = bucket name). + _existing_buckets: >- + {{ + _existing_buckets_output.stdout_lines + | select('match', '^[0-9a-f]{16}\\s') + | map('regex_replace', '^\\S+\\s+\\S+\\s+(\\S+).*$', '\\1') + | list + }} when: garage_s3_keys | length > 0 - name: Get unique bucket names @@ -64,12 +81,37 @@ - item not in _existing_buckets failed_when: false +- name: Get current bucket permissions + community.docker.docker_container_exec: + container: "{{ garage_service_name }}" + command: /garage bucket info {{ item.1.name }} + loop: "{{ garage_s3_keys | subelements('buckets', skip_missing=True) }}" + loop_control: + label: "{{ item.1.name }}" + register: _bucket_info_results + changed_when: false + when: garage_s3_keys | length > 0 + - name: Set bucket permissions using key IDs community.docker.docker_container_exec: container: "{{ garage_service_name }}" - command: /garage bucket allow {{ item.1.name }} {% for perm in item.1.permissions %}--{{ perm }} {% endfor %}--key {{ _key_id_map[item.0.name] }} - loop: "{{ garage_s3_keys | subelements('buckets', skip_missing=True) }}" - when: garage_s3_keys | length > 0 + command: /garage bucket allow {{ item.item.1.name }} {% for perm in item.item.1.permissions %}--{{ perm }} {% endfor %}--key {{ _key_id_map[item.item.0.name] }} + loop: "{{ _bucket_info_results.results }}" + loop_control: + label: "{{ item.item.1.name }} -> {{ item.item.0.name }}" + when: + - garage_s3_keys | length > 0 + - >- + (item.stdout | regex_search( + '(?m)^\s*' ~ _wanted_flags ~ '\s+' ~ _key_id_map[item.item.0.name] + )) is none + vars: + _wanted_flags: >- + {{ + ('R' if 'read' in item.item.1.permissions else '-') + ~ ('W' if 'write' in item.item.1.permissions else '-') + ~ ('O' if 'owner' in item.item.1.permissions else '-') + }} # Export key credentials for use by other roles - name: Get detailed key information for all keys @@ -78,6 +120,7 @@ command: /garage key info {{ item.name }} --show-secret loop: "{{ garage_s3_keys }}" register: _key_details_results + changed_when: false when: garage_s3_keys | length > 0 - name: Build garage S3 credentials map