Skip to content

Commit d40281f

Browse files
sar_facts - refact module (#154) (#155)
* sar_facts - refact module * sar_facts - add changelogs fragment * sar_facts - fix changelogs fragment (cherry picked from commit 5303f96) Co-authored-by: Nocchia <133043574+NomakCooper@users.noreply.github.com>
1 parent ec6f78b commit d40281f

3 files changed

Lines changed: 125 additions & 63 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
minor_changes:
2+
- sar_facts - Updated to follow the Ansible standard, it now uses ``module.run_command()`` and ``module.get_bin_path()`` (https://github.com/3A2DEV/ans2dev.general/pull/154).

plugins/modules/sar_facts.py

Lines changed: 23 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
- Retrieves SAR data using the C(sar) command from system logs.
1616
- Supports filtering by date range, time range, and partition details.
1717
- Returns performance metrics such as CPU utilization, memory usage, disk activity, and network statistics.
18+
- ans2dev.general.sar_facts only supports sar data with a V(12H) time format and automatically converts format to V(24H).
1819
version_added: "0.1.0"
1920
options:
2021
date_start:
@@ -139,12 +140,10 @@
139140
'''
140141

141142
from ansible.module_utils.basic import AnsibleModule
142-
import os
143-
import subprocess
144143
from datetime import datetime, timedelta
144+
import os
145145

146146
SAR_LOG_PATHS = ["/var/log/sa/", "/var/log/sysstat/"]
147-
SAR_BIN_PATHS = ["/usr/bin/sar", "/usr/sbin/sar", "/bin/sar"]
148147

149148
SAR_FACT_MAPPING = {
150149
"cpu": "sar_cpu",
@@ -156,33 +155,20 @@
156155
}
157156

158157

159-
def locate_sar():
160-
"""Finds the SAR binary in the system."""
161-
for path in SAR_BIN_PATHS:
162-
if os.path.exists(path):
163-
return path
164-
return None
165-
166-
167158
def find_sar_file(date_str):
168-
"""Finds the SAR log file for a given date."""
169159
try:
170160
date_obj = datetime.strptime(date_str, "%Y-%m-%d")
171161
day = date_obj.strftime("%d")
172-
173162
for path in SAR_LOG_PATHS:
174163
file_path = os.path.join(path, f"sa{day}")
175164
if os.path.exists(file_path):
176165
return file_path
177-
178166
except ValueError:
179167
return None
180-
181168
return None
182169

183170

184171
def run_sar_command(module, sar_bin, sar_file, sar_type, time_start, time_end, partition, average, date_str):
185-
"""Executes the SAR command and returns parsed results."""
186172
command = [sar_bin, "-f", sar_file]
187173

188174
sar_flags = {
@@ -202,21 +188,17 @@ def run_sar_command(module, sar_bin, sar_file, sar_type, time_start, time_end, p
202188
if time_end:
203189
command.extend(["-e", time_end])
204190

205-
try:
206-
result = subprocess.run(command, capture_output=True, text=True, check=True)
207-
return parse_sar_output(result.stdout, sar_type, average, date_str)
208-
except subprocess.CalledProcessError as e:
209-
module.fail_json(msg=f"Failed to execute SAR command: {str(e)}")
191+
rc, stdout, stderr = module.run_command(command)
192+
if rc != 0:
193+
module.fail_json(msg=f"Failed to execute SAR command: {stderr}")
194+
return parse_sar_output(stdout, sar_type, average, date_str)
210195

211196

212197
def convert_to_24h(time_str, am_pm):
213198
return datetime.strptime(f"{time_str} {am_pm}", "%I:%M:%S %p").strftime("%H:%M:%S")
214199

215200

216201
def parse_sar_output(output, sar_type, average, date_str):
217-
"""Parses SAR output by finding the header line and converting the first two columns (TIME, AM/PM)
218-
into 24H format. Il campo AM/PM viene preservato nel dizionario finale.
219-
Vengono anche filtrate le righe che contengono 'Linux' o 'restart'."""
220202
import re
221203
parsed_data = []
222204
header = None
@@ -225,31 +207,26 @@ def is_valid_time(token):
225207
return re.match(r'^\d{1,2}:\d{2}:\d{2}$', token)
226208

227209
for line in output.splitlines():
228-
# Filtra le righe che contengono "Linux" o "restart"
229210
if re.search(r'\b(Linux|restart)\b', line, flags=re.IGNORECASE):
230211
continue
231212

232213
parts = line.split()
233214
if not parts:
234215
continue
235216

236-
# Gestione del flag "Average:"
237217
if parts[0] == "Average:":
238218
parts = parts[1:]
239219
if not average:
240220
continue
241221

242-
# Se l'header non è definito, prendi la riga completa se inizia con orario valido
243222
if header is None:
244223
if len(parts) >= 2 and is_valid_time(parts[0]) and parts[1] in ["AM", "PM"]:
245-
header = parts # conserva l'intero header
224+
header = parts
246225
continue
247226

248-
# Processa le righe dati: controlla se i primi due token sono orario e AM/PM
249227
if len(parts) >= 2 and is_valid_time(parts[0]) and parts[1] in ["AM", "PM"]:
250228
converted = convert_to_24h(parts[0], parts[1])
251229
data_entry = {"date": date_str, "time": converted}
252-
# Mappa i dati a partire dall'indice 1 del header (per preservare la colonna AM/PM)
253230
for idx in range(1, min(len(header), len(parts))):
254231
data_entry[header[idx]] = parts[idx]
255232
parsed_data.append(data_entry)
@@ -296,15 +273,28 @@ def main():
296273
date_list = [date_start] if date_start else []
297274

298275
collected_data = []
276+
277+
sar_bin = module.get_bin_path("sar", required=True)
278+
299279
for date in date_list:
300280
sar_file = find_sar_file(date)
301281
if sar_file:
302-
collected_data.extend(run_sar_command(module, locate_sar(), sar_file, sar_type, time_start, time_end, partition, average, date))
282+
collected_data.extend(
283+
run_sar_command(
284+
module,
285+
sar_bin,
286+
sar_file,
287+
sar_type,
288+
time_start,
289+
time_end,
290+
partition,
291+
average,
292+
date
293+
)
294+
)
303295

304296
fact_name = SAR_FACT_MAPPING.get(sar_type, f"sar_{sar_type}")
305-
306297
result = {'ansible_facts': {fact_name: collected_data}}
307-
308298
module.exit_json(**result)
309299

310300

tests/unit/plugins/modules/test_sar_facts.py

Lines changed: 100 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,7 @@
77

88
import unittest
99
from unittest.mock import patch, MagicMock
10-
import subprocess
1110

12-
# Import the sar_facts module from the collection.
1311
from ansible_collections.ans2dev.general.plugins.modules import sar_facts # type: ignore
1412

1513

@@ -22,15 +20,31 @@ def fail_json(*args, **kwargs):
2220

2321

2422
class TestSarFactsModule(unittest.TestCase):
23+
def run_sar_fact_test(self, test_params, fake_sar_output, expected_fact_key, expected_sample_value):
24+
with patch.object(sar_facts, 'AnsibleModule') as mock_AnsibleModule:
25+
fake_module = MagicMock()
26+
fake_module.params = test_params
27+
fake_module.exit_json.side_effect = exit_json
28+
fake_module.fail_json.side_effect = fail_json
29+
fake_module.run_command.return_value = (0, fake_sar_output, "")
30+
fake_module.get_bin_path.return_value = "/usr/bin/sar"
31+
mock_AnsibleModule.return_value = fake_module
32+
33+
with patch.object(sar_facts, 'find_sar_file', return_value="/dummy/sa01"):
34+
with self.assertRaises(Exception) as context:
35+
sar_facts.main()
36+
result_str = str(context.exception)
37+
38+
self.assertIn(expected_fact_key, result_str)
39+
self.assertIn(expected_sample_value, result_str)
40+
self.assertIn("2025-05-01", result_str)
41+
2542
def test_sar_cpu(self):
26-
# Simulated output for the 'sar' command.
2743
fake_sar_output = (
2844
"08:00:00 AM %user %system %idle\n"
2945
"08:00:00 AM 5.00 2.00 93.00\n"
3046
"08:10:00 AM 6.00 3.00 91.00\n"
3147
)
32-
33-
# Parameters for a SAR facts call (CPU type).
3448
test_params = {
3549
'date_start': '2025-05-01',
3650
'date_end': '2025-05-01',
@@ -40,36 +54,92 @@ def test_sar_cpu(self):
4054
'average': False,
4155
'partition': False
4256
}
57+
self.run_sar_fact_test(test_params, fake_sar_output, "sar_cpu", "5.00")
4358

44-
# Patch AnsibleModule in sar_facts.
45-
with patch.object(sar_facts, 'AnsibleModule') as mock_AnsibleModule:
46-
fake_module = MagicMock()
47-
fake_module.params = test_params
48-
fake_module.exit_json.side_effect = exit_json
49-
fake_module.fail_json.side_effect = fail_json
50-
fake_module.run_command.return_value = (0, fake_sar_output, "")
51-
mock_AnsibleModule.return_value = fake_module
59+
def test_sar_memory(self):
60+
fake_sar_output = (
61+
"08:00:00 AM %memused %commit\n"
62+
"08:00:00 AM 75.00 60.00\n"
63+
"08:10:00 AM 76.00 59.50\n"
64+
)
65+
test_params = {
66+
'date_start': '2025-05-01',
67+
'date_end': '2025-05-01',
68+
'time_start': '08:00:00',
69+
'time_end': '10:00:00',
70+
'type': 'memory',
71+
'average': False,
72+
'partition': False
73+
}
74+
self.run_sar_fact_test(test_params, fake_sar_output, "sar_mem", "75.00")
5275

53-
# Patch file-check and external command functions.
54-
with patch.object(sar_facts, 'find_sar_file', return_value="/dummy/sa01"), \
55-
patch.object(sar_facts, 'subprocess') as mock_subprocess:
76+
def test_sar_swap(self):
77+
fake_sar_output = (
78+
"08:00:00 AM %swpused %swpcad\n"
79+
"08:00:00 AM 10.00 0.50\n"
80+
"08:10:00 AM 11.00 0.60\n"
81+
)
82+
test_params = {
83+
'date_start': '2025-05-01',
84+
'date_end': '2025-05-01',
85+
'time_start': '08:00:00',
86+
'time_end': '10:00:00',
87+
'type': 'swap',
88+
'average': False,
89+
'partition': False
90+
}
91+
self.run_sar_fact_test(test_params, fake_sar_output, "sar_swap", "10.00")
5692

57-
# Simulate a successful subprocess.run call.
58-
mock_subprocess.run.return_value = subprocess.CompletedProcess(
59-
args=["/usr/bin/sar", "-f", "/dummy/sa01"],
60-
returncode=0,
61-
stdout=fake_sar_output
62-
)
93+
def test_sar_network(self):
94+
fake_sar_output = (
95+
"08:00:00 AM IFACE rxpck/s txpck/s %ifutil\n"
96+
"08:00:00 AM eth0 100.00 200.00 0.50\n"
97+
"08:10:00 AM eth0 110.00 210.00 0.55\n"
98+
)
99+
test_params = {
100+
'date_start': '2025-05-01',
101+
'date_end': '2025-05-01',
102+
'time_start': '08:00:00',
103+
'time_end': '10:00:00',
104+
'type': 'network',
105+
'average': False,
106+
'partition': False
107+
}
108+
self.run_sar_fact_test(test_params, fake_sar_output, "sar_net", "100.00")
63109

64-
with self.assertRaises(Exception) as context:
65-
sar_facts.main()
110+
def test_sar_disk(self):
111+
fake_sar_output = (
112+
"08:00:00 AM DEV %util await rkB/s wkB/s\n"
113+
"08:00:00 AM sda 90.00 5.00 100.00 200.00\n"
114+
"08:10:00 AM sda 91.00 5.10 101.00 201.00\n"
115+
)
116+
test_params = {
117+
'date_start': '2025-05-01',
118+
'date_end': '2025-05-01',
119+
'time_start': '08:00:00',
120+
'time_end': '10:00:00',
121+
'type': 'disk',
122+
'average': False,
123+
'partition': False
124+
}
125+
self.run_sar_fact_test(test_params, fake_sar_output, "sar_disk", "90.00")
66126

67-
result_str = str(context.exception)
68-
# Verify that exit_json output contains expected SAR fact keys and values.
69-
self.assertIn("sar_cpu", result_str)
70-
self.assertIn("5.00", result_str)
71-
self.assertIn("2025-05-01", result_str)
72-
self.assertIn("exit_json called", result_str)
127+
def test_sar_load(self):
128+
fake_sar_output = (
129+
"08:00:00 AM ldavg-1 ldavg-5 ldavg-15\n"
130+
"08:00:00 AM 0.50 0.60 0.70\n"
131+
"08:10:00 AM 0.55 0.65 0.75\n"
132+
)
133+
test_params = {
134+
'date_start': '2025-05-01',
135+
'date_end': '2025-05-01',
136+
'time_start': '08:00:00',
137+
'time_end': '10:00:00',
138+
'type': 'load',
139+
'average': False,
140+
'partition': False
141+
}
142+
self.run_sar_fact_test(test_params, fake_sar_output, "sar_load", "0.50")
73143

74144

75145
if __name__ == '__main__':

0 commit comments

Comments
 (0)