| # Copyright 2022 The Chromium Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| """Adds python interface to erminectl tools on workstation products.""" |
| |
| import logging |
| import subprocess |
| import time |
| from typing import List, Tuple |
| |
| |
| class BaseErmineCtl: |
| """Compatible class for automating control of Ermine and its OOBE. |
| |
| Must be used after checking if the tool exists. |
| |
| Usage: |
| ctl = base_ermine_ctl.BaseErmineCtl(some_target) |
| if ctl.exists: |
| ctl.take_to_shell() |
| |
| logging.info('In the shell') |
| else: |
| logging.info('Tool does not exist!') |
| |
| This is only necessary after a target reboot or provision (IE pave). |
| """ |
| |
| _OOBE_PASSWORD = 'workstation_test_password' |
| _TOOL = 'erminectl' |
| _OOBE_SUBTOOL = 'oobe' |
| _MAX_STATE_TRANSITIONS = 5 |
| |
| # Mapping between the current state and the next command to run |
| # to move it to the next state. |
| _STATE_TO_NEXT = { |
| 'SetPassword': ['set_password', _OOBE_PASSWORD], |
| 'Unknown': ['skip'], |
| 'Shell': [], |
| 'Login': ['login', _OOBE_PASSWORD], |
| } |
| _COMPLETE_STATE = 'Shell' |
| |
| _READY_TIMEOUT = 10 |
| _WAIT_ATTEMPTS = 10 |
| _WAIT_FOR_READY_SLEEP_SEC = 3 |
| |
| def __init__(self): |
| self._ermine_exists = False |
| self._ermine_exists_check = False |
| |
| # pylint: disable=no-self-use |
| # Overridable method to determine how command gets executed. |
| def execute_command_async(self, args: List[str]) -> subprocess.Popen: |
| """Executes command asynchronously, returning immediately.""" |
| raise NotImplementedError |
| |
| # pylint: enable=no-self-use |
| |
| @property |
| def exists(self) -> bool: |
| """Returns the existence of the tool. |
| |
| Checks whether the tool exists on and caches the result. |
| |
| Returns: |
| True if the tool exists, False if not. |
| """ |
| if not self._ermine_exists_check: |
| self._ermine_exists = self._execute_tool(['--help'], |
| can_fail=True) == 0 |
| self._ermine_exists_check = True |
| logging.debug('erminectl exists: %s', |
| ('true' if self._ermine_exists else 'false')) |
| return self._ermine_exists |
| |
| @property |
| def status(self) -> Tuple[int, str]: |
| """Returns the status of ermine. |
| |
| Note that if the tool times out or does not exist, a non-zero code |
| is returned. |
| |
| Returns: |
| Tuple of (return code, status as string). -1 for timeout. |
| Raises: |
| AssertionError: if the tool does not exist. |
| """ |
| assert self.exists, (f'Tool {self._TOOL} cannot have a status if' |
| ' it does not exist') |
| # Executes base command, which returns status. |
| proc = self._execute_tool_async([]) |
| try: |
| proc.wait(timeout=self._READY_TIMEOUT) |
| except subprocess.TimeoutExpired: |
| logging.warning('Timed out waiting for status') |
| return -1, 'Timeout' |
| stdout, _ = proc.communicate() |
| return proc.returncode, stdout.strip() |
| |
| @property |
| def ready(self) -> bool: |
| """Indicates if the tool is ready for regular use. |
| |
| Returns: |
| False if not ready, and True if ready. |
| Raises: |
| AssertionError: if the tool does not exist. |
| """ |
| assert self.exists, (f'Tool {self._TOOL} cannot be ready if' |
| ' it does not exist') |
| return_code, _ = self.status |
| return return_code == 0 |
| |
| def _execute_tool_async(self, command: List[str]) -> subprocess.Popen: |
| """Executes a sub-command asynchronously. |
| |
| Args: |
| command: list of strings to compose the command. Forwards to the |
| command runner. |
| Returns: |
| Popen of the subprocess. |
| """ |
| full_command = [self._TOOL, self._OOBE_SUBTOOL] |
| full_command.extend(command) |
| |
| # Returns immediately with Popen. |
| return self.execute_command_async(full_command) |
| |
| def _execute_tool(self, command: List[str], can_fail: bool = False) -> int: |
| """Executes a sub-command of the tool synchronously. |
| Raises exception if non-zero returncode is given and |can_fail| = False. |
| |
| Args: |
| command: list of strings to compose the command. Forwards to the |
| command runner. |
| can_fail: Whether or not the command can fail. |
| Raises: |
| RuntimeError: if non-zero returncode is returned and can_fail = |
| False. |
| Returns: |
| Return code of command execution if |can_fail| is True. |
| """ |
| proc = self._execute_tool_async(command) |
| stdout, stderr = proc.communicate() |
| if not can_fail and proc.returncode != 0: |
| raise RuntimeError(f'Command {" ".join(command)} failed.' |
| f'\nSTDOUT: {stdout}\nSTDERR: {stderr}') |
| return proc.returncode |
| |
| def wait_until_ready(self) -> None: |
| """Waits until the tool is ready through sleep-poll. |
| |
| The tool may not be ready after a pave or restart. |
| This checks the status and exits after its ready or Timeout. |
| |
| Raises: |
| TimeoutError: if tool is not ready after certain amount of attempts. |
| AssertionError: if tool does not exist. |
| """ |
| assert self.exists, f'Tool {self._TOOL} must exist to use it.' |
| for _ in range(self._WAIT_ATTEMPTS): |
| if self.ready: |
| return |
| time.sleep(self._WAIT_FOR_READY_SLEEP_SEC) |
| raise TimeoutError('Timed out waiting for a valid status to return') |
| |
| def take_to_shell(self) -> None: |
| """Takes device to shell after waiting for tool to be ready. |
| |
| Examines the current state of the device after waiting for it to be |
| ready. Once ready, goes through the states of logging in. This is: |
| - CreatePassword -> Skip screen -> Shell |
| - Login -> Shell |
| - Shell |
| |
| Regardless of starting state, this will exit once the shell state is |
| reached. |
| |
| Raises: |
| NotImplementedError: if an unknown state is reached. |
| RuntimeError: If number of state transitions exceeds the max number |
| that is expected. |
| """ |
| self.wait_until_ready() |
| _, state = self.status |
| max_states = self._MAX_STATE_TRANSITIONS |
| while state != self._COMPLETE_STATE and max_states: |
| max_states -= 1 |
| command = self._STATE_TO_NEXT.get(state) |
| logging.debug('Ermine state is: %s', state) |
| if command is None: |
| raise NotImplementedError('Encountered invalid state: %s' % |
| state) |
| self._execute_tool(command) |
| _, state = self.status |
| |
| if not max_states: |
| raise RuntimeError('Did not transition to shell in %d attempts.' |
| ' Please file a bug.' % |
| self._MAX_STATE_TRANSITIONS) |