From 410383e95f2b905f97055cbcc24cf44befeeeb18 Mon Sep 17 00:00:00 2001 From: "patchback[bot]" <45432694+patchback[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 14:34:42 +0200 Subject: [PATCH] [PR #12084/8468fea3 backport][stable-12] composer: config file hash to evaluate whether a change occurred (#12131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit composer: config file hash to evaluate whether a change occurred (#12084) * composer: Use config file hash to evaluate whether a change occurred (instead of unreliable output). Ensure `--working-dir` option consistently comes first (sudo-friendly) * Update plugins/modules/composer.py * whitespace fixes + changelog fragment * Update changelogs/fragments/composer-working-dir-and-config-sha256.yaml * Update changelogs/fragments/composer-working-dir-and-config-sha256.yaml * Update plugins/modules/composer.py * fragment * ruff format plugins/modules/composer.py --------- (cherry picked from commit 8468fea3b0a6e90c37a7fc2df5b241b48a220857) Co-authored-by: Raphaƫl Droz Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> Co-authored-by: Felix Fontein --- ...omposer-working-dir-and-config-sha256.yaml | 12 ++++ plugins/modules/composer.py | 58 ++++++++++++++++++- tests/unit/plugins/modules/test_composer.py | 29 +++++++++- tests/unit/plugins/modules/test_composer.yaml | 52 +++++++++++++++-- 4 files changed, 143 insertions(+), 8 deletions(-) create mode 100644 changelogs/fragments/composer-working-dir-and-config-sha256.yaml diff --git a/changelogs/fragments/composer-working-dir-and-config-sha256.yaml b/changelogs/fragments/composer-working-dir-and-config-sha256.yaml new file mode 100644 index 0000000000..eb4a9ec198 --- /dev/null +++ b/changelogs/fragments/composer-working-dir-and-config-sha256.yaml @@ -0,0 +1,12 @@ +# 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 + +bugfixes: + - composer - the ``--working-dir`` option is now always placed first on the command line, before the + subcommand, to ensure consistent behavior across all composer commands + (https://github.com/ansible-collections/community.general/issues/5204, https://github.com/ansible-collections/community.general/pull/12084). + - composer - use file checksum to determine ``changed`` status when ``command=config``, + instead of relying on the command return output. The module now compares SHA256 checksums of + relevant configuration files (``composer.json``, ``auth.json``, or their global equivalents) + before and after running the command + (https://github.com/ansible-collections/community.general/pull/12084). diff --git a/plugins/modules/composer.py b/plugins/modules/composer.py index c7d5cfe67f..1d28b99de9 100644 --- a/plugins/modules/composer.py +++ b/plugins/modules/composer.py @@ -186,9 +186,10 @@ def composer_command(module, command, arguments=None, options=None): if global_command: global_arg = ["global"] + working_dir_option = [] else: global_arg = [] - options.extend(["--working-dir", module.params["working_dir"]]) + working_dir_option = ["--working-dir", module.params["working_dir"]] if module.params["executable"] is None: php_path = module.get_bin_path("php", True, ["/usr/local/bin"]) @@ -200,10 +201,50 @@ def composer_command(module, command, arguments=None, options=None): else: composer_path = module.params["composer_executable"] - cmd = [php_path, composer_path] + global_arg + command + options + arguments + cmd = [php_path, composer_path] + working_dir_option + global_arg + command + options + arguments return module.run_command(cmd) +def get_composer_home(module): + """Get the composer home directory by running 'composer config --global home'.""" + if module.params["executable"] is None: + php_path = module.get_bin_path("php", True, ["/usr/local/bin"]) + else: + php_path = module.params["executable"] + + if module.params["composer_executable"] is None: + composer_path = module.get_bin_path("composer", True, ["/usr/local/bin"]) + else: + composer_path = module.params["composer_executable"] + + cmd = [php_path, composer_path, "config", "--global", "--no-interaction", "home"] + rc, out, err = module.run_command(cmd) + if rc == 0: + return out.strip() + return None + + +def get_config_files(module): + """Get list of config files that might be changed by 'composer config'.""" + files = ["composer.json", "auth.json"] + global_command = module.params["global_command"] + working_dir = module.params["working_dir"] + + if global_command: + composer_home = get_composer_home(module) + if composer_home: + files = [os.path.join(composer_home, f) for f in files] + elif working_dir: + files = [os.path.join(working_dir, f) for f in files] + + return files + + +def hash_config_files(module, files): + """Return a dict mapping file path to its sha256 hash (or None if the file does not exist).""" + return {f: (module.sha256(f) if os.path.isfile(f) else None) for f in files} + + def main(): module = AnsibleModule( argument_spec=dict( @@ -280,6 +321,12 @@ def main(): else: module.exit_json(skipped=True, msg=f"command '{command}' does not support check mode, skipping") + # For 'config' command in non-check mode, use sha256 hashing to detect changes + use_hash_detection = command == "config" and not module.check_mode + if use_hash_detection: + config_files = get_config_files(module) + hashes_before = hash_config_files(module, config_files) + rc, out, err = composer_command(module, [command], arguments, options) if rc != 0: @@ -288,7 +335,12 @@ def main(): else: # Composer version > 1.0.0-alpha9 now use stderr for standard notification messages output = parse_out(out + err) - module.exit_json(changed=has_changed(output), msg=output, stdout=out + err) + if use_hash_detection: + hashes_after = hash_config_files(module, config_files) + changed = hashes_before != hashes_after + else: + changed = has_changed(output) + module.exit_json(changed=changed, msg=output, stdout=out + err) if __name__ == "__main__": diff --git a/tests/unit/plugins/modules/test_composer.py b/tests/unit/plugins/modules/test_composer.py index b0ea50a47d..0f4c1ff8e4 100644 --- a/tests/unit/plugins/modules/test_composer.py +++ b/tests/unit/plugins/modules/test_composer.py @@ -22,4 +22,31 @@ class OsPathExistsMock(TestCaseMock): pass -UTHelper.from_module(composer, __name__, mocks=[RunCommandMock, OsPathExistsMock]) +class OsPathIsfileMock(TestCaseMock): + name = "os_path_isfile" + + def setup(self, mocker): + mocker.patch("os.path.isfile", return_value=self.mock_specs.get("return_value", False)) + + def check(self, test_case, results): + pass + + +class Sha256Mock(TestCaseMock): + name = "sha256" + + def setup(self, mocker): + values = list(self.mock_specs.get("return_values", [])) + + def _sha256_side_effect(path): + if values: + return values.pop(0) + return "default_hash" + + mocker.patch("ansible.module_utils.basic.AnsibleModule.sha256", side_effect=_sha256_side_effect) + + def check(self, test_case, results): + pass + + +UTHelper.from_module(composer, __name__, mocks=[RunCommandMock, OsPathExistsMock, OsPathIsfileMock, Sha256Mock]) diff --git a/tests/unit/plugins/modules/test_composer.yaml b/tests/unit/plugins/modules/test_composer.yaml index 323f1b2516..b71cc4083f 100644 --- a/tests/unit/plugins/modules/test_composer.yaml +++ b/tests/unit/plugins/modules/test_composer.yaml @@ -5,22 +5,28 @@ --- anchors: help_install: &help_install - command: ["/testbin/php", "/testbin/composer", "help", "install", "--working-dir", "/var/www/foo", "--no-interaction", "--format=json"] + command: ["/testbin/php", "/testbin/composer", "--working-dir", "/var/www/foo", "help", "install", "--no-interaction", "--format=json"] rc: 0 out: | {"definition": {"options": ["a", "b", "c"]}} err: '' help_create_project: &help_create_project - command: ["/testbin/php", "/testbin/composer", "help", "create-project", "--working-dir", "/var/www/foo", "--no-interaction", "--format=json"] + command: ["/testbin/php", "/testbin/composer", "--working-dir", "/var/www/foo", "help", "create-project", "--no-interaction", "--format=json"] rc: 0 out: | {"definition": {"options": ["a", "b", "c"]}} err: '' run_create_project: &run_create_project - command: ["/testbin/php", "/testbin/composer", "create-project", "--working-dir", "/var/www/foo", "vendor/package"] + command: ["/testbin/php", "/testbin/composer", "--working-dir", "/var/www/foo", "create-project", "vendor/package"] rc: 0 out: '' err: '' + help_config: &help_config + command: ["/testbin/php", "/testbin/composer", "--working-dir", "/var/www/foo", "help", "config", "--no-interaction", "--format=json"] + rc: 0 + out: | + {"definition": {"options": ["a", "b", "c"]}} + err: '' test_cases: - id: composer @@ -32,7 +38,7 @@ test_cases: mocks: run_command: - *help_install - - command: ["/testbin/php", "/testbin/composer", "install", "--working-dir", "/var/www/foo"] + - command: ["/testbin/php", "/testbin/composer", "--working-dir", "/var/www/foo", "install"] rc: 0 out: '' err: '' @@ -78,3 +84,41 @@ test_cases: run_command: - *help_create_project - *run_create_project + + - id: config_no_change + input: + command: config + arguments: "setting-key setting-value" + working_dir: "/var/www/foo" + output: + changed: false + mocks: + os_path_isfile: + return_value: true + sha256: + return_values: ["hash1", "hash2", "hash1", "hash2"] + run_command: + - *help_config + - command: ["/testbin/php", "/testbin/composer", "--working-dir", "/var/www/foo", "config", "setting-key", "setting-value"] + rc: 0 + out: '' + err: '' + + - id: config_changed + input: + command: config + arguments: "setting-key setting-value" + working_dir: "/var/www/foo" + output: + changed: true + mocks: + os_path_isfile: + return_value: true + sha256: + return_values: ["hash1", "hash2", "hash1_changed", "hash2"] + run_command: + - *help_config + - command: ["/testbin/php", "/testbin/composer", "--working-dir", "/var/www/foo", "config", "setting-key", "setting-value"] + rc: 0 + out: '' + err: ''