From 309fe6d4d007933852706a2500331a184ce41dff Mon Sep 17 00:00:00 2001 From: Yousefnezhad Date: Mon, 12 May 2025 11:54:27 +0330 Subject: [PATCH] initial nfs_exports_info module --- .github/BOTMETA.yml | 2 + plugins/modules/nfs_exports_info.py | 123 ++++++++++++++++++ .../plugins/modules/test_nfs_exports_info.py | 56 ++++++++ 3 files changed, 181 insertions(+) create mode 100644 plugins/modules/nfs_exports_info.py create mode 100644 tests/unit/plugins/modules/test_nfs_exports_info.py diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 4095986151..16fc49622f 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -986,6 +986,8 @@ files: ignore: mcodd $modules/nexmo.py: maintainers: sivel + $modules/nfs_exports_info.py: + maintainers: yousefenzhad $modules/nginx_status_info.py: maintainers: resmo $modules/nictagadm.py: diff --git a/plugins/modules/nfs_exports_info.py b/plugins/modules/nfs_exports_info.py new file mode 100644 index 0000000000..0ebecc1b6a --- /dev/null +++ b/plugins/modules/nfs_exports_info.py @@ -0,0 +1,123 @@ +# Copyright: (c) 2025, Samaneh Yousefnezhad +# GNU General Public License v3.0+ +# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: nfs_exports_info + +short_description: Extract folders, IPs, and options from /etc/exports + +description: > + This module retrieves and processes the contents of the /etc/exports file from a remote server, + mapping folders to their corresponding IP addresses and access options. + +author: + - Samaneh Yousefnezhad (@yousefenzhad) + +options: + output_format: + description: + - The format of the returned mapping. + - If set to C(ips_per_share), output maps shared folders to IPs and options. + - If set to C(shares_per_ip), output maps IPs to shared folders and options. + required: true + type: str + choices: ['ips_per_share', 'shares_per_ip'] +""" + +EXAMPLES = r""" +- name: Get IPs and options per shared folder + fava.infra.nfs_exports_info: + output_format: 'ips_per_share' + +- name: Get shared folders and options per IP + fava.infra.nfs_exports_info: + output_format: 'shares_per_ip' +""" + +RETURN = r""" +exports_info: + description: A mapping of shared folders to IPs and their options, or the reverse. + type: dict + returned: always + +file_digest: + description: SHA1 hash of /etc/exports file for integrity verification. + type: str + returned: always +""" + +from ansible.module_utils.basic import AnsibleModule +import re + + +def get_exports(module, output_format, file_path="/etc/exports"): + try: + exports_file_digest = module.digest_from_file(file_path, 'sha1') + if exports_file_digest is None: + module.fail_json(msg=f"{file_path} file not found") + + with open(file_path, 'r') as f: + output_lines = f.readlines() + + exports = {} + pattern = r'\s*(\S+)\s+(.+)' + + for line in output_lines: + if line.strip() and not line.strip().startswith('#'): + match = re.match(pattern, line) + if not match: + continue + + folder = match.group(1) + rest = match.group(2) + + entries = re.findall(r'(\d+\.\d+\.\d+\.\d+)\(([^)]+)\)', rest) + for ip, options_str in entries: + options = options_str.split(',') + + if output_format == "ips_per_share": + entry = {"ip": ip, "options": options} + exports.setdefault(folder, []).append(entry) + + elif output_format == "shares_per_ip": + entry = {"folder": folder, "options": options} + exports.setdefault(ip, []).append(entry) + + return { + 'exports_info': exports, + 'file_digest': exports_file_digest + } + + except FileNotFoundError: + module.fail_json(msg=f"{file_path} file not found") + except Exception as e: + module.fail_json(msg=str(e)) + + +def main(): + module = AnsibleModule( + argument_spec=dict( + output_format=dict(type='str', required=True, choices=['ips_per_share', 'shares_per_ip']) + ), + supports_check_mode=True + ) + + output_format = module.params['output_format'] + exports_info = get_exports(module, output_format) + + module.exit_json( + changed=False, + exports_info=exports_info['exports_info'], + file_digest=exports_info['file_digest'] + ) + + +if __name__ == '__main__': + main() + +__all__ = ['get_exports'] diff --git a/tests/unit/plugins/modules/test_nfs_exports_info.py b/tests/unit/plugins/modules/test_nfs_exports_info.py new file mode 100644 index 0000000000..6ebe1fef99 --- /dev/null +++ b/tests/unit/plugins/modules/test_nfs_exports_info.py @@ -0,0 +1,56 @@ +import pytest +from unittest.mock import mock_open, patch, MagicMock +from nfs_exports_info import get_exports + + +@pytest.fixture +def fake_exports_content(): + return """ +# Sample exports +/srv/nfs1 192.168.1.10(rw,sync) 192.168.1.20(ro,sync) +/srv/nfs2 192.168.1.30(rw,no_root_squash) +""" + + +def test_get_exports_ips_per_share(fake_exports_content): + mock_module = MagicMock() + mock_module.digest_from_file.return_value = "fake_sha1_digest" + + with patch("builtins.open", mock_open(read_data=fake_exports_content)): + result = get_exports(mock_module, "ips_per_share") + + expected = { + '/srv/nfs1': [ + {'ip': '192.168.1.10', 'options': ['rw', 'sync']}, + {'ip': '192.168.1.20', 'options': ['ro', 'sync']} + ], + '/srv/nfs2': [ + {'ip': '192.168.1.30', 'options': ['rw', 'no_root_squash']} + ] + } + + assert result['exports_info'] == expected + assert result['file_digest'] == "fake_sha1_digest" + + +def test_get_exports_shares_per_ip(fake_exports_content): + mock_module = MagicMock() + mock_module.digest_from_file.return_value = "fake_sha1_digest" + + with patch("builtins.open", mock_open(read_data=fake_exports_content)): + result = get_exports(mock_module, "shares_per_ip") + + expected = { + '192.168.1.10': [ + {'folder': '/srv/nfs1', 'options': ['rw', 'sync']} + ], + '192.168.1.20': [ + {'folder': '/srv/nfs1', 'options': ['ro', 'sync']} + ], + '192.168.1.30': [ + {'folder': '/srv/nfs2', 'options': ['rw', 'no_root_squash']} + ] + } + + assert result['exports_info'] == expected + assert result['file_digest'] == "fake_sha1_digest"