Skip to content

Commit b3366ae

Browse files
committed
Add repository cache utility
Introduce a singleton class for caching repository configurations with automatic refresh based on a time-to-live (TTL) mechanism. Implement methods to fetch repositories from the cache or API, and handle cache invalidation on errors. - Added `RepositoryCache` class with singleton pattern. - Implemented `_needs_refresh` to check TTL expiration. - Created `_refresh_cache` to update cache from API. - Provided `get_repository` method for fetching cached data. - Included error handling during cache refresh. Tests: - Verified singleton behavior of `RepositoryCache`. - Tested cache refresh logic and repository retrieval with filters. - Ensured proper handling of non-existent repositories and API errors.
1 parent c9990de commit b3366ae

File tree

2 files changed

+255
-0
lines changed

2 files changed

+255
-0
lines changed

Diff for: plugins/module_utils/repositoryCache.py

+78
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright: (c) 2025, Brian Veltman <[email protected]>
4+
# GNU General Public License v3.0+ (see https://www.gnu.org/licenses/gpl-3.0.txt)
5+
6+
7+
from __future__ import (absolute_import, division, print_function)
8+
__metaclass__ = type
9+
10+
from datetime import (
11+
datetime,
12+
timedelta
13+
)
14+
import json
15+
from ansible.module_utils.urls import (
16+
open_url
17+
)
18+
19+
20+
class RepositoryCache:
21+
"""Cache for repository configurations"""
22+
_instance = None
23+
_cache = {}
24+
_last_update = None
25+
_cache_ttl = 300 # 5 minutes
26+
27+
def __new__(cls):
28+
if cls._instance is None:
29+
cls._instance = super(RepositoryCache, cls).__new__(cls)
30+
return cls._instance
31+
32+
def get_repository(self, base_url, name, type=None, format=None, headers=None, validate_certs=True, timeout=30):
33+
"""Get repository from cache or API"""
34+
# Check if cache needs refresh
35+
if self._needs_refresh():
36+
self._refresh_cache(base_url, headers, validate_certs, timeout)
37+
38+
# Look for repository in cache
39+
for cache_key, repo in self._cache.items():
40+
if (repo['name'] == name and
41+
(format is None or repo['format'] == format) and
42+
(type is None or repo['type'] == type)):
43+
return repo
44+
45+
return None
46+
47+
def _needs_refresh(self):
48+
"""Check if cache needs to be refreshed"""
49+
if not self._last_update:
50+
return True
51+
52+
return datetime.now() - self._last_update > timedelta(seconds=self._cache_ttl)
53+
54+
def _refresh_cache(self, base_url, headers, validate_certs, timeout):
55+
"""Refresh cache from API"""
56+
url = f"{base_url}/service/rest/v1/repositorySettings"
57+
58+
try:
59+
response = open_url(
60+
url,
61+
headers=headers,
62+
validate_certs=validate_certs,
63+
timeout=timeout,
64+
method='GET'
65+
)
66+
repositories = json.loads(response.read())
67+
68+
# Update cache
69+
self._cache = {
70+
f"{base_url}:{repo['format']}:{repo['type']}:{repo['name']}": repo
71+
for repo in repositories
72+
}
73+
self._last_update = datetime.now()
74+
except Exception:
75+
# On failure, invalidate cache
76+
self._cache = {}
77+
self._last_update = None
78+
raise
+177
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
#!/usr/bin/env python
2+
# -*- coding: utf-8 -*-
3+
#
4+
# Copyright: (c) 2025, Brian Veltman <[email protected]>
5+
# GNU General Public License v3.0+ (see https://www.gnu.org/licenses/gpl-3.0.txt)
6+
7+
8+
from __future__ import (absolute_import, division, print_function)
9+
__metaclass__ = type
10+
11+
import json
12+
from datetime import (
13+
datetime,
14+
timedelta
15+
)
16+
from unittest.mock import MagicMock, patch
17+
18+
import pytest
19+
20+
from ansible_collections.cloudkrafter.nexus.plugins.module_utils.repositoryCache import RepositoryCache
21+
22+
23+
class TestRepositoryCache:
24+
"""Test cases for RepositoryCache utility"""
25+
26+
@pytest.fixture
27+
def cache(self):
28+
"""Fixture to provide clean cache instance"""
29+
cache = RepositoryCache()
30+
cache._cache = {}
31+
cache._last_update = None
32+
return cache
33+
34+
@pytest.fixture
35+
def mock_repo_data(self):
36+
"""Fixture providing sample repository data"""
37+
return [
38+
{
39+
"name": "test-repo",
40+
"format": "npm",
41+
"type": "proxy",
42+
"url": "http://localhost:8081/repository/test-repo",
43+
"online": True
44+
}
45+
]
46+
47+
def test_singleton_pattern(self):
48+
"""Test that RepositoryCache is a singleton"""
49+
cache1 = RepositoryCache()
50+
cache2 = RepositoryCache()
51+
assert cache1 is cache2
52+
53+
def test_needs_refresh_initial(self, cache):
54+
"""Test _needs_refresh returns True on initial state"""
55+
assert cache._needs_refresh() is True
56+
57+
def test_needs_refresh_recent(self, cache):
58+
"""Test _needs_refresh returns False for recent updates"""
59+
cache._last_update = datetime.now()
60+
assert cache._needs_refresh() is False
61+
62+
def test_needs_refresh_expired(self, cache):
63+
"""Test _needs_refresh returns True for expired cache"""
64+
cache._last_update = datetime.now() - timedelta(seconds=301) # TTL + 1
65+
assert cache._needs_refresh() is True
66+
67+
def test_get_repository_with_format_and_type(self, cache):
68+
"""Test getting repository with format and type filtering"""
69+
# Setup cache with multiple repositories
70+
cache._cache = {
71+
"http://localhost:8081:npm:proxy:test-repo": {
72+
"name": "test-repo",
73+
"format": "npm",
74+
"type": "proxy"
75+
},
76+
"http://localhost:8081:npm:hosted:test-repo": {
77+
"name": "test-repo",
78+
"format": "npm",
79+
"type": "hosted"
80+
}
81+
}
82+
cache._last_update = datetime.now()
83+
84+
# Test specific format and type
85+
result = cache.get_repository(
86+
"http://localhost:8081",
87+
"test-repo",
88+
format="npm",
89+
type="proxy",
90+
headers={},
91+
validate_certs=True,
92+
timeout=30
93+
)
94+
95+
assert result["type"] == "proxy"
96+
assert result["format"] == "npm"
97+
assert result["name"] == "test-repo"
98+
99+
def test_get_repository_no_match(self, cache, mock_repo_data):
100+
"""Test getting non-existent repository"""
101+
mock_response = MagicMock()
102+
mock_response.read.return_value = json.dumps(
103+
mock_repo_data).encode('utf-8')
104+
105+
with patch('ansible_collections.cloudkrafter.nexus.plugins.module_utils.repositoryCache.open_url') as mock_open_url:
106+
mock_open_url.return_value = mock_response
107+
108+
result = cache.get_repository(
109+
"http://localhost:8081",
110+
"non-existent",
111+
format="maven",
112+
type="proxy",
113+
headers={"accept": "application/json"},
114+
validate_certs=True,
115+
timeout=30
116+
)
117+
118+
assert result is None
119+
120+
def test_get_repository_refresh(self, cache, mock_repo_data):
121+
"""Test getting repository with cache refresh"""
122+
mock_response = MagicMock()
123+
mock_response.read.return_value = json.dumps(
124+
mock_repo_data).encode('utf-8')
125+
126+
with patch('ansible_collections.cloudkrafter.nexus.plugins.module_utils.repositoryCache.open_url') as mock_open_url:
127+
mock_open_url.return_value = mock_response
128+
129+
result = cache.get_repository(
130+
base_url="http://localhost:8081",
131+
name="test-repo",
132+
format="npm",
133+
type="proxy",
134+
headers={"accept": "application/json"},
135+
validate_certs=True,
136+
timeout=30
137+
)
138+
139+
# Verify result
140+
assert result["name"] == "test-repo"
141+
assert result["format"] == "npm"
142+
assert result["type"] == "proxy"
143+
144+
# Verify API call
145+
mock_open_url.assert_called_once_with(
146+
'http://localhost:8081/service/rest/v1/repositorySettings',
147+
headers={"accept": "application/json"},
148+
validate_certs=True,
149+
timeout=30,
150+
method='GET'
151+
)
152+
153+
def test_refresh_cache_error(self, cache):
154+
"""Test cache invalidation on refresh error"""
155+
cache._cache = {
156+
"http://localhost:8081:npm:proxy:test-repo": {
157+
"name": "test-repo",
158+
"format": "npm",
159+
"type": "proxy"
160+
}
161+
}
162+
cache._last_update = datetime.now()
163+
164+
with patch('ansible_collections.cloudkrafter.nexus.plugins.module_utils.repositoryCache.open_url') as mock_open_url:
165+
mock_open_url.side_effect = Exception("API Error")
166+
167+
with pytest.raises(Exception) as excinfo:
168+
cache._refresh_cache(
169+
base_url="http://localhost:8081",
170+
headers={},
171+
validate_certs=True,
172+
timeout=30
173+
)
174+
175+
assert "API Error" in str(excinfo.value)
176+
assert cache._cache == {}
177+
assert cache._last_update is None

0 commit comments

Comments
 (0)