blob: caad0a3e3cf94e44e0f9717593f1eba52b3f9fd9 [file]
#!/usr/bin/env python3
#
# Copyright 2025 The Fuchsia Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import logging
import os
import shlex
import shutil
import signal
import subprocess
import time
from dataclasses import dataclass
from typing import IO, Mapping
from libs.net import wait_for_port
from libs.proc.runner import (
CalledProcessError,
CalledProcessTransportError,
Runner,
)
from libs.types import Json
from libs.validation import MapValidator
from mobly import logger, signals
# TODO(https://fxbug.dev/567167756): Merge SSHProvider and SshConnection into a
# single SSH library.
DEFAULT_SSH_PORT: int = 22
DEFAULT_SSH_TIMEOUT_SEC: float = 60.0
DEFAULT_SSH_CONNECT_TIMEOUT_SEC: int = 90
DEFAULT_SSH_SERVER_ALIVE_INTERVAL: int = 30
# The default package repository for all components.
class SSHResult:
"""Result of an SSH command."""
def __init__(
self,
process: (
subprocess.CompletedProcess[bytes]
| subprocess.CompletedProcess[str]
| CalledProcessError
),
) -> None:
if isinstance(process.stdout, bytes):
self._stdout_bytes = process.stdout
elif isinstance(process.stdout, str):
self._stdout = process.stdout
else:
raise TypeError(
"Expected process.stdout to be either bytes or str, "
f"got {type(process.stdout)}"
)
if isinstance(process.stderr, bytes):
self._stderr_bytes = process.stderr
elif isinstance(process.stderr, str):
self._stderr = process.stderr
else:
raise TypeError(
"Expected process.stderr to be either bytes or str, "
f"got {type(process.stderr)}"
)
self._exit_status = process.returncode
def __str__(self) -> str:
if self.exit_status == 0:
return self.stdout
return f'status {self.exit_status}, stdout: "{self.stdout}", stderr: "{self.stderr}"'
@property
def stdout(self) -> str:
if not hasattr(self, "_stdout"):
self._stdout = self._stdout_bytes.decode("utf-8", errors="replace")
return self._stdout
@property
def stdout_bytes(self) -> bytes:
if not hasattr(self, "_stdout_bytes"):
self._stdout_bytes = self._stdout.encode()
return self._stdout_bytes
@property
def stderr(self) -> str:
if not hasattr(self, "_stderr"):
self._stderr = self._stderr_bytes.decode("utf-8", errors="replace")
return self._stderr
@property
def exit_status(self) -> int:
return self._exit_status
class SSHError(signals.TestError):
"""A SSH command returned with a non-zero status code."""
def __init__(
self, command: list[str], result: CalledProcessError, elapsed_sec: float
):
if result.returncode < 0:
try:
reason = f"died with {signal.Signals(-result.returncode)}"
except ValueError:
reason = f"died with unknown signal {-result.returncode}"
else:
reason = f"unexpectedly returned {result.returncode}"
super().__init__(
f'SSH command "{" ".join(command)}" {reason} after {elapsed_sec:.2f}s\n'
f'stderr: {result.stderr.decode("utf-8", errors="replace")}\n'
f'stdout: {result.stdout.decode("utf-8", errors="replace")}\n'
)
self.result = result
@dataclass
class SSHConfig:
"""SSH client config."""
# SSH flags. See ssh(1) for full details.
user: str
host_name: str
identity_file: str
ssh_binary: str = "ssh"
config_file: str = "/dev/null"
port: int = 22
#
# SSH options. See ssh_config(5) for full details.
#
connect_timeout: int = DEFAULT_SSH_CONNECT_TIMEOUT_SEC
server_alive_interval: int = DEFAULT_SSH_SERVER_ALIVE_INTERVAL
strict_host_key_checking: bool = False
user_known_hosts_file: str = "/dev/null"
log_level: str = "ERROR"
# Force allocation of a pseudo-tty. This can be used to execute arbitrary
# screen-based programs on a remote machine, which can be very useful, e.g.
# when implementing menu services.
force_tty: bool = False
def full_command(self, command: list[str]) -> list[str]:
"""Generate the complete command to execute command over SSH.
Args:
command: The command to run over SSH
force_tty: Force pseudo-terminal allocation. This can be used to
execute arbitrary screen-based programs on a remote machine,
which can be very useful, e.g. when implementing menu services.
Returns:
Arguments composing the complete call to SSH.
"""
return [
self.ssh_binary,
# SSH flags
"-i",
self.identity_file,
"-F",
self.config_file,
"-p",
str(self.port),
# SSH configuration options
"-o",
f"ConnectTimeout={self.connect_timeout}",
"-o",
f"ServerAliveInterval={self.server_alive_interval}",
"-o",
f'StrictHostKeyChecking={"yes" if self.strict_host_key_checking else "no"}',
"-o",
f"UserKnownHostsFile={self.user_known_hosts_file}",
"-o",
f"LogLevel={self.log_level}",
"-o",
f'RequestTTY={"force" if self.force_tty else "auto"}',
f"{self.user}@{self.host_name}",
] + command
@staticmethod
def from_config(config: Mapping[str, Json]) -> SSHConfig:
c = MapValidator(config)
ssh_binary_path = c.get(str, "ssh_binary_path", None)
if ssh_binary_path is None:
found_path = shutil.which("ssh")
if not isinstance(found_path, str):
raise ValueError("Failed to find ssh in $PATH")
ssh_binary_path = found_path
return SSHConfig(
user=c.get(str, "user"),
host_name=c.get(str, "host"),
identity_file=c.get(str, "identity_file"),
ssh_binary=ssh_binary_path,
config_file=c.get(str, "ssh_config", "/dev/null"),
port=c.get(int, "port", 22),
connect_timeout=c.get(int, "connect_timeout", 30),
)
class SSHProvider(Runner):
"""Device-specific provider for SSH clients."""
def __init__(self, config: SSHConfig) -> None:
"""
Args:
config: SSH client config
"""
logger_tag = f"ssh | {config.host_name}"
if config.port != DEFAULT_SSH_PORT:
logger_tag += f":{config.port}"
# Escape IPv6 interface identifier if present.
logger_tag = logger_tag.replace("%", "%%")
self.log = logger.PrefixLoggerAdapter(
logging.getLogger(),
{
logger.PrefixLoggerAdapter.EXTRA_KEY_LOG_PREFIX: f"[{logger_tag}]",
},
)
self.config = config
try:
self.wait_until_reachable()
self.log.info("sshd is reachable")
except Exception as e:
raise TimeoutError("sshd is unreachable") from e
def wait_until_reachable(self) -> None:
"""Wait for the device to become reachable via SSH.
Raises:
TimeoutError: connect_timeout has expired without a successful SSH
connection to the device
CalledProcessTransportError: SSH is available on the device but
connect_timeout has expired and SSH fails to run
subprocess.TimeoutExpired: when the timeout expires while waiting
for a child process
"""
timeout_sec = self.config.connect_timeout
timeout = time.time() + timeout_sec
wait_for_port(
self.config.host_name, self.config.port, timeout_sec=timeout_sec
)
while True:
try:
self._run(
["echo"],
stdin=None,
timeout_sec=timeout_sec,
log_output=True,
)
return
except CalledProcessTransportError as e:
# Repeat if necessary; _run() can exit prematurely by receiving
# SSH transport errors. These errors can be caused by sshd not
# being fully initialized yet.
if time.time() < timeout:
continue
else:
raise e
def wait_until_unreachable(
self,
interval_sec: int = 1,
timeout_sec: int = DEFAULT_SSH_CONNECT_TIMEOUT_SEC,
) -> None:
"""Wait for the device to become unreachable via SSH.
Args:
interval_sec: Seconds to wait between unreachability attempts
timeout_sec: Seconds to wait until raising TimeoutError
Raises:
TimeoutError: when timeout_sec has expired without an unsuccessful
SSH connection to the device
"""
timeout = time.time() + timeout_sec
while True:
try:
wait_for_port(
self.config.host_name,
self.config.port,
timeout_sec=interval_sec,
)
except TimeoutError:
return
if time.time() >= timeout:
raise TimeoutError(
f"Connection to {self.config.host_name} is still reachable "
f"after {timeout_sec}s"
)
time.sleep(interval_sec)
def run(
self,
command: str | list[str],
stdin: bytes | None = None,
timeout_sec: float | None = DEFAULT_SSH_TIMEOUT_SEC,
log_output: bool = True,
connect_retries: int = 3,
) -> subprocess.CompletedProcess[bytes]:
"""Run a command on the device then exit.
Args:
command: String to send to the device.
stdin: Standard input to command.
timeout_sec: Seconds to wait for the command to complete.
connect_retries: Amount of times to retry connect on fail.
Raises:
CalledProcessError: when the process exits with a non-zero status
subprocess.TimeoutExpired: when the timeout expires while waiting
for a child process
CalledProcessTransportError: when the underlying transport fails
Returns:
SSHResults from the executed command.
"""
if isinstance(command, str):
s = shlex.shlex(command, posix=True, punctuation_chars=True)
s.whitespace_split = True
command = list(s)
return self._run_with_retry(
command, stdin, timeout_sec, log_output, connect_retries
)
def _run_with_retry(
self,
command: list[str],
stdin: bytes | None,
timeout_sec: float | None,
log_output: bool,
connect_retries: int,
) -> subprocess.CompletedProcess[bytes]:
err: Exception = ValueError("connect_retries cannot be 0")
for _ in range(0, connect_retries):
try:
return self._run(command, stdin, timeout_sec, log_output)
except CalledProcessTransportError as e:
err = e
self.log.warning("Connect failed: %s", e)
raise err
def _run(
self,
command: list[str],
stdin: bytes | None,
timeout_sec: float | None,
log_output: bool,
) -> subprocess.CompletedProcess[bytes]:
start = time.perf_counter()
with self.start(command) as process:
try:
stdout, stderr = process.communicate(stdin, timeout_sec)
except subprocess.TimeoutExpired as e:
process.kill()
process.wait()
raise e
except: # Including KeyboardInterrupt, communicate handled that.
process.kill()
# We don't call process.wait() as Popen.__exit__ does that for
# us.
raise
elapsed = time.perf_counter() - start
exit_code = process.poll()
if log_output:
self.log.debug(
"Command %s exited with %d after %.2fs\nstdout: %s\nstderr: %s",
" ".join(command),
exit_code,
elapsed,
stdout.decode("utf-8", errors="replace"),
stderr.decode("utf-8", errors="replace"),
)
else:
self.log.debug(
"Command %s exited with %d after %.2fs",
" ".join(command),
exit_code,
elapsed,
)
if exit_code is None:
raise ValueError(
f'Expected process to be terminated: "{" ".join(command)}"'
)
if exit_code:
err = CalledProcessError(
exit_code, process.args, output=stdout, stderr=stderr
)
if err.returncode == 255:
reason = stderr.decode("utf-8", errors="replace")
if (
"Name or service not known" in reason
or "Host does not exist" in reason
):
raise CalledProcessTransportError(
f"Hostname {self.config.host_name} cannot be resolved to an address"
) from err
if "Connection timed out" in reason:
raise CalledProcessTransportError(
f"Failed to establish a connection to {self.config.host_name} within {timeout_sec}s"
) from err
if "Connection refused" in reason:
raise CalledProcessTransportError(
f"Connection refused by {self.config.host_name}"
) from err
raise err
return subprocess.CompletedProcess(
process.args, exit_code, stdout, stderr
)
def run_async(self, command: str) -> subprocess.CompletedProcess[bytes]:
s = shlex.shlex(command, posix=True, punctuation_chars=True)
s.whitespace_split = True
command_split = list(s)
process = self.start(command_split)
return subprocess.CompletedProcess(
self.config.full_command(command_split),
returncode=0,
stdout=str(process.pid).encode("utf-8"),
stderr=None,
)
def start(
self,
command: list[str],
stdout: IO[bytes] | int = subprocess.PIPE,
stdin: IO[bytes] | int = subprocess.PIPE,
stderr: IO[bytes] | int = subprocess.PIPE,
) -> subprocess.Popen[bytes]:
full_command = self.config.full_command(command)
self.log.debug(
f"Starting: {' '.join(command)}\nFull command: {' '.join(full_command)}"
)
return subprocess.Popen(
full_command,
stdin=stdin,
stdout=stdout if stdout else subprocess.PIPE,
stderr=stderr,
preexec_fn=os.setpgrp,
)