vitro 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vitro/__init__.py +13 -0
- vitro/configs/__init__.py +8 -0
- vitro/configs/logging.json +40 -0
- vitro/devices/__init__.py +1 -0
- vitro/devices/base_devices/__init__.py +5 -0
- vitro/devices/base_devices/vitro_device.py +57 -0
- vitro/exceptions.py +98 -0
- vitro/libraries/__init__.py +1 -0
- vitro/libraries/connection_factory.py +78 -0
- vitro/libraries/connections/__init__.py +1 -0
- vitro/libraries/connections/connect_and_run.py +70 -0
- vitro/libraries/connections/ldap_authenticated_serial.py +122 -0
- vitro/libraries/connections/local_cmd.py +159 -0
- vitro/libraries/connections/ser2net_connection.py +84 -0
- vitro/libraries/connections/serial_connection.py +93 -0
- vitro/libraries/connections/ssh_connection.py +181 -0
- vitro/libraries/connections/telnet.py +122 -0
- vitro/libraries/device_manager.py +107 -0
- vitro/libraries/docker_factory/__init__.py +1 -0
- vitro/libraries/docker_factory/docker_compose_generator.py +226 -0
- vitro/libraries/docker_factory/templates/docker-compose.acs.tmpl.json +19 -0
- vitro/libraries/docker_factory/templates/docker-compose.ext_voip.tmpl.json +16 -0
- vitro/libraries/docker_factory/templates/docker-compose.fxs.tmpl.json +14 -0
- vitro/libraries/docker_factory/templates/docker-compose.lan.tmpl.json +17 -0
- vitro/libraries/docker_factory/templates/docker-compose.orchestrator.tmpl.json +20 -0
- vitro/libraries/docker_factory/templates/docker-compose.sip.tmpl.json +16 -0
- vitro/libraries/docker_factory/templates/docker-compose.tmpl.json +81 -0
- vitro/libraries/docker_factory/templates/docker-compose.wan.tmpl.json +18 -0
- vitro/libraries/interactive_shell.py +299 -0
- vitro/libraries/utils.py +195 -0
- vitro/libraries/vitro_config.py +303 -0
- vitro/libraries/vitro_pexpect.py +139 -0
- vitro/main.py +101 -0
- vitro/plugins/__init__.py +1 -0
- vitro/plugins/core.py +208 -0
- vitro/plugins/hookspecs/__init__.py +1 -0
- vitro/plugins/hookspecs/core.py +224 -0
- vitro/plugins/hookspecs/devices.py +360 -0
- vitro/plugins/no_reservation.py +22 -0
- vitro/plugins/setup_environment.py +179 -0
- vitro/py.typed +0 -0
- vitro/type_hints.py +5 -0
- vitro-0.8.0.dist-info/METADATA +162 -0
- vitro-0.8.0.dist-info/RECORD +48 -0
- vitro-0.8.0.dist-info/WHEEL +5 -0
- vitro-0.8.0.dist-info/entry_points.txt +7 -0
- vitro-0.8.0.dist-info/licenses/LICENSE +34 -0
- vitro-0.8.0.dist-info/top_level.txt +1 -0
vitro/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Automated testing of network devices."""
|
|
2
|
+
|
|
3
|
+
__version__ = "2025.12.17a0"
|
|
4
|
+
|
|
5
|
+
from pluggy import HookimplMarker, HookspecMarker
|
|
6
|
+
|
|
7
|
+
PROJECT_NAME = "vitro"
|
|
8
|
+
|
|
9
|
+
hookspec = HookspecMarker(PROJECT_NAME)
|
|
10
|
+
hookimpl = HookimplMarker(PROJECT_NAME)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
__all__ = ["hookimpl", "hookspec"]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"disable_existing_loggers": true,
|
|
3
|
+
"formatters": {
|
|
4
|
+
"pexpect": {
|
|
5
|
+
"class": "logging.Formatter",
|
|
6
|
+
"format": "%(asctime)s %(levelname)-8s- %(message)-100s (%(name)s)"
|
|
7
|
+
},
|
|
8
|
+
"root": {
|
|
9
|
+
"class": "logging.Formatter",
|
|
10
|
+
"format": "%(asctime)s %(levelname)-8s- %(message)-100s (%(name)s:%(funcName)s:%(lineno)d)"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"handlers": {
|
|
14
|
+
"pexpect": {
|
|
15
|
+
"class": "logging.StreamHandler",
|
|
16
|
+
"formatter": "pexpect",
|
|
17
|
+
"stream": "ext://sys.stderr"
|
|
18
|
+
},
|
|
19
|
+
"root": {
|
|
20
|
+
"class": "logging.StreamHandler",
|
|
21
|
+
"formatter": "root",
|
|
22
|
+
"stream": "ext://sys.stderr"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"loggers": {
|
|
26
|
+
"pexpect": {
|
|
27
|
+
"handlers": [
|
|
28
|
+
"pexpect"
|
|
29
|
+
],
|
|
30
|
+
"propagate": false
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"root": {
|
|
34
|
+
"handlers": [
|
|
35
|
+
"root"
|
|
36
|
+
],
|
|
37
|
+
"level": "DEBUG"
|
|
38
|
+
},
|
|
39
|
+
"version": 1
|
|
40
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Vitro devices package."""
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Vitro base device template."""
|
|
2
|
+
|
|
3
|
+
from argparse import Namespace
|
|
4
|
+
|
|
5
|
+
from vitro.libraries.vitro_pexpect import VitroPexpect
|
|
6
|
+
from vitro.type_hints import DeviceConfigType
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class VitroDevice:
|
|
10
|
+
"""Vitro base device which all devices inherit from."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, config: DeviceConfigType, cmdline_args: Namespace) -> None:
|
|
13
|
+
"""Initialize vitro base device.
|
|
14
|
+
|
|
15
|
+
:param config: device configuration
|
|
16
|
+
:param cmdline_args: command line arguments
|
|
17
|
+
"""
|
|
18
|
+
self._config: DeviceConfigType = config
|
|
19
|
+
self._cmdline_args = cmdline_args
|
|
20
|
+
|
|
21
|
+
def _extract_property_value(self, property_name: str) -> str:
|
|
22
|
+
name = self._config.get(property_name)
|
|
23
|
+
if name is None:
|
|
24
|
+
msg = f"{property_name} is not set in the configuration: {self._config=}"
|
|
25
|
+
raise RuntimeError(msg)
|
|
26
|
+
return name
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def config(self) -> DeviceConfigType:
|
|
30
|
+
"""Get device configuration.
|
|
31
|
+
|
|
32
|
+
:returns: device configuration
|
|
33
|
+
"""
|
|
34
|
+
return self._config
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def device_name(self) -> str:
|
|
38
|
+
"""Get name of the device.
|
|
39
|
+
|
|
40
|
+
:returns: device name
|
|
41
|
+
"""
|
|
42
|
+
return self._extract_property_value("name")
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def device_type(self) -> str:
|
|
46
|
+
"""Get type of the device.
|
|
47
|
+
|
|
48
|
+
:returns: device type
|
|
49
|
+
"""
|
|
50
|
+
return self._extract_property_value("type")
|
|
51
|
+
|
|
52
|
+
def get_interactive_consoles(self) -> dict[str, VitroPexpect]:
|
|
53
|
+
"""Get interactive consoles from device.
|
|
54
|
+
|
|
55
|
+
:returns: interactive consoles of the device
|
|
56
|
+
"""
|
|
57
|
+
return {}
|
vitro/exceptions.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Vitro exceptions for all plugins and modules used by framework."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class VitroError(Exception):
|
|
7
|
+
"""Base exception all vitro exceptions inherit from."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ShellPromptUndefinedError(VitroError):
|
|
11
|
+
"""Raise this when shell prompt is not defined."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DeviceConnectionError(VitroError):
|
|
15
|
+
"""Raise this on device connection error."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SSHConnectionError(DeviceConnectionError):
|
|
19
|
+
"""Raise this on SSH connection failure."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SCPConnectionError(DeviceConnectionError):
|
|
23
|
+
"""Raise this on SCP connection failure."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class EnvConfigError(VitroError):
|
|
27
|
+
"""Raise this on environment configuration error."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DeviceRequirementError(VitroError):
|
|
31
|
+
"""Raise this on device requirement error."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DeviceNotFoundError(VitroError):
|
|
35
|
+
"""Raise this on device is not available."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class FileLockTimeoutError(VitroError):
|
|
39
|
+
"""Raise this on file lock timeout."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ConfigurationFailureError(VitroError):
|
|
43
|
+
"""Raise this on device configuration failure."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DeviceBootFailureError(VitroError):
|
|
47
|
+
"""Raise this on device boot failure."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TR069ResponseErrorError(VitroError):
|
|
51
|
+
"""Raise this on TR069 response error."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TR069FaultCodeError(VitroError):
|
|
55
|
+
"""Raise this on TR069 response error."""
|
|
56
|
+
|
|
57
|
+
faultdict: dict[str, Any]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class UseCaseFailureError(VitroError):
|
|
61
|
+
"""Raise this on failures in use cases."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class NotSupportedError(VitroError):
|
|
65
|
+
"""Raise this on feature not supported."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SNMPError(VitroError):
|
|
69
|
+
"""Raise this on any SNMP related error."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class VoiceError(VitroError):
|
|
73
|
+
"""Raise this on any voice related errors."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# TODO: maybe move to testsuite
|
|
77
|
+
class TeardownError(VitroError):
|
|
78
|
+
"""Raise this on any test teardown failure."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ContingencyCheckError(VitroError):
|
|
82
|
+
"""Raise this on any contingency check failure."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class WifiError(VitroError):
|
|
86
|
+
"""Raise this on any wifi related errors."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class MulticastError(VitroError):
|
|
90
|
+
"""Raise this on any multicast related errors."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class CodeError(VitroError):
|
|
94
|
+
"""Raise this if an code assert fails.
|
|
95
|
+
|
|
96
|
+
This exception is only meant for custom assert
|
|
97
|
+
clause used inside libraries.
|
|
98
|
+
"""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Vitro libraries package."""
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Connection decider module."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from vitro.exceptions import EnvConfigError
|
|
6
|
+
from vitro.libraries.connections.ldap_authenticated_serial import (
|
|
7
|
+
LdapAuthenticatedSerial,
|
|
8
|
+
)
|
|
9
|
+
from vitro.libraries.connections.local_cmd import LocalCmd
|
|
10
|
+
from vitro.libraries.connections.ser2net_connection import Ser2NetConnection
|
|
11
|
+
from vitro.libraries.connections.serial_connection import SerialConnection
|
|
12
|
+
from vitro.libraries.connections.ssh_connection import SSHConnection
|
|
13
|
+
from vitro.libraries.connections.telnet import TelnetConnection
|
|
14
|
+
from vitro.libraries.vitro_pexpect import VitroPexpect
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def connection_factory(
|
|
18
|
+
connection_type: str,
|
|
19
|
+
connection_name: str,
|
|
20
|
+
**kwargs: Any, # noqa: ANN401
|
|
21
|
+
) -> VitroPexpect:
|
|
22
|
+
"""Return connection of given type.
|
|
23
|
+
|
|
24
|
+
:param connection_type: type of the connection
|
|
25
|
+
:param connection_name: name of the connection
|
|
26
|
+
:param kwargs: arguments to the connection
|
|
27
|
+
:returns: VitroPexpect: connection of given type
|
|
28
|
+
:raises EnvConfigError: when given connection type is not supported
|
|
29
|
+
"""
|
|
30
|
+
connection_dispatcher = {
|
|
31
|
+
"ssh_connection": SSHConnection,
|
|
32
|
+
"authenticated_ssh": SSHConnection,
|
|
33
|
+
"ldap_authenticated_serial": LdapAuthenticatedSerial,
|
|
34
|
+
"local_cmd": LocalCmd,
|
|
35
|
+
"serial": SerialConnection,
|
|
36
|
+
"ser2net": _ser2net_param_parser,
|
|
37
|
+
"telnet": _telnet_param_parser,
|
|
38
|
+
}
|
|
39
|
+
connection_obj = connection_dispatcher.get(connection_type)
|
|
40
|
+
if connection_obj is not None and callable(connection_obj):
|
|
41
|
+
if connection_type == "ssh_connection":
|
|
42
|
+
kwargs.pop("password")
|
|
43
|
+
return connection_obj(connection_name, **kwargs)
|
|
44
|
+
# Handle unsupported connection types
|
|
45
|
+
msg = f"Unsupported connection type: {connection_type}"
|
|
46
|
+
raise EnvConfigError(msg)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _telnet_param_parser(
|
|
50
|
+
connection_name: str,
|
|
51
|
+
**kwargs: Any, # noqa: ANN401
|
|
52
|
+
) -> TelnetConnection:
|
|
53
|
+
return TelnetConnection(
|
|
54
|
+
session_name=connection_name,
|
|
55
|
+
command="telnet",
|
|
56
|
+
save_console_logs=kwargs.pop("save_console_logs"),
|
|
57
|
+
args=[
|
|
58
|
+
kwargs["ip_addr"],
|
|
59
|
+
kwargs["port"],
|
|
60
|
+
kwargs["shell_prompt"],
|
|
61
|
+
],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _ser2net_param_parser(
|
|
66
|
+
connection_name: str,
|
|
67
|
+
**kwargs: Any, # noqa: ANN401
|
|
68
|
+
) -> Ser2NetConnection:
|
|
69
|
+
return Ser2NetConnection(
|
|
70
|
+
connection_name,
|
|
71
|
+
"telnet",
|
|
72
|
+
kwargs["save_console_logs"],
|
|
73
|
+
[
|
|
74
|
+
kwargs["ip_addr"],
|
|
75
|
+
kwargs["port"],
|
|
76
|
+
kwargs["shell_prompt"],
|
|
77
|
+
],
|
|
78
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Vitro connections package."""
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Connect and run module."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from time import sleep
|
|
5
|
+
from typing import ParamSpec, Protocol, TypeVar, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from vitro.exceptions import DeviceConnectionError
|
|
8
|
+
|
|
9
|
+
P = ParamSpec("P")
|
|
10
|
+
T = TypeVar("T")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@runtime_checkable
|
|
14
|
+
class RuntimeConnectable(Protocol):
|
|
15
|
+
"""Runtine connectable protocol class."""
|
|
16
|
+
|
|
17
|
+
def connect_console(self) -> None:
|
|
18
|
+
"""Connect to the console."""
|
|
19
|
+
|
|
20
|
+
def disconnect_console(self) -> None:
|
|
21
|
+
"""Disconnect from the console."""
|
|
22
|
+
|
|
23
|
+
def is_console_connected(self) -> bool:
|
|
24
|
+
"""Get status of the connection."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def connect_and_run(func: Callable[P, T]) -> Callable[P, T]:
|
|
28
|
+
"""Connect run and disconnect to console at runtime.
|
|
29
|
+
|
|
30
|
+
Note: This is implemented only for instance methods
|
|
31
|
+
|
|
32
|
+
:param func: the decorated method
|
|
33
|
+
:return: True or False
|
|
34
|
+
:rtype: Callable[P, T]
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
38
|
+
instance = args[0]
|
|
39
|
+
exc_to_raise = None
|
|
40
|
+
if not isinstance(instance, RuntimeConnectable):
|
|
41
|
+
msg = (
|
|
42
|
+
f"Provided instance {instance} do not ,"
|
|
43
|
+
f"follows the protocol RuntimeConnectable .i.e {RuntimeConnectable}"
|
|
44
|
+
)
|
|
45
|
+
raise TypeError(msg)
|
|
46
|
+
|
|
47
|
+
if not instance.is_console_connected():
|
|
48
|
+
# Adding a retry
|
|
49
|
+
for _ in range(10):
|
|
50
|
+
try:
|
|
51
|
+
instance.connect_console()
|
|
52
|
+
break
|
|
53
|
+
except DeviceConnectionError:
|
|
54
|
+
sleep(15)
|
|
55
|
+
raise
|
|
56
|
+
else:
|
|
57
|
+
raise DeviceConnectionError
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
output = func(*args, **kwargs)
|
|
61
|
+
except Exception as e: # noqa: BLE001 pylint: disable=W0718
|
|
62
|
+
exc_to_raise = e
|
|
63
|
+
finally:
|
|
64
|
+
if instance.is_console_connected():
|
|
65
|
+
instance.disconnect_console()
|
|
66
|
+
if exc_to_raise:
|
|
67
|
+
raise exc_to_raise
|
|
68
|
+
return output
|
|
69
|
+
|
|
70
|
+
return wrapper
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""SSH connection module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
import pexpect
|
|
8
|
+
|
|
9
|
+
from vitro.exceptions import DeviceConnectionError
|
|
10
|
+
from vitro.libraries.connections.ssh_connection import SSHConnection
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from pexpect.spawnbase import _InputRePattern
|
|
14
|
+
|
|
15
|
+
_CONNECTION_FAILED_STR: str = "Failed to connect to device via serial"
|
|
16
|
+
_EOF_INDEX = 2
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LdapAuthenticatedSerial(SSHConnection):
|
|
20
|
+
"""Connect to a serial with ldap credentials."""
|
|
21
|
+
|
|
22
|
+
def __init__( # noqa: PLR0913
|
|
23
|
+
self,
|
|
24
|
+
name: str,
|
|
25
|
+
ip_addr: str,
|
|
26
|
+
ldap_credentials: str,
|
|
27
|
+
shell_prompt: list[_InputRePattern],
|
|
28
|
+
port: int = 22,
|
|
29
|
+
save_console_logs: str = "",
|
|
30
|
+
**kwargs: dict[str, Any], # ignore other arguments # noqa: ARG002
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Initialize ldap authenticated serial connection.
|
|
33
|
+
|
|
34
|
+
:param name: connection name
|
|
35
|
+
:type name: str
|
|
36
|
+
:param ip_addr: server ip address
|
|
37
|
+
:type ip_addr: str
|
|
38
|
+
:param ldap_credentials: ldap credentials
|
|
39
|
+
:type ldap_credentials: str
|
|
40
|
+
:param shell_prompt: shell prompt patterns
|
|
41
|
+
:type shell_prompt: list[str]
|
|
42
|
+
:param port: port number, defaults to 22
|
|
43
|
+
:type port: int
|
|
44
|
+
:param save_console_logs: save console logs to disk, defaults to ""
|
|
45
|
+
:type save_console_logs: str
|
|
46
|
+
:param kwargs: other keyword arguments
|
|
47
|
+
:raises ValueError: invalid LDAP credentials
|
|
48
|
+
"""
|
|
49
|
+
if ";" not in ldap_credentials:
|
|
50
|
+
msg = "Invalid LDAP credentials"
|
|
51
|
+
raise ValueError(msg)
|
|
52
|
+
username, password = ldap_credentials.split(";")
|
|
53
|
+
super().__init__(
|
|
54
|
+
name,
|
|
55
|
+
ip_addr,
|
|
56
|
+
username,
|
|
57
|
+
shell_prompt,
|
|
58
|
+
port,
|
|
59
|
+
password,
|
|
60
|
+
save_console_logs,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def login_to_server(self, password: str | None = None) -> None:
|
|
64
|
+
"""Login to serial server.
|
|
65
|
+
|
|
66
|
+
:param password: LDAP password
|
|
67
|
+
:type password: str
|
|
68
|
+
:raises DeviceConnectionError: failed to connect to device via serial
|
|
69
|
+
"""
|
|
70
|
+
if password is None:
|
|
71
|
+
password = self._password
|
|
72
|
+
if self.expect(["Password:", pexpect.EOF, pexpect.TIMEOUT]):
|
|
73
|
+
raise DeviceConnectionError(_CONNECTION_FAILED_STR)
|
|
74
|
+
self.sendline(password)
|
|
75
|
+
|
|
76
|
+
if (
|
|
77
|
+
self.expect_exact(
|
|
78
|
+
["OpenGear Serial Server", pexpect.TIMEOUT, pexpect.EOF],
|
|
79
|
+
timeout=10,
|
|
80
|
+
)
|
|
81
|
+
== _EOF_INDEX
|
|
82
|
+
):
|
|
83
|
+
raise DeviceConnectionError(_CONNECTION_FAILED_STR)
|
|
84
|
+
# In case of SSH communication over different geological WAN:
|
|
85
|
+
# The SSH channel does not start to display data post connection.
|
|
86
|
+
# Instead the user needs to enter some key to refresh. e.g. ENTER
|
|
87
|
+
# This is generally due to poor connection.
|
|
88
|
+
# Providing a few input below and flushing the buffer after 5 sec.
|
|
89
|
+
self.sendline()
|
|
90
|
+
self.sendline()
|
|
91
|
+
self.expect(pexpect.TIMEOUT, timeout=5)
|
|
92
|
+
|
|
93
|
+
async def login_to_server_async(self, password: str | None = None) -> None:
|
|
94
|
+
"""Login to serial server.
|
|
95
|
+
|
|
96
|
+
:param password: LDAP password
|
|
97
|
+
:type password: str
|
|
98
|
+
:raises DeviceConnectionError: failed to connect to device via serial
|
|
99
|
+
"""
|
|
100
|
+
if password is None:
|
|
101
|
+
password = self._password
|
|
102
|
+
if await self.expect(["Password:", pexpect.EOF, pexpect.TIMEOUT], async_=True):
|
|
103
|
+
raise DeviceConnectionError(_CONNECTION_FAILED_STR)
|
|
104
|
+
self.sendline(password)
|
|
105
|
+
|
|
106
|
+
if (
|
|
107
|
+
await self.expect_exact(
|
|
108
|
+
["OpenGear Serial Server", pexpect.TIMEOUT, pexpect.EOF],
|
|
109
|
+
timeout=10,
|
|
110
|
+
async_=True,
|
|
111
|
+
)
|
|
112
|
+
== _EOF_INDEX
|
|
113
|
+
):
|
|
114
|
+
raise DeviceConnectionError(_CONNECTION_FAILED_STR)
|
|
115
|
+
# In case of SSH communication over different geological WAN:
|
|
116
|
+
# The SSH channel does not start to display data post connection.
|
|
117
|
+
# Instead the user needs to enter some key to refresh. e.g. ENTER
|
|
118
|
+
# This is generally due to poor connection.
|
|
119
|
+
# Providing a few input below and flushing the buffer after 5 sec.
|
|
120
|
+
self.sendline()
|
|
121
|
+
self.sendline()
|
|
122
|
+
await self.expect(pexpect.TIMEOUT, timeout=5, async_=True)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Connect to a device with a local command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
import pexpect
|
|
8
|
+
|
|
9
|
+
from vitro.exceptions import (
|
|
10
|
+
DeviceConnectionError,
|
|
11
|
+
ShellPromptUndefinedError,
|
|
12
|
+
VitroError,
|
|
13
|
+
)
|
|
14
|
+
from vitro.libraries.vitro_pexpect import VitroPexpect
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from pexpect.spawnbase import _InputRePattern
|
|
18
|
+
|
|
19
|
+
_CONNECTION_ERROR_THRESHOLD = 2
|
|
20
|
+
_CONNECTION_FAILED_STR: str = "Connection failed with Local Command"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LocalCmd(VitroPexpect):
|
|
24
|
+
"""Connect to a device with a local command."""
|
|
25
|
+
|
|
26
|
+
def __init__( # pylint: disable=too-many-arguments # noqa: PLR0913
|
|
27
|
+
self, # pylint: disable=unused-argument
|
|
28
|
+
name: str,
|
|
29
|
+
conn_command: str,
|
|
30
|
+
save_console_logs: str,
|
|
31
|
+
shell_prompt: list[_InputRePattern] | None = None,
|
|
32
|
+
args: list[str] | None = None,
|
|
33
|
+
*,
|
|
34
|
+
username: str = "root",
|
|
35
|
+
password: str | None = None,
|
|
36
|
+
**kwargs: Any, # noqa: ANN401
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Initialize local command connection.
|
|
39
|
+
|
|
40
|
+
:param name: connection name
|
|
41
|
+
:type name: str
|
|
42
|
+
:param conn_command: command to start the session
|
|
43
|
+
:type conn_command: str
|
|
44
|
+
:param save_console_logs: save console logs to disk
|
|
45
|
+
:type save_console_logs: str
|
|
46
|
+
:param shell_prompt: shell prompt pattern, defaults to None
|
|
47
|
+
:type shell_prompt: list[str]
|
|
48
|
+
:param args: arguments to the command, defaults to None
|
|
49
|
+
:type args: list[str], optional
|
|
50
|
+
:param username: effective user inside the local command session.
|
|
51
|
+
Defaults to ``"root"``, matching the default ``docker exec``
|
|
52
|
+
behaviour. Set when the local command launches as a
|
|
53
|
+
non-root user (e.g. ``docker exec --user alice ...``) so
|
|
54
|
+
:meth:`sudo_sendline` knows whether escalation is required.
|
|
55
|
+
:type username: str
|
|
56
|
+
:param password: password used for sudo escalation when
|
|
57
|
+
``username != "root"``. Required only when the sudoers
|
|
58
|
+
policy prompts for one; may be ``None`` for NOPASSWD
|
|
59
|
+
configurations.
|
|
60
|
+
:type password: str | None
|
|
61
|
+
:param kwargs: additional keyword args
|
|
62
|
+
"""
|
|
63
|
+
self._shell_prompt = shell_prompt
|
|
64
|
+
self._username = username
|
|
65
|
+
self._password = password
|
|
66
|
+
if args is None:
|
|
67
|
+
args = []
|
|
68
|
+
super().__init__(
|
|
69
|
+
name,
|
|
70
|
+
conn_command,
|
|
71
|
+
save_console_logs,
|
|
72
|
+
args,
|
|
73
|
+
**kwargs,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# pylint: disable=duplicate-code
|
|
77
|
+
def login_to_server(self, password: str | None = None) -> None:
|
|
78
|
+
"""Login.
|
|
79
|
+
|
|
80
|
+
:param password: ssh password
|
|
81
|
+
:raises DeviceConnectionError: connection failed via local command
|
|
82
|
+
:raises ValueError: if shell prompt is unavailable
|
|
83
|
+
"""
|
|
84
|
+
if password is not None:
|
|
85
|
+
if self.expect(
|
|
86
|
+
["password:", pexpect.EOF, pexpect.TIMEOUT],
|
|
87
|
+
):
|
|
88
|
+
raise DeviceConnectionError(_CONNECTION_FAILED_STR)
|
|
89
|
+
self.sendline(password)
|
|
90
|
+
# TODO: temp fix for now. To be decided if shell prompt to be used.
|
|
91
|
+
if not self._shell_prompt:
|
|
92
|
+
raise ShellPromptUndefinedError
|
|
93
|
+
if (
|
|
94
|
+
self.expect(
|
|
95
|
+
[
|
|
96
|
+
pexpect.EOF,
|
|
97
|
+
pexpect.TIMEOUT,
|
|
98
|
+
*self._shell_prompt,
|
|
99
|
+
],
|
|
100
|
+
)
|
|
101
|
+
< _CONNECTION_ERROR_THRESHOLD
|
|
102
|
+
):
|
|
103
|
+
raise DeviceConnectionError(_CONNECTION_FAILED_STR)
|
|
104
|
+
|
|
105
|
+
def execute_command(self, command: str, timeout: int = -1) -> str:
|
|
106
|
+
"""Execute a command in the local command session.
|
|
107
|
+
|
|
108
|
+
:param command: command to execute
|
|
109
|
+
:param timeout: timeout in seconds. defaults to -1
|
|
110
|
+
:returns: command output
|
|
111
|
+
:raises ValueError: if shell prompt is unavailable
|
|
112
|
+
"""
|
|
113
|
+
if not self._shell_prompt:
|
|
114
|
+
raise ShellPromptUndefinedError
|
|
115
|
+
self.sendline(command)
|
|
116
|
+
self.expect_exact(command)
|
|
117
|
+
self.expect(self.linesep)
|
|
118
|
+
# TODO: is this needed? is the shell prompt of Local(Jenkins or any user)?
|
|
119
|
+
self.expect(self._shell_prompt, timeout=timeout)
|
|
120
|
+
return self.get_last_output()
|
|
121
|
+
|
|
122
|
+
def sudo_sendline(self, cmd: str) -> None:
|
|
123
|
+
"""Add sudo in the sendline if username is not root.
|
|
124
|
+
|
|
125
|
+
Mirrors :meth:`SSHConnection.sudo_sendline`: when ``_username``
|
|
126
|
+
is not ``"root"``, prime sudo with ``sudo true``, supply
|
|
127
|
+
``_password`` if sudo prompts for one, then send the command
|
|
128
|
+
prefixed with ``sudo``. For ``_username == "root"`` the method
|
|
129
|
+
collapses to a plain :meth:`sendline` — identical to the root
|
|
130
|
+
branch of :class:`SSHConnection`.
|
|
131
|
+
|
|
132
|
+
:param cmd: command to send
|
|
133
|
+
:raises VitroError: if sudo prompts for a password but none was
|
|
134
|
+
configured on the connection.
|
|
135
|
+
:raises ShellPromptUndefinedError: if no shell prompt was
|
|
136
|
+
configured on the connection.
|
|
137
|
+
"""
|
|
138
|
+
if not self._shell_prompt:
|
|
139
|
+
raise ShellPromptUndefinedError
|
|
140
|
+
if self._username != "root":
|
|
141
|
+
self.sendline("sudo true")
|
|
142
|
+
password_requested = self.expect(
|
|
143
|
+
[*self._shell_prompt, "password for .*:", "Password:"],
|
|
144
|
+
)
|
|
145
|
+
if password_requested:
|
|
146
|
+
if self._password is None:
|
|
147
|
+
msg = (
|
|
148
|
+
"sudo prompted for a password but none was configured "
|
|
149
|
+
f"for LocalCmd (username={self._username!r}). "
|
|
150
|
+
"Supply a password at construction time or use a "
|
|
151
|
+
"NOPASSWD sudoers entry for this user."
|
|
152
|
+
)
|
|
153
|
+
raise VitroError(msg)
|
|
154
|
+
self.sendline(self._password)
|
|
155
|
+
self.expect(self._shell_prompt)
|
|
156
|
+
cmd = "sudo " + cmd
|
|
157
|
+
self.sendline(cmd)
|
|
158
|
+
|
|
159
|
+
# pylint: enable=duplicate-code
|