import pytest import json from tests import mock from cephadm.tuned_profiles import TunedProfileUtils, SYSCTL_DIR from cephadm.inventory import TunedProfileStore from ceph.utils import datetime_now from ceph.deployment.service_spec import TunedProfileSpec, PlacementSpec from cephadm.ssh import SSHManager from orchestrator import HostSpec from typing import List, Dict class SaveError(Exception): pass class FakeCache: def __init__(self, hosts, schedulable_hosts, unreachable_hosts): self.hosts = hosts self.unreachable_hosts = [HostSpec(h) for h in unreachable_hosts] self.schedulable_hosts = [HostSpec(h) for h in schedulable_hosts] self.last_tuned_profile_update = {} def get_hosts(self): return self.hosts def get_schedulable_hosts(self): return self.schedulable_hosts def get_unreachable_hosts(self): return self.unreachable_hosts def get_draining_hosts(self): return [] def is_host_unreachable(self, hostname: str): return hostname in [h.hostname for h in self.get_unreachable_hosts()] def is_host_schedulable(self, hostname: str): return hostname in [h.hostname for h in self.get_schedulable_hosts()] def is_host_draining(self, hostname: str): return hostname in [h.hostname for h in self.get_draining_hosts()] @property def networks(self): return {h: {'a': {'b': ['c']}} for h in self.hosts} def host_needs_tuned_profile_update(self, host, profile_name): return profile_name == 'p2' class FakeMgr: def __init__(self, hosts: List[str], schedulable_hosts: List[str], unreachable_hosts: List[str], profiles: Dict[str, TunedProfileSpec]): self.cache = FakeCache(hosts, schedulable_hosts, unreachable_hosts) self.tuned_profiles = TunedProfileStore(self) self.tuned_profiles.profiles = profiles self.ssh = SSHManager(self) self.offline_hosts = [] self.log_refresh_metadata = False def set_store(self, what: str, value: str): raise SaveError(f'{what}: {value}') def get_store(self, what: str): if what == 'tuned_profiles': return json.dumps({'x': TunedProfileSpec('x', PlacementSpec(hosts=['x']), {'x': 'x'}).to_json(), 'y': TunedProfileSpec('y', PlacementSpec(hosts=['y']), {'y': 'y'}).to_json()}) return '' class TestTunedProfiles: tspec1 = TunedProfileSpec('p1', PlacementSpec(hosts=['a', 'b', 'c']), {'setting1': 'value1', 'setting2': 'value2', 'setting with space': 'value with space'}) tspec2 = TunedProfileSpec('p2', PlacementSpec(hosts=['a', 'c']), {'something': 'something_else', 'high': '5'}) tspec3 = TunedProfileSpec('p3', PlacementSpec(hosts=['c']), {'wow': 'wow2', 'setting with space': 'value with space', 'down': 'low'}) def profiles_to_calls(self, tp: TunedProfileUtils, profiles: List[TunedProfileSpec]) -> List[Dict[str, str]]: # this function takes a list of tuned profiles and returns a mapping from # profile names to the string that will be written to the actual config file on the host. res = [] for p in profiles: p_str = tp._profile_to_str(p) res.append({p.profile_name: p_str}) return res @mock.patch("cephadm.tuned_profiles.TunedProfileUtils._remove_stray_tuned_profiles") @mock.patch("cephadm.tuned_profiles.TunedProfileUtils._write_tuned_profiles") def test_write_all_tuned_profiles(self, _write_profiles, _rm_profiles): profiles = {'p1': self.tspec1, 'p2': self.tspec2, 'p3': self.tspec3} mgr = FakeMgr(['a', 'b', 'c'], ['a', 'b', 'c'], [], profiles) tp = TunedProfileUtils(mgr) tp._write_all_tuned_profiles() # need to check that _write_tuned_profiles is correctly called with the # profiles that match the tuned profile placements and with the correct # strings that should be generated from the settings the profiles have. # the _profiles_to_calls helper allows us to generated the input we # should check against calls = [ mock.call('a', self.profiles_to_calls(tp, [self.tspec1, self.tspec2])), mock.call('b', self.profiles_to_calls(tp, [self.tspec1])), mock.call('c', self.profiles_to_calls(tp, [self.tspec1, self.tspec2, self.tspec3])) ] _write_profiles.assert_has_calls(calls, any_order=True) @mock.patch('cephadm.ssh.SSHManager.check_execute_command') def test_rm_stray_tuned_profiles(self, _check_execute_command): profiles = {'p1': self.tspec1, 'p2': self.tspec2, 'p3': self.tspec3} # for this test, going to use host "a" and put 4 cephadm generated # profiles "p1" "p2", "p3" and "who" only two of which should be there ("p1", "p2") # as well as a file not generated by cephadm. Only the "p3" and "who" # profiles should be removed from the host. This should total to 4 # calls to check_execute_command, 1 "ls", 2 "rm", and 1 "sysctl --system" _check_execute_command.return_value = '\n'.join(['p1-cephadm-tuned-profile.conf', 'p2-cephadm-tuned-profile.conf', 'p3-cephadm-tuned-profile.conf', 'who-cephadm-tuned-profile.conf', 'dont-touch-me']) mgr = FakeMgr(['a', 'b', 'c'], ['a', 'b', 'c'], [], profiles) tp = TunedProfileUtils(mgr) tp._remove_stray_tuned_profiles('a', self.profiles_to_calls(tp, [self.tspec1, self.tspec2])) calls = [ mock.call('a', ['ls', SYSCTL_DIR], log_command=False), mock.call('a', ['rm', '-f', f'{SYSCTL_DIR}/p3-cephadm-tuned-profile.conf']), mock.call('a', ['rm', '-f', f'{SYSCTL_DIR}/who-cephadm-tuned-profile.conf']), mock.call('a', ['sysctl', '--system']) ] _check_execute_command.assert_has_calls(calls, any_order=True) @mock.patch('cephadm.ssh.SSHManager.check_execute_command') @mock.patch('cephadm.ssh.SSHManager.write_remote_file') def test_write_tuned_profiles(self, _write_remote_file, _check_execute_command): profiles = {'p1': self.tspec1, 'p2': self.tspec2, 'p3': self.tspec3} # for this test we will use host "a" and have it so host_needs_tuned_profile_update # returns True for p2 and False for p1 (see FakeCache class). So we should see # 2 ssh calls, one to write p2, one to run sysctl --system _check_execute_command.return_value = 'success' _write_remote_file.return_value = 'success' mgr = FakeMgr(['a', 'b', 'c'], ['a', 'b', 'c'], [], profiles) tp = TunedProfileUtils(mgr) tp._write_tuned_profiles('a', self.profiles_to_calls(tp, [self.tspec1, self.tspec2])) _check_execute_command.assert_called_with('a', ['sysctl', '--system']) _write_remote_file.assert_called_with( 'a', f'{SYSCTL_DIR}/p2-cephadm-tuned-profile.conf', tp._profile_to_str(self.tspec2).encode('utf-8')) def test_dont_write_to_unreachable_hosts(self): profiles = {'p1': self.tspec1, 'p2': self.tspec2, 'p3': self.tspec3} # list host "a" and "b" as hosts that exist, "a" will be # a normal, schedulable host and "b" is considered unreachable mgr = FakeMgr(['a', 'b'], ['a'], ['b'], profiles) tp = TunedProfileUtils(mgr) assert 'a' not in tp.mgr.cache.last_tuned_profile_update assert 'b' not in tp.mgr.cache.last_tuned_profile_update # with an online host, should proceed as normal. Providing # no actual profiles here though so the only actual action taken # is updating the entry in the last_tuned_profile_update dict tp._write_tuned_profiles('a', {}) assert 'a' in tp.mgr.cache.last_tuned_profile_update # trying to write to an unreachable host should be a no-op # and return immediately. No entry for 'b' should be added # to the last_tuned_profile_update dict tp._write_tuned_profiles('b', {}) assert 'b' not in tp.mgr.cache.last_tuned_profile_update def test_store(self): mgr = FakeMgr(['a', 'b', 'c'], ['a', 'b', 'c'], [], {}) tps = TunedProfileStore(mgr) save_str_p1 = 'tuned_profiles: ' + json.dumps({'p1': self.tspec1.to_json()}) tspec1_updated = self.tspec1.copy() tspec1_updated.settings.update({'new-setting': 'new-value'}) save_str_p1_updated = 'tuned_profiles: ' + json.dumps({'p1': tspec1_updated.to_json()}) save_str_p1_updated_p2 = 'tuned_profiles: ' + \ json.dumps({'p1': tspec1_updated.to_json(), 'p2': self.tspec2.to_json()}) tspec2_updated = self.tspec2.copy() tspec2_updated.settings.pop('something') save_str_p1_updated_p2_updated = 'tuned_profiles: ' + \ json.dumps({'p1': tspec1_updated.to_json(), 'p2': tspec2_updated.to_json()}) save_str_p2_updated = 'tuned_profiles: ' + json.dumps({'p2': tspec2_updated.to_json()}) with pytest.raises(SaveError) as e: tps.add_profile(self.tspec1) assert str(e.value) == save_str_p1 assert 'p1' in tps with pytest.raises(SaveError) as e: tps.add_setting('p1', 'new-setting', 'new-value') assert str(e.value) == save_str_p1_updated assert 'new-setting' in tps.list_profiles()[0].settings with pytest.raises(SaveError) as e: tps.add_profile(self.tspec2) assert str(e.value) == save_str_p1_updated_p2 assert 'p2' in tps assert 'something' in tps.list_profiles()[1].settings with pytest.raises(SaveError) as e: tps.rm_setting('p2', 'something') assert 'something' not in tps.list_profiles()[1].settings assert str(e.value) == save_str_p1_updated_p2_updated with pytest.raises(SaveError) as e: tps.rm_profile('p1') assert str(e.value) == save_str_p2_updated assert 'p1' not in tps assert 'p2' in tps assert len(tps.list_profiles()) == 1 assert tps.list_profiles()[0].profile_name == 'p2' cur_last_updated = tps.last_updated('p2') new_last_updated = datetime_now() assert cur_last_updated != new_last_updated tps.set_last_updated('p2', new_last_updated) assert tps.last_updated('p2') == new_last_updated # check FakeMgr get_store func to see what is expected to be found in Key Store here tps.load() assert 'x' in tps assert 'y' in tps assert [p for p in tps.list_profiles() if p.profile_name == 'x'][0].settings == {'x': 'x'} assert [p for p in tps.list_profiles() if p.profile_name == 'y'][0].settings == {'y': 'y'}