File: host.py

package info (click to toggle)
pytest-multihost 3.4-1
  • links: PTS, VCS
  • area: main
  • in suites: bookworm, forky, sid, trixie
  • size: 164 kB
  • sloc: python: 801; makefile: 4
file content (297 lines) | stat: -rw-r--r-- 10,441 bytes parent folder | download | duplicates (3)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
#
# Copyright (C) 2013  Red Hat
# Copyright (C) 2014  pytest-multihost contributors
# See COPYING for license
#

"""Host class for integration testing"""

import os
import socket
import subprocess

from pytest_multihost import transport
from pytest_multihost.util import check_config_dict_empty, shell_quote

try:
    basestring
except NameError:
    basestring = str


class BaseHost(object):
    """Representation of a remote host

    See README for an overview of the core classes.
    """
    transport_class = transport.SSHTransport
    command_prelude = b''

    def __init__(self, domain, hostname, role, ip=None,
                 external_hostname=None, username=None, password=None,
                 test_dir=None, host_type=None):
        self.host_type = host_type
        self.domain = domain
        self.role = str(role)
        if username is None:
            self.ssh_username = self.config.ssh_username
        else:
            self.ssh_username = username
        if password is None:
            self.ssh_key_filename = self.config.ssh_key_filename
            self.ssh_password = self.config.ssh_password
        else:
            self.ssh_key_filename = None
            self.ssh_password = password
        if test_dir is None:
            self.test_dir = domain.config.test_dir
        else:
            self.test_dir = test_dir

        shortname, dot, ext_domain = hostname.partition('.')
        self.shortname = shortname

        self.hostname = (hostname[:-1]
                         if hostname.endswith('.')
                         else shortname + '.' + self.domain.name)

        self.external_hostname = str(external_hostname or hostname)

        self.netbios = self.domain.name.split('.')[0].upper()

        self.logger_name = '%s.%s.%s' % (
            self.__module__, type(self).__name__, shortname)
        self.log = self.config.get_logger(self.logger_name)

        if ip:
            self.ip = str(ip)
        else:
            if self.config.ipv6:
                # $(dig +short $M $rrtype|tail -1)
                dig = subprocess.Popen(
                    ['dig', '+short', self.external_hostname, 'AAAA'])
                stdout, stderr = dig.communicate()
                self.ip = stdout.splitlines()[-1].strip()
            else:
                try:
                    self.ip = socket.gethostbyname(self.external_hostname)
                except socket.gaierror:
                    self.ip = None

            if not self.ip:
                raise RuntimeError('Could not determine IP address of %s' %
                                   self.external_hostname)

        self.host_key = None
        self.ssh_port = 22

        self.env_sh_path = os.path.join(self.test_dir, 'env.sh')

        self.log_collectors = []

    def __str__(self):
        template = ('<{s.__class__.__name__} {s.hostname} ({s.role})>')
        return template.format(s=self)

    def __repr__(self):
        template = ('<{s.__module__}.{s.__class__.__name__} '
                    '{s.hostname} ({s.role})>')
        return template.format(s=self)

    def add_log_collector(self, collector):
        """Register a log collector for this host"""
        self.log_collectors.append(collector)

    def remove_log_collector(self, collector):
        """Unregister a log collector"""
        self.log_collectors.remove(collector)

    @classmethod
    def from_dict(cls, dct, domain):
        """Load this Host from a dict"""
        if isinstance(dct, basestring):
            dct = {'name': dct}
        try:
            role = dct.pop('role').lower()
        except KeyError:
            role = domain.static_roles[0]

        hostname = dct.pop('name')
        if '.' not in hostname:
            hostname = '.'.join((hostname, domain.name))

        ip = dct.pop('ip', None)
        external_hostname = dct.pop('external_hostname', None)

        username = dct.pop('username', None)
        password = dct.pop('password', None)
        host_type = dct.pop('host_type', 'default')

        check_config_dict_empty(dct, 'host %s' % hostname)

        return cls(domain, hostname, role,
                   ip=ip,
                   external_hostname=external_hostname,
                   username=username,
                   password=password,
                   host_type=host_type)

    def to_dict(self):
        """Export info about this Host to a dict"""
        result = {
            'name': str(self.hostname),
            'ip': self.ip,
            'role': self.role,
            'external_hostname': self.external_hostname,
        }
        if self.host_type != 'default':
            result['host_type'] = self.host_type
        return result

    @property
    def config(self):
        """The Config that this Host is a part of"""
        return self.domain.config

    @property
    def transport(self):
        """Provides means to manipulate files & run processs on the remote host

        Accessing this property might connect to the remote Host
        (usually via SSH).
        """
        try:
            return self._transport
        except AttributeError:
            cls = self.transport_class
            if cls:
                # transport_class is None in the base class and must be
                # set in subclasses.
                # Pylint reports that calling None will fail
                self._transport = cls(self)  # pylint: disable=E1102
            else:
                raise NotImplementedError('transport class not available')
            return self._transport

    def reset_connection(self):
        """Reset the connection

        The next time a connection is needed, a new Transport object will be
        made. This new transport will take into account any configuration
        changes, such as external_hostname, ssh_username, etc., that were made
        on the Host.
        """
        try:
            del self._transport
        except:
            pass

    def get_file_contents(self, filename, encoding=None):
        """Shortcut for transport.get_file_contents"""
        return self.transport.get_file_contents(filename, encoding=encoding)

    def put_file_contents(self, filename, contents, encoding='utf-8'):
        """Shortcut for transport.put_file_contents"""
        self.transport.put_file_contents(filename, contents, encoding=encoding)

    def collect_log(self, filename):
        """Call all registered log collectors on the given filename"""
        for collector in self.log_collectors:
            collector(self, filename)

    def run_command(self, argv, set_env=True, stdin_text=None,
                    log_stdout=True, raiseonerr=True,
                    cwd=None, bg=False, encoding='utf-8'):
        """Run the given command on this host

        Returns a Command instance. The command will have already run in the
        shell when this method returns, so its stdout_text, stderr_text, and
        returncode attributes will be available.

        :param argv: Command to run, as either a Popen-style list, or a string
                     containing a shell script
        :param set_env: If true, env.sh exporting configuration variables will
                        be sourced before running the command.
        :param stdin_text: If given, will be written to the command's stdin
        :param log_stdout: If false, standard output will not be logged
                           (but will still be available as cmd.stdout_text)
        :param raiseonerr: If true, an exception will be raised if the command
                           does not exit with return code 0
        :param cwd: The working directory for the command
        :param bg: If True, runs command in background.
                   In this case, either the result should be used in a ``with``
                   statement, or ``wait()`` should be called explicitly
                   when the command is finished.
        :param encoding: Encoding for the resulting Command instance's
                         ``stdout_text`` and ``stderr_text``, and for
                         ``stdin_text``, ``argv``, etc. if they are not
                         bytestrings already.
        """
        def encode(string):
            if not isinstance(string, bytes):
                return string.encode(encoding)
            else:
                return string

        command = self.transport.start_shell(argv, log_stdout=log_stdout,
                                             encoding=encoding)
        # Set working directory
        if cwd is None:
            cwd = self.test_dir
        command.stdin.write(b'cd %s\n' % shell_quote(encode(cwd)))

        # Set the environment
        if set_env:
            quoted = shell_quote(encode(self.env_sh_path))
            command.stdin.write(b'. %s\n' % quoted)

        if self.command_prelude:
            command.stdin.write(encode(self.command_prelude))

        if stdin_text:
            command.stdin.write(b"echo -en ")
            command.stdin.write(_echo_quote(encode(stdin_text)))
            command.stdin.write(b" | ")

        if isinstance(argv, basestring):
            # Run a shell command given as a string
            command.stdin.write(b'(')
            command.stdin.write(encode(argv))
            command.stdin.write(b')')
        else:
            # Run a command given as a popen-style list (no shell expansion)
            for arg in argv:
                command.stdin.write(shell_quote(encode(arg)))
                command.stdin.write(b' ')

        command.stdin.write(b'\nexit\n')
        command.stdin.flush()
        command.raiseonerr = raiseonerr
        if not bg:
            command.wait()
        return command


def _echo_quote(bytestring):
    """Encode a bytestring for use with bash & "echo -en"
    """
    bytestring = bytestring.replace(b"\\", br"\\")
    bytestring = bytestring.replace(b"\0", br"\x00")
    bytestring = bytestring.replace(b"'", br"'\''")
    return b"'" + bytestring + b"'"


class Host(BaseHost):
    """A Unix host"""
    command_prelude = b'set -e\n'


class WinHost(BaseHost):
    """
    Representation of a remote Windows host.
    """

    def __init__(self, domain, hostname, role, **kwargs):
        # Set test_dir to the Windows directory, if not given explicitly
        kwargs.setdefault('test_dir', domain.config.windows_test_dir)
        super(WinHost, self).__init__(domain, hostname, role, **kwargs)