]>
Commit | Line | Data |
---|---|---|
3efd9988 | 1 | |
20effc67 TL |
2 | from mgr_module import MgrModule, CommandResult, HandleCommandResult, CLICommand, Option |
3 | import enum | |
a4b75251 TL |
4 | import json |
5 | import random | |
6 | import sys | |
7 | import threading | |
20effc67 TL |
8 | from code import InteractiveInterpreter |
9 | from contextlib import redirect_stderr, redirect_stdout | |
10 | from io import StringIO | |
11 | from typing import Any, Dict, List, Optional, Tuple | |
12 | ||
13 | ||
14 | # These workloads are things that can be requested to run inside the | |
15 | # serve() function | |
16 | class Workload(enum.Enum): | |
17 | COMMAND_SPAM = 'command_spam' | |
18 | THROW_EXCEPTION = 'throw_exception' | |
19 | SHUTDOWN = 'shutdown' | |
3efd9988 FG |
20 | |
21 | ||
22 | class Module(MgrModule): | |
23 | """ | |
24 | This module is for testing the ceph-mgr python interface from within | |
25 | a running ceph-mgr daemon. | |
26 | ||
27 | It implements a sychronous self-test command for calling the functions | |
28 | in the MgrModule interface one by one, and a background "workload" | |
29 | command for causing the module to perform some thrashing-type | |
30 | activities in its serve() thread. | |
31 | """ | |
32 | ||
11fdf7f2 TL |
33 | # The test code in qa/ relies on these options existing -- they |
34 | # are of course not really used for anything in the module | |
35 | MODULE_OPTIONS = [ | |
20effc67 TL |
36 | Option(name='testkey'), |
37 | Option(name='testlkey'), | |
38 | Option(name='testnewline'), | |
39 | Option(name='roption1'), | |
40 | Option(name='roption2', | |
41 | type='str', | |
42 | default='xyz'), | |
43 | Option(name='rwoption1'), | |
44 | Option(name='rwoption2', | |
45 | type='int'), | |
46 | Option(name='rwoption3', | |
47 | type='float'), | |
48 | Option(name='rwoption4', | |
49 | type='str'), | |
50 | Option(name='rwoption5', | |
51 | type='bool'), | |
52 | Option(name='rwoption6', | |
53 | type='bool', | |
54 | default=True), | |
55 | Option(name='rwoption7', | |
56 | type='int', | |
57 | min=1, | |
58 | max=42), | |
11fdf7f2 | 59 | ] |
3efd9988 | 60 | |
20effc67 | 61 | def __init__(self, *args: Any, **kwargs: Any) -> None: |
3efd9988 FG |
62 | super(Module, self).__init__(*args, **kwargs) |
63 | self._event = threading.Event() | |
20effc67 TL |
64 | self._workload: Optional[Workload] = None |
65 | self._health: Dict[str, Dict[str, Any]] = {} | |
66 | self._repl = InteractiveInterpreter(dict(mgr=self)) | |
67 | ||
68 | @CLICommand('mgr self-test python-version', perm='r') | |
69 | def python_version(self) -> Tuple[int, str, str]: | |
70 | ''' | |
71 | Query the version of the embedded Python runtime | |
72 | ''' | |
73 | major = sys.version_info.major | |
74 | minor = sys.version_info.minor | |
75 | micro = sys.version_info.micro | |
76 | return 0, f'{major}.{minor}.{micro}', '' | |
77 | ||
78 | @CLICommand('mgr self-test run') | |
79 | def run(self) -> Tuple[int, str, str]: | |
80 | ''' | |
81 | Run mgr python interface tests | |
82 | ''' | |
83 | self._self_test() | |
84 | return 0, '', 'Self-test succeeded' | |
85 | ||
86 | @CLICommand('mgr self-test background start') | |
87 | def backgroun_start(self, workload: Workload) -> Tuple[int, str, str]: | |
88 | ''' | |
89 | Activate a background workload (one of command_spam, throw_exception) | |
90 | ''' | |
91 | self._workload = workload | |
92 | self._event.set() | |
93 | return 0, '', 'Running `{0}` in background'.format(self._workload) | |
94 | ||
95 | @CLICommand('mgr self-test background stop') | |
96 | def background_stop(self) -> Tuple[int, str, str]: | |
97 | ''' | |
98 | Stop background workload if any is running | |
99 | ''' | |
100 | if self._workload: | |
101 | was_running = self._workload | |
102 | self._workload = None | |
3efd9988 | 103 | self._event.set() |
20effc67 TL |
104 | return 0, '', 'Stopping background workload `{0}`'.format( |
105 | was_running) | |
3efd9988 | 106 | else: |
20effc67 TL |
107 | return 0, '', 'No background workload was running' |
108 | ||
109 | @CLICommand('mgr self-test config get') | |
110 | def config_get(self, key: str) -> Tuple[int, str, str]: | |
111 | ''' | |
112 | Peek at a configuration value | |
113 | ''' | |
114 | return 0, str(self.get_module_option(key)), '' | |
115 | ||
116 | @CLICommand('mgr self-test config get_localized') | |
117 | def config_get_localized(self, key: str) -> Tuple[int, str, str]: | |
118 | ''' | |
119 | Peek at a configuration value (localized variant) | |
120 | ''' | |
121 | return 0, str(self.get_localized_module_option(key)), '' | |
122 | ||
123 | @CLICommand('mgr self-test remote') | |
124 | def test_remote(self) -> Tuple[int, str, str]: | |
125 | ''' | |
126 | Test inter-module calls | |
127 | ''' | |
128 | self._test_remote_calls() | |
129 | return 0, '', 'Successfully called' | |
130 | ||
131 | @CLICommand('mgr self-test module') | |
132 | def module(self, module: str) -> Tuple[int, str, str]: | |
133 | ''' | |
134 | Run another module's self_test() method | |
135 | ''' | |
136 | try: | |
137 | r = self.remote(module, "self_test") | |
138 | except RuntimeError as e: | |
139 | return -1, '', "Test failed: {0}".format(e) | |
140 | else: | |
141 | return 0, str(r), "Self-test OK" | |
142 | ||
143 | @CLICommand('mgr self-test cluster-log') | |
144 | def do_cluster_log(self, | |
145 | channel: str, | |
146 | priority: str, | |
147 | message: str) -> Tuple[int, str, str]: | |
148 | ''' | |
149 | Create an audit log record. | |
150 | ''' | |
151 | priority_map = { | |
152 | 'info': self.ClusterLogPrio.INFO, | |
153 | 'security': self.ClusterLogPrio.SEC, | |
154 | 'warning': self.ClusterLogPrio.WARN, | |
155 | 'error': self.ClusterLogPrio.ERROR | |
156 | } | |
157 | self.cluster_log(channel, | |
158 | priority_map[priority], | |
159 | message) | |
160 | return 0, '', 'Successfully called' | |
161 | ||
162 | @CLICommand('mgr self-test health set') | |
163 | def health_set(self, checks: str) -> Tuple[int, str, str]: | |
164 | ''' | |
165 | Set a health check from a JSON-formatted description. | |
166 | ''' | |
11fdf7f2 | 167 | try: |
20effc67 | 168 | health_check = json.loads(checks) |
11fdf7f2 | 169 | except Exception as e: |
494da23a | 170 | return -1, "", "Failed to decode JSON input: {}".format(e) |
11fdf7f2 TL |
171 | |
172 | try: | |
20effc67 | 173 | for check, info in health_check.items(): |
11fdf7f2 TL |
174 | self._health[check] = { |
175 | "severity": str(info["severity"]), | |
176 | "summary": str(info["summary"]), | |
9f95a23c | 177 | "count": 123, |
11fdf7f2 TL |
178 | "detail": [str(m) for m in info["detail"]] |
179 | } | |
180 | except Exception as e: | |
494da23a | 181 | return -1, "", "Invalid health check format: {}".format(e) |
11fdf7f2 TL |
182 | |
183 | self.set_health_checks(self._health) | |
184 | return 0, "", "" | |
185 | ||
20effc67 TL |
186 | @CLICommand('mgr self-test health clear') |
187 | def health_clear(self, checks: Optional[List[str]] = None) -> Tuple[int, str, str]: | |
188 | ''' | |
189 | Clear health checks by name. If no names provided, clear all. | |
190 | ''' | |
191 | if checks is not None: | |
192 | for check in checks: | |
11fdf7f2 TL |
193 | if check in self._health: |
194 | del self._health[check] | |
195 | else: | |
196 | self._health = dict() | |
197 | ||
198 | self.set_health_checks(self._health) | |
199 | return 0, "", "" | |
200 | ||
20effc67 TL |
201 | @CLICommand('mgr self-test insights_set_now_offset') |
202 | def insights_set_now_offset(self, hours: int) -> Tuple[int, str, str]: | |
203 | ''' | |
204 | Set the now time for the insights module. | |
205 | ''' | |
11fdf7f2 TL |
206 | self.remote("insights", "testing_set_now_time_offset", hours) |
207 | return 0, "", "" | |
208 | ||
20effc67 | 209 | def _self_test(self) -> None: |
3efd9988 FG |
210 | self.log.info("Running self-test procedure...") |
211 | ||
212 | self._self_test_osdmap() | |
213 | self._self_test_getters() | |
214 | self._self_test_config() | |
11fdf7f2 | 215 | self._self_test_store() |
3efd9988 FG |
216 | self._self_test_misc() |
217 | self._self_test_perf_counters() | |
218 | ||
20effc67 | 219 | def _self_test_getters(self) -> None: |
3efd9988 FG |
220 | self.version |
221 | self.get_context() | |
222 | self.get_mgr_id() | |
223 | ||
224 | # In this function, we will assume that the system is in a steady | |
225 | # state, i.e. if a server/service appears in one call, it will | |
226 | # not have gone by the time we call another function referring to it | |
227 | ||
228 | objects = [ | |
20effc67 TL |
229 | "fs_map", |
230 | "osdmap_crush_map_text", | |
231 | "osd_map", | |
232 | "config", | |
233 | "mon_map", | |
234 | "service_map", | |
235 | "osd_metadata", | |
236 | "pg_summary", | |
237 | "pg_status", | |
238 | "pg_dump", | |
239 | "pg_ready", | |
240 | "df", | |
241 | "pg_stats", | |
242 | "pool_stats", | |
243 | "osd_stats", | |
244 | "osd_ping_times", | |
245 | "health", | |
246 | "mon_status", | |
247 | "mgr_map" | |
248 | ] | |
3efd9988 | 249 | for obj in objects: |
11fdf7f2 TL |
250 | assert self.get(obj) is not None |
251 | ||
252 | assert self.get("__OBJ_DNE__") is None | |
3efd9988 FG |
253 | |
254 | servers = self.list_servers() | |
255 | for server in servers: | |
20effc67 | 256 | self.get_server(server['hostname']) # type: ignore |
3efd9988 FG |
257 | |
258 | osdmap = self.get('osd_map') | |
259 | for o in osdmap['osds']: | |
260 | osd_id = o['osd'] | |
261 | self.get_metadata("osd", str(osd_id)) | |
262 | ||
263 | self.get_daemon_status("osd", "0") | |
3efd9988 | 264 | |
20effc67 | 265 | def _self_test_config(self) -> None: |
3efd9988 FG |
266 | # This is not a strong test (can't tell if values really |
267 | # persisted), it's just for the python interface bit. | |
268 | ||
11fdf7f2 TL |
269 | self.set_module_option("testkey", "testvalue") |
270 | assert self.get_module_option("testkey") == "testvalue" | |
271 | ||
272 | self.set_localized_module_option("testkey", "foo") | |
273 | assert self.get_localized_module_option("testkey") == "foo" | |
274 | ||
275 | # Must return the default value defined in MODULE_OPTIONS. | |
276 | value = self.get_localized_module_option("rwoption6") | |
277 | assert isinstance(value, bool) | |
278 | assert value is True | |
279 | ||
280 | # Use default value. | |
281 | assert self.get_module_option("roption1") is None | |
282 | assert self.get_module_option("roption1", "foobar") == "foobar" | |
283 | assert self.get_module_option("roption2") == "xyz" | |
284 | assert self.get_module_option("roption2", "foobar") == "xyz" | |
285 | ||
286 | # Option type is not defined => return as string. | |
287 | self.set_module_option("rwoption1", 8080) | |
288 | value = self.get_module_option("rwoption1") | |
289 | assert isinstance(value, str) | |
290 | assert value == "8080" | |
291 | ||
292 | # Option type is defined => return as integer. | |
293 | self.set_module_option("rwoption2", 10) | |
294 | value = self.get_module_option("rwoption2") | |
295 | assert isinstance(value, int) | |
296 | assert value == 10 | |
297 | ||
298 | # Option type is defined => return as float. | |
299 | self.set_module_option("rwoption3", 1.5) | |
300 | value = self.get_module_option("rwoption3") | |
301 | assert isinstance(value, float) | |
302 | assert value == 1.5 | |
303 | ||
304 | # Option type is defined => return as string. | |
305 | self.set_module_option("rwoption4", "foo") | |
306 | value = self.get_module_option("rwoption4") | |
307 | assert isinstance(value, str) | |
308 | assert value == "foo" | |
309 | ||
310 | # Option type is defined => return as bool. | |
311 | self.set_module_option("rwoption5", False) | |
312 | value = self.get_module_option("rwoption5") | |
313 | assert isinstance(value, bool) | |
314 | assert value is False | |
315 | ||
20effc67 TL |
316 | # Option value range is specified |
317 | try: | |
318 | self.set_module_option("rwoption7", 43) | |
319 | except Exception as e: | |
320 | assert isinstance(e, ValueError) | |
321 | else: | |
322 | message = "should raise if value is not in specified range" | |
323 | assert False, message | |
324 | ||
11fdf7f2 TL |
325 | # Specified module does not exist => return None. |
326 | assert self.get_module_option_ex("foo", "bar") is None | |
327 | ||
328 | # Specified key does not exist => return None. | |
329 | assert self.get_module_option_ex("dashboard", "bar") is None | |
330 | ||
331 | self.set_module_option_ex("telemetry", "contact", "test@test.com") | |
332 | assert self.get_module_option_ex("telemetry", "contact") == "test@test.com" | |
333 | ||
334 | # No option default value, so use the specified one. | |
335 | assert self.get_module_option_ex("dashboard", "password") is None | |
336 | assert self.get_module_option_ex("dashboard", "password", "foobar") == "foobar" | |
337 | ||
338 | # Option type is not defined => return as string. | |
339 | self.set_module_option_ex("selftest", "rwoption1", 1234) | |
340 | value = self.get_module_option_ex("selftest", "rwoption1") | |
341 | assert isinstance(value, str) | |
342 | assert value == "1234" | |
343 | ||
344 | # Option type is defined => return as integer. | |
345 | self.set_module_option_ex("telemetry", "interval", 60) | |
346 | value = self.get_module_option_ex("telemetry", "interval") | |
347 | assert isinstance(value, int) | |
348 | assert value == 60 | |
349 | ||
350 | # Option type is defined => return as bool. | |
351 | self.set_module_option_ex("telemetry", "leaderboard", True) | |
352 | value = self.get_module_option_ex("telemetry", "leaderboard") | |
353 | assert isinstance(value, bool) | |
354 | assert value is True | |
355 | ||
20effc67 | 356 | def _self_test_store(self) -> None: |
11fdf7f2 TL |
357 | existing_keys = set(self.get_store_prefix("test").keys()) |
358 | self.set_store("testkey", "testvalue") | |
359 | assert self.get_store("testkey") == "testvalue" | |
360 | ||
20effc67 TL |
361 | assert (set(self.get_store_prefix("test").keys()) |
362 | == {"testkey"} | existing_keys) | |
3efd9988 | 363 | |
20effc67 | 364 | def _self_test_perf_counters(self) -> None: |
3efd9988 FG |
365 | self.get_perf_schema("osd", "0") |
366 | self.get_counter("osd", "0", "osd.op") | |
20effc67 TL |
367 | # get_counter |
368 | # get_all_perf_coutners | |
3efd9988 | 369 | |
20effc67 | 370 | def _self_test_misc(self) -> None: |
3efd9988 FG |
371 | self.set_uri("http://this.is.a.test.com") |
372 | self.set_health_checks({}) | |
373 | ||
20effc67 | 374 | def _self_test_osdmap(self) -> None: |
3efd9988 FG |
375 | osdmap = self.get_osdmap() |
376 | osdmap.get_epoch() | |
377 | osdmap.get_crush_version() | |
378 | osdmap.dump() | |
379 | ||
380 | inc = osdmap.new_incremental() | |
381 | osdmap.apply_incremental(inc) | |
382 | inc.get_epoch() | |
383 | inc.dump() | |
384 | ||
385 | crush = osdmap.get_crush() | |
386 | crush.dump() | |
387 | crush.get_item_name(-1) | |
388 | crush.get_item_weight(-1) | |
389 | crush.find_takes() | |
390 | crush.get_take_weight_osd_map(-1) | |
391 | ||
20effc67 TL |
392 | # osdmap.get_pools_by_take() |
393 | # osdmap.calc_pg_upmaps() | |
394 | # osdmap.map_pools_pgs_up() | |
3efd9988 | 395 | |
20effc67 TL |
396 | # inc.set_osd_reweights |
397 | # inc.set_crush_compat_weight_set_weights | |
3efd9988 FG |
398 | |
399 | self.log.info("Finished self-test procedure.") | |
400 | ||
20effc67 | 401 | def _test_remote_calls(self) -> None: |
11fdf7f2 | 402 | # Test making valid call |
20effc67 | 403 | self.remote("influx", "self_test") |
11fdf7f2 TL |
404 | |
405 | # Test calling module that exists but isn't enabled | |
406 | # (arbitrarily pick a non-always-on module to use) | |
407 | disabled_module = "telegraf" | |
408 | mgr_map = self.get("mgr_map") | |
409 | assert disabled_module not in mgr_map['modules'] | |
410 | ||
411 | # (This works until the Z release in about 2027) | |
412 | latest_release = sorted(mgr_map['always_on_modules'].keys())[-1] | |
413 | assert disabled_module not in mgr_map['always_on_modules'][latest_release] | |
414 | ||
415 | try: | |
416 | self.remote(disabled_module, "handle_command", {"prefix": "influx self-test"}) | |
417 | except ImportError: | |
418 | pass | |
419 | else: | |
420 | raise RuntimeError("ImportError not raised for disabled module") | |
421 | ||
422 | # Test calling module that doesn't exist | |
423 | try: | |
20effc67 | 424 | self.remote("idontexist", "self_test") |
11fdf7f2 TL |
425 | except ImportError: |
426 | pass | |
427 | else: | |
428 | raise RuntimeError("ImportError not raised for nonexistent module") | |
429 | ||
430 | # Test calling method that doesn't exist | |
431 | try: | |
20effc67 | 432 | self.remote("influx", "idontexist") |
11fdf7f2 TL |
433 | except NameError: |
434 | pass | |
435 | else: | |
436 | raise RuntimeError("KeyError not raised") | |
437 | ||
20effc67 | 438 | def remote_from_orchestrator_cli_self_test(self, what: str) -> Any: |
9f95a23c TL |
439 | import orchestrator |
440 | if what == 'OrchestratorError': | |
20effc67 | 441 | return orchestrator.OrchResult(result=None, exception=orchestrator.OrchestratorError('hello, world')) |
9f95a23c | 442 | elif what == "ZeroDivisionError": |
20effc67 | 443 | return orchestrator.OrchResult(result=None, exception=ZeroDivisionError('hello, world')) |
9f95a23c | 444 | assert False, repr(what) |
11fdf7f2 | 445 | |
20effc67 TL |
446 | def shutdown(self) -> None: |
447 | self._workload = Workload.SHUTDOWN | |
3efd9988 FG |
448 | self._event.set() |
449 | ||
20effc67 | 450 | def _command_spam(self) -> None: |
3efd9988 FG |
451 | self.log.info("Starting command_spam workload...") |
452 | while not self._event.is_set(): | |
453 | osdmap = self.get_osdmap() | |
454 | dump = osdmap.dump() | |
455 | count = len(dump['osds']) | |
456 | i = int(random.random() * count) | |
457 | w = random.random() | |
458 | ||
459 | result = CommandResult('') | |
460 | self.send_command(result, 'mon', '', json.dumps({ | |
461 | 'prefix': 'osd reweight', | |
462 | 'id': i, | |
20effc67 | 463 | 'weight': w}), '') |
3efd9988 | 464 | |
20effc67 | 465 | _ = osdmap.get_crush().dump() |
3efd9988 FG |
466 | r, outb, outs = result.wait() |
467 | ||
468 | self._event.clear() | |
469 | self.log.info("Ended command_spam workload...") | |
470 | ||
20effc67 TL |
471 | @CLICommand('mgr self-test eval') |
472 | def eval(self, | |
473 | s: Optional[str] = None, | |
474 | inbuf: Optional[str] = None) -> HandleCommandResult: | |
475 | ''' | |
476 | eval given source | |
477 | ''' | |
478 | source = s or inbuf | |
479 | if source is None: | |
480 | return HandleCommandResult(-1, '', 'source is not specified') | |
481 | ||
482 | err = StringIO() | |
483 | out = StringIO() | |
484 | with redirect_stderr(err), redirect_stdout(out): | |
485 | needs_more = self._repl.runsource(source) | |
486 | if needs_more: | |
487 | retval = 2 | |
488 | stdout = '' | |
489 | stderr = '' | |
490 | else: | |
491 | retval = 0 | |
492 | stdout = out.getvalue() | |
493 | stderr = err.getvalue() | |
494 | return HandleCommandResult(retval, stdout, stderr) | |
495 | ||
496 | def serve(self) -> None: | |
3efd9988 | 497 | while True: |
20effc67 | 498 | if self._workload == Workload.COMMAND_SPAM: |
3efd9988 | 499 | self._command_spam() |
20effc67 | 500 | elif self._workload == Workload.SHUTDOWN: |
3efd9988 FG |
501 | self.log.info("Shutting down...") |
502 | break | |
20effc67 | 503 | elif self._workload == Workload.THROW_EXCEPTION: |
11fdf7f2 | 504 | raise RuntimeError("Synthetic exception in serve") |
3efd9988 FG |
505 | else: |
506 | self.log.info("Waiting for workload request...") | |
507 | self._event.wait() | |
508 | self._event.clear() |