etcd3: re-enable and fix tests, add unit tests (#11678)

* etcd3: re-enable and fix tests, add unit tests

- Add unit tests for community.general.etcd3 module (12 tests covering
  state=present/absent, idempotency, check mode, and error paths)
- Fix integration test setup: update etcd binary to v3.6.9 (from v3.2.14),
  download from GitHub releases, add health-check retry loop after start
- Work around etcd3 Python library incompatibility with protobuf >= 4.x
  by setting PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python
- Update to FQCNs throughout integration tests
- Re-enable both etcd3 and lookup_etcd3 integration targets

Fixes https://github.com/ansible-collections/community.general/issues/322

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* improve use of multiple context managers

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alexei Znamensky
2026-03-25 15:55:16 +13:00
committed by GitHub
parent cc59f7ebeb
commit d06c83eb68
11 changed files with 347 additions and 184 deletions

View File

@@ -7,4 +7,3 @@ destructive
skip/osx
skip/macos
skip/freebsd
disabled # see https://github.com/ansible-collections/community.general/issues/322

View File

@@ -5,14 +5,9 @@
####################################################################
# test code for the etcd3 module
# Copyright (c) 2017, Jean-Philippe Evrard <jean-philippe@evrard.me>
# Copyright (c) 2017, Jean-Philippe Evrard <jean-philippe@evrard.me>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# ============================================================
- name: run_tests for supported distros
- name: Run tests
include_tasks: run_tests.yml
when:
- ansible_facts.distribution | lower ~ "-" ~ ansible_facts.distribution_major_version | lower != 'centos-6'

View File

@@ -1,81 +1,83 @@
---
# test code for the etcd3 module
# Copyright (c) 2017, Jean-Philippe Evrard <jean-philippe@evrard.me>
# Copyright 2020, SCC France, Eric Belhomme <ebelhomme@fr.scc.com>
# Copyright (c) 2017, Jean-Philippe Evrard <jean-philippe@evrard.me>
# Copyright (c) 2020, SCC France, Eric Belhomme <ebelhomme@fr.scc.com>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# ============================================================
# The etcd3 Python library uses protobuf-generated code that is incompatible
# with protobuf >= 4.x unless the pure-Python implementation is selected.
- environment:
PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION: python
block:
- name: Check mode, show need change
community.general.etcd3:
key: "foo"
value: "bar"
state: "present"
register: _etcd3_prst_chktst
check_mode: true
# Integration tests
- name: Check mode, show need change
etcd3:
key: "foo"
value: "bar"
state: "present"
register: _etcd3_prst_chktst
check_mode: true
- name: Change to new value
community.general.etcd3:
key: "foo"
value: "bar"
state: "present"
register: _etcd3_prst_chgtst
- name: Change to new value
etcd3:
key: "foo"
value: "bar"
state: "present"
register: _etcd3_prst_chgtst
- name: Idempotency test, show unchanged
community.general.etcd3:
key: "foo"
value: "bar"
state: "present"
register: _etcd3_prst_idmptnttst
- name: Idempotency test, show unchanged.
etcd3:
key: "foo"
value: "bar"
state: "present"
register: _etcd3_prst_idmptnttst
- name: Idempotency test in check mode, show unchanged
community.general.etcd3:
key: "foo"
value: "bar"
state: "present"
register: _etcd3_prst_idmptntchktst
check_mode: true
- name: Idempotency test in check mode, show unchanged
etcd3:
key: "foo"
value: "bar"
state: "present"
register: _etcd3_prst_idmptntchktst
check_mode: true
- name: Check mode, show need removal of key
community.general.etcd3:
key: "foo"
value: "baz"
state: "absent"
register: _etcd3_absnt_chktst
check_mode: true
- name: Check mode, show need removal of key
etcd3:
key: "foo"
value: "baz"
state: "absent"
register: _etcd3_absnt_chktst
check_mode: true
- name: Remove foo key
community.general.etcd3:
key: "foo"
value: "baz"
state: "absent"
register: _etcd3_absnt_chgtst
- name: Remove foo key
etcd3:
key: "foo"
value: "baz"
state: "absent"
register: _etcd3_absnt_chgtst
- name: Idempotency test in check mode, show unchanged
community.general.etcd3:
key: "foo"
value: "baz"
state: "absent"
register: _etcd3_absnt_idmptnttst
check_mode: true
- name: Idempotency test in check mode, show unchanged
etcd3:
key: "foo"
value: "baz"
state: "absent"
register: _etcd3_absnt_idmptnttst
check_mode: true
- name: Idempotency test, show unchanged
community.general.etcd3:
key: "foo"
value: "baz"
state: "absent"
register: _etcd3_absnt_idmptntchktst
- name: Idempotency test, show unchanged
etcd3:
key: "foo"
value: "baz"
state: "absent"
register: _etcd3_absnt_idmptntchktst
- name: Checking the status are expected
assert:
that:
- _etcd3_prst_chktst is changed
- _etcd3_prst_chgtst is changed
- _etcd3_prst_idmptnttst is not changed
- _etcd3_prst_idmptntchktst is not changed
- _etcd3_absnt_chktst is changed
- _etcd3_absnt_chgtst is changed
- _etcd3_absnt_idmptnttst is not changed
- _etcd3_absnt_idmptntchktst is not changed
- name: Check that statuses are as expected
ansible.builtin.assert:
that:
- _etcd3_prst_chktst is changed
- _etcd3_prst_chgtst is changed
- _etcd3_prst_idmptnttst is not changed
- _etcd3_prst_idmptntchktst is not changed
- _etcd3_absnt_chktst is changed
- _etcd3_absnt_chgtst is changed
- _etcd3_absnt_idmptnttst is not changed
- _etcd3_absnt_idmptntchktst is not changed

View File

@@ -4,9 +4,7 @@
azp/posix/1
destructive
needs/file/tests/utils/constraints.txt
needs/target/setup_etcd3
skip/osx
skip/macos
skip/freebsd
disabled # see https://github.com/ansible-collections/community.general/issues/322

View File

@@ -4,6 +4,11 @@
# SPDX-License-Identifier: GPL-3.0-or-later
set -eux
# The etcd3 Python library uses protobuf-generated code that is incompatible
# with protobuf >= 4.x unless the pure-Python implementation is selected.
# This must be set in the controller process so that lookup plugins are affected.
export PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python
ANSIBLE_ROLES_PATH=../ \
ansible-playbook dependencies.yml -v "$@"

View File

@@ -5,12 +5,12 @@
####################################################################
# lookup_etcd3 integration tests
# Copyright 2020, SCC France, Eric Belhomme <ebelhomme@fr.scc.com>
# Copyright (c) 2020, SCC France, Eric Belhomme <ebelhomme@fr.scc.com>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
- name: put key/values with an etcd prefix
etcd3:
- name: Put key/values with an etcd prefix
community.general.etcd3:
key: "{{ etcd3_prefix }}foo{{ item }}"
value: "bar{{ item }}"
state: present
@@ -19,10 +19,11 @@
- 2
- 3
- name: put a single key/values in etcd
etcd3:
- name: Put a single key/value in etcd
community.general.etcd3:
key: "{{ etcd3_singlekey }}"
value: "foobar"
state: present
- import_tasks: tests.yml
- name: Import lookup tests
ansible.builtin.import_tasks: tests.yml

View File

@@ -1,16 +1,12 @@
---
# setup etcd3 for integration tests on module/lookup
# (c) 2017, Jean-Philippe Evrard <jean-philippe@evrard.me>
# 2020, SCC France, Eric Belhomme <ebelhomme@fr.scc.com>
#
# Copyright (c) 2017, Jean-Philippe Evrard <jean-philippe@evrard.me>
# Copyright (c) 2020, SCC France, Eric Belhomme <ebelhomme@fr.scc.com>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# # Copyright (c) 2018, Ansible Project
#
etcd3_ver: "v3.2.14"
etcd3_download_server: "https://storage.googleapis.com/etcd"
# etcd3_download_server: "https://github.com/coreos/etcd/releases/download"
etcd3_download_url: "{{ etcd3_download_server }}/{{ etcd3_ver }}/etcd-{{ etcd3_ver }}-linux-amd64.tar.gz"
etcd3_ver: "v3.6.9"
etcd3_download_url: "https://github.com/etcd-io/etcd/releases/download/{{ etcd3_ver }}/etcd-{{ etcd3_ver }}-linux-amd64.tar.gz"
etcd3_download_location: /tmp/etcd-download-test
etcd3_path: "{{ etcd3_download_location }}/etcd-{{ etcd3_ver }}-linux-amd64"

View File

@@ -5,89 +5,49 @@
####################################################################
# setup etcd3 for integration tests on module/lookup
# Copyright 2017, Jean-Philippe Evrard <jean-philippe@evrard.me>
# Copyright 2020, SCC France, Eric Belhomme <ebelhomme@fr.scc.com>
# Copyright (c) 2017, Jean-Philippe Evrard <jean-philippe@evrard.me>
# Copyright (c) 2020, SCC France, Eric Belhomme <ebelhomme@fr.scc.com>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# ============================================================
- name: Install etcd3 Python library
ansible.builtin.pip:
name: "{{ etcd3_pip_module }}"
state: present
# setup etcd3 for supported distros
- block:
- name: Check if etcdctl is already usable
ansible.builtin.command: "{{ etcd3_path }}/etcdctl --endpoints=localhost:2379 endpoint health"
changed_when: false
failed_when: false
register: etcd3_health_check
- include_vars: '{{ item }}'
with_first_found:
- files:
- '{{ ansible_facts.distribution }}-{{ ansible_facts.distribution_major_version }}.yml'
- '{{ ansible_facts.distribution }}-{{ ansible_facts.distribution_version }}.yml'
- '{{ ansible_facts.os_family }}-{{ ansible_facts.distribution_major_version }}.yml'
- '{{ ansible_facts.os_family }}.yml'
- 'default.yml'
paths: '../vars'
- name: Set up etcd3 binary
when: etcd3_health_check.rc != 0
block:
- name: Ensure clean download directory
ansible.builtin.file:
path: "{{ etcd3_download_location }}"
state: absent
- name: Upgrade setuptools python2 module
pip:
name: setuptools<45
extra_args: --upgrade
state: present
when: python_suffix == ''
- name: Create download directory
ansible.builtin.file:
path: "{{ etcd3_download_location }}"
state: directory
- name: Install etcd3 python modules
pip:
name: "{{ etcd3_pip_module }}"
extra_args: --only-binary grpcio
state: present
- name: Download etcd3
ansible.builtin.unarchive:
src: "{{ etcd3_download_url }}"
dest: "{{ etcd3_download_location }}"
remote_src: true
# Check if re-installing etcd3 is required
- name: Check if etcd3ctl exists for reuse.
shell: "ETCDCTL_API=3 {{ etcd3_path }}/etcdctl --endpoints=localhost:2379 get foo"
args:
executable: /bin/bash
- name: Start etcd3
ansible.builtin.shell: "nohup {{ etcd3_path }}/etcd > /tmp/etcd3.log 2>&1 &"
changed_when: true
- name: Wait for etcd3 to be ready
ansible.builtin.command: "{{ etcd3_path }}/etcdctl --endpoints=localhost:2379 endpoint health"
register: etcd3_ready
until: etcd3_ready.rc == 0
retries: 10
delay: 3
changed_when: false
failed_when: false
register: _testetcd3ctl
- block:
# Installing etcd3
- name: If can't reuse, prepare download folder
file:
path: "{{ etcd3_download_location }}"
state: directory
register: _etcddownloadexists
when:
- _testetcd3ctl.rc != 0
- name: Delete download folder if already exists (to start clean)
file:
path: "{{ etcd3_download_location }}"
state: absent
when:
- _etcddownloadexists is not changed
- name: Recreate download folder if purged
file:
path: "{{ etcd3_download_location }}"
state: directory
when:
- _etcddownloadexists is not changed
- name: Download etcd3
unarchive:
src: "{{ etcd3_download_url }}"
dest: "{{ etcd3_download_location }}"
remote_src: true
# Running etcd3 and kill afterwards if it wasn't running before.
- name: Run etcd3
shell: "{{ etcd3_path }}/etcd &"
register: _etcd3run
changed_when: true
# - name: kill etcd3
# command: "pkill etcd"
when:
- _testetcd3ctl.rc != 0
when:
- ansible_facts.distribution | lower ~ "-" ~ ansible_facts.distribution_major_version | lower != 'centos-6'

View File

@@ -1,8 +0,0 @@
---
# Copyright (c) Ansible Project
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# SuSE's python 3.6.10 comes with six 1.11.0 as distutil
# we restrict to etcd3 < 0.11 to avoid pip to try to upgrade six
etcd3_pip_module: 'etcd3<0.11'

View File

@@ -1,6 +0,0 @@
---
# Copyright (c) Ansible Project
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# default should don't touch anything

View File

@@ -0,0 +1,221 @@
# Copyright (c) 2026, Ansible Project
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
import json
from unittest.mock import MagicMock
import pytest
from ansible_collections.community.internal_test_tools.tests.unit.plugins.modules.utils import set_module_args
from ansible_collections.community.general.plugins.modules import etcd3 as etcd3_module
BASE_ARGS = {
"key": "foo",
"value": "bar",
"state": "present",
"host": "localhost",
"port": 2379,
}
@pytest.fixture
def fake_etcd3(mocker):
"""Inject a mock etcd3 library into the module namespace and enable HAS_ETCD."""
mock_lib = MagicMock()
mocker.patch.object(etcd3_module, "etcd3", mock_lib, create=True)
mocker.patch.object(etcd3_module, "HAS_ETCD", True)
return mock_lib
def make_client(fake_etcd3, existing_value=None):
"""Configure fake_etcd3.client() to return a mock with get() returning the given value."""
mock_client = MagicMock()
if existing_value is not None:
mock_client.get.return_value = (existing_value.encode(), MagicMock())
else:
mock_client.get.return_value = (None, None)
fake_etcd3.client.return_value = mock_client
return mock_client
# ---------------------------------------------------------------------------
# state=present
# ---------------------------------------------------------------------------
def test_present_new_key(capfd, fake_etcd3):
"""state=present with a new key: should put and report changed."""
mock_client = make_client(fake_etcd3, existing_value=None)
with pytest.raises(SystemExit), set_module_args(BASE_ARGS):
etcd3_module.main()
out, dummy = capfd.readouterr()
result = json.loads(out)
assert result["changed"] is True
assert result["key"] == "foo"
mock_client.put.assert_called_once_with("foo", "bar")
def test_present_same_value(capfd, fake_etcd3):
"""state=present with existing key and same value: no change."""
mock_client = make_client(fake_etcd3, existing_value="bar")
with pytest.raises(SystemExit), set_module_args(BASE_ARGS):
etcd3_module.main()
out, dummy = capfd.readouterr()
result = json.loads(out)
assert result["changed"] is False
assert result["old_value"] == "bar"
mock_client.put.assert_not_called()
def test_present_different_value(capfd, fake_etcd3):
"""state=present with existing key and different value: should put and report changed."""
mock_client = make_client(fake_etcd3, existing_value="old_value")
with pytest.raises(SystemExit), set_module_args(BASE_ARGS):
etcd3_module.main()
out, dummy = capfd.readouterr()
result = json.loads(out)
assert result["changed"] is True
assert result["old_value"] == "old_value"
mock_client.put.assert_called_once_with("foo", "bar")
# ---------------------------------------------------------------------------
# state=absent
# ---------------------------------------------------------------------------
def test_absent_existing_key(capfd, fake_etcd3):
"""state=absent with existing key: should delete and report changed."""
mock_client = make_client(fake_etcd3, existing_value="bar")
with pytest.raises(SystemExit), set_module_args(dict(BASE_ARGS, state="absent")):
etcd3_module.main()
out, dummy = capfd.readouterr()
result = json.loads(out)
assert result["changed"] is True
mock_client.delete.assert_called_once_with("foo")
def test_absent_nonexistent_key(capfd, fake_etcd3):
"""state=absent with key not present: no change."""
mock_client = make_client(fake_etcd3, existing_value=None)
with pytest.raises(SystemExit), set_module_args(dict(BASE_ARGS, state="absent")):
etcd3_module.main()
out, dummy = capfd.readouterr()
result = json.loads(out)
assert result["changed"] is False
mock_client.delete.assert_not_called()
# ---------------------------------------------------------------------------
# check mode
# ---------------------------------------------------------------------------
def test_present_check_mode_new_key(capfd, fake_etcd3):
"""state=present in check mode with new key: reports changed but no actual put."""
mock_client = make_client(fake_etcd3, existing_value=None)
with pytest.raises(SystemExit), set_module_args(dict(BASE_ARGS, _ansible_check_mode=True)):
etcd3_module.main()
out, dummy = capfd.readouterr()
result = json.loads(out)
assert result["changed"] is True
mock_client.put.assert_not_called()
def test_present_check_mode_same_value(capfd, fake_etcd3):
"""state=present in check mode with same value: no change, no put."""
mock_client = make_client(fake_etcd3, existing_value="bar")
with pytest.raises(SystemExit), set_module_args(dict(BASE_ARGS, _ansible_check_mode=True)):
etcd3_module.main()
out, dummy = capfd.readouterr()
result = json.loads(out)
assert result["changed"] is False
mock_client.put.assert_not_called()
def test_absent_check_mode_existing_key(capfd, fake_etcd3):
"""state=absent in check mode with existing key: reports changed but no actual delete."""
mock_client = make_client(fake_etcd3, existing_value="bar")
with pytest.raises(SystemExit), set_module_args(dict(BASE_ARGS, state="absent", _ansible_check_mode=True)):
etcd3_module.main()
out, dummy = capfd.readouterr()
result = json.loads(out)
assert result["changed"] is True
mock_client.delete.assert_not_called()
def test_absent_check_mode_nonexistent_key(capfd, fake_etcd3):
"""state=absent in check mode with missing key: no change, no delete."""
mock_client = make_client(fake_etcd3, existing_value=None)
with pytest.raises(SystemExit), set_module_args(dict(BASE_ARGS, state="absent", _ansible_check_mode=True)):
etcd3_module.main()
out, dummy = capfd.readouterr()
result = json.loads(out)
assert result["changed"] is False
mock_client.delete.assert_not_called()
# ---------------------------------------------------------------------------
# error paths
# ---------------------------------------------------------------------------
def test_connection_failure(capfd, fake_etcd3):
"""Connection to etcd cluster fails: module should fail."""
fake_etcd3.client.side_effect = Exception("connection refused")
with pytest.raises(SystemExit), set_module_args(BASE_ARGS):
etcd3_module.main()
out, dummy = capfd.readouterr()
result = json.loads(out)
assert result["failed"] is True
assert "Cannot connect to etcd cluster" in result["msg"]
def test_get_failure(capfd, fake_etcd3):
"""etcd.get() raises: module should fail."""
mock_client = MagicMock()
mock_client.get.side_effect = Exception("read timeout")
fake_etcd3.client.return_value = mock_client
with pytest.raises(SystemExit), set_module_args(BASE_ARGS):
etcd3_module.main()
out, dummy = capfd.readouterr()
result = json.loads(out)
assert result["failed"] is True
assert "Cannot reach data" in result["msg"]
def test_missing_library(capfd, mocker):
"""etcd3 library not installed: module should fail."""
mocker.patch.object(etcd3_module, "HAS_ETCD", False)
with pytest.raises(SystemExit), set_module_args(BASE_ARGS):
etcd3_module.main()
out, dummy = capfd.readouterr()
result = json.loads(out)
assert result["failed"] is True