Skip to content

Commit a6bb1c3

Browse files
committed
MK8S-184 - Add test for restart script
1 parent 092df26 commit a6bb1c3

File tree

1 file changed

+258
-0
lines changed

1 file changed

+258
-0
lines changed
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
"""Tests for the restart-on-ca-change.py script."""
2+
3+
import importlib.util
4+
import os
5+
import os.path
6+
from unittest import TestCase
7+
from unittest.mock import MagicMock, mock_open, patch
8+
9+
# The script has a hyphenated filename, so we need importlib to load it
10+
_SCRIPT_PATH = os.path.join(
11+
os.path.dirname(os.path.abspath(__file__)),
12+
os.pardir,
13+
os.pardir,
14+
"metalk8s",
15+
"addons",
16+
"prometheus-operator",
17+
"deployed",
18+
"files",
19+
"restart-on-ca-change.py",
20+
)
21+
_spec = importlib.util.spec_from_file_location("restart_on_ca_change", _SCRIPT_PATH)
22+
restart_on_ca_change = importlib.util.module_from_spec(_spec)
23+
_spec.loader.exec_module(restart_on_ca_change)
24+
25+
26+
class TestHashDir(TestCase):
27+
"""Tests for hash_dir function."""
28+
29+
def test_empty_directory(self):
30+
"""hash_dir returns a consistent hash for an empty directory."""
31+
with patch("os.listdir", return_value=[]):
32+
result = restart_on_ca_change.hash_dir("/tmp/secrets")
33+
self.assertIsInstance(result, str)
34+
self.assertEqual(len(result), 64)
35+
36+
def test_skips_dotfiles(self):
37+
"""hash_dir ignores files starting with a dot."""
38+
with patch("os.listdir", return_value=[".hidden", "ca.crt"]), \
39+
patch("os.path.isfile", return_value=True), \
40+
patch("builtins.open", mock_open(read_data=b"cert-data")):
41+
result_with_dot = restart_on_ca_change.hash_dir("/tmp/secrets")
42+
43+
with patch("os.listdir", return_value=["ca.crt"]), \
44+
patch("os.path.isfile", return_value=True), \
45+
patch("builtins.open", mock_open(read_data=b"cert-data")):
46+
result_without_dot = restart_on_ca_change.hash_dir("/tmp/secrets")
47+
48+
self.assertEqual(result_with_dot, result_without_dot)
49+
50+
def test_different_content_different_hash(self):
51+
"""hash_dir returns different hashes for different file contents."""
52+
with patch("os.listdir", return_value=["ca.crt"]), \
53+
patch("os.path.isfile", return_value=True), \
54+
patch("builtins.open", mock_open(read_data=b"cert-v1")):
55+
hash_v1 = restart_on_ca_change.hash_dir("/tmp/secrets")
56+
57+
with patch("os.listdir", return_value=["ca.crt"]), \
58+
patch("os.path.isfile", return_value=True), \
59+
patch("builtins.open", mock_open(read_data=b"cert-v2")):
60+
hash_v2 = restart_on_ca_change.hash_dir("/tmp/secrets")
61+
62+
self.assertNotEqual(hash_v1, hash_v2)
63+
64+
def test_sorted_order_is_deterministic(self):
65+
"""hash_dir processes files in sorted order."""
66+
with patch("os.listdir", return_value=["b.crt", "a.crt"]), \
67+
patch("os.path.isfile", return_value=True), \
68+
patch("builtins.open", mock_open(read_data=b"data")):
69+
hash_unsorted = restart_on_ca_change.hash_dir("/tmp/secrets")
70+
71+
with patch("os.listdir", return_value=["a.crt", "b.crt"]), \
72+
patch("os.path.isfile", return_value=True), \
73+
patch("builtins.open", mock_open(read_data=b"data")):
74+
hash_sorted = restart_on_ca_change.hash_dir("/tmp/secrets")
75+
76+
self.assertEqual(hash_unsorted, hash_sorted)
77+
78+
79+
class TestMain(TestCase):
80+
"""Tests for main function."""
81+
82+
@patch("os.listdir", return_value=[])
83+
def test_empty_ca_directory_skips(self, _mock_listdir):
84+
"""main skips when CA directory is empty."""
85+
with patch("builtins.print") as mock_print:
86+
restart_on_ca_change.main()
87+
mock_print.assert_called_once_with(
88+
"CA directory empty, skipping"
89+
)
90+
91+
@patch("os.listdir", return_value=[".ca-hash-previous"])
92+
def test_only_dotfiles_skips(self, _mock_listdir):
93+
"""main skips when directory only contains dotfiles."""
94+
with patch("builtins.print") as mock_print:
95+
restart_on_ca_change.main()
96+
mock_print.assert_called_once_with(
97+
"CA directory empty, skipping"
98+
)
99+
100+
@patch("os.path.exists", return_value=False)
101+
@patch.object(restart_on_ca_change, "hash_dir", return_value="abc123")
102+
@patch("os.listdir", return_value=["ca.crt"])
103+
def test_initial_load_skips_restart(
104+
self, _mock_listdir, _mock_hash, _mock_exists
105+
):
106+
"""main writes hash and skips restart on initial load."""
107+
with patch("builtins.open", mock_open()), \
108+
patch("builtins.print") as mock_print:
109+
restart_on_ca_change.main()
110+
mock_print.assert_called_once_with(
111+
"Initial CA load, skipping restart"
112+
)
113+
114+
@patch.object(restart_on_ca_change, "hash_dir", return_value="new-hash")
115+
@patch("os.path.exists", return_value=True)
116+
@patch("os.listdir", return_value=["ca.crt"])
117+
def test_unchanged_hash_no_restart(
118+
self, _mock_listdir, _mock_exists, _mock_hash
119+
):
120+
"""main does nothing when hash has not changed."""
121+
with patch("builtins.open", mock_open(read_data="new-hash")), \
122+
patch("builtins.print") as mock_print:
123+
restart_on_ca_change.main()
124+
mock_print.assert_not_called()
125+
126+
@patch.dict(
127+
os.environ,
128+
{
129+
"POD_NAMESPACE": "metalk8s-monitoring",
130+
"DEPLOYMENT_NAME": "oauth2-proxy-prometheus",
131+
},
132+
)
133+
@patch.object(
134+
restart_on_ca_change, "trigger_restart"
135+
)
136+
@patch.object(
137+
restart_on_ca_change, "hash_dir", return_value="new-hash"
138+
)
139+
@patch("os.path.exists", return_value=True)
140+
@patch("os.listdir", return_value=["ca.crt"])
141+
def test_changed_hash_triggers_restart(
142+
self,
143+
_mock_listdir,
144+
_mock_exists,
145+
_mock_hash,
146+
mock_restart,
147+
):
148+
"""main triggers restart when hash has changed."""
149+
with patch("builtins.open", mock_open(read_data="old-hash")), \
150+
patch("builtins.print") as mock_print:
151+
restart_on_ca_change.main()
152+
153+
mock_restart.assert_called_once_with(
154+
"metalk8s-monitoring", "oauth2-proxy-prometheus"
155+
)
156+
self.assertIn(
157+
"Rolling restart triggered",
158+
mock_print.call_args[0][0],
159+
)
160+
161+
@patch.dict(
162+
os.environ,
163+
{
164+
"POD_NAMESPACE": "metalk8s-monitoring",
165+
"DEPLOYMENT_NAME": "oauth2-proxy-prometheus",
166+
},
167+
)
168+
@patch.object(
169+
restart_on_ca_change, "hash_dir", return_value="new-hash"
170+
)
171+
@patch("os.path.exists", return_value=True)
172+
@patch("os.listdir", return_value=["ca.crt"])
173+
def test_api_failure_exits_with_error(
174+
self, _mock_listdir, _mock_exists, _mock_hash
175+
):
176+
"""main exits with code 1 when the API call fails."""
177+
import urllib.error
178+
179+
with patch("builtins.open", mock_open(read_data="old-hash")), \
180+
patch.object(
181+
restart_on_ca_change,
182+
"trigger_restart",
183+
side_effect=urllib.error.URLError("refused"),
184+
), \
185+
patch("builtins.print"), \
186+
self.assertRaises(SystemExit) as ctx:
187+
restart_on_ca_change.main()
188+
189+
self.assertEqual(ctx.exception.code, 1)
190+
191+
@patch.dict(
192+
os.environ,
193+
{
194+
"POD_NAMESPACE": "metalk8s-monitoring",
195+
"DEPLOYMENT_NAME": "oauth2-proxy-prometheus",
196+
},
197+
)
198+
@patch.object(
199+
restart_on_ca_change, "hash_dir", return_value="new-hash"
200+
)
201+
@patch("os.path.exists", return_value=True)
202+
@patch("os.listdir", return_value=["ca.crt"])
203+
def test_hash_not_persisted_on_api_failure(
204+
self, _mock_listdir, _mock_exists, _mock_hash
205+
):
206+
"""Hash file is not updated when the API call fails."""
207+
import urllib.error
208+
209+
mock_file = mock_open(read_data="old-hash")
210+
211+
with patch("builtins.open", mock_file), \
212+
patch.object(
213+
restart_on_ca_change,
214+
"trigger_restart",
215+
side_effect=urllib.error.URLError("refused"),
216+
), \
217+
patch("builtins.print"):
218+
try:
219+
restart_on_ca_change.main()
220+
except SystemExit:
221+
pass
222+
223+
# Only the hash file read should have happened, no write
224+
write_calls = mock_file().write.call_args_list
225+
self.assertEqual(len(write_calls), 0)
226+
227+
228+
class TestTriggerRestart(TestCase):
229+
"""Tests for trigger_restart function."""
230+
231+
@patch("urllib.request.urlopen")
232+
@patch("ssl.create_default_context")
233+
@patch(
234+
"builtins.open",
235+
mock_open(read_data="fake-token"),
236+
)
237+
def test_sends_patch_request(self, _mock_ssl, mock_urlopen):
238+
"""trigger_restart sends a PATCH to the K8s API."""
239+
restart_on_ca_change.trigger_restart(
240+
"metalk8s-monitoring", "oauth2-proxy-prometheus"
241+
)
242+
243+
mock_urlopen.assert_called_once()
244+
req = mock_urlopen.call_args[0][0]
245+
self.assertEqual(req.method, "PATCH")
246+
self.assertIn(
247+
"/namespaces/metalk8s-monitoring/"
248+
"deployments/oauth2-proxy-prometheus",
249+
req.full_url,
250+
)
251+
self.assertEqual(
252+
req.get_header("Content-type"),
253+
"application/strategic-merge-patch+json",
254+
)
255+
self.assertIn(
256+
"Bearer fake-token",
257+
req.get_header("Authorization"),
258+
)

0 commit comments

Comments
 (0)