blob: c7519867869f558b9f838f639440bc894e9bbf55 [file] [log] [blame]
# 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)