blob: c0a5f8a15ad933c487d40962b9362cad1958d61c [file]
############################################################################
# SPDX-License-Identifier: Apache-2.0
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership. The
# ASF licenses this file to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance with the
# License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#
############################################################################
"""Device common interface."""
import re
import shlex
import subprocess
import time
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Optional
from ntfc.device.cmds import CmdReturn, CmdStatus
from ntfc.device.state import DeviceState, DeviceStateManager
from ntfc.log.logger import logger
from .getos import get_os
if TYPE_CHECKING:
from ntfc.coreconfig import CoreConfig
from ntfc.log.handler import LogHandler
_ANSI_ESCAPE_RE = re.compile(rb"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
__all__ = ["CmdReturn", "CmdStatus", "DeviceCommon"]
###############################################################################
# Class: DeviceCommon
###############################################################################
class DeviceCommon(ABC):
"""Device common interface."""
_BUSY_LOOP_TIMEOUT = 180 # 180 sec with no data read from target
def __init__(self, conf: "CoreConfig", echo: bool = True):
"""Initialize common device."""
self._conf = conf
# get OS abstraction
self._dev = get_os(conf)
# logs handler
self._logs: Optional[LogHandler] = None
self._pending_device_events: list[str] = []
# device health - use state manager with heartbeat support
self._state_mgr = DeviceStateManager(
busyloop_threshold=float(self._BUSY_LOOP_TIMEOUT),
crash_signatures=self._dev.crash_signatures,
heartbeat_send_fn=self.send_cmd_read_until_pattern,
)
self.clear_fault_flags()
self._read_all_sleep = 0.1
self._has_echo = echo
self._start_time: Optional[float] = None
def _mark_started(self) -> None:
"""Mark device start time for runtime tracking."""
self._start_time = time.monotonic()
def _runtime_since_start(self) -> Optional[float]:
"""Return runtime since start, if available."""
if self._start_time is None:
return None
return time.monotonic() - self._start_time
def _log_device_line(self, line: str) -> None:
"""Log or buffer a pre-formatted device log line."""
if self._logs is None:
self._pending_device_events.append(line)
return
self._logs.write_device(line)
def _log_device_event(self, message: str) -> None:
"""Log device control/status event with timestamp."""
ts = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
self._log_device_line(f"{ts} | {message}\n")
def _log_runtime_event(self, action: str) -> None:
"""Log runtime duration for a device action."""
runtime = self._runtime_since_start()
if runtime is None:
message = f"{action} runtime=unknown"
else:
message = f"{action} runtime={runtime:.2f}s"
logger.info(message)
self._log_device_event(message)
def _log_console_input(self, data: bytes) -> None:
"""Log console input (commands) with timestamp."""
ts = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
text = data.decode("utf-8", errors="replace")
text = text.replace("\r", "\\r").replace("\n", "\\n")
self._log_device_line(f"{ts} | console_in | {text}\n")
def _console_log(self, data: bytes) -> None:
"""Log console output."""
if self._logs is not None: # pragma: no cover
self._logs.write_console(data)
def _wait_for_boot(self, timeout: int = 5) -> bool:
"""Wait for device booted."""
self._log_device_event(f"wait_for_boot start timeout={timeout}s")
start = time.monotonic()
end_time = time.time() + timeout
while time.time() < end_time:
# send new line and expect prompt in returned data
ret = self.send_command(b"\n", 1)
if self._dev.prompt in ret:
elapsed = time.monotonic() - start
self._log_device_event(
f"wait_for_boot exit status=success elapsed={elapsed:.2f}s"
)
return True
time.sleep(1)
elapsed = time.monotonic() - start
self._log_device_event(
f"wait_for_boot exit status=timeout elapsed={elapsed:.2f}s"
)
return False
def _read_all(self, timeout: float = 1.0) -> bytes:
"""Read data from the device."""
output = b""
end_time = time.time() + timeout
while True:
chunk = self._read()
output += chunk
time_now = time.time()
# check for any sign of system crash
if self._state_mgr.check_crash(output): # pragma: no cover
logger.info("Crash detected!")
self._log_device_event("fault detected: crash")
break
# check for busy loop
# trigger an error if there was no data to read for a long time
if not chunk:
if ( # pragma: no cover
not self._state_mgr.is_busy_loop()
and self._state_mgr.check_busy_loop_timeout()
):
self._log_device_event("fault detected: busyloop")
break
else:
self._state_mgr.update_activity()
# check for timeout
if time_now > end_time:
break
# need to sleep for a while, otherwise host CPU load jumps to 100%
time.sleep(self._read_all_sleep)
clean = _ANSI_ESCAPE_RE.sub(b"", output)
return clean
def dev_is_health(self) -> bool: # pragma: no cover
"""Check if the serial device is OK."""
if not self._dev_is_health_priv():
return False
return not self._state_mgr.is_unhealthy()
@DeviceStateManager.mark_command
def send_command(self, cmd: bytes | str, timeout: int = 1) -> bytes:
"""Send command to the device and get the response."""
# convert string to bytes
if not isinstance(cmd, bytes):
cmd = cmd.encode("utf-8")
self._log_console_input(cmd)
# read any pending output and drop
_ = self._read_all(timeout=0)
self._console_log(_)
# log written command if echo is not supported by DTU
if not self._has_echo:
self._console_log(cmd)
# write command and get response
self._write(cmd)
rsp = self._read_all(timeout=timeout)
logger.info("Sent command: %s", cmd)
# console log
self._console_log(rsp)
return rsp
def _read_until_pattern_loop( # noqa: C901
self,
initial_output: bytes,
pattern: bytes,
timeout: int,
fail_pattern: Optional[bytes] = None,
) -> CmdReturn:
"""Read device output until pattern or fail_pattern matches.
:param initial_output: Output already accumulated before the loop
(e.g. command echo)
:param pattern: Regex pattern whose match signals success
:param timeout: Timeout in seconds
:param fail_pattern: Regex pattern whose match signals failure and
causes an immediate exit
:return: CmdReturn : command return data
"""
end_time = time.time() + timeout
output = initial_output
_match = None
ret = CmdStatus.TIMEOUT
while True:
chunk = self._read_all(0.1)
output += chunk
self._console_log(chunk)
# limit output data to process, otherwise re.search can stack
# REVISIT: its possible to miss some pattern in output
output_max = 100000
if len(output) > output_max: # pragma: no cover
output = output[-output_max:]
if fail_pattern and re.search(fail_pattern, output):
logger.debug(
f">>fail match: {output!r}, search: {fail_pattern!r}<<"
)
ret = CmdStatus.FAILED
break
_match = re.search(pattern, output)
if _match:
logger.debug(f">>match: {output!r}, search: {pattern!r}<<")
ret = CmdStatus.SUCCESS
break
# check for timeout
if time.time() > end_time:
ret = CmdStatus.TIMEOUT
break
# exit before timeout if dev crashed
if not self.dev_is_health(): # pragma: no cover
break
# check for output flood condition.
# If we still get some data from dev, its possible that we stuck
# in some command
if ret == CmdStatus.TIMEOUT:
chunk = self._read_all(0.1)
if len(chunk) > 0:
if not self._state_mgr.is_unhealthy():
self._log_device_event("fault detected: flood")
self._state_mgr.set_unhealthy("Flood detected")
self._console_log(chunk)
return CmdReturn(ret, _match, output.decode("utf-8", errors="replace"))
@DeviceStateManager.mark_command
def send_cmd_read_until_pattern(
self,
cmd: bytes,
pattern: bytes,
timeout: int,
fail_pattern: Optional[bytes] = None,
) -> CmdReturn:
"""Send command to device and read until the specified pattern.
:param cmd: (bytes) command to send to device
:param pattern: (bytes, or list of (bytes), optional)
String or regex pattern to look for. If a list,
patterns will be concatenated with '.*'.
The pattern will be converted to bytes for matching.
Default is None.
:param timeout: (int) timeout value in seconds
:param fail_pattern: (bytes, optional) Regex pattern whose presence in
the output immediately terminates the read and returns FAILED.
:return: CmdReturn : command return data
"""
if not isinstance(cmd, bytes):
raise TypeError("Command must be bytes")
if not isinstance(pattern, bytes):
raise TypeError("Pattern must be bytes")
self._log_console_input(cmd)
# Clear buffer for any spurious data
_ = self._read_all(timeout=0)
self._console_log(_)
# Log written command if echo is not supported by DTU
if not self._has_echo: # pragma: no cover
self._console_log(cmd)
# Write command
self._write(cmd)
logger.info("Sent command: %s", cmd)
# Read initial response
initial_output = self._read_all(timeout=0)
self._console_log(initial_output)
return self._read_until_pattern_loop(
initial_output, pattern, timeout, fail_pattern
)
@DeviceStateManager.mark_command
def read_until_pattern(
self,
pattern: bytes,
timeout: int,
fail_pattern: Optional[bytes] = None,
) -> CmdReturn:
"""Read device output until a pattern without sending a command.
Useful for catching output from an already-running program.
:param pattern: Regex pattern whose match signals success
:param timeout: Timeout in seconds
:param fail_pattern: (bytes, optional) Regex pattern whose presence in
the output immediately terminates the read and returns FAILED.
:return: CmdReturn : command return data
"""
if not isinstance(pattern, bytes):
raise TypeError("Pattern must by bytes")
return self._read_until_pattern_loop(
b"", pattern, timeout, fail_pattern
)
def send_ctrl_cmd(self, ctrl_char: str) -> CmdStatus:
"""Send control command to the device."""
self._log_device_event(f"send_ctrl_cmd ctrl+{ctrl_char}")
self._write_ctrl(ctrl_char)
logger.info(f"Sent Ctrl+{ctrl_char}.")
return CmdStatus.SUCCESS
def log_event(self, message: str) -> None:
"""Public hook for device event logging."""
self._log_device_event(message)
def start_log_collect(self, logs: "LogHandler") -> None:
"""Start device log collector."""
self._logs = logs
if self._pending_device_events:
self._logs.writelines_device(self._pending_device_events)
self._pending_device_events.clear()
def stop_log_collect(self) -> None:
"""Stop device log collector."""
self._logs = None
def clear_fault_flags(self) -> None:
"""Clear fault flags."""
self._state_mgr.reset_all_states()
def _system_cmd(self, cmd: str) -> None: # pragma: no cover
logger.info(f"system command: {cmd}")
subprocess.run(
shlex.split(cmd),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=True,
)
@property
def prompt(self) -> bytes:
"""Return target device prompt."""
return self._dev.prompt
@property
def no_cmd(self) -> str:
"""Return command not found string."""
return self._dev.no_cmd
@property
def busyloop(self) -> bool:
"""Check if the device is in busy loop."""
return self._state_mgr.is_busy_loop()
@property
def flood(self) -> bool:
"""Check if the device is in flood state."""
return self._state_mgr.get_current_state() == DeviceState.UNHEALTHY
@property
def crash(self) -> bool:
"""Check if the device is crashed."""
return self._state_mgr.is_crashed()
@property
def panic_char(self) -> str:
"""Get force panic character."""
return self._dev.panic_char
@abstractmethod
def _read(self) -> bytes:
"""Read data from the device."""
@abstractmethod
def _write(self, data: bytes) -> None:
"""Write to the device."""
@abstractmethod
def _write_ctrl(self, c: str) -> None:
"""Write a control character to the device."""
@abstractmethod
def _dev_is_health_priv(self) -> bool:
"""Check if the device is OK."""
def start(self) -> None:
"""Start device."""
self._mark_started()
self._log_device_event("start")
self._start_impl()
def stop(self) -> None:
"""Stop the device and release resources."""
self._log_device_event("stop")
self._stop_impl()
@property
@abstractmethod
def name(self) -> str:
"""Get device name."""
@property
@abstractmethod
def notalive(self) -> bool:
"""Check if the device is dead."""
def poweroff(self, hard: bool = True) -> bool:
"""Poweroff the device.
:param hard: (bool) If True use hardware poweroff (system command for
serial devices, process restart for host devices). If False use
software poweroff (OS shell command). Default is True.
:return: (bool) True if the poweroff was successful, False otherwise.
"""
self._log_runtime_event("poweroff")
if not hard:
self.send_command(self._dev.poweroff_cmd)
return True
return self._poweroff_impl()
def reboot(self, timeout: int = 1, hard: bool = True) -> bool:
"""Reboot the device.
:param timeout: (int) Timeout in seconds for the reboot operation.
:param hard: (bool) If True use hardware reboot (system command for
serial devices, process restart for host devices). If False use
software reboot (OS shell command). Default is True.
:return: (bool) True if the reboot was successful, False otherwise.
"""
self._log_runtime_event("reboot")
self._log_device_event(f"reboot timeout={timeout}s")
if not hard:
self.send_command(self._dev.reboot_cmd)
self._mark_started()
return self._wait_for_boot(timeout)
success = self._reboot_impl(timeout)
if success:
self._mark_started()
success = self._wait_for_boot(timeout)
return success
@abstractmethod
def _start_impl(self) -> None:
"""Start device implementation."""
@abstractmethod
def _stop_impl(self) -> None:
"""Stop the device and release resources."""
@abstractmethod
def _poweroff_impl(self) -> bool:
"""Hardware poweroff device implementation.
:return: (bool) True on success, False otherwise.
"""
@abstractmethod
def _reboot_impl(self, timeout: int) -> bool:
"""Hardware reboot device implementation.
:param timeout: (int) Timeout in seconds.
:return: (bool) True on success, False otherwise.
"""