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