• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2022 The Chromium Authors
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4"""Adds python interface to erminectl tools on workstation products."""
5
6import logging
7import subprocess
8import time
9from typing import List, Tuple
10
11
12class BaseErmineCtl:
13    """Compatible class for automating control of Ermine and its OOBE.
14
15    Must be used after checking if the tool exists.
16
17    Usage:
18      ctl = base_ermine_ctl.BaseErmineCtl(some_target)
19      if ctl.exists:
20        ctl.take_to_shell()
21
22        logging.info('In the shell')
23      else:
24        logging.info('Tool does not exist!')
25
26    This is only necessary after a target reboot or provision (IE pave).
27    """
28
29    _OOBE_PASSWORD = 'workstation_test_password'
30    _TOOL = 'erminectl'
31    _OOBE_SUBTOOL = 'oobe'
32    _MAX_STATE_TRANSITIONS = 5
33
34    # Mapping between the current state and the next command to run
35    # to move it to the next state.
36    _STATE_TO_NEXT = {
37        'SetPassword': ['set_password', _OOBE_PASSWORD],
38        'Unknown': ['skip'],
39        'Shell': [],
40        'Login': ['login', _OOBE_PASSWORD],
41    }
42    _COMPLETE_STATE = 'Shell'
43
44    _READY_TIMEOUT = 10
45    _WAIT_ATTEMPTS = 10
46    _WAIT_FOR_READY_SLEEP_SEC = 3
47
48    def __init__(self):
49        self._ermine_exists = False
50        self._ermine_exists_check = False
51
52    # pylint: disable=no-self-use
53    # Overridable method to determine how command gets executed.
54    def execute_command_async(self, args: List[str]) -> subprocess.Popen:
55        """Executes command asynchronously, returning immediately."""
56        raise NotImplementedError
57
58    # pylint: enable=no-self-use
59
60    @property
61    def exists(self) -> bool:
62        """Returns the existence of the tool.
63
64        Checks whether the tool exists on and caches the result.
65
66        Returns:
67          True if the tool exists, False if not.
68        """
69        if not self._ermine_exists_check:
70            self._ermine_exists = self._execute_tool(['--help'],
71                                                     can_fail=True) == 0
72            self._ermine_exists_check = True
73            logging.debug('erminectl exists: %s',
74                          ('true' if self._ermine_exists else 'false'))
75        return self._ermine_exists
76
77    @property
78    def status(self) -> Tuple[int, str]:
79        """Returns the status of ermine.
80
81        Note that if the tool times out or does not exist, a non-zero code
82        is returned.
83
84        Returns:
85          Tuple of (return code, status as string). -1 for timeout.
86        Raises:
87          AssertionError: if the tool does not exist.
88        """
89        assert self.exists, (f'Tool {self._TOOL} cannot have a status if'
90                             ' it does not exist')
91        # Executes base command, which returns status.
92        proc = self._execute_tool_async([])
93        try:
94            proc.wait(timeout=self._READY_TIMEOUT)
95        except subprocess.TimeoutExpired:
96            logging.warning('Timed out waiting for status')
97            return -1, 'Timeout'
98        stdout, _ = proc.communicate()
99        return proc.returncode, stdout.strip()
100
101    @property
102    def ready(self) -> bool:
103        """Indicates if the tool is ready for regular use.
104
105        Returns:
106          False if not ready, and True if ready.
107        Raises:
108          AssertionError: if the tool does not exist.
109        """
110        assert self.exists, (f'Tool {self._TOOL} cannot be ready if'
111                             ' it does not exist')
112        return_code, _ = self.status
113        return return_code == 0
114
115    def _execute_tool_async(self, command: List[str]) -> subprocess.Popen:
116        """Executes a sub-command asynchronously.
117
118        Args:
119          command: list of strings to compose the command. Forwards to the
120            command runner.
121        Returns:
122          Popen of the subprocess.
123        """
124        full_command = [self._TOOL, self._OOBE_SUBTOOL]
125        full_command.extend(command)
126
127        # Returns immediately with Popen.
128        return self.execute_command_async(full_command)
129
130    def _execute_tool(self, command: List[str], can_fail: bool = False) -> int:
131        """Executes a sub-command of the tool synchronously.
132        Raises exception if non-zero returncode is given and |can_fail| = False.
133
134        Args:
135            command: list of strings to compose the command. Forwards to the
136                command runner.
137            can_fail: Whether or not the command can fail.
138        Raises:
139            RuntimeError: if non-zero returncode is returned and can_fail =
140                False.
141        Returns:
142            Return code of command execution if |can_fail| is True.
143        """
144        proc = self._execute_tool_async(command)
145        stdout, stderr = proc.communicate()
146        if not can_fail and proc.returncode != 0:
147            raise RuntimeError(f'Command {" ".join(command)} failed.'
148                               f'\nSTDOUT: {stdout}\nSTDERR: {stderr}')
149        return proc.returncode
150
151    def wait_until_ready(self) -> None:
152        """Waits until the tool is ready through sleep-poll.
153
154        The tool may not be ready after a pave or restart.
155        This checks the status and exits after its ready or Timeout.
156
157        Raises:
158          TimeoutError: if tool is not ready after certain amount of attempts.
159          AssertionError: if tool does not exist.
160        """
161        assert self.exists, f'Tool {self._TOOL} must exist to use it.'
162        for _ in range(self._WAIT_ATTEMPTS):
163            if self.ready:
164                return
165            time.sleep(self._WAIT_FOR_READY_SLEEP_SEC)
166        raise TimeoutError('Timed out waiting for a valid status to return')
167
168    def take_to_shell(self) -> None:
169        """Takes device to shell after waiting for tool to be ready.
170
171        Examines the current state of the device after waiting for it to be
172        ready. Once ready, goes through the states of logging in. This is:
173        - CreatePassword -> Skip screen -> Shell
174        - Login -> Shell
175        - Shell
176
177        Regardless of starting state, this will exit once the shell state is
178        reached.
179
180        Raises:
181          NotImplementedError: if an unknown state is reached.
182          RuntimeError: If number of state transitions exceeds the max number
183            that is expected.
184        """
185        self.wait_until_ready()
186        _, state = self.status
187        max_states = self._MAX_STATE_TRANSITIONS
188        while state != self._COMPLETE_STATE and max_states:
189            max_states -= 1
190            command = self._STATE_TO_NEXT.get(state)
191            logging.debug('Ermine state is: %s', state)
192            if command is None:
193                raise NotImplementedError('Encountered invalid state: %s' %
194                                          state)
195            self._execute_tool(command)
196            _, state = self.status
197
198        if not max_states:
199            raise RuntimeError('Did not transition to shell in %d attempts.'
200                               ' Please file a bug.' %
201                               self._MAX_STATE_TRANSITIONS)
202