Role in collection cannot find module in the same collection

We have an internal collection with roles, one of which lists available updates, and another that actually performs the updates, excluding necessary packages. I am trying to tweak the role that lists updates so that it doesn’t list the packages that are excluded in the actual update. I was unable to find a combination of Ansible functions/filters to accomplish this, so I have resorted to trying to write a module within this collection that helps with some string formatting. However, the role cannot find the module, even though they are within the same collection.

Below you can find my directory structure, role contents, and module contents:

# Working directory
β”œβ”€β”€ CHANGELOG.md
β”œβ”€β”€ README.md
β”œβ”€β”€ galaxy.yml
β”œβ”€β”€ meta
β”‚   └── runtime.yml
β”œβ”€β”€ plugins
β”‚   └── modules
β”‚       β”œβ”€β”€ __init__.py
β”‚       └── add_string.py
β”œβ”€β”€ renovate.json
└── roles
    β”œβ”€β”€ list_linux_updates
    β”‚   β”œβ”€β”€ handlers
    β”‚   β”‚   └── main.yml
    β”‚   β”œβ”€β”€ meta
    β”‚   β”‚   β”œβ”€β”€ argument_specs.yml
    β”‚   β”‚   └── main.yml
    β”‚   └── tasks
    β”‚       └── main.yml
    └── update_linux_packages
        β”œβ”€β”€ defaults
        β”‚   └── main.yml
        β”œβ”€β”€ meta
        β”‚   β”œβ”€β”€ argument_specs.yml
        β”‚   └── main.yml
        └── tasks
            └── main.yml
# list_linux_updates role
---
- name: List updates
  block:
    - name: Updates for RedHat/SUSE and derived distributions
      when: ansible_facts.pkg_mgr == 'dnf'
      block:
        - name: Prepare submitted packages to be excluded
          company.update_roles.add_string:
            in_list: list_linux_updates_packages_exclude
            in_string: "--exclude"
            prepend: true
          register: list_linux_updates_packages_exclude_prepped

        - name: Store submitted packages to be excluded
          ansible.builtin.set_fact:
            list_linux_updates_packages_exclude_joined: "{{ list_linux_updates_packages_exclude_prepped.output | join(' ') }}"

        - name: Gather available package updates for RedHat/SUSE systems
          ansible.builtin.command:
            cmd: "dnf check-update --quiet {{ list_linux_updates_packages_exclude_joined }}"
          register: list_linux_updates_updatable_packages_raw_RH
          changed_when: list_linux_updates_updatable_packages_raw_RH.rc in [0, 100]
          failed_when: list_linux_updates_updatable_packages_raw_RH.rc not in [0, 100]
          notify:
            - Write RedHat packages to variable

    - name: Updates for Debian/Ubuntu and derived distributions
      when: ansible_facts.pkg_mgr == 'apt'
      block:
        - name: Gather available package updates for Debian/Ubuntu systems
          ansible.builtin.command:
            cmd: apt-get upgrade --dry-run
          register: list_linux_updates_updatable_packages_raw_DB
          changed_when: list_linux_updates_updatable_packages_raw_DB.rc == 0
          notify:
            - Write Debian packages to variable
# Module
#!/usr/bin/python
# Created using the official module in a collection example:
# https://docs.ansible.com/projects/ansible/latest/dev_guide/developing_modules_general.html#creating-a-module-in-a-collection
DOCUMENTATION = r'''
---
module: add_string
author: department_X
version_added: "1.0.0"
short_description: Prepends or appends a string to each string item in a list.
description:
  - Prepends or appends a string to each string item in a list.
  - It makes no changes on any host, and is purely needed for some formatting of text.
options:
    in_list:
        description: This is a list of strings to which the string must be added.
        required: true
        type: list
    in_string:
        description: This is the string to add to each element in the list.
        required: true
        type: str
    prepend:
        description: Prepends string to each element. If false, it will append instead.
        required: false
        type: bool
        default: true
'''

EXAMPLES = r'''
# Base usage
- name: Add "added" to every object in the list
  company.update_roles.add_string:
    in_list: ['ring', 'around', 'the', 'rosie']
    in_string: 'added'
  register: prepend_text

- name: Print output
  ansible.builtin.debug:
    var: prepend_text.output
# returns: ['added ring','added around','added the','added rosie']

# Append instead of prepend
- name: Add "added" to every object in the list
  company.update_roles.add_string:
    in_list: ['ring', 'around', 'the', 'rosie']
    in_string: 'added'
    prepend: false

- name: Print output
  ansible.builtin.debug:
    var: append_text.output
# returns: ['ring added','around added','the added','rosie added']
'''

RETURN = r'''
# These are examples of possible return values, and in general should use other names for return values.
input:
    description: The original name param that was passed in.
    type: list
    returned: always
    sample: ['ring','around','the','rosie']
output:
    description: The output message that the test module generates.
    type: list
    returned: always
    sample: ['added ring','added around','added the','added rosie']
'''

from ansible.module_utils.basic import AnsibleModule

def run_module():
    # define available arguments/parameters a user can pass to the module
    module_args = dict(
        in_list=dict(type='list', required=True),
        in_string=dict(type='str', required=True),
        prepend=dict(type='bool', required=False, default=True)
    )

    result = dict(
        changed=False,
        input=[],
        output=[]
    )

    module = AnsibleModule(
        argument_spec=module_args,
        supports_check_mode=True
    )

    if module.check_mode:
        module.exit_json(**result)

    result['input'] = module.params['in_list']
    result['output'] = add_string(module.params['in_list'], module.params['in_string'], module.params['prepend'])

    if module.params['in_list'] != result['output']:
        result['changed'] = True

    module.exit_json(**result)

def add_string(in_list: list, in_string: str, prepend: bool):
    if in_list == []:
        return []
    else:
        out_list = []
        for item in in_list:
            if prepend:
                out_list.append(str(item).strip() + " " + in_string.strip())
            else:
                out_list.append(in_string.strip() + " " + str(item).strip())
        return out_list

def main():
    run_module()

if __name__ == '__main__':
    main()

I have tried to test it via command line, but it yields this error:

ansible localhost -m company.update_roles.add_string -a "in_list=['one','two','three'] in_string=word" --playbook-dir=$PWD

# result:
[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: Error loading plugin 'company.update_roles.add_string': No module named 'ansible_collections.company'
[ERROR]: Task failed: Cannot resolve 'company.update_roles.add_string' to an action or module. Does anyone here have an inkling of what I might be missing?

Task failed.
Origin: <adhoc 'company.update_roles.add_string' task>

{'action': 'company.update_roles.add_string', 'args': {'in_list': "['one','two','three']", 'in_string': 'word'}, [...]

<<< caused by >>>

Cannot resolve 'company.update_roles.add_string' to an action or module.
Origin: <CLI option '-m'>

company.update_roles.add_string

localhost | FAILED! => {
    "changed": false,
    "msg": "Task failed: Cannot resolve 'company.update_roles.add_string' to an action or module."
}

Which is pretty similar to the error I get when running ansible-lint:

$ ansible-lint
WARNING  Unable to load module company.update_roles.add_string at roles/list_linux_updates/tasks/main.yml:7 for options validation
WARNING  Invalid value (None)for resolved_fqcn attribute of company.update_roles.add_string module.
WARNING  Listing 1 violation(s) that are fatal
# Rule Violation Summary
  1 syntax-check profile:min tags:core,unskippable
Failed: 1 failure(s), 0 warning(s) on 27 files.
syntax-check[unknown-module]: couldn't resolve module/action 'company.update_roles.add_string'. This often indicates a misspelling, missing collection, or incorrect module path.
roles/list_linux_updates/tasks/main.yml:7:11

I’m sure that there are plenty of things wrong with the module itself, but so long as I cannot test it, I won’t know and learn. I also have a bit of a feeling that this may not be the intended use of modules, or even the best way to tackle the issue at hand, but it’s the only one that I could think of that has proven somewhat fruitful. Nevertheless, I do want to know how I can use modules within the same collection.

You do not need custom plugin for that.

---
- name: add prefix to a list of strings
  hosts: all
  gather_facts: false
  tasks:
    - name: add prefix to a list of strings
      ansible.builtin.assert:
        that:
          - ( list_of_strings | map('regex_replace', '^(.*)$', prefix ~ '\\1') ) == list_of_strings_replaced
      vars:
        prefix: "hello "
        list_of_strings:
          - one
          - two
          - three
        list_of_strings_replaced:
          - hello one
          - hello two
          - hello three

map run regex replace for each element in the list.

'regex_replace', '^(.*)$', prefix ~ '\\1') 
'^(.*)$' - all in the parenthesis means so called group, first parenthesis should be refered as group 1
prefix ~ '\\1' - contatenate prefix variable with group 1, group 1 is effectively compelete sting - from beginning ^ to and end $ (regex simbols that mean beginning and the end respectively)

Let’s check if this works.

bash-5.2$ uv run --with ansible-core==2.19 ansible-playbook -i localhost, playbook.yml 
[WARNING]: Skipping plugin (/opt/homebrew/lib/python3.14/site-packages/ara/plugins/callback/ara_default.py), cannot load: No module named 'ara'

PLAY [add prefix to a list of strings] **********************************************************************************************************************************************************************************************************************

TASK [add prefix to a list of strings] **********************************************************************************************************************************************************************************************************************
ok: [localhost] => {
    "changed": false,
    "msg": "All assertions passed"
}

PLAY RECAP **************************************************************************************************************************************************************************************************************************************************
localhost                  : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   


1 Like

and example for postfix

---
- name: add postfix to a list of strings
  hosts: all
  gather_facts: false
  tasks:
    - name: add postfix to a list of strings
      ansible.builtin.assert:
        that:
          - ( list_of_strings | map('regex_replace', '^(.*)$', '\\1' ~ postfix) ) == list_of_strings_replaced
      vars:
        postfix: " good bye"
        list_of_strings:
          - one
          - two
          - three
        list_of_strings_replaced:
          - one good bye
          - two good bye
          - three good bye

this is the main change

'\\1' ~ postfix
bash-5.2$ uv run --with ansible-core==2.19 ansible-playbook -i localhost, playbook2.yml 
[WARNING]: Skipping plugin (/opt/homebrew/lib/python3.14/site-packages/ara/plugins/callback/ara_default.py), cannot load: No module named 'ara'

PLAY [add postfix to a list of strings] *********************************************************************************************************************************************************************************************************************

TASK [add postfix to a list of strings] *********************************************************************************************************************************************************************************************************************
ok: [localhost] => {
    "changed": false,
    "msg": "All assertions passed"
}

PLAY RECAP **************************************************************************************************************************************************************************************************************************************************
localhost                  : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   


Two things that come to my mind:

  1. __init__.py is messing with your module loading so Ansible does not pick it up properly.
  2. Your collection is not installed to COLLECTIONS_PATH which is usually ~/ansible/collections

You did not show your playbook so we don’t know how you test your role.

__init__.py shouldn’t be a problem (unless it contains something funky). Not being in the collection search path sounds more likely.

Also ansible-lint not finding the module might be caused by yet another thing, and not be related to why it doesn’t work in a regular playbook. Since that isn’t mentioned: does the role work from a regular playbook?