diff --git a/lib/trellis/plugins/filter/filters.py b/lib/trellis/plugins/filter/filters.py index 9480446a8e..0133afdbc1 100644 --- a/lib/trellis/plugins/filter/filters.py +++ b/lib/trellis/plugins/filter/filters.py @@ -12,6 +12,19 @@ def underscore(value): ''' Convert dots to underscore in a string ''' return value.replace('.', '_') +def php_extensions(value, php_version, package_state): + """Normalize php_extensions_custom into a dict. + + Accepts either a dict (passed through unchanged for back-compat) or a + list of short names. Each short name is expanded to + "php-": "". + """ + if isinstance(value, dict): + return value + if isinstance(value, list): + return {f"php{php_version}-{name}": package_state for name in value} + return {} + def get_nested_attr(data, attr_path): """Helper to safely get a nested attribute from a dict.""" keys = attr_path.split('.') @@ -58,4 +71,5 @@ def filters(self): 'select_sites': select_sites, 'to_env': to_env, 'underscore': underscore, + 'php_extensions': php_extensions, } diff --git a/roles/common/tasks/main.yml b/roles/common/tasks/main.yml index 2c1a3e25ad..9eaee31d1e 100644 --- a/roles/common/tasks/main.yml +++ b/roles/common/tasks/main.yml @@ -31,16 +31,32 @@ msg: "{{ lookup('template', 'package_vars_wrong_format_msg.j2') }}" when: package_vars_wrong_format | count > 0 vars: - package_vars: + # All package vars must remain dicts except php_extensions_custom, + # which additionally accepts a list of short names. + package_vars_dict_only: apt_packages_default: "{{ apt_packages_default }}" apt_packages_custom: "{{ apt_packages_custom }}" memcached_packages_default: "{{ memcached_packages_default }}" memcached_packages_custom: "{{ memcached_packages_custom }}" php_extensions_default: "{{ php_extensions_default }}" - php_extensions_custom: "{{ php_extensions_custom }}" sshd_packages_default: "{{ sshd_packages_default }}" sshd_packages_custom: "{{ sshd_packages_custom }}" - package_vars_wrong_format: "{{ package_vars | dict2items | rejectattr('value', 'mapping') | map(attribute='key') | list }}" + package_vars_list_or_dict: + php_extensions_custom: "{{ php_extensions_custom }}" + # dict-only wrong: anything that is not a mapping + package_vars_dict_only_wrong: "{{ package_vars_dict_only | dict2items | rejectattr('value', 'mapping') | map(attribute='key') | list }}" + # list-or-dict wrong: not a mapping and not a (sequence that is not a string) + package_vars_list_or_dict_valid_keys: >- + {{ package_vars_list_or_dict | dict2items + | selectattr('value', 'mapping') | map(attribute='key') | list + | union(package_vars_list_or_dict | dict2items + | selectattr('value', 'sequence') + | rejectattr('value', 'string') + | map(attribute='key') | list) }} + package_vars_list_or_dict_wrong: >- + {{ package_vars_list_or_dict | dict2items | map(attribute='key') | list + | difference(package_vars_list_or_dict_valid_keys) }} + package_vars_wrong_format: "{{ package_vars_dict_only_wrong | union(package_vars_list_or_dict_wrong) }}" tags: [memcached, php, sshd] - name: Verify dict format for package combined variables diff --git a/roles/php/defaults/main.yml b/roles/php/defaults/main.yml index 7e85d53792..f95cdc3084 100644 --- a/roles/php/defaults/main.yml +++ b/roles/php/defaults/main.yml @@ -3,8 +3,9 @@ memcached_sessions: false redis_sessions: false redis_sessions_database: 1 -php_extensions_custom: {} -php_extensions: "{{ php_extensions_default | combine(php_extensions_custom) }}" +php_extensions_custom: [] +php_extensions_custom_normalized: "{{ php_extensions_custom | php_extensions(php_version, apt_package_state) }}" +php_extensions: "{{ php_extensions_default | combine(php_extensions_custom_normalized) }}" php_error_reporting: 'E_ALL & ~E_DEPRECATED & ~E_STRICT' php_display_errors: 'Off' diff --git a/tests/templates/test_php_extensions_custom.py b/tests/templates/test_php_extensions_custom.py new file mode 100644 index 0000000000..4d20bfd054 --- /dev/null +++ b/tests/templates/test_php_extensions_custom.py @@ -0,0 +1,41 @@ +"""Tests for the php_extensions_custom normalization (issue #1666). + +The defaults file (roles/php/defaults/main.yml) calls the +`php_extensions` filter on the raw input. The filter is the single +place where list vs. dict coercion happens, so testing the filter +covers both the new list form and the legacy dict form. +""" + +from lib.trellis.plugins.filter.filters import php_extensions + + +def test_filter_expands_list_of_short_names() -> None: + result = php_extensions(["soap", "gd"], "8.3", "present") + assert result == { + "php8.3-soap": "present", + "php8.3-gd": "present", + } + + +def test_filter_passes_legacy_dict_through_unchanged() -> None: + legacy = {"php8.3-soap": "present"} + assert php_extensions(legacy, "8.3", "present") is legacy + + +def test_filter_returns_empty_dict_for_empty_input() -> None: + assert php_extensions([], "8.3", "present") == {} + + +def test_filter_returns_empty_dict_for_unexpected_types() -> None: + assert php_extensions("not-a-list-or-dict", "8.3", "present") == {} + assert php_extensions(None, "8.3", "present") == {} + + +def test_filter_uses_provided_php_version() -> None: + result = php_extensions(["soap"], "8.2", "present") + assert result == {"php8.2-soap": "present"} + + +def test_filter_uses_provided_package_state() -> None: + result = php_extensions(["soap"], "8.3", "latest") + assert result == {"php8.3-soap": "latest"}