Skip to content
Open
Show file tree
Hide file tree
Changes from 51 commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
309fe6d
initial nfs_exports_info module
May 12, 2025
05a326c
change nfs_exports_info
May 12, 2025
e0c4e3c
add line at the end of file
May 12, 2025
4aa341a
Fix: add __future__ imports to test for Python 2 compatibility
May 12, 2025
8949e2b
Fix: add __metaclass__ for Python 2 compatibility in test
May 12, 2025
fef3a83
Fix E302: add 2 blank lines before top-level function
May 12, 2025
2c75f3c
correct ERROR collecting
May 13, 2025
e97bc68
add blank
May 13, 2025
096f019
error mock module
May 13, 2025
6367ff0
new line
May 13, 2025
a0fae4c
new line end of file
May 13, 2025
fc5073e
blank line at end of file
May 13, 2025
9c2dbfc
FATAL: Command "pytest "
May 13, 2025
128bb8b
Error Found 3 pep8 issue
May 13, 2025
7dd2544
SPDX Format
May 13, 2025
fafc2c0
NOX Errors
May 17, 2025
2c3bdff
trailing whitespace
May 17, 2025
5eda6b9
SPDX-License
May 19, 2025
e77b24f
Erorr community
May 21, 2025
edba62e
Format Error
Jun 11, 2025
14cff08
suggestion Error
Jun 11, 2025
37bd9a5
Error regular expression
Jun 11, 2025
acfc096
regexp Error
Jun 11, 2025
c80e76f
Syntax Error
Jun 11, 2025
da148f6
space Error
Jun 11, 2025
e6b5d52
trailing Error
Jun 11, 2025
4004e07
blank Error
Jun 11, 2025
10e12a5
Blank Error
Jun 11, 2025
4cc0b77
2 Blank Errors
Jun 11, 2025
1d65305
again Blank
Jun 11, 2025
9944b60
2 blank again
Jun 11, 2025
1d57e66
Blanks
Jun 11, 2025
09c3e35
Blank Again
Jun 11, 2025
7f328ec
Blank Error
Jun 11, 2025
04bf2be
version_added
Jun 15, 2025
faf1a67
paragraphs Error
Jun 15, 2025
db1ac68
SHA Changes
Jun 15, 2025
bccf55b
Blanks Errors
Jun 15, 2025
4f4c4bd
Blanks Again
Jun 15, 2025
d448a63
Space Error
Jun 15, 2025
8d35794
Blanks
Jun 16, 2025
ed5e9de
1 Blank
Jun 16, 2025
d088911
changes
Jun 16, 2025
07c0ac9
Errors1
Jun 16, 2025
bce822a
Suggested change
Jun 17, 2025
66ff472
Suggested change Error
Jun 17, 2025
138e65d
Changed
Jun 17, 2025
4a19530
Blank
Jun 17, 2025
ce0fcbb
os Error
Jun 17, 2025
a5bfc7e
Change again
Jun 17, 2025
1a99b2a
Changed1
Jun 17, 2025
a5a9d42
Comment
Aug 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/BOTMETA.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
167 changes: 167 additions & 0 deletions plugins/modules/nfs_exports_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
#!/usr/bin/python

# SPDX-FileCopyrightText: (c) 2025, Samaneh Yousefnezhad <[email protected]>
# 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 absolute_import, division, print_function
__metaclass__ = type

DOCUMENTATION = r"""
---
module: nfs_exports_info

short_description: Extract folders, IPs, and options from C(/etc/exports)

description:
- This module retrieves and processes the contents of the C(/etc/exports) file from a remote server,
mapping folders to their corresponding IP addresses and access options.

author:
- Samaneh Yousefnezhad (@yousefenzhad)
version_added: "11.1.0"

extends_documentation_fragment:
- community.general.attributes
- community.general.attributes.info_module

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
community.general.nfs_exports_info:
output_format: 'ips_per_share'
register: result

- name: Get shared folders and options per IP
community.general.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.
- What it is depends on O(output_format).
type: dict
returned: always
Comment on lines +51 to +56
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally, I think there should be two different return values, something like exports_per_ip, exports_per_share, and the format should be fixed for each one, instead of having a single return value with a variable format. The info part is redundant, given the module name.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yousefenzhad this is still open, and something that really should be changed. Using the same name for two very different things is a bad idea and prevents this to be documented correctly.


file_digest:
description:
- A dictionary containing various hash values of the /etc/exports file for integrity verification.
- Keys are the hash algorithm names (e.g., 'sha256', 'sha1', 'md5'), and values are their corresponding hexadecimal digests.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- A dictionary containing various hash values of the /etc/exports file for integrity verification.
- Keys are the hash algorithm names (e.g., 'sha256', 'sha1', 'md5'), and values are their corresponding hexadecimal digests.
- A dictionary containing various hash values of the C(/etc/exports) file for integrity verification.
- Keys are the hash algorithm names (for example C(sha256), C(sha1), C(md5)), and values are their corresponding hexadecimal digests.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! I've made the change and pushed it.

- At least one hash value is guaranteed to be present if the file exists and is readable.
type: dict
returned: always
sample:
sha256: "a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890"
sha1: "f7e8d9c0b1a23c4d5e6f7a8b9c0d1e2f3a4b5c6d"
md5: "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d"
"""

from ansible.module_utils.basic import AnsibleModule
import re
import hashlib


def get_exports(module, output_format, file_path="/etc/exports"):
IP_ENTRY_PATTERN = re.compile(r'(\d+\.\d+\.\d+\.\d+)\(([^)]+)\)')
MAIN_LINE_PATTERN = re.compile(r'\s*(\S+)\s+(.+)')

file_digests = {}
hash_algorithms = ['sha256', 'sha1', 'md5']

try:

if not module.file_exists(file_path):
module.fail_json(msg="{} file not found".format(file_path))

file_content_bytes = None
try:
with open(file_path, 'rb') as f:
file_content_bytes = f.read()
except IOError:
module.fail_json(msg="Could not read {}".format(file_path))

if file_content_bytes:
for algo in hash_algorithms:
try:
hasher = hashlib.new(algo)
hasher.update(file_content_bytes)
file_digests[algo] = hasher.hexdigest()
except ValueError:
module.warn("Hash algorithm '{}' not available on this system. Skipping.".format(algo))
except Exception as ex:
module.warn("Error calculating '{}' hash: {}".format(algo, ex))
exports = {}

output_lines = []
if file_content_bytes:
output_lines = file_content_bytes.decode('utf-8', errors='ignore').splitlines()
for line in output_lines:
line = line.strip()
if not line or line.startswith('#'):
continue
match = MAIN_LINE_PATTERN.match(line)
if not match:
continue

folder = match.group(1)
rest = match.group(2)

entries = IP_ENTRY_PATTERN.findall(rest)
for ip, options_str in entries:
options = options_str.split(',')

if output_format == "ips_per_share":
entry = {"ip": ip, "options": options}
if folder not in exports:
exports[folder] = []
exports[folder].append(entry)

elif output_format == "shares_per_ip":
entry = {"folder": folder, "options": options}
if ip not in exports:
exports[ip] = []
exports[ip].append(entry)

return {
'exports_info': exports,
'file_digest': file_digests
}

except Exception as e:
module.fail_json(msg="Error while processing exports: {}".format(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']
result = get_exports(module, output_format)

module.exit_json(
changed=False,
exports_info=result['exports_info'],
file_digest=result['file_digest']
)


if __name__ == '__main__':
main()

__all__ = ['get_exports']
91 changes: 91 additions & 0 deletions tests/unit/plugins/modules/test_nfs_exports_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Copyright (c) 2025, Samaneh Yousefnezhad <[email protected]>
# 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 absolute_import, division, print_function
__metaclass__ = type

from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import mock_open, patch, MagicMock

import pytest
import sys
import hashlib

from ansible_collections.community.general.plugins.modules.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 calculate_expected_digests(content_string):
content_bytes = content_string.encode('utf-8')
digests = {}
hash_algorithms = ['sha256', 'sha1', 'md5']
for algo in hash_algorithms:
try:
hasher = hashlib.new(algo)
hasher.update(content_bytes)
digests[algo] = hasher.hexdigest()
except ValueError:
pass
return digests


def test_get_exports_ips_per_share(fake_exports_content):
mock_module = MagicMock()
mock_module.file_exists.return_value = True
mock_module.warn.return_value = None
mock_module.fail_json.side_effect = Exception("fail_json called")
patch_target = "builtins.open" if sys.version_info[0] == 3 else "__builtin__.open"

with patch(patch_target, mock_open(read_data=fake_exports_content.encode('utf-8'))):
result = get_exports(mock_module, "ips_per_share")

expected_exports_info = {
'/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']}
]
}

expected_file_digests = calculate_expected_digests(fake_exports_content)

assert result['exports_info'] == expected_exports_info
assert result['file_digest'] == expected_file_digests


def test_get_exports_shares_per_ip(fake_exports_content):
mock_module = MagicMock()
mock_module.file_exists.return_value = True
mock_module.warn.return_value = None
mock_module.fail_json.side_effect = Exception("fail_json called")
patch_target = "builtins.open" if sys.version_info[0] == 3 else "__builtin__.open"

with patch(patch_target, mock_open(read_data=fake_exports_content.encode('utf-8'))):
result = get_exports(mock_module, "shares_per_ip")

expected_exports_info = {
'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']}
]
}

expected_file_digests = calculate_expected_digests(fake_exports_content)

assert result['exports_info'] == expected_exports_info
assert result['file_digest'] == expected_file_digests