1# Copyright (c) 2014 The Chromium OS Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5# This module helps launch pseudomodem as a subprocess. It helps with the
6# initial setup of pseudomodem, as well as ensures proper cleanup.
7# For details about the options accepted by pseudomodem, please check the
8# |pseudomodem| module.
9# This module also doubles as the python entry point to run pseudomodem from the
10# command line. To avoid confusion, please use the shell script run_pseudomodem
11# to run pseudomodem from command line.
12
13import dbus
14import json
15import logging
16import os
17import pwd
18import signal
19import stat
20import sys
21import subprocess
22import tempfile
23
24import common
25from autotest_lib.client.bin import utils
26from autotest_lib.client.common_lib import error
27from autotest_lib.client.cros import service_stopper
28from autotest_lib.client.cros.cellular import mm1_constants
29from autotest_lib.client.cros.cellular import net_interface
30
31import pm_constants
32import pseudomodem
33
34# TODO(pprabhu) Move this to the right utils file.
35# pprabhu: I haven't yet figured out which of the myriad utils files I should
36# update. There is an implementation of |nuke_subprocess| that does not take
37# timeout_hint_seconds in common_lib/utils.py, but |poll_for_condition|
38# is not available there.
39def nuke_subprocess(subproc, timeout_hint_seconds=0):
40    """
41    Attempt to kill the given subprocess via an escalating series of signals.
42
43    Between each attempt, the process is given |timeout_hint_seconds| to clean
44    up. So, the function may take up to 3 * |timeout_hint_seconds| time to
45    finish.
46
47    @param subproc: The python subprocess to nuke.
48    @param timeout_hint_seconds: The time to wait between successive attempts.
49    @returns: The result from the subprocess, None if we failed to kill it.
50
51    """
52    # check if the subprocess is still alive, first
53    if subproc.poll() is not None:
54        return subproc.poll()
55
56    signal_queue = [signal.SIGINT, signal.SIGTERM, signal.SIGKILL]
57    for sig in signal_queue:
58        logging.info('Nuking %s with %s', subproc.pid, sig)
59        utils.signal_pid(subproc.pid, sig)
60        try:
61            utils.poll_for_condition(
62                    lambda: subproc.poll() is not None,
63                    timeout=timeout_hint_seconds)
64            return subproc.poll()
65        except utils.TimeoutError:
66            pass
67    return None
68
69
70class PseudoModemManagerContextException(Exception):
71    """ Exception class for exceptions raised by PseudoModemManagerContext. """
72    pass
73
74
75class PseudoModemManagerContext(object):
76    """
77    A context to launch pseudomodem in background.
78
79    Tests should use |PeudoModemManagerContext| to launch pseudomodem. It is
80    intended to be used with the |with| clause like so:
81
82    with PseudoModemManagerContext(...):
83        # Run test
84
85    pseudomodem will be launch in a subprocess safely when entering the |with|
86    block, and cleaned up when exiting.
87
88    """
89    SHORT_TIMEOUT_SECONDS = 4
90    # Some actions are dependent on hardware cooperating. We need to wait longer
91    # for these. Try to minimize using this constant.
92    WAIT_FOR_HARDWARE_TIMEOUT_SECONDS = 12
93    TEMP_FILE_PREFIX = 'pseudomodem_'
94    REAL_MANAGER_SERVICES = ['modemmanager', 'cromo']
95    REAL_MANAGER_PROCESSES = ['ModemManager', 'cromo']
96    TEST_OBJECT_ARG_FLAGS = ['test-modem-arg',
97                             'test-sim-arg',
98                             'test-state-machine-factory-arg']
99
100    def __init__(self,
101                 use_pseudomodem,
102                 flags_map=None,
103                 block_output=True,
104                 bus=None):
105        """
106        @param use_pseudomodem: This flag can be used to treat pseudomodem as a
107                no-op. When |True|, pseudomodem is launched as expected. When
108                |False|, this operation is a no-op, and pseudomodem will not be
109                launched.
110        @param flags_map: This is a map of pseudomodem arguments. See
111                |pseudomodem| module for the list of supported arguments. For
112                example, to launch pseudomodem with a modem of family 3GPP, use:
113                    with PseudoModemManager(True, flags_map={'family' : '3GPP}):
114                        # Do stuff
115        @param block_output: If True, output from the pseudomodem process is not
116                piped to stdout. This is the default.
117        @param bus: A handle to the dbus.SystemBus. If you use dbus in your
118                tests, you should obtain a handle to the bus and pass it in
119                here. Not doing so can cause incompatible mainloop settings in
120                the dbus module.
121
122        """
123        self._use_pseudomodem = use_pseudomodem
124        self._block_output = block_output
125
126        self._temp_files = []
127        self.cmd_line_flags = self._ConvertMapToFlags(flags_map if flags_map
128                                                      else {})
129        self._service_stopper = service_stopper.ServiceStopper(
130                self.REAL_MANAGER_SERVICES)
131        self._net_interface = None
132        self._null_pipe = None
133        self._exit_error_file_path = None
134        self._pseudomodem_process = None
135
136        self._bus = bus
137        if not self._bus:
138            # Currently, the glib mainloop, or a wrapper thereof are the only
139            # mainloops we ever use with dbus. So, it's a comparatively safe bet
140            # to set that up as the mainloop here.
141            # Ideally, if a test wants to use dbus, it should pass us its own
142            # bus.
143            dbus_loop = dbus.mainloop.glib.DBusGMainLoop()
144            self._bus = dbus.SystemBus(private=True, mainloop=dbus_loop)
145
146
147    @property
148    def cmd_line_flags(self):
149        """ The command line flags that will be passed to pseudomodem. """
150        return self._cmd_line_flags
151
152
153    @cmd_line_flags.setter
154    def cmd_line_flags(self, val):
155        """
156        Set the command line flags to be passed to pseudomodem.
157
158        @param val: The flags.
159
160        """
161        logging.info('Command line flags for pseudomodem set to: |%s|', val)
162        self._cmd_line_flags = val
163
164
165    def __enter__(self):
166        return self.Start()
167
168
169    def __exit__(self, *args):
170        return self.Stop(*args)
171
172
173    def Start(self):
174        """ Start the context. This launches pseudomodem. """
175        if not self._use_pseudomodem:
176            return self
177
178        self._CheckPseudoModemArguments()
179
180        self._service_stopper.stop_services()
181        self._WaitForRealModemManagersToDie()
182
183        self._net_interface = net_interface.PseudoNetInterface()
184        self._net_interface.Setup()
185
186        toplevel = os.path.dirname(os.path.realpath(__file__))
187        cmd = [os.path.join(toplevel, 'pseudomodem.py')]
188        cmd = cmd + self.cmd_line_flags
189
190        fd, self._exit_error_file_path = self._CreateTempFile()
191        os.close(fd)  # We don't need the fd.
192        cmd = cmd + [pseudomodem.EXIT_ERROR_FILE_FLAG,
193                     self._exit_error_file_path]
194
195        # Setup health checker for child process.
196        signal.signal(signal.SIGCHLD, self._SigchldHandler)
197
198        if self._block_output:
199            self._null_pipe = open(os.devnull, 'w')
200            self._pseudomodem_process = subprocess.Popen(
201                    cmd,
202                    preexec_fn=PseudoModemManagerContext._SetUserModem,
203                    close_fds=True,
204                    stdout=self._null_pipe,
205                    stderr=self._null_pipe)
206        else:
207            self._pseudomodem_process = subprocess.Popen(
208                    cmd,
209                    preexec_fn=PseudoModemManagerContext._SetUserModem,
210                    close_fds=True)
211        self._EnsurePseudoModemUp()
212        return self
213
214
215    def Stop(self, *args):
216        """ Exit the context. This terminates pseudomodem. """
217        if not self._use_pseudomodem:
218            return
219
220        # Remove health check on child process.
221        signal.signal(signal.SIGCHLD, signal.SIG_DFL)
222
223        if self._pseudomodem_process:
224            if self._pseudomodem_process.poll() is None:
225                if (nuke_subprocess(self._pseudomodem_process,
226                                    self.SHORT_TIMEOUT_SECONDS) is
227                    None):
228                    logging.warning('Failed to clean up the launched '
229                                    'pseudomodem process')
230            self._pseudomodem_process = None
231
232        if self._null_pipe:
233            self._null_pipe.close()
234            self._null_pipe = None
235
236        if self._net_interface:
237            self._net_interface.Teardown()
238            self._net_interface = None
239
240        self._DeleteTempFiles()
241        self._service_stopper.restore_services()
242
243
244    def _ConvertMapToFlags(self, flags_map):
245        """
246        Convert the argument map given to the context to flags for pseudomodem.
247
248        @param flags_map: A map of flags. The keys are the names of the flags
249                accepted by pseudomodem. The value, if not None, is the value
250                for that flag. We do not support |None| as the value for a flag.
251        @returns: the list of flags to pass to pseudomodem.
252
253        """
254        cmd_line_flags = []
255        for key, value in flags_map.iteritems():
256            cmd_line_flags.append('--' + key)
257            if key in self.TEST_OBJECT_ARG_FLAGS:
258                cmd_line_flags.append(self._DumpArgToFile(value))
259            elif value:
260                cmd_line_flags.append(value)
261        return cmd_line_flags
262
263
264    def _DumpArgToFile(self, arg):
265        """
266        Dump a given python list to a temp file in json format.
267
268        This is used to pass arguments to custom objects from tests that
269        are to be instantiated by pseudomodem. The argument must be a list. When
270        running pseudomodem, this list will be unpacked to get the arguments.
271
272        @returns: Absolute path to the tempfile created.
273
274        """
275        fd, arg_file_path = self._CreateTempFile()
276        arg_file = os.fdopen(fd, 'wb')
277        json.dump(arg, arg_file)
278        arg_file.close()
279        return arg_file_path
280
281
282    def _WaitForRealModemManagersToDie(self):
283        """
284        Wait for real modem managers to quit. Die otherwise.
285
286        Sometimes service stopper does not kill ModemManager process, if it is
287        launched by something other than upstart. We want to ensure that the
288        process is dead before continuing.
289
290        This method can block for up to a minute. Sometimes, ModemManager can
291        take up to a 10 seconds to die after service stopper has stopped it. We
292        wait for it to clean up before concluding that the process is here to
293        stay.
294
295        @raises: PseudoModemManagerContextException if a modem manager process
296                does not quit in a reasonable amount of time.
297        """
298        def _IsProcessRunning(process):
299            try:
300                utils.run('pgrep -x %s' % process)
301                return True
302            except error.CmdError:
303                return False
304
305        for manager in self.REAL_MANAGER_PROCESSES:
306            try:
307                utils.poll_for_condition(
308                        lambda:not _IsProcessRunning(manager),
309                        timeout=self.WAIT_FOR_HARDWARE_TIMEOUT_SECONDS)
310            except utils.TimeoutError:
311                err_msg = ('%s is still running. '
312                           'It may interfere with pseudomodem.' %
313                           manager)
314                logging.error(err_msg)
315                raise PseudoModemManagerContextException(err_msg)
316
317
318    def _CheckPseudoModemArguments(self):
319        """
320        Parse the given pseudomodem arguments.
321
322        By parsing the arguments in the context, we can provide early feedback
323        about incorrect arguments.
324
325        """
326        pseudomodem.ParseArguments(self.cmd_line_flags)
327
328
329    @staticmethod
330    def _SetUserModem():
331        """
332        Set the unix user of the calling process to |modem|.
333
334        This functions is called by the launched subprocess so that pseudomodem
335        can be launched as the |modem| user.
336        On encountering an error, this method will terminate the process.
337
338        """
339        try:
340            pwd_data = pwd.getpwnam(pm_constants.MM1_USER)
341        except KeyError as e:
342            logging.error('Could not find uid for user %s [%s]',
343                          pm_constants.MM1_USER, str(e))
344            sys.exit(1)
345
346        logging.debug('Setting UID to %d', pwd_data.pw_uid)
347        try:
348            os.setuid(pwd_data.pw_uid)
349        except OSError as e:
350            logging.error('Could not set uid to %d [%s]',
351                          pwd_data.pw_uid, str(e))
352            sys.exit(1)
353
354
355    def _EnsurePseudoModemUp(self):
356        """ Makes sure that pseudomodem in child process is ready. """
357        def _LivenessCheck():
358            try:
359                testing_object = self._bus.get_object(
360                        mm1_constants.I_MODEM_MANAGER,
361                        pm_constants.TESTING_PATH)
362                return testing_object.IsAlive(
363                        dbus_interface=pm_constants.I_TESTING)
364            except dbus.DBusException as e:
365                logging.debug('LivenessCheck: No luck yet. (%s)', str(e))
366                return False
367
368        utils.poll_for_condition(
369                _LivenessCheck,
370                timeout=self.SHORT_TIMEOUT_SECONDS,
371                exception=PseudoModemManagerContextException(
372                        'pseudomodem did not initialize properly.'))
373
374
375    def _CreateTempFile(self):
376        """
377        Creates a tempfile such that the child process can read/write it.
378
379        The file path is stored in a list so that the file can be deleted later
380        using |_DeleteTempFiles|.
381
382        @returns: (fd, arg_file_path)
383                 fd: A file descriptor for the created file.
384                 arg_file_path: Full path of the created file.
385
386        """
387        fd, arg_file_path = tempfile.mkstemp(prefix=self.TEMP_FILE_PREFIX)
388        self._temp_files.append(arg_file_path)
389        # Set file permissions so that pseudomodem process can read/write it.
390        cur_mod = os.stat(arg_file_path).st_mode
391        os.chmod(arg_file_path,
392                 cur_mod | stat.S_IRGRP | stat.S_IROTH | stat.S_IWGRP |
393                 stat.S_IWOTH)
394        return fd, arg_file_path
395
396
397    def _DeleteTempFiles(self):
398        """ Deletes all temp files created by this context. """
399        for file_path in self._temp_files:
400            try:
401                os.remove(file_path)
402            except OSError as e:
403                logging.warning('Failed to delete temp file: %s (error %s)',
404                                file_path, str(e))
405
406
407    def _SigchldHandler(self, signum, frame):
408        """
409        Signal handler for SIGCHLD.
410
411        This is setup while the pseudomodem subprocess is running. A call to
412        this signal handler may signify early termination of the subprocess.
413
414        @param signum: The signal number.
415        @param frame: Ignored.
416
417        """
418        if not self._pseudomodem_process:
419            # We can receive a SIGCHLD even before the setup of the child
420            # process is complete.
421            return
422        if self._pseudomodem_process.poll() is not None:
423            # See if child process left detailed error report
424            error_reason, error_traceback = pseudomodem.ExtractExitError(
425                    self._exit_error_file_path)
426            logging.error('pseudomodem child process quit early!')
427            logging.error('Reason: %s', error_reason)
428            for line in error_traceback:
429                logging.error('Traceback: %s', line.strip())
430            raise PseudoModemManagerContextException(
431                    'pseudomodem quit early! (%s)' %
432                    error_reason)
433