| Dennis Dmitriev | 6f59add | 2016-10-18 13:45:27 +0300 | [diff] [blame] | 1 | #    Copyright 2016 Mirantis, Inc. | 
 | 2 | # | 
 | 3 | #    Licensed under the Apache License, Version 2.0 (the "License"); you may | 
 | 4 | #    not use this file except in compliance with the License. You may obtain | 
 | 5 | #    a copy of the License at | 
 | 6 | # | 
 | 7 | #         http://www.apache.org/licenses/LICENSE-2.0 | 
 | 8 | # | 
 | 9 | #    Unless required by applicable law or agreed to in writing, software | 
 | 10 | #    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT | 
 | 11 | #    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the | 
 | 12 | #    License for the specific language governing permissions and limitations | 
 | 13 | #    under the License. | 
 | 14 |  | 
 | 15 | import random | 
 | 16 |  | 
 | 17 | from devops.helpers import helpers | 
 | 18 | from devops.helpers import ssh_client | 
 | 19 | from paramiko import rsakey | 
| Dennis Dmitriev | 99b26fe | 2017-04-26 12:34:44 +0300 | [diff] [blame] | 20 | import yaml | 
| Dennis Dmitriev | 6f59add | 2016-10-18 13:45:27 +0300 | [diff] [blame] | 21 |  | 
 | 22 | from tcp_tests import logger | 
 | 23 | from tcp_tests.helpers import utils | 
 | 24 |  | 
 | 25 | LOG = logger.logger | 
 | 26 |  | 
 | 27 |  | 
 | 28 | class UnderlaySSHManager(object): | 
 | 29 |     """Keep the list of SSH access credentials to Underlay nodes. | 
 | 30 |  | 
 | 31 |        This object is initialized using config.underlay.ssh. | 
 | 32 |  | 
 | 33 |        :param config_ssh: JSONList of SSH access credentials for nodes: | 
 | 34 |           [ | 
 | 35 |             { | 
 | 36 |               node_name: node1, | 
 | 37 |               address_pool: 'public-pool01', | 
 | 38 |               host: , | 
 | 39 |               port: , | 
 | 40 |               keys: [], | 
 | 41 |               keys_source_host: None, | 
 | 42 |               login: , | 
 | 43 |               password: , | 
| Dennis Dmitriev | 474e3f7 | 2016-10-21 16:46:09 +0300 | [diff] [blame] | 44 |               roles: [], | 
| Dennis Dmitriev | 6f59add | 2016-10-18 13:45:27 +0300 | [diff] [blame] | 45 |             }, | 
 | 46 |             { | 
 | 47 |               node_name: node1, | 
 | 48 |               address_pool: 'private-pool01', | 
 | 49 |               host: | 
 | 50 |               port: | 
 | 51 |               keys: [] | 
 | 52 |               keys_source_host: None, | 
 | 53 |               login: | 
 | 54 |               password: | 
| Dennis Dmitriev | 474e3f7 | 2016-10-21 16:46:09 +0300 | [diff] [blame] | 55 |               roles: [], | 
| Dennis Dmitriev | 6f59add | 2016-10-18 13:45:27 +0300 | [diff] [blame] | 56 |             }, | 
 | 57 |             { | 
 | 58 |               node_name: node2, | 
 | 59 |               address_pool: 'public-pool01', | 
 | 60 |               keys_source_host: node1 | 
 | 61 |               ... | 
 | 62 |             } | 
 | 63 |             , | 
 | 64 |             ... | 
 | 65 |           ] | 
 | 66 |  | 
 | 67 |        self.node_names(): list of node names registered in underlay. | 
 | 68 |        self.remote(): SSHClient object by a node name (w/wo address pool) | 
 | 69 |                       or by a hostname. | 
 | 70 |     """ | 
| Dennis Dmitriev | 2a13a13 | 2016-11-04 00:56:23 +0200 | [diff] [blame] | 71 |     __config = None | 
| Dennis Dmitriev | 6f59add | 2016-10-18 13:45:27 +0300 | [diff] [blame] | 72 |     config_ssh = None | 
 | 73 |     config_lvm = None | 
 | 74 |  | 
| Dennis Dmitriev | 2a13a13 | 2016-11-04 00:56:23 +0200 | [diff] [blame] | 75 |     def __init__(self, config): | 
| Dennis Dmitriev | 6f59add | 2016-10-18 13:45:27 +0300 | [diff] [blame] | 76 |         """Read config.underlay.ssh object | 
 | 77 |  | 
 | 78 |            :param config_ssh: dict | 
 | 79 |         """ | 
| Dennis Dmitriev | 2a13a13 | 2016-11-04 00:56:23 +0200 | [diff] [blame] | 80 |         self.__config = config | 
| Dennis Dmitriev | 6f59add | 2016-10-18 13:45:27 +0300 | [diff] [blame] | 81 |         if self.config_ssh is None: | 
 | 82 |             self.config_ssh = [] | 
 | 83 |  | 
 | 84 |         if self.config_lvm is None: | 
 | 85 |             self.config_lvm = {} | 
 | 86 |  | 
| Dennis Dmitriev | 2a13a13 | 2016-11-04 00:56:23 +0200 | [diff] [blame] | 87 |         self.add_config_ssh(self.__config.underlay.ssh) | 
| Dennis Dmitriev | 6f59add | 2016-10-18 13:45:27 +0300 | [diff] [blame] | 88 |  | 
 | 89 |     def add_config_ssh(self, config_ssh): | 
 | 90 |  | 
 | 91 |         if config_ssh is None: | 
 | 92 |             config_ssh = [] | 
 | 93 |  | 
 | 94 |         for ssh in config_ssh: | 
 | 95 |             ssh_data = { | 
 | 96 |                 # Required keys: | 
 | 97 |                 'node_name': ssh['node_name'], | 
 | 98 |                 'host': ssh['host'], | 
 | 99 |                 'login': ssh['login'], | 
 | 100 |                 'password': ssh['password'], | 
 | 101 |                 # Optional keys: | 
 | 102 |                 'address_pool': ssh.get('address_pool', None), | 
 | 103 |                 'port': ssh.get('port', None), | 
 | 104 |                 'keys': ssh.get('keys', []), | 
| Dennis Dmitriev | 474e3f7 | 2016-10-21 16:46:09 +0300 | [diff] [blame] | 105 |                 'roles': ssh.get('roles', []), | 
| Dennis Dmitriev | 6f59add | 2016-10-18 13:45:27 +0300 | [diff] [blame] | 106 |             } | 
 | 107 |  | 
 | 108 |             if 'keys_source_host' in ssh: | 
 | 109 |                 node_name = ssh['keys_source_host'] | 
 | 110 |                 remote = self.remote(node_name) | 
 | 111 |                 keys = self.__get_keys(remote) | 
 | 112 |                 ssh_data['keys'].extend(keys) | 
 | 113 |  | 
 | 114 |             self.config_ssh.append(ssh_data) | 
 | 115 |  | 
 | 116 |     def remove_config_ssh(self, config_ssh): | 
 | 117 |         if config_ssh is None: | 
 | 118 |             config_ssh = [] | 
 | 119 |  | 
 | 120 |         for ssh in config_ssh: | 
 | 121 |             ssh_data = { | 
 | 122 |                 # Required keys: | 
 | 123 |                 'node_name': ssh['node_name'], | 
 | 124 |                 'host': ssh['host'], | 
 | 125 |                 'login': ssh['login'], | 
 | 126 |                 'password': ssh['password'], | 
 | 127 |                 # Optional keys: | 
 | 128 |                 'address_pool': ssh.get('address_pool', None), | 
 | 129 |                 'port': ssh.get('port', None), | 
 | 130 |                 'keys': ssh.get('keys', []), | 
| Dennis Dmitriev | 474e3f7 | 2016-10-21 16:46:09 +0300 | [diff] [blame] | 131 |                 'roles': ssh.get('roles', []), | 
| Dennis Dmitriev | 6f59add | 2016-10-18 13:45:27 +0300 | [diff] [blame] | 132 |             } | 
 | 133 |             self.config_ssh.remove(ssh_data) | 
 | 134 |  | 
 | 135 |     def __get_keys(self, remote): | 
 | 136 |         keys = [] | 
 | 137 |         remote.execute('cd ~') | 
 | 138 |         key_string = './.ssh/id_rsa' | 
 | 139 |         if remote.exists(key_string): | 
 | 140 |             with remote.open(key_string) as f: | 
 | 141 |                 keys.append(rsakey.RSAKey.from_private_key(f)) | 
 | 142 |         return keys | 
 | 143 |  | 
 | 144 |     def __ssh_data(self, node_name=None, host=None, address_pool=None): | 
 | 145 |  | 
 | 146 |         ssh_data = None | 
 | 147 |  | 
 | 148 |         if host is not None: | 
 | 149 |             for ssh in self.config_ssh: | 
 | 150 |                 if host == ssh['host']: | 
 | 151 |                     ssh_data = ssh | 
 | 152 |                     break | 
 | 153 |  | 
 | 154 |         elif node_name is not None: | 
 | 155 |             for ssh in self.config_ssh: | 
 | 156 |                 if node_name == ssh['node_name']: | 
 | 157 |                     if address_pool is not None: | 
 | 158 |                         if address_pool == ssh['address_pool']: | 
 | 159 |                             ssh_data = ssh | 
 | 160 |                             break | 
 | 161 |                     else: | 
 | 162 |                         ssh_data = ssh | 
 | 163 |         if ssh_data is None: | 
 | 164 |             raise Exception('Auth data for node was not found using ' | 
 | 165 |                             'node_name="{}" , host="{}" , address_pool="{}"' | 
 | 166 |                             .format(node_name, host, address_pool)) | 
 | 167 |         return ssh_data | 
 | 168 |  | 
 | 169 |     def node_names(self): | 
 | 170 |         """Get list of node names registered in config.underlay.ssh""" | 
 | 171 |  | 
 | 172 |         names = []  # List is used to keep the original order of names | 
 | 173 |         for ssh in self.config_ssh: | 
 | 174 |             if ssh['node_name'] not in names: | 
 | 175 |                 names.append(ssh['node_name']) | 
 | 176 |         return names | 
 | 177 |  | 
 | 178 |     def enable_lvm(self, lvmconfig): | 
 | 179 |         """Method for enabling lvm oh hosts in environment | 
 | 180 |  | 
 | 181 |         :param lvmconfig: dict with ids or device' names of lvm storage | 
 | 182 |         :raises: devops.error.DevopsCalledProcessError, | 
 | 183 |         devops.error.TimeoutError, AssertionError, ValueError | 
 | 184 |         """ | 
 | 185 |         def get_actions(lvm_id): | 
 | 186 |             return [ | 
 | 187 |                 "systemctl enable lvm2-lvmetad.service", | 
 | 188 |                 "systemctl enable lvm2-lvmetad.socket", | 
 | 189 |                 "systemctl start lvm2-lvmetad.service", | 
 | 190 |                 "systemctl start lvm2-lvmetad.socket", | 
 | 191 |                 "pvcreate {} && pvs".format(lvm_id), | 
 | 192 |                 "vgcreate default {} && vgs".format(lvm_id), | 
 | 193 |                 "lvcreate -L 1G -T default/pool && lvs", | 
 | 194 |             ] | 
 | 195 |         lvmpackages = ["lvm2", "liblvm2-dev", "thin-provisioning-tools"] | 
 | 196 |         for node_name in self.node_names(): | 
 | 197 |             lvm = lvmconfig.get(node_name, None) | 
 | 198 |             if not lvm: | 
 | 199 |                 continue | 
 | 200 |             if 'id' in lvm: | 
 | 201 |                 lvmdevice = '/dev/disk/by-id/{}'.format(lvm['id']) | 
 | 202 |             elif 'device' in lvm: | 
 | 203 |                 lvmdevice = '/dev/{}'.format(lvm['device']) | 
 | 204 |             else: | 
 | 205 |                 raise ValueError("Unknown LVM device type") | 
 | 206 |             if lvmdevice: | 
 | 207 |                 self.apt_install_package( | 
 | 208 |                     packages=lvmpackages, node_name=node_name, verbose=True) | 
 | 209 |                 for command in get_actions(lvmdevice): | 
 | 210 |                     self.sudo_check_call(command, node_name=node_name, | 
 | 211 |                                          verbose=True) | 
 | 212 |         self.config_lvm = dict(lvmconfig) | 
 | 213 |  | 
 | 214 |     def host_by_node_name(self, node_name, address_pool=None): | 
 | 215 |         ssh_data = self.__ssh_data(node_name=node_name, | 
 | 216 |                                    address_pool=address_pool) | 
 | 217 |         return ssh_data['host'] | 
 | 218 |  | 
 | 219 |     def remote(self, node_name=None, host=None, address_pool=None): | 
 | 220 |         """Get SSHClient by a node name or hostname. | 
 | 221 |  | 
 | 222 |            One of the following arguments should be specified: | 
 | 223 |            - host (str): IP address or hostname. If specified, 'node_name' is | 
 | 224 |                          ignored. | 
 | 225 |            - node_name (str): Name of the node stored to config.underlay.ssh | 
 | 226 |            - address_pool (str): optional for node_name. | 
 | 227 |                                  If None, use the first matched node_name. | 
 | 228 |         """ | 
 | 229 |         ssh_data = self.__ssh_data(node_name=node_name, host=host, | 
 | 230 |                                    address_pool=address_pool) | 
 | 231 |         return ssh_client.SSHClient( | 
 | 232 |             host=ssh_data['host'], | 
 | 233 |             port=ssh_data['port'] or 22, | 
 | 234 |             username=ssh_data['login'], | 
 | 235 |             password=ssh_data['password'], | 
 | 236 |             private_keys=ssh_data['keys']) | 
 | 237 |  | 
 | 238 |     def check_call( | 
 | 239 |             self, cmd, | 
 | 240 |             node_name=None, host=None, address_pool=None, | 
 | 241 |             verbose=False, timeout=None, | 
 | 242 |             error_info=None, | 
 | 243 |             expected=None, raise_on_err=True): | 
 | 244 |         """Execute command on the node_name/host and check for exit code | 
 | 245 |  | 
 | 246 |         :type cmd: str | 
 | 247 |         :type node_name: str | 
 | 248 |         :type host: str | 
 | 249 |         :type verbose: bool | 
 | 250 |         :type timeout: int | 
 | 251 |         :type error_info: str | 
 | 252 |         :type expected: list | 
 | 253 |         :type raise_on_err: bool | 
 | 254 |         :rtype: list stdout | 
 | 255 |         :raises: devops.error.DevopsCalledProcessError | 
 | 256 |         """ | 
 | 257 |         remote = self.remote(node_name=node_name, host=host, | 
 | 258 |                              address_pool=address_pool) | 
 | 259 |         return remote.check_call( | 
 | 260 |             command=cmd, verbose=verbose, timeout=timeout, | 
 | 261 |             error_info=error_info, expected=expected, | 
 | 262 |             raise_on_err=raise_on_err) | 
 | 263 |  | 
 | 264 |     def apt_install_package(self, packages=None, node_name=None, host=None, | 
 | 265 |                             **kwargs): | 
 | 266 |         """Method to install packages on ubuntu nodes | 
 | 267 |  | 
 | 268 |         :type packages: list | 
 | 269 |         :type node_name: str | 
 | 270 |         :type host: str | 
 | 271 |         :raises: devops.error.DevopsCalledProcessError, | 
 | 272 |         devops.error.TimeoutError, AssertionError, ValueError | 
 | 273 |  | 
 | 274 |         Other params of check_call and sudo_check_call are allowed | 
 | 275 |         """ | 
 | 276 |         expected = kwargs.pop('expected', None) | 
 | 277 |         if not packages or not isinstance(packages, list): | 
 | 278 |             raise ValueError("packages list should be provided!") | 
 | 279 |         install = "apt-get install -y {}".format(" ".join(packages)) | 
 | 280 |         # Should wait until other 'apt' jobs are finished | 
 | 281 |         pgrep_expected = [0, 1] | 
 | 282 |         pgrep_command = "pgrep -a -f apt" | 
 | 283 |         helpers.wait( | 
 | 284 |             lambda: (self.check_call( | 
 | 285 |                 pgrep_command, expected=pgrep_expected, host=host, | 
 | 286 |                 node_name=node_name, **kwargs).exit_code == 1 | 
 | 287 |             ), interval=30, timeout=1200, | 
 | 288 |             timeout_msg="Timeout reached while waiting for apt lock" | 
 | 289 |         ) | 
 | 290 |         # Install packages | 
 | 291 |         self.sudo_check_call("apt-get update", node_name=node_name, host=host, | 
 | 292 |                              **kwargs) | 
 | 293 |         self.sudo_check_call(install, expected=expected, node_name=node_name, | 
 | 294 |                              host=host, **kwargs) | 
 | 295 |  | 
 | 296 |     def sudo_check_call( | 
 | 297 |             self, cmd, | 
 | 298 |             node_name=None, host=None, address_pool=None, | 
 | 299 |             verbose=False, timeout=None, | 
 | 300 |             error_info=None, | 
 | 301 |             expected=None, raise_on_err=True): | 
 | 302 |         """Execute command with sudo on node_name/host and check for exit code | 
 | 303 |  | 
 | 304 |         :type cmd: str | 
 | 305 |         :type node_name: str | 
 | 306 |         :type host: str | 
 | 307 |         :type verbose: bool | 
 | 308 |         :type timeout: int | 
 | 309 |         :type error_info: str | 
 | 310 |         :type expected: list | 
 | 311 |         :type raise_on_err: bool | 
 | 312 |         :rtype: list stdout | 
 | 313 |         :raises: devops.error.DevopsCalledProcessError | 
 | 314 |         """ | 
 | 315 |         remote = self.remote(node_name=node_name, host=host, | 
 | 316 |                              address_pool=address_pool) | 
 | 317 |         with remote.get_sudo(remote): | 
 | 318 |             return remote.check_call( | 
 | 319 |                 command=cmd, verbose=verbose, timeout=timeout, | 
 | 320 |                 error_info=error_info, expected=expected, | 
 | 321 |                 raise_on_err=raise_on_err) | 
 | 322 |  | 
 | 323 |     def dir_upload(self, host, source, destination): | 
 | 324 |         """Upload local directory content to remote host | 
 | 325 |  | 
 | 326 |         :param host: str, remote node name | 
 | 327 |         :param source: str, local directory path | 
 | 328 |         :param destination: str, local directory path | 
 | 329 |         """ | 
 | 330 |         with self.remote(node_name=host) as remote: | 
 | 331 |             remote.upload(source, destination) | 
 | 332 |  | 
 | 333 |     def get_random_node(self): | 
 | 334 |         """Get random node name | 
 | 335 |  | 
 | 336 |         :return: str, name of node | 
 | 337 |         """ | 
 | 338 |         return random.choice(self.node_names()) | 
 | 339 |  | 
 | 340 |     def yaml_editor(self, file_path, node_name=None, host=None, | 
 | 341 |                     address_pool=None): | 
 | 342 |         """Returns an initialized YamlEditor instance for context manager | 
 | 343 |  | 
 | 344 |         Usage (with 'underlay' fixture): | 
 | 345 |  | 
 | 346 |         # Local YAML file | 
 | 347 |         with underlay.yaml_editor('/path/to/file') as editor: | 
 | 348 |             editor.content[key] = "value" | 
 | 349 |  | 
 | 350 |         # Remote YAML file on TCP host | 
 | 351 |         with underlay.yaml_editor('/path/to/file', | 
 | 352 |                                   host=config.tcp.tcp_host) as editor: | 
 | 353 |             editor.content[key] = "value" | 
 | 354 |         """ | 
 | 355 |         # Local YAML file | 
 | 356 |         if node_name is None and host is None: | 
 | 357 |             return utils.YamlEditor(file_path=file_path) | 
 | 358 |  | 
 | 359 |         # Remote YAML file | 
 | 360 |         ssh_data = self.__ssh_data(node_name=node_name, host=host, | 
 | 361 |                                    address_pool=address_pool) | 
 | 362 |         return utils.YamlEditor( | 
 | 363 |             file_path=file_path, | 
 | 364 |             host=ssh_data['host'], | 
 | 365 |             port=ssh_data['port'] or 22, | 
 | 366 |             username=ssh_data['login'], | 
 | 367 |             password=ssh_data['password'], | 
 | 368 |             private_keys=ssh_data['keys']) | 
| Dennis Dmitriev | 010f4cd | 2016-11-01 20:43:51 +0200 | [diff] [blame] | 369 |  | 
| Dennis Dmitriev | 99b26fe | 2017-04-26 12:34:44 +0300 | [diff] [blame] | 370 |     def read_template(self, file_path): | 
 | 371 |         """Read yaml as a jinja template""" | 
 | 372 |         options = { | 
 | 373 |             'config': self.__config, | 
 | 374 |         } | 
 | 375 |         template = utils.render_template(file_path, options=options) | 
 | 376 |         return yaml.load(template) |