Skip to content
This repository was archived by the owner on Apr 7, 2022. It is now read-only.

Commit f9b19da

Browse files
author
Jaroslav Henner
authored
[1LP][RFR] Prepare connect_ssh (#10056)
* Make trying_ips not default to ininite loop, make it use math.inf. Having the rounds parameter may not be the best idea. Perhaps it is better to make it mandatory parameter. Setting it to math.inf will result in ininite ammount of attempts to connect. * Prepare connect_ssh * Redesign reconnection stuff. Make it use unquirking client-factory method. * Remove retry_connect_rounds * React on review of unquirked_ssh_client. Add more doc, accept only kwargs. * Make _try_batch_of_ips underscored. * Remove f string.
1 parent 2b7a842 commit f9b19da

File tree

6 files changed

+180
-42
lines changed

6 files changed

+180
-42
lines changed

cfme/fixtures/utility_vm.py

Lines changed: 16 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -16,77 +16,54 @@
1616
ping, but the required service may not be up and ready.
1717
"""
1818
import os.path
19-
import time
20-
from contextlib import contextmanager
2119

2220
import pytest
2321

2422
from cfme.base.credential import SSHCredential
2523
from cfme.utils.conf import cfme_data
2624
from cfme.utils.generators import random_vm_name
2725
from cfme.utils.log import logger
28-
from cfme.utils.net import net_check
26+
from cfme.utils.net import pick_responding_ip
2927
from cfme.utils.ssh import SSHClient
3028
from cfme.utils.virtual_machines import deploy_template
31-
from cfme.utils.wait import TimedOutError
3229

3330

34-
def _trying_ips(vm, attempts=60, interval=10):
35-
for attempt in range(attempts):
36-
for ip in getattr(vm, 'all_ips', []):
37-
yield ip
38-
return
39-
time.sleep(interval)
40-
41-
42-
@contextmanager
43-
def connect_ssh(vm, creds):
44-
for ip in _trying_ips(vm):
45-
try:
46-
with SSHClient(hostname=ip, username=creds.principal, password=creds.secret) as client:
47-
logger.info("SSH connected to IP %s", ip)
48-
result = client.run_command("true")
49-
if not result.success:
50-
raise Exception(f"Command `true` failed on ip {ip}.")
51-
yield client
52-
return
53-
except Exception as ex:
54-
logger.info("Failed to connect with SSH to %s: %s", ip, ex)
55-
else:
56-
raise TimedOutError(f"Coudln't find an IP responding to ssh for vm {vm}")
57-
58-
59-
def _pick_responding_ip(vm, port):
60-
for ip in _trying_ips(vm):
61-
if net_check(port, ip):
62-
return ip
63-
else:
64-
raise TimedOutError(f"Coudln't find an IP of vm {vm} with port {port} responding")
31+
ATTEMPT_TIMEOUT = 10
32+
""" How long to wait until connection is determined as not successful. """
33+
IP_PICK_TIMEOUT = 600
34+
""" How many rounds to connect all the vm IPs. """
35+
ROUNDS_DELAY = 10
36+
""" How long to delay after unsucessful connection round. """
6537

6638

6739
@pytest.fixture
6840
def utility_vm_nfs_ip(utility_vm):
6941
vm, _, _ = utility_vm
7042
one_of_the_nfs_ports = 111
71-
yield _pick_responding_ip(vm, one_of_the_nfs_ports)
43+
yield pick_responding_ip(lambda: vm.all_ips, one_of_the_nfs_ports,
44+
IP_PICK_TIMEOUT, ROUNDS_DELAY, ATTEMPT_TIMEOUT)
7245

7346

7447
@pytest.fixture
7548
def utility_vm_samba_ip(utility_vm):
7649
vm, _, _ = utility_vm
77-
yield _pick_responding_ip(vm, 445)
50+
yield pick_responding_ip(lambda: vm.all_ips, 445,
51+
IP_PICK_TIMEOUT, ROUNDS_DELAY, ATTEMPT_TIMEOUT)
7852

7953

8054
@pytest.fixture(scope='module')
8155
def utility_vm_proxy_data(utility_vm):
8256
vm, __, data = utility_vm
83-
yield _pick_responding_ip(vm, data.proxy.port), data.proxy.port
57+
ip = pick_responding_ip(lambda: vm.all_ips, data.proxy.port,
58+
IP_PICK_TIMEOUT, ROUNDS_DELAY, ATTEMPT_TIMEOUT)
59+
yield ip, data.proxy.port
8460

8561

8662
@pytest.fixture(scope='module')
8763
def utility_vm_ssh(utility_vm):
8864
vm, injected_user_cred, __ = utility_vm
89-
ip = _pick_responding_ip(vm, 22)
65+
ip = pick_responding_ip(lambda: vm.all_ips, 22,
66+
IP_PICK_TIMEOUT, ROUNDS_DELAY, ATTEMPT_TIMEOUT)
9067

9168
with SSHClient(
9269
hostname=ip,

cfme/utils/net.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,51 @@ def ip_echo_socket(port=32123):
5858
conn.close()
5959

6060

61+
def pick_responding_ip(vm, port, num_sec, rounds_delay_second, attempt_timeout):
62+
"""
63+
Given a vm and port, pick one of the vm's addresses that is connectible
64+
on the given port
65+
66+
Args:
67+
vm: mgmt vm
68+
port: port number to attempt connecting to
69+
num_sec: Minimal ammount of time how long to keep checking (slight
70+
variation may happen -- approximately the attempt_timeout).
71+
rounds_delay_second: The delay to wait after checking each IP round in
72+
immediate succession.
73+
attempt_timeout: A connection timeout for every connection attempt.
74+
75+
Raise TimedOutError if no such IP is found.
76+
"""
77+
78+
def connection_factory(ip):
79+
if net_check(port, ip, attempt_timeout):
80+
return ip
81+
82+
return retry_connect(vm, connection_factory, num_sec, rounds_delay_second)
83+
84+
85+
def retry_connect(ips_getter, connection_factory, num_sec, delay):
86+
def _try_batch_of_ips():
87+
for ip in ips_getter():
88+
try:
89+
connection = connection_factory(ip)
90+
except Exception as ex:
91+
logger.warning(f"Failed to connect {ip}: {ex}")
92+
continue
93+
else:
94+
logger.info(f"Connected to IP {ip}")
95+
# No other IPs should be attempted, so return.
96+
return connection
97+
return False
98+
connection, _ = wait_for(_try_batch_of_ips, num_sec=num_sec, delay=delay)
99+
return connection
100+
101+
102+
def retry_connect_vm(vm, connection_factory, num_sec, delay):
103+
return retry_connect(lambda: vm.all_ips, connection_factory, num_sec, delay)
104+
105+
61106
def net_check(port, addr=None, force=False, timeout=10):
62107
"""Checks the availablility of a port"""
63108
port = int(port)

cfme/utils/ssh.py

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from cfme.utils import ports
1919
from cfme.utils.log import logger
2020
from cfme.utils.net import net_check
21+
from cfme.utils.net import retry_connect_vm
2122
from cfme.utils.path import project_path
2223
from cfme.utils.quote import quote
2324
from cfme.utils.timeutil import parsetime
@@ -28,6 +29,9 @@
2829
# Default blocking time before giving up on an ssh command execution,
2930
# in seconds (float)
3031
RUNCMD_TIMEOUT = 1200.0
32+
CONNECT_RETRIES_TIMEOUT = 2 * 60
33+
CONNECT_TIMEOUT = 10
34+
CONNECT_SSH_DELAY = 1
3135

3236

3337
@attr.s(frozen=True, eq=False)
@@ -121,7 +125,7 @@ class SSHClient(paramiko.SSHClient):
121125
stdout: If specified, overrides the system stdout file for streaming output.
122126
stderr: If specified, overrides the system stderr file for streaming output.
123127
"""
124-
def __init__(self, stream_output=False, **connect_kwargs):
128+
def __init__(self, *, stream_output=False, **connect_kwargs):
125129
super().__init__()
126130
self._streaming = stream_output
127131
# deprecated/useless karg, included for backward-compat
@@ -135,11 +139,12 @@ def __init__(self, stream_output=False, **connect_kwargs):
135139
self.oc_password = connect_kwargs.pop('oc_password', False)
136140
self.f_stdout = connect_kwargs.pop('stdout', sys.stdout)
137141
self.f_stderr = connect_kwargs.pop('stderr', sys.stderr)
142+
self._use_check_port = connect_kwargs.pop('use_check_port', True)
138143
self.strict_host_key_checking = connect_kwargs.pop('strict_host_key_checking', True)
139144

140145
# load the defaults for ssh, including current_appliance and default credentials keys
141146
compiled_kwargs = dict(
142-
timeout=10,
147+
timeout=CONNECT_TIMEOUT,
143148
allow_agent=False,
144149
look_for_keys=False,
145150
gss_auth=False,
@@ -231,7 +236,9 @@ def connect(self, hostname=None, **kwargs):
231236

232237
if not self.connected:
233238
self._connect_kwargs.update(kwargs)
234-
wait_for(self._check_port, handle_exception=True, timeout='2m', delay=5)
239+
if self._use_check_port:
240+
wait_for(self._check_port, handle_exception=True,
241+
timeout=CONNECT_TIMEOUT, delay=5)
235242
try:
236243
conn = super().connect(**self._connect_kwargs)
237244
except paramiko.ssh_exception.BadHostKeyException:
@@ -842,3 +849,42 @@ def keygen():
842849
with _ssh_pubkey_file.open('w') as f:
843850
f.write("{} {} {}\n".format(pub.get_name(), pub.get_base64(),
844851
'autogenerated cfme_tests key'))
852+
853+
854+
def unquirked_ssh_client(**kwargs):
855+
""" A factory for the SSHClient dealing with some of its quirks:
856+
857+
* When hostname is None, the SSHClient will connect to the default
858+
appliance.
859+
* Set use_check_port to False to disable waiting for port being
860+
available in the SSHClient.
861+
* The SSHClient does not connect in the object creation time.
862+
"""
863+
if kwargs['hostname'] is None:
864+
raise ValueError('The hostname argument cannot be None!')
865+
client = SSHClient(use_check_port=False, **kwargs)
866+
client.connect()
867+
return client
868+
869+
870+
def connect_ssh(vm, creds,
871+
num_sec=CONNECT_RETRIES_TIMEOUT,
872+
connect_timeout=CONNECT_TIMEOUT,
873+
delay=CONNECT_SSH_DELAY):
874+
"""
875+
This function attempts to connect all the IPs of the vm in several
876+
round. After each round it will delay an ammount of seconds specified as
877+
`delay`. Once connective IP is found, it will return connected SSHClient.
878+
The `connect_timeout` specifies a timeout for each connection attempt.
879+
"""
880+
881+
def _connection_factory(ip):
882+
return unquirked_ssh_client(
883+
hostname=ip,
884+
username=creds.principal, password=creds.secret,
885+
timeout=connect_timeout)
886+
887+
msg = "The num_sec is smaller then connect_timeout. This looks like a bug."
888+
assert num_sec > connect_timeout, msg
889+
890+
return retry_connect_vm(vm, _connection_factory, num_sec=num_sec, delay=delay)
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from types import SimpleNamespace
2+
from unittest.mock import Mock
3+
from unittest.mock import PropertyMock
4+
5+
import pytest
6+
7+
from cfme.utils.ssh import connect_ssh
8+
9+
10+
creds = SimpleNamespace(principal='mockuser', secret='mockpass')
11+
12+
13+
@pytest.fixture(autouse=True)
14+
def nosleep(mocker):
15+
mocker.patch('time.sleep')
16+
17+
18+
@pytest.fixture
19+
def vm_mock():
20+
vm = Mock()
21+
all_ips_mock = PropertyMock()
22+
type(vm).all_ips = all_ips_mock
23+
all_ips_mock.side_effect = [
24+
[None],
25+
['NOT_WORKING_IP_MOCK'],
26+
['NOT_WORKING_IP_MOCK', 'OTHER_NOT_WORKING_IP_MOCK',
27+
'WORKING_IP_MOCK', 'SHOULD_NOT_REACH_THIS_MOCK'],
28+
['SHOULD_NOT_REACH_THIS_MOCK', 'SHOULD_NOT_REACH_THIS_MOCK']
29+
]
30+
return vm
31+
32+
33+
@pytest.fixture
34+
def vm_mock2():
35+
vm = Mock()
36+
all_ips_mock = PropertyMock()
37+
type(vm).all_ips = all_ips_mock
38+
all_ips_mock.side_effect = [
39+
['WORKING_IP_MOCK', 'SHOULD_NOT_REACH_THIS_MOCK']
40+
]
41+
return vm
42+
43+
44+
class SSHClientMock:
45+
def __init__(self, **kwargs):
46+
self.kwargs = kwargs
47+
self.close = Mock()
48+
self.run_command = Mock()
49+
self.run_command.success.return_value = True
50+
51+
# disabling check_port is required for not making repeated connection
52+
# attempts in the SSHClient
53+
assert kwargs['use_check_port'] is False
54+
55+
def connect(self):
56+
hostname = self.kwargs['hostname']
57+
if hostname == 'WORKING_IP_MOCK':
58+
return self
59+
elif hostname == 'SHOULD_NOT_REACH_THIS_MOCK':
60+
pytest.fail('We should not have reached checking this IP!')
61+
else:
62+
raise Exception(f'This was raised for IP {hostname}.')
63+
64+
65+
def test_connect_ssh(mocker, vm_mock, vm_mock2):
66+
mocker.patch('cfme.utils.ssh.SSHClient', SSHClientMock)
67+
assert connect_ssh(vm_mock, creds, num_sec=3, connect_timeout=1, delay=1).run_command()
68+
assert connect_ssh(vm_mock2, creds, num_sec=3, connect_timeout=1, delay=1).run_command()

requirements/frozen.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ pyperclip==1.7.0
259259
pytesseract==0.3.3
260260
pytest==5.4.1
261261
pytest-polarion-collect==0.23.1
262+
pytest-mock==2.0.0
262263
python-box==3.2.4
263264
python-bugzilla==2.3.0
264265
python-cinderclient==4.1.0

requirements/template_non_imported.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@ pdfminer.six
99
polarion-docstrings
1010
polarion-tools-common
1111
pre-commit
12+
pytest-mock
1213
pytest-polarion-collect
1314
pytest-report-parameters

0 commit comments

Comments
 (0)