Dennis Dmitriev | f5f2e60 | 2017-11-03 15:36:19 +0200 | [diff] [blame] | 1 | # Copyright 2019 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 os |
| 16 | import netaddr |
| 17 | import yaml |
Anna Arhipova | 9a9fb37 | 2023-04-06 12:47:07 +0200 | [diff] [blame] | 18 | import pytest |
Dennis Dmitriev | f5f2e60 | 2017-11-03 15:36:19 +0200 | [diff] [blame] | 19 | |
| 20 | from devops.helpers import helpers |
| 21 | from devops.helpers.helpers import ssh_client |
| 22 | from retry import retry |
| 23 | |
| 24 | from cached_property import cached_property |
| 25 | |
| 26 | from heatclient import client as heatclient |
| 27 | from heatclient import exc as heat_exceptions |
| 28 | from heatclient.common import template_utils |
| 29 | from keystoneauth1.identity import v3 as keystone_v3 |
| 30 | from keystoneauth1 import session as keystone_session |
| 31 | |
| 32 | import requests |
| 33 | from requests.packages.urllib3.exceptions import InsecureRequestWarning |
| 34 | |
| 35 | from oslo_config import cfg |
| 36 | from paramiko.ssh_exception import ( |
| 37 | AuthenticationException, |
| 38 | BadAuthenticationType) |
| 39 | |
| 40 | from tcp_tests import settings |
| 41 | from tcp_tests import settings_oslo |
| 42 | from tcp_tests.helpers import exceptions |
| 43 | from tcp_tests import logger |
| 44 | |
| 45 | LOG = logger.logger |
| 46 | |
| 47 | EXPECTED_STACK_STATUS = "CREATE_COMPLETE" |
Dmitry Tyzhnenko | c800aad | 2019-05-27 18:10:46 +0300 | [diff] [blame] | 48 | BAD_STACK_STATUSES = ["CREATE_FAILED", "DELETE_FAILED"] |
Dennis Dmitriev | f5f2e60 | 2017-11-03 15:36:19 +0200 | [diff] [blame] | 49 | |
| 50 | # Disable multiple notifications like: |
| 51 | # "InsecureRequestWarning: Unverified HTTPS request is being made." |
| 52 | requests.packages.urllib3.disable_warnings(InsecureRequestWarning) |
| 53 | |
| 54 | |
| 55 | class EnvironmentManagerHeat(object): |
| 56 | """Class-helper for creating VMs via devops environments""" |
| 57 | |
| 58 | __config = None |
| 59 | |
| 60 | # Do not use self.__heatclient directly! Use properties |
| 61 | # for necessary resources with catching HTTPUnauthorized exception |
| 62 | __heatclient = None |
| 63 | |
| 64 | def __init__(self, config=None): |
| 65 | """Create/connect to the Heat stack with test environment |
| 66 | |
| 67 | :param config: oslo.config object |
| 68 | :param config.hardware.heat_version: Heat version |
| 69 | :param config.hardware.os_auth_url: OS auth URL to access heat |
| 70 | :param config.hardware.os_username: OS username |
| 71 | :param config.hardware.os_password: OS password |
| 72 | :param config.hardware.os_project_name: OS tenant name |
| 73 | """ |
| 74 | self.__config = config |
| 75 | |
| 76 | if not self.__config.hardware.heat_stack_name: |
| 77 | self.__config.hardware.heat_stack_name = settings.ENV_NAME |
| 78 | |
| 79 | self.__init_heatclient() |
| 80 | |
| 81 | try: |
| 82 | stack_status = self._current_stack.stack_status |
| 83 | if stack_status != EXPECTED_STACK_STATUS: |
| 84 | raise exceptions.EnvironmentWrongStatus( |
| 85 | self.__config.hardware.heat_stack_name, |
| 86 | EXPECTED_STACK_STATUS, |
| 87 | stack_status |
| 88 | ) |
| 89 | LOG.info("Heat stack '{0}' already exists".format( |
| 90 | self.__config.hardware.heat_stack_name)) |
| 91 | except heat_exceptions.HTTPNotFound: |
| 92 | self._create_environment() |
| 93 | LOG.info("Heat stack '{0}' created".format( |
| 94 | self.__config.hardware.heat_stack_name)) |
| 95 | |
| 96 | self.set_address_pools_config() |
| 97 | self.set_dhcp_ranges_config() |
| 98 | |
| 99 | @cached_property |
| 100 | def _keystone_session(self): |
| 101 | keystone_auth = keystone_v3.Password( |
| 102 | auth_url=settings.OS_AUTH_URL, |
| 103 | username=settings.OS_USERNAME, |
| 104 | password=settings.OS_PASSWORD, |
| 105 | project_name=settings.OS_PROJECT_NAME, |
Dennis Dmitriev | c902ad8 | 2019-04-12 13:41:30 +0300 | [diff] [blame] | 106 | user_domain_name=settings.OS_USER_DOMAIN_NAME, |
Dennis Dmitriev | f5f2e60 | 2017-11-03 15:36:19 +0200 | [diff] [blame] | 107 | project_domain_name='Default') |
| 108 | return keystone_session.Session(auth=keystone_auth, verify=False) |
| 109 | |
| 110 | def __init_heatclient(self): |
| 111 | token = self._keystone_session.get_token() |
| 112 | endpoint_url = self._keystone_session.get_endpoint( |
| 113 | service_type='orchestration', endpoint_type='publicURL') |
| 114 | self.__heatclient = heatclient.Client( |
| 115 | version=settings.OS_HEAT_VERSION, endpoint=endpoint_url, |
| 116 | token=token, insecure=True) |
| 117 | |
| 118 | @property |
| 119 | def _current_stack(self): |
| 120 | return self.__stacks.get( |
| 121 | self.__config.hardware.heat_stack_name) |
| 122 | |
| 123 | @property |
| 124 | def __stacks(self): |
| 125 | try: |
| 126 | return self.__heatclient.stacks |
| 127 | except heat_exceptions.HTTPUnauthorized: |
| 128 | LOG.warning("Authorization token outdated, refreshing") |
| 129 | self.__init_heatclient() |
| 130 | return self.__heatclient.stacks |
| 131 | |
| 132 | @property |
| 133 | def __resources(self): |
| 134 | try: |
| 135 | return self.__heatclient.resources |
| 136 | except heat_exceptions.HTTPUnauthorized: |
| 137 | LOG.warning("Authorization token outdated, refreshing") |
| 138 | self.__init_heatclient() |
| 139 | return self.__heatclient.resources |
| 140 | |
Dennis Dmitriev | 4015adc | 2019-04-15 18:33:44 +0300 | [diff] [blame] | 141 | def __get_stack_parent(self, stack_id, stacks): |
| 142 | """Find the parent ID of the specified stack_id in the 'stacks' list""" |
| 143 | for stack in stacks: |
| 144 | if stack_id == stack.id: |
| 145 | if stack.parent: |
Oleksii Butenko | 9cd9cbf | 2019-08-01 18:30:16 +0300 | [diff] [blame] | 146 | try: |
| 147 | return self.__get_stack_parent(stack.parent, stacks) |
| 148 | except Exception: |
| 149 | return stack.id |
Dennis Dmitriev | 4015adc | 2019-04-15 18:33:44 +0300 | [diff] [blame] | 150 | else: |
| 151 | return stack.id |
| 152 | raise Exception("stack with ID {} not found!".format(stack_id)) |
| 153 | |
| 154 | @property |
| 155 | def __nested_resources(self): |
Anna Arhipova | 9a9fb37 | 2023-04-06 12:47:07 +0200 | [diff] [blame] | 156 | if hasattr(pytest, 'resources'): |
| 157 | return pytest.resources |
| 158 | pytest.resources = list() |
Dennis Dmitriev | 4015adc | 2019-04-15 18:33:44 +0300 | [diff] [blame] | 159 | stacks = [s for s in self.__stacks.list(show_nested=True)] |
| 160 | current_stack_id = self._current_stack.id |
| 161 | for stack in stacks: |
| 162 | parent_stack_id = self.__get_stack_parent(stack.id, stacks) |
| 163 | if parent_stack_id == current_stack_id: |
| 164 | # Add resources to list |
| 165 | LOG.info("Get resources from stack {0}" |
| 166 | .format(stack.stack_name)) |
Anna Arhipova | 9a9fb37 | 2023-04-06 12:47:07 +0200 | [diff] [blame] | 167 | pytest.resources.extend([ |
Dennis Dmitriev | 4015adc | 2019-04-15 18:33:44 +0300 | [diff] [blame] | 168 | res for res in self.__resources.list(stack.id) |
| 169 | ]) |
Anna Arhipova | 9a9fb37 | 2023-04-06 12:47:07 +0200 | [diff] [blame] | 170 | LOG.info("Found {0} resources".format(len(pytest.resources))) |
| 171 | return pytest.resources |
Dennis Dmitriev | 4015adc | 2019-04-15 18:33:44 +0300 | [diff] [blame] | 172 | |
Dennis Dmitriev | f5f2e60 | 2017-11-03 15:36:19 +0200 | [diff] [blame] | 173 | def _get_resources_by_type(self, resource_type): |
| 174 | res = [] |
Dennis Dmitriev | 4015adc | 2019-04-15 18:33:44 +0300 | [diff] [blame] | 175 | for item in self.__nested_resources: |
Dennis Dmitriev | f5f2e60 | 2017-11-03 15:36:19 +0200 | [diff] [blame] | 176 | if item.resource_type == resource_type: |
| 177 | resource = self.__resources.get( |
Dennis Dmitriev | 4015adc | 2019-04-15 18:33:44 +0300 | [diff] [blame] | 178 | item.stack_name, |
Dennis Dmitriev | f5f2e60 | 2017-11-03 15:36:19 +0200 | [diff] [blame] | 179 | item.resource_name) |
| 180 | res.append(resource) |
| 181 | return res |
| 182 | |
| 183 | @cached_property |
| 184 | def _nodes(self): |
| 185 | """Get list of nodenames from heat |
| 186 | |
| 187 | Returns list of dicts. |
| 188 | Example: |
| 189 | - name: cfg01 |
| 190 | roles: |
| 191 | - salt_master |
| 192 | addresses: # Optional. May be an empty dict |
| 193 | admin-pool01: p.p.p.202 |
| 194 | - name: ctl01 |
| 195 | roles: |
| 196 | - salt_minion |
| 197 | - openstack_controller |
| 198 | - openstack_messaging |
| 199 | - openstack_database |
| 200 | addresses: {} # Optional. May be an empty dict |
| 201 | |
| 202 | 'name': taken from heat template resource's ['name'] parameter |
| 203 | 'roles': a list taken from resource's ['metadata']['roles'] parameter |
| 204 | """ |
| 205 | address_pools = self._address_pools |
| 206 | nodes = [] |
| 207 | for heat_node in self._get_resources_by_type("OS::Nova::Server"): |
| 208 | # addresses will have the following dict structure: |
| 209 | # {'admin-pool01': <floating_ip1>, |
| 210 | # 'private-pool01': <floating_ip2>, |
| 211 | # 'external-pool01': <floating_ip3> |
| 212 | # } |
| 213 | # , where key is one of roles from OS::Neutron::Subnet, |
| 214 | # and value is a floating IP associated to the fixed IP |
| 215 | # in this subnet (if exists). |
| 216 | # If no floating IPs associated to the server, |
| 217 | # then addresses will be an empty list. |
| 218 | addresses = {} |
| 219 | for network in heat_node.attributes['addresses']: |
| 220 | fixed = None |
| 221 | floating = None |
| 222 | for address in heat_node.attributes['addresses'][network]: |
| 223 | addr_type = address['OS-EXT-IPS:type'] |
| 224 | if addr_type == 'fixed': |
| 225 | fixed = address['addr'] |
| 226 | elif addr_type == 'floating': |
| 227 | floating = address['addr'] |
| 228 | else: |
| 229 | LOG.error("Unexpected OS-EXT-IPS:type={0} " |
| 230 | "in node '{1}' for network '{2}'" |
| 231 | .format(addr_type, |
| 232 | heat_node.attributes['name'], |
| 233 | network)) |
Dennis Dmitriev | 4015adc | 2019-04-15 18:33:44 +0300 | [diff] [blame] | 234 | if fixed is None and floating is None: |
Dennis Dmitriev | f5f2e60 | 2017-11-03 15:36:19 +0200 | [diff] [blame] | 235 | LOG.error("Unable to determine the correct IP address " |
| 236 | "in node '{0}' for network '{1}'" |
| 237 | .format(heat_node.attributes['name'], network)) |
| 238 | continue |
| 239 | # Check which address pool has the fixed address, and set |
| 240 | # the floating address as the access to this address pool. |
| 241 | for address_pool in address_pools: |
| 242 | pool_net = netaddr.IPNetwork(address_pool['cidr']) |
| 243 | if fixed in pool_net: |
| 244 | for role in address_pool['roles']: |
Dennis Dmitriev | c902ad8 | 2019-04-12 13:41:30 +0300 | [diff] [blame] | 245 | # addresses[role] = floating |
| 246 | # Use fixed addresses for SSH access |
| 247 | addresses[role] = fixed |
Dennis Dmitriev | 4015adc | 2019-04-15 18:33:44 +0300 | [diff] [blame] | 248 | if 'metadata' not in heat_node.attributes or \ |
| 249 | 'roles' not in heat_node.attributes['metadata']: |
| 250 | raise Exception("Node {} doesn't have metadata:roles:[...,...]" |
| 251 | .format(heat_node.attributes['name'])) |
Dennis Dmitriev | f5f2e60 | 2017-11-03 15:36:19 +0200 | [diff] [blame] | 252 | |
| 253 | nodes.append({ |
| 254 | 'name': heat_node.attributes['name'], |
| 255 | 'roles': yaml.load(heat_node.attributes['metadata']['roles']), |
| 256 | 'addresses': addresses, |
| 257 | }) |
| 258 | return nodes |
| 259 | |
| 260 | @cached_property |
| 261 | def _address_pools(self): |
| 262 | """Get address pools from subnets OS::Neutron::Subnet |
| 263 | |
| 264 | Returns list of dicts. |
| 265 | Example: |
| 266 | - roles: |
| 267 | - admin-pool01 |
| 268 | cidr: x.x.x.x/y |
| 269 | start: x.x.x.2 |
| 270 | end: x.x.x.254 |
| 271 | gateway: x.x.x.1 # or None |
| 272 | """ |
| 273 | pools = [] |
| 274 | for heat_subnet in self._get_resources_by_type("OS::Neutron::Subnet"): |
| 275 | pools.append({ |
| 276 | 'roles': heat_subnet.attributes['tags'], |
| 277 | 'cidr': heat_subnet.attributes['cidr'], |
| 278 | 'gateway': heat_subnet.attributes['gateway_ip'], |
| 279 | 'start': heat_subnet.attributes[ |
| 280 | 'allocation_pools'][0]['start'], |
| 281 | 'end': heat_subnet.attributes['allocation_pools'][0]['end'], |
| 282 | }) |
| 283 | return pools |
| 284 | |
| 285 | def _get_nodes_by_roles(self, roles=None): |
| 286 | nodes = [] |
| 287 | if roles is None: |
| 288 | return self._nodes |
| 289 | |
| 290 | for node in self._nodes: |
| 291 | if set(node['roles']).intersection(set(roles)): |
| 292 | nodes.append(node) |
| 293 | return nodes |
| 294 | |
| 295 | def get_ssh_data(self, roles=None): |
| 296 | """Generate ssh config for Underlay |
| 297 | |
| 298 | :param roles: list of strings |
| 299 | """ |
| 300 | if roles is None: |
| 301 | raise Exception("No roles specified for the environment!") |
| 302 | |
| 303 | config_ssh = [] |
| 304 | for d_node in self._get_nodes_by_roles(roles=roles): |
| 305 | for pool_name in d_node['addresses']: |
| 306 | ssh_data = { |
| 307 | 'node_name': d_node['name'], |
| 308 | 'minion_id': d_node['name'], |
| 309 | 'roles': d_node['roles'], |
| 310 | 'address_pool': pool_name, |
| 311 | 'host': d_node['addresses'][pool_name], |
| 312 | 'login': settings.SSH_NODE_CREDENTIALS['login'], |
| 313 | 'password': settings.SSH_NODE_CREDENTIALS['password'], |
| 314 | 'keys': [k['private'] |
| 315 | for k in self.__config.underlay.ssh_keys] |
| 316 | } |
| 317 | config_ssh.append(ssh_data) |
| 318 | return config_ssh |
| 319 | |
| 320 | def _get_resources_with_wrong_status(self): |
| 321 | res = [] |
Dennis Dmitriev | 4015adc | 2019-04-15 18:33:44 +0300 | [diff] [blame] | 322 | for item in self.__nested_resources: |
Dennis Dmitriev | f5f2e60 | 2017-11-03 15:36:19 +0200 | [diff] [blame] | 323 | if item.resource_status in BAD_STACK_STATUSES: |
| 324 | res.append({ |
| 325 | 'resource_name': item.resource_name, |
| 326 | 'resource_status': item.resource_status, |
| 327 | 'resource_status_reason': item.resource_status_reason, |
| 328 | 'resource_type': item.resource_type |
| 329 | }) |
| 330 | wrong_resources = '\n'.join([ |
| 331 | "*** Heat stack resource '{0}' ({1}) has wrong status '{2}': {3}" |
| 332 | .format(item['resource_name'], |
| 333 | item['resource_type'], |
| 334 | item['resource_status'], |
| 335 | item['resource_status_reason']) |
| 336 | for item in res |
| 337 | ]) |
| 338 | return wrong_resources |
| 339 | |
Dmitry Tyzhnenko | c800aad | 2019-05-27 18:10:46 +0300 | [diff] [blame] | 340 | def wait_of_stack_status(self, status, delay=30, tries=60, |
| 341 | wait_for_delete=False): |
Dennis Dmitriev | f5f2e60 | 2017-11-03 15:36:19 +0200 | [diff] [blame] | 342 | |
| 343 | @retry(exceptions.EnvironmentWrongStatus, delay=delay, tries=tries) |
| 344 | def wait(): |
Dmitry Tyzhnenko | c800aad | 2019-05-27 18:10:46 +0300 | [diff] [blame] | 345 | try: |
| 346 | st = self._current_stack.stack_status |
| 347 | except heat_exceptions.HTTPNotFound as ex: |
| 348 | if wait_for_delete is True: |
| 349 | return |
| 350 | raise ex |
Denis V. Meltsaykin | e68b445 | 2021-01-19 00:48:11 +0100 | [diff] [blame] | 351 | except heat_exceptions.HTTPException as ex: |
| 352 | # tolerate HTTP timeouts from Heat |
| 353 | if ex.code == 504: |
| 354 | raise exceptions.EnvironmentWrongStatus( |
| 355 | self.__config.hardware.heat_stack_name, |
| 356 | status, |
| 357 | "Heat API Temporary Unavailable" |
| 358 | ) |
| 359 | else: |
| 360 | raise ex |
Dennis Dmitriev | f5f2e60 | 2017-11-03 15:36:19 +0200 | [diff] [blame] | 361 | if st == status: |
| 362 | return |
| 363 | elif st in BAD_STACK_STATUSES: |
| 364 | wrong_resources = self._get_resources_with_wrong_status() |
| 365 | raise exceptions.EnvironmentBadStatus( |
| 366 | self.__config.hardware.heat_stack_name, |
| 367 | status, |
| 368 | st, |
| 369 | wrong_resources |
| 370 | ) |
| 371 | else: |
| 372 | LOG.info("Stack {0} status: {1}".format( |
| 373 | self.__config.hardware.heat_stack_name, st)) |
| 374 | raise exceptions.EnvironmentWrongStatus( |
| 375 | self.__config.hardware.heat_stack_name, |
| 376 | status, |
| 377 | st |
| 378 | ) |
| 379 | LOG.info("Waiting for stack '{0}' status <{1}>".format( |
| 380 | self.__config.hardware.heat_stack_name, status)) |
| 381 | wait() |
| 382 | |
| 383 | def revert_snapshot(self, name): |
| 384 | """Revert snapshot by name |
| 385 | |
| 386 | - Revert the heat snapshot in the environment |
| 387 | - Try to reload 'config' object from a file 'config_<name>.ini' |
| 388 | If the file not found, then pass with defaults. |
| 389 | - Set <name> as the current state of the environment after reload |
| 390 | |
| 391 | :param name: string |
| 392 | """ |
| 393 | LOG.info("Reading INI config (without reverting env to snapshot) " |
| 394 | "named '{0}'".format(name)) |
| 395 | |
| 396 | try: |
| 397 | test_config_path = self._get_snapshot_config_name(name) |
| 398 | settings_oslo.reload_snapshot_config(self.__config, |
| 399 | test_config_path) |
| 400 | except cfg.ConfigFilesNotFoundError as conf_err: |
| 401 | LOG.error("Config file(s) {0} not found!".format( |
| 402 | conf_err.config_files)) |
| 403 | |
| 404 | self.__config.hardware.current_snapshot = name |
| 405 | |
| 406 | def create_snapshot(self, name, *args, **kwargs): |
| 407 | """Create named snapshot of current env. |
| 408 | |
| 409 | - Create a snapshot for the environment |
| 410 | - Save 'config' object to a file 'config_<name>.ini' |
| 411 | |
| 412 | :name: string |
| 413 | """ |
Dennis Dmitriev | fa1774a | 2019-05-28 15:27:44 +0300 | [diff] [blame] | 414 | if not settings.MAKE_SNAPSHOT_STAGES: |
| 415 | msg = ("[ SKIP snapshot '{0}' because MAKE_SNAPSHOT_STAGES=false ]" |
| 416 | .format(name)) |
| 417 | LOG.info("\n\n{0}\n{1}".format(msg, '*' * len(msg))) |
| 418 | return |
| 419 | |
Dennis Dmitriev | f5f2e60 | 2017-11-03 15:36:19 +0200 | [diff] [blame] | 420 | LOG.info("Store INI config (without env snapshot) named '{0}'" |
| 421 | .format(name)) |
| 422 | self.__config.hardware.current_snapshot = name |
| 423 | settings_oslo.save_config(self.__config, |
| 424 | name, |
| 425 | self.__config.hardware.heat_stack_name) |
| 426 | |
| 427 | def _get_snapshot_config_name(self, snapshot_name): |
| 428 | """Get config name for the environment""" |
| 429 | env_name = self.__config.hardware.heat_stack_name |
| 430 | if env_name is None: |
| 431 | env_name = 'config' |
| 432 | test_config_path = os.path.join( |
| 433 | settings.LOGS_DIR, '{0}_{1}.ini'.format(env_name, snapshot_name)) |
| 434 | return test_config_path |
| 435 | |
| 436 | def has_snapshot(self, name): |
| 437 | # Heat doesn't support live snapshots, so just |
| 438 | # check if an INI file was created for this environment, |
| 439 | # assuming that the environment has the configuration |
| 440 | # described in this INI. |
| 441 | return self.has_snapshot_config(name) |
| 442 | |
| 443 | def has_snapshot_config(self, name): |
| 444 | test_config_path = self._get_snapshot_config_name(name) |
| 445 | return os.path.isfile(test_config_path) |
| 446 | |
| 447 | def start(self, underlay_node_roles, timeout=480): |
| 448 | """Start environment""" |
| 449 | LOG.warning("HEAT Manager doesn't support start environment feature. " |
| 450 | "Waiting for finish the bootstrap process on the nodes " |
| 451 | "with accessible SSH") |
| 452 | |
Dennis Dmitriev | f5f2e60 | 2017-11-03 15:36:19 +0200 | [diff] [blame] | 453 | check_cloudinit_finished = ('[ -f /is_cloud_init_finished ] || ' |
| 454 | '[ -f /var/log/mcp/.bootstrap_done ]') |
Dennis Dmitriev | f5f2e60 | 2017-11-03 15:36:19 +0200 | [diff] [blame] | 455 | passed = {} |
Hanna Arhipova | a02e09a | 2021-06-04 17:10:38 +0300 | [diff] [blame] | 456 | nodes_by_roles = self._get_nodes_by_roles(roles=underlay_node_roles) |
| 457 | for node in nodes_by_roles: |
Dennis Dmitriev | f5f2e60 | 2017-11-03 15:36:19 +0200 | [diff] [blame] | 458 | try: |
| 459 | node_ip = self.node_ip(node) |
| 460 | except exceptions.EnvironmentNodeAccessError: |
| 461 | LOG.warning("Node {0} doesn't have accessible IP address" |
| 462 | ", skipping".format(node['name'])) |
| 463 | continue |
| 464 | |
| 465 | LOG.info("Waiting for SSH on node '{0}' / {1} ...".format( |
| 466 | node['name'], node_ip)) |
| 467 | |
Anna Arhipova | 9a9fb37 | 2023-04-06 12:47:07 +0200 | [diff] [blame] | 468 | def _readiness_check( |
| 469 | host, |
| 470 | port, |
| 471 | username=settings.SSH_NODE_CREDENTIALS['login'], |
| 472 | password=settings.SSH_NODE_CREDENTIALS['password'], |
| 473 | timeout=0): |
Dennis Dmitriev | f5f2e60 | 2017-11-03 15:36:19 +0200 | [diff] [blame] | 474 | try: |
| 475 | ssh = ssh_client.SSHClient( |
| 476 | host=host, port=port, |
| 477 | auth=ssh_client.SSHAuth( |
| 478 | username=username, |
| 479 | password=password)) |
| 480 | |
Anna Arhipova | 9a9fb37 | 2023-04-06 12:47:07 +0200 | [diff] [blame] | 481 | def bootstrap_is_successful(): |
| 482 | is_cloudinit_completed = ssh.execute( |
| 483 | "tail -n1 /var/log/cloud-init.log |" |
| 484 | "grep -q 'finish: modules-final: SUCCESS'" |
| 485 | )['exit_code'] == 0 |
| 486 | # cfg node doesn't have |
| 487 | # 'finish: modules-final: SUCCESS' line |
| 488 | # in the logs because the cfg node is rebooted during |
| 489 | # bootstrap. Here is /var/log/mcp/.bootstrap_done |
| 490 | # file used as a flag |
| 491 | is_manually_set_flag = ssh.execute( |
Dennis Dmitriev | f5f2e60 | 2017-11-03 15:36:19 +0200 | [diff] [blame] | 492 | check_cloudinit_finished)['exit_code'] == 0 |
Anna Arhipova | 9a9fb37 | 2023-04-06 12:47:07 +0200 | [diff] [blame] | 493 | |
| 494 | return is_cloudinit_completed or is_manually_set_flag |
| 495 | |
| 496 | def no_cloudinit(): |
| 497 | return ssh.execute("[ ! -d /var/lib/cloud/instance ]" |
| 498 | )['exit_code'] == 0 |
| 499 | |
| 500 | if bootstrap_is_successful(): |
| 501 | return True |
| 502 | elif no_cloudinit(): |
| 503 | return ssh.execute('echo ok')['exit_code'] == 0 |
Dennis Dmitriev | f5f2e60 | 2017-11-03 15:36:19 +0200 | [diff] [blame] | 504 | else: |
Anna Arhipova | 9a9fb37 | 2023-04-06 12:47:07 +0200 | [diff] [blame] | 505 | return False |
Dennis Dmitriev | f5f2e60 | 2017-11-03 15:36:19 +0200 | [diff] [blame] | 506 | |
| 507 | except (AuthenticationException, BadAuthenticationType): |
| 508 | return True |
| 509 | except Exception: |
| 510 | return False |
| 511 | |
| 512 | def _ssh_wait(host, |
| 513 | port, |
| 514 | username=settings.SSH_NODE_CREDENTIALS['login'], |
Hanna Arhipova | a02e09a | 2021-06-04 17:10:38 +0300 | [diff] [blame] | 515 | password=settings.SSH_NODE_CREDENTIALS['password']): |
| 516 | """ |
| 517 | Collects hosts have passed the SSH / cloud-init checks. |
| 518 | And returns True if host has passed this check |
Dennis Dmitriev | f5f2e60 | 2017-11-03 15:36:19 +0200 | [diff] [blame] | 519 | |
Hanna Arhipova | a02e09a | 2021-06-04 17:10:38 +0300 | [diff] [blame] | 520 | :param host: str, IP address to connect via SSH |
| 521 | :param port: int, port to connect via SSH |
| 522 | :param username: str, name of SSH user |
| 523 | :param password: str, password for SSH user |
| 524 | :return: True if host has passed the probe test |
| 525 | """ |
| 526 | if host in passed and passed[host] >= 1: |
Dennis Dmitriev | f5f2e60 | 2017-11-03 15:36:19 +0200 | [diff] [blame] | 527 | # host already passed the check |
| 528 | return True |
| 529 | |
Hanna Arhipova | a02e09a | 2021-06-04 17:10:38 +0300 | [diff] [blame] | 530 | for _node in nodes_by_roles: |
| 531 | ip = self.node_ip(_node) |
Dennis Dmitriev | f5f2e60 | 2017-11-03 15:36:19 +0200 | [diff] [blame] | 532 | if ip not in passed: |
| 533 | passed[ip] = 0 |
Anna Arhipova | 9a9fb37 | 2023-04-06 12:47:07 +0200 | [diff] [blame] | 534 | if _readiness_check(ip, port): |
Dennis Dmitriev | f5f2e60 | 2017-11-03 15:36:19 +0200 | [diff] [blame] | 535 | passed[ip] += 1 |
Anna Arhipova | 9a9fb37 | 2023-04-06 12:47:07 +0200 | [diff] [blame] | 536 | LOG.info("{} is already ready".format(_node['name'])) |
| 537 | nodes_by_roles.remove(_node) |
Dennis Dmitriev | f5f2e60 | 2017-11-03 15:36:19 +0200 | [diff] [blame] | 538 | else: |
| 539 | passed[ip] = 0 |
| 540 | |
| 541 | helpers.wait( |
| 542 | lambda: _ssh_wait(node_ip, 22), |
| 543 | timeout=timeout, |
Hanna Arhipova | 71c7eac | 2021-06-01 14:39:31 +0300 | [diff] [blame] | 544 | timeout_msg="Node '{}' didn't open SSH " |
| 545 | "(or didn't complete cloud-init) " |
| 546 | "in {} sec".format( |
Dennis Dmitriev | f5f2e60 | 2017-11-03 15:36:19 +0200 | [diff] [blame] | 547 | node['name'], timeout |
| 548 | ) |
| 549 | ) |
| 550 | LOG.info('Heat stack "{0}" ready' |
| 551 | .format(self.__config.hardware.heat_stack_name)) |
| 552 | |
Dmitry Tyzhnenko | c800aad | 2019-05-27 18:10:46 +0300 | [diff] [blame] | 553 | def _verify_resources_status(self, status): |
| 554 | """Check that all resources have verified `status` |
| 555 | |
| 556 | In case when all resources have expected status return empty list, |
| 557 | otherwise return a list with resources with incorrect status. |
| 558 | """ |
| 559 | ret = [r for r in self.__nested_resources if |
| 560 | r.resource_status != status] |
| 561 | return ret |
| 562 | |
Dennis Dmitriev | f5f2e60 | 2017-11-03 15:36:19 +0200 | [diff] [blame] | 563 | def _create_environment(self): |
| 564 | tpl_files, template = template_utils.get_template_contents( |
| 565 | self.__config.hardware.heat_conf_path) |
| 566 | env_files_list = [] |
| 567 | env_files, env = ( |
| 568 | template_utils.process_multiple_environments_and_files( |
| 569 | env_paths=[self.__config.hardware.heat_env_path], |
| 570 | env_list_tracker=env_files_list)) |
| 571 | |
Dmitriy Kruglov | f40de7e | 2020-03-03 12:06:00 +0100 | [diff] [blame] | 572 | mcp_version = settings.MCP_VERSION |
| 573 | if "2019.2" in settings.MCP_VERSION: |
| 574 | mcp_version = "2019.2.0" |
Dennis Dmitriev | f5f2e60 | 2017-11-03 15:36:19 +0200 | [diff] [blame] | 575 | fields = { |
| 576 | 'stack_name': self.__config.hardware.heat_stack_name, |
| 577 | 'template': template, |
| 578 | 'files': dict(list(tpl_files.items()) + list(env_files.items())), |
| 579 | 'environment': env, |
Dennis Dmitriev | c902ad8 | 2019-04-12 13:41:30 +0300 | [diff] [blame] | 580 | 'parameters': { |
Dmitriy Kruglov | f40de7e | 2020-03-03 12:06:00 +0100 | [diff] [blame] | 581 | 'mcp_version': mcp_version, |
Dennis Dmitriev | c902ad8 | 2019-04-12 13:41:30 +0300 | [diff] [blame] | 582 | 'env_name': settings.ENV_NAME, |
Hanna Arhipova | 31cb1d8 | 2021-01-27 09:41:11 +0200 | [diff] [blame] | 583 | 'deploy_empty_node': bool(settings.DEPLOY_EMPTY_NODE) |
Dennis Dmitriev | c902ad8 | 2019-04-12 13:41:30 +0300 | [diff] [blame] | 584 | } |
Dennis Dmitriev | f5f2e60 | 2017-11-03 15:36:19 +0200 | [diff] [blame] | 585 | } |
| 586 | |
| 587 | if env_files_list: |
| 588 | fields['environment_files'] = env_files_list |
Dmitry Tyzhnenko | c800aad | 2019-05-27 18:10:46 +0300 | [diff] [blame] | 589 | |
| 590 | @retry(heat_exceptions.HTTPBadGateway, delay=15, tries=20) |
| 591 | def safe_heat_stack_create(): |
| 592 | self.__stacks.create(**fields) |
| 593 | |
Hanna Arhipova | 168fc02 | 2020-09-04 14:36:17 +0300 | [diff] [blame] | 594 | @retry(exceptions.EnvironmentBadStatus, delay=60, tries=1) |
Dmitry Tyzhnenko | c800aad | 2019-05-27 18:10:46 +0300 | [diff] [blame] | 595 | def safe_create(): |
| 596 | self.delete_environment() |
| 597 | safe_heat_stack_create() |
| 598 | self.wait_of_stack_status(EXPECTED_STACK_STATUS, tries=140) |
| 599 | LOG.info("Stack '%s' created", |
| 600 | self.__config.hardware.heat_stack_name) |
| 601 | incorrect_resources = self._verify_resources_status( |
| 602 | EXPECTED_STACK_STATUS) |
| 603 | if incorrect_resources: |
| 604 | LOG.info("Recreate the stack because some resources have " |
| 605 | "incorrect status") |
| 606 | for r in incorrect_resources: |
| 607 | LOG.error( |
| 608 | 'The resource %s has status %s. But it should be %s', |
| 609 | r.resource_name, |
| 610 | r.resource_status, |
| 611 | EXPECTED_STACK_STATUS) |
| 612 | st = self._current_stack.stack_status |
| 613 | raise exceptions.EnvironmentBadStatus( |
| 614 | self.__config.hardware.heat_stack_name, |
| 615 | EXPECTED_STACK_STATUS, |
| 616 | st, |
| 617 | incorrect_resources) |
| 618 | safe_create() |
Dennis Dmitriev | f5f2e60 | 2017-11-03 15:36:19 +0200 | [diff] [blame] | 619 | |
| 620 | def stop(self): |
| 621 | """Stop environment""" |
| 622 | LOG.warning("HEAT Manager doesn't support stop environment feature") |
| 623 | pass |
| 624 | |
Dmitry Tyzhnenko | c800aad | 2019-05-27 18:10:46 +0300 | [diff] [blame] | 625 | def delete_environment(self): |
| 626 | if list(self.__stacks.list( |
| 627 | stack_name=self.__config.hardware.heat_stack_name)): |
| 628 | LOG.info("Delete stack '%s'", |
| 629 | self.__config.hardware.heat_stack_name) |
| 630 | |
| 631 | @retry(heat_exceptions.HTTPBadGateway, delay=15, tries=20) |
| 632 | def safe_heat_stack_delete(): |
| 633 | self.__stacks.delete(self._current_stack.id) |
| 634 | |
| 635 | safe_heat_stack_delete() |
| 636 | self.wait_of_stack_status('DELETE_COMPLETE', |
| 637 | delay=30, tries=20, |
| 638 | wait_for_delete=True) |
| 639 | else: |
| 640 | LOG.warning("Can't delete stack '%s' due is absent", |
| 641 | self.__config.hardware.heat_stack_name) |
| 642 | |
Dennis Dmitriev | f5f2e60 | 2017-11-03 15:36:19 +0200 | [diff] [blame] | 643 | # TODO(ddmitriev): add all Environment methods |
| 644 | @staticmethod |
| 645 | def node_ip(node, address_pool_name='admin-pool01'): |
| 646 | """Determine node's IP |
| 647 | |
| 648 | :param node: a dict element from the self._nodes |
| 649 | :return: string |
| 650 | """ |
| 651 | if address_pool_name in node['addresses']: |
| 652 | addr = node['addresses'][address_pool_name] |
| 653 | LOG.debug('{0} IP= {1}'.format(node['name'], addr)) |
| 654 | return addr |
| 655 | else: |
| 656 | raise exceptions.EnvironmentNodeAccessError( |
| 657 | node['name'], |
| 658 | "No addresses available for the subnet {0}" |
| 659 | .format(address_pool_name)) |
| 660 | |
| 661 | def set_address_pools_config(self): |
| 662 | """Store address pools CIDRs in config object""" |
| 663 | for ap in self._address_pools: |
| 664 | for role in ap['roles']: |
| 665 | self.__config.underlay.address_pools[role] = ap['cidr'] |
| 666 | |
| 667 | def set_dhcp_ranges_config(self): |
| 668 | """Store DHCP ranges in config object""" |
| 669 | for ap in self._address_pools: |
| 670 | for role in ap['roles']: |
| 671 | self.__config.underlay.dhcp_ranges[role] = { |
| 672 | "cidr": ap['cidr'], |
| 673 | "start": ap['start'], |
| 674 | "end": ap['end'], |
| 675 | "gateway": ap['gateway'], |
| 676 | } |
| 677 | |
| 678 | def wait_for_node_state(self, node_name, state, timeout): |
| 679 | raise NotImplementedError() |
| 680 | |
| 681 | def warm_shutdown_nodes(self, underlay, nodes_prefix, timeout=600): |
| 682 | raise NotImplementedError() |
| 683 | |
| 684 | def warm_restart_nodes(self, underlay, nodes_prefix, timeout=600): |
| 685 | raise NotImplementedError() |
| 686 | |
| 687 | @property |
| 688 | def slave_nodes(self): |
| 689 | raise NotImplementedError() |