Skip to content

Commit d67bb7b

Browse files
committed
ROS version update fixes, #295
1 parent 46120a2 commit d67bb7b

7 files changed

Lines changed: 135 additions & 52 deletions

File tree

mktxp/collector/address_list_collector.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def collect(router_entry):
4545
@staticmethod
4646
def _collect_and_yield_metrics(router_entry, address_list_names, ip_version, metric_labels, reduced_metric_labels, translation_table):
4747
ipv6_suffix = '_ipv6' if ip_version == 'ipv6' else ''
48-
48+
4949
# Collect and yield address list entries
5050
records = AddressListMetricsDataSource.metric_records(
5151
router_entry,

mktxp/collector/bandwidth_collector.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,33 +27,33 @@ def get_result(bandwidth_dict):
2727

2828
class BandwidthCollector(BaseCollector):
2929
''' MKTXP collector
30-
'''
30+
'''
3131
def __init__(self):
3232
self.pool = None
33-
self.last_call_timestamp = 0
34-
33+
self.last_call_timestamp = 0
34+
3535
def collect(self):
3636
if not config_handler.system_entry.bandwidth:
3737
return
3838

3939
if self.pool is None:
4040
self.pool = get_context("spawn").Pool()
4141

42-
if result_list:
42+
if result_list:
4343
result_dict = result_list[0]
44-
bandwidth_records = [{'direction': key, 'bandwidth': str(result_dict[key])} for key in ('download', 'upload')]
45-
bandwidth_metrics = BaseCollector.gauge_collector('internet_bandwidth', 'Internet bandwidth in bits per second',
44+
bandwidth_records = [{'direction': key, 'bandwidth': str(result_dict[key])} for key in ('download', 'upload')]
45+
bandwidth_metrics = BaseCollector.gauge_collector('internet_bandwidth', 'Internet bandwidth in bits per second',
4646
bandwidth_records, 'bandwidth', ['direction'], add_id_labels = False)
4747
yield bandwidth_metrics
4848

4949
latency_records = [{'latency': str(result_dict['ping'])}]
50-
latency_metrics = BaseCollector.gauge_collector('internet_latency', 'Internet latency in milliseconds',
50+
latency_metrics = BaseCollector.gauge_collector('internet_latency', 'Internet latency in milliseconds',
5151
latency_records, 'latency', [], add_id_labels = False)
5252
yield latency_metrics
5353

54-
ts = datetime.now().timestamp()
54+
ts = datetime.now().timestamp()
5555
if (ts - self.last_call_timestamp) > config_handler.system_entry.bandwidth_test_interval:
56-
self.pool.apply_async(BandwidthCollector.bandwidth_worker, callback=get_result)
56+
self.pool.apply_async(BandwidthCollector.bandwidth_worker, callback=get_result)
5757
self.last_call_timestamp = ts
5858

5959
def __del__(self):

mktxp/collector/base_collector.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def _de_duplicate_records(router_records, metric_labels, verbose_reporting = Non
3838
for record in router_records:
3939
custom_labels_dict = record.get(MKTXPConfigKeys.CUSTOM_LABELS_METADATA_ID, {})
4040
label_values_tuple = tuple(record.get(label, custom_labels_dict.get(label, '')) for label in metric_labels)
41-
41+
4242
if label_values_tuple in unique_records:
4343
if verbose_reporting:
4444
print(f"Warning: Duplicate metric record found for labels {dict(zip(metric_labels, label_values_tuple))}. Keeping last.")
@@ -56,7 +56,7 @@ def info_collector(name: str, documentation: str, router_records, metric_labels=
5656
metric_labels = BaseCollector._add_id_labels(metric_labels)
5757
if add_custom_labels:
5858
metric_labels = BaseCollector._add_custom_labels(metric_labels, records_for_labels)
59-
59+
6060
collector = InfoMetricFamily(f'mktxp_{name}', documentation=documentation, labels=metric_labels)
6161

6262
deduplicated_records = BaseCollector._de_duplicate_records(records_for_processing, metric_labels, verbose_reporting)

mktxp/collector/bgp_collector.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def collect(router_entry):
3030
'uptime': lambda value: BaseOutputProcessor.parse_timedelta_milliseconds(value) if value else '0'
3131
}
3232
bgp_records = BGPMetricsDataSource.metric_records(router_entry, metric_labels=bgp_labels, translation_table = translation_table)
33-
33+
3434
if bgp_records:
3535
session_info_labes = ['name', 'remote_address', 'remote_as', 'local_as', 'remote_afi', 'local_afi']
3636
bgp_sessions_metrics = BaseCollector.info_collector('bgp_sessions', 'BGP sessions info', bgp_records, session_info_labes)

mktxp/collector/capsman_collector.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
class CapsmanCollector(BaseCollector):
2323
''' CAPsMAN Metrics collector
24-
'''
24+
'''
2525
@staticmethod
2626
def collect(router_entry):
2727
if not router_entry.config_entry.capsman:
@@ -54,7 +54,7 @@ def collect(router_entry):
5454
# translate / trim / augment registration records
5555
for registration_record in registration_records:
5656
BaseOutputProcessor.augment_record(router_entry, registration_record)
57-
57+
5858
tx_byte_metrics = BaseCollector.counter_collector('capsman_clients_tx_bytes', 'Number of sent packet bytes', registration_records, 'tx_bytes', ['dhcp_name', 'mac_address'])
5959
yield tx_byte_metrics
6060

@@ -64,7 +64,7 @@ def collect(router_entry):
6464
signal_strength_metrics = BaseCollector.gauge_collector('capsman_clients_signal_strength', 'Client devices signal strength', registration_records, 'rx_signal', ['dhcp_name', 'mac_address'])
6565
yield signal_strength_metrics
6666

67-
registration_metrics = BaseCollector.info_collector('capsman_clients_devices', 'Registered client devices info',
67+
registration_metrics = BaseCollector.info_collector('capsman_clients_devices', 'Registered client devices info',
6868
registration_records, ['dhcp_name', 'dhcp_address', 'rx_signal', 'ssid', 'tx_rate', 'rx_rate', 'interface', 'mac_address', 'uptime'])
6969
yield registration_metrics
7070

mktxp/utils/utils.py

Lines changed: 41 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def run_cmd(cmd, shell = False, quiet = False):
5656

5757
def parse_mkt_uptime(time):
5858
time_dict = re.match(r'((?P<weeks>\d+)w)?((?P<days>\d+)d)?((?P<hours>\d+)h)?((?P<minutes>\d+)m)?((?P<seconds>\d+)s)?', time).groupdict()
59-
delta = timedelta(**{key: int(value) for key, value in time_dict.items() if value}).total_seconds()
59+
delta = timedelta(**{key: int(value) for key, value in time_dict.items() if value}).total_seconds()
6060
return int(delta) if delta else 0
6161

6262
def str2bool(str_value, default = None):
@@ -71,7 +71,7 @@ def str2bool(str_value, default = None):
7171
return default
7272
else:
7373
raise ValueError(f'Invalid truth value: {str_value}')
74-
74+
7575
class FSHelper:
7676
''' File System ops helper
7777
'''
@@ -229,15 +229,15 @@ def __init__(self, interval, func, args=[], kwargs={}, process_name = None, repe
229229
self.process_name = process_name
230230
self.interval = interval
231231
self.restartable = restartable
232-
232+
233233
self.func = func
234234
self.args = args
235235
self.kwargs = kwargs
236-
236+
237237
self.finished = Event()
238238
self.run_once = Event()
239239
if not repeatable:
240-
self.run_once.set()
240+
self.run_once.set()
241241
self.process = Process(name = self.process_name, target=self._execute)
242242

243243
def start(self):
@@ -256,7 +256,7 @@ def _execute(self):
256256
self.func(*self.args, **self.kwargs)
257257
if self.finished.is_set() or self.run_once.is_set():
258258
break
259-
self.finished.wait(self.interval)
259+
self.finished.wait(self.interval)
260260

261261
class Benchmark:
262262
def __enter__(self):
@@ -281,29 +281,43 @@ def get_ttl_hash(seconds=3600):
281281
return round(time.time() / seconds)
282282

283283

284+
# Timeout for fetching update RSS feeds (in seconds)
285+
UPDATE_CHECK_TIMEOUT = 10
286+
284287
@lru_cache(maxsize=5)
285288
def get_available_updates(channel, ttl_hash=get_ttl_hash()):
286289
"""Check the RSS feed for available updates for a given update channel.
287290
This method fetches the RSS feed and returns all version from the parsed XML.
288-
Version numbers are parsed into version.Version instances (part of setuptools)."""
291+
Version numbers are parsed into version.Version instances (part of setuptools).
292+
293+
Errors are handled internally so that lru_cache caches the (empty) result,
294+
preventing repeated blocking retries on transient failures."""
289295
del ttl_hash
290-
rss_feed = CHANNEL_RSS_FEED_MAPPING[channel]
296+
rss_feed = CHANNEL_RSS_FEED_MAPPING.get(channel)
297+
if not rss_feed:
298+
print(f'Unknown update channel: {channel}')
299+
return []
291300

292301
print(f'Fetching available ROS releases from {rss_feed}')
293302
versions = []
294-
with urllib.request.urlopen(rss_feed) as response:
295-
result = response.read()
296-
root = ET.fromstring(result)
297-
channel = root[0]
298-
299-
for child in channel:
300-
# iterate over all updates
301-
if child.tag == 'item':
302-
title = child[0]
303-
# extract and parse the version number from title
304-
version_text = re.findall(r'[\d+\.]+', title.text)[0]
305-
version_number = parse(version_text)
306-
versions.append(version_number)
303+
try:
304+
with urllib.request.urlopen(rss_feed, timeout=UPDATE_CHECK_TIMEOUT) as response:
305+
result = response.read()
306+
root = ET.fromstring(result)
307+
rss_channel = root[0]
308+
309+
for child in rss_channel:
310+
# iterate over all updates
311+
if child.tag == 'item':
312+
title = child[0]
313+
# extract and parse the version number from title
314+
version_text = re.findall(r'[\d+\.]+', title.text)[0]
315+
version_number = parse(version_text)
316+
versions.append(version_number)
317+
except urllib.error.HTTPError as err:
318+
print(f'Update feed returned: {err}')
319+
except Exception as err:
320+
print(f'Could not check for updates, because: {err}')
307321
return versions
308322

309323

@@ -315,7 +329,7 @@ def parse_ros_version(string):
315329
1.2.3, stable
316330
317331
>>> parse_ros_version('7.14.3 (long-term)')
318-
7.14.3, long-term
332+
7.14.3, long-term
319333
"""
320334

321335
match = re.findall(r'([\d\.]+).*?\(([\w-]+)\)', string)
@@ -355,22 +369,13 @@ def check_for_updates(cur_version):
355369
"""Try to check if there is a newer version available.
356370
If anything goes wrong, it returns the same version.
357371
Returns a tuple: (<current version>, <newest version>)"""
358-
error = False
359372
try:
360373
cur_version, channel = parse_ros_version(cur_version)
361374
available_versions = get_available_updates(channel)
362-
newest_version = sorted(available_versions)[-1]
363-
except KeyError:
364-
print(f'unknown update channel {channel}')
365-
error = True
366-
except urllib.error.HTTPError as err:
367-
print(f'update feed returned: {err}')
368-
error = True
375+
if available_versions:
376+
newest_version = sorted(available_versions)[-1]
377+
return cur_version, newest_version
369378
except Exception as err:
370-
print(f'could not check for updates, because: {err}')
371-
error = True
372-
373-
if error:
374-
return cur_version, cur_version
379+
print(f'Could not check for updates, because: {err}')
375380

376-
return cur_version, newest_version
381+
return cur_version, cur_version

tests/utils/test_utils.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
## GNU General Public License for more details.
1313

1414
from datetime import timedelta
15+
from unittest.mock import patch, MagicMock
16+
import urllib.error
1517
import pytest
1618
from mktxp.utils import utils
1719
from packaging.version import parse
@@ -94,3 +96,79 @@ def test_str2bool_raise_value_error(str_value):
9496

9597
assert utils.str2bool(str_value, False) == False
9698
assert utils.str2bool(str_value, True) == True
99+
100+
101+
class TestGetAvailableUpdates:
102+
"""Tests for get_available_updates and check_for_updates."""
103+
104+
def setup_method(self):
105+
# Clear lru_cache between tests
106+
utils.get_available_updates.cache_clear()
107+
108+
def test_urlopen_called_with_timeout(self):
109+
"""urlopen must be called with an explicit timeout."""
110+
mock_response = MagicMock()
111+
mock_response.read.return_value = b'''<?xml version="1.0"?>
112+
<rss><channel>
113+
<item><title>7.16 changelog</title></item>
114+
</channel></rss>'''
115+
mock_response.__enter__ = lambda s: s
116+
mock_response.__exit__ = MagicMock(return_value=False)
117+
118+
with patch('urllib.request.urlopen', return_value=mock_response) as mock_urlopen:
119+
utils.get_available_updates('stable', ttl_hash=0)
120+
mock_urlopen.assert_called_once_with(
121+
'https://mikrotik.com/current.rss',
122+
timeout=utils.UPDATE_CHECK_TIMEOUT,
123+
)
124+
125+
def test_timeout_returns_empty_list(self):
126+
"""A timeout should return an empty list (not raise), so lru_cache caches it."""
127+
with patch('urllib.request.urlopen', side_effect=TimeoutError('timed out')):
128+
result = utils.get_available_updates('stable', ttl_hash=1)
129+
assert result == []
130+
131+
def test_http_error_returns_empty_list(self):
132+
"""An HTTP error should return an empty list."""
133+
with patch('urllib.request.urlopen', side_effect=urllib.error.HTTPError(
134+
url=None, code=503, msg='Service Unavailable', hdrs=None, fp=None
135+
)):
136+
result = utils.get_available_updates('stable', ttl_hash=2)
137+
assert result == []
138+
139+
def test_unknown_channel_returns_empty_list(self):
140+
"""An unknown channel should return an empty list without attempting a fetch."""
141+
with patch('urllib.request.urlopen') as mock_urlopen:
142+
result = utils.get_available_updates('nonexistent', ttl_hash=3)
143+
assert result == []
144+
mock_urlopen.assert_not_called()
145+
146+
def test_failure_result_is_cached(self):
147+
"""After a failure, the empty list result should be cached by lru_cache."""
148+
with patch('urllib.request.urlopen', side_effect=TimeoutError('timed out')) as mock_urlopen:
149+
utils.get_available_updates('stable', ttl_hash=4)
150+
utils.get_available_updates('stable', ttl_hash=4)
151+
# urlopen should only be called once; second call uses cache
152+
mock_urlopen.assert_called_once()
153+
154+
def test_check_for_updates_on_fetch_failure(self):
155+
"""check_for_updates should return cur == newest when the feed fetch fails."""
156+
with patch('urllib.request.urlopen', side_effect=TimeoutError('timed out')):
157+
cur, newest = utils.check_for_updates('7.15 (stable)')
158+
assert cur == newest == parse('7.15')
159+
160+
def test_check_for_updates_success(self):
161+
"""check_for_updates should return the newest version on success."""
162+
mock_response = MagicMock()
163+
mock_response.read.return_value = b'''<?xml version="1.0"?>
164+
<rss><channel>
165+
<item><title>7.16 changelog</title></item>
166+
<item><title>7.15.3 changelog</title></item>
167+
</channel></rss>'''
168+
mock_response.__enter__ = lambda s: s
169+
mock_response.__exit__ = MagicMock(return_value=False)
170+
171+
with patch('urllib.request.urlopen', return_value=mock_response):
172+
cur, newest = utils.check_for_updates('7.15 (stable)')
173+
assert cur == parse('7.15')
174+
assert newest == parse('7.16')

0 commit comments

Comments
 (0)