blob: 590f1de1a263a9dbdb92940daea263bef31cef2c [file] [log] [blame]
# Copyright 2019 - The Android Open Source Project
#
# Licensed 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.
"""Ssh Utilities."""
from __future__ import print_function
import logging
import subprocess
import threading
from distutils.spawn import find_executable
from acloud import errors
from acloud.internal import constants
from acloud.internal.lib import utils
logger = logging.getLogger(__name__)
_SSH_CMD = ("-i %(rsa_key_file)s "
"-q -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no")
_SSH_IDENTITY = "-l %(login_user)s %(ip_addr)s"
_SSH_CMD_MAX_RETRY = 4
_SSH_CMD_RETRY_SLEEP = 3
_WAIT_FOR_SSH_MAX_TIMEOUT = 20
def _SshCall(cmd, timeout=None):
"""Runs a single SSH command.
SSH returns code 0 for "Successful execution".
Args:
cmd: String of the full SSH command to run, including the SSH binary and its arguments.
timeout: Optional integer, number of seconds to give
Returns:
An exit status of 0 indicates that it ran successfully.
"""
logger.info("Running command \"%s\"", cmd)
process = subprocess.Popen(cmd, shell=True, stdin=None,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
if timeout:
# TODO: if process is killed, out error message to log.
timer = threading.Timer(timeout, process.kill)
timer.start()
while True:
if process.poll() is not None:
break
if timeout:
timer.cancel()
process.stdout.close()
return process.returncode
def _SshLogOutput(cmd, timeout=None, show_output=False):
"""Runs a single SSH command while logging its output and processes its return code.
Output is streamed to the log at the debug level for more interactive debugging.
SSH returns error code 255 for "failed to connect", so this is interpreted as a failure in
SSH rather than a failure on the target device and this is converted to a different exception
type.
Args:
cmd: String of the full SSH command to run, including the SSH binary and its arguments.
timeout: Optional integer, number of seconds to give.
show_output: Boolean, True to show command output in screen.
Raises:
errors.DeviceConnectionError: Failed to connect to the GCE instance.
subprocess.CalledProc: The process exited with an error on the instance.
"""
logger.info("Running command \"%s\"", cmd)
# This code could use check_output instead, but this construction supports
# streaming the logs as they are received.
process = subprocess.Popen(cmd, shell=True, stdin=None,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
if timeout:
# TODO: if process is killed, out error message to log.
timer = threading.Timer(timeout, process.kill)
timer.start()
while True:
output = process.stdout.readline()
# poll() can return "0" for success, None means it is still running.
if output == "" and process.poll() is not None:
break
if output:
if show_output:
print(output.strip())
else:
# fetch_cvd and launch_cvd can be noisy, so left at debug
logger.debug(output.strip())
if timeout:
timer.cancel()
process.stdout.close()
if process.returncode == 255:
raise errors.DeviceConnectionError(
"Failed to send command to instance (%s)" % cmd)
elif process.returncode != 0:
raise subprocess.CalledProcessError(process.returncode, cmd)
def ShellCmdWithRetry(cmd, timeout=None, show_output=False):
"""Runs a shell command on remote device.
If the network is unstable and causes SSH connect fail, it will retry. When
it retry in a short time, you may encounter unstable network. We will use
the mechanism of RETRY_BACKOFF_FACTOR. The retry time for each failure is
times * retries.
Args:
cmd: String of the full SSH command to run, including the SSH binary and its arguments.
timeout: Optional integer, number of seconds to give.
show_output: Boolean, True to show command output in screen.
Raises:
errors.DeviceConnectionError: For any non-zero return code of
remote_cmd.
"""
utils.RetryExceptionType(
exception_types=errors.DeviceConnectionError,
max_retries=_SSH_CMD_MAX_RETRY,
functor=_SshLogOutput,
sleep_multiplier=_SSH_CMD_RETRY_SLEEP,
retry_backoff_factor=utils.DEFAULT_RETRY_BACKOFF_FACTOR,
cmd=cmd,
timeout=timeout,
show_output=show_output)
class IP(object):
""" A class that control the IP address."""
def __init__(self, external=None, internal=None, ip=None):
"""Init for IP.
Args:
external: String, external ip.
internal: String, internal ip.
ip: String, default ip to set for either external and internal
if neither is set.
"""
self.external = external or ip
self.internal = internal or ip
class Ssh(object):
"""A class that control the remote instance via the IP address.
Attributes:
_ip: an IP object.
_gce_user: String of user login into the instance.
_ssh_private_key_path: Path to the private key file.
_extra_args_ssh_tunnel: String, extra args for ssh or scp.
"""
def __init__(self, ip, gce_user, ssh_private_key_path,
extra_args_ssh_tunnel=None, report_internal_ip=False):
self._ip = ip.internal if report_internal_ip else ip.external
self._gce_user = gce_user
self._ssh_private_key_path = ssh_private_key_path
self._extra_args_ssh_tunnel = extra_args_ssh_tunnel
def Run(self, target_command, timeout=None, show_output=False):
"""Run a shell command over SSH on a remote instance.
Example:
ssh:
base_cmd_list is ["ssh", "-i", "~/private_key_path" ,"-l" , "user", "1.1.1.1"]
target_command is "remote command"
scp:
base_cmd_list is ["scp", "-i", "~/private_key_path"]
target_command is "{src_file} {dst_file}"
Args:
target_command: String, text of command to run on the remote instance.
timeout: Integer, the maximum time to wait for the command to respond.
show_output: Boolean, True to show command output in screen.
"""
ShellCmdWithRetry(self.GetBaseCmd(constants.SSH_BIN) + " " + target_command,
timeout,
show_output)
def GetBaseCmd(self, execute_bin):
"""Get a base command over SSH on a remote instance.
Example:
execute bin is ssh:
ssh -i ~/private_key_path $extra_args -l user 1.1.1.1
execute bin is scp:
scp -i ~/private_key_path $extra_args
Args:
execute_bin: String, execute type, e.g. ssh or scp.
Returns:
Strings of base connection command.
Raises:
errors.UnknownType: Don't support the execute bin.
"""
base_cmd = [find_executable(execute_bin)]
base_cmd.append(_SSH_CMD % {"rsa_key_file": self._ssh_private_key_path})
if self._extra_args_ssh_tunnel:
base_cmd.append(self._extra_args_ssh_tunnel)
if execute_bin == constants.SSH_BIN:
base_cmd.append(_SSH_IDENTITY %
{"login_user":self._gce_user, "ip_addr":self._ip})
return " ".join(base_cmd)
if execute_bin == constants.SCP_BIN:
return " ".join(base_cmd)
raise errors.UnknownType("Don't support the execute bin %s." % execute_bin)
def CheckSshConnection(self, timeout):
"""Run remote 'uptime' ssh command to check ssh connection.
Args:
timeout: Integer, the maximum time to wait for the command to respond.
Raises:
errors.DeviceConnectionError: Ssh isn't ready in the remote instance.
"""
remote_cmd = [self.GetBaseCmd(constants.SSH_BIN)]
remote_cmd.append("uptime")
if _SshCall(" ".join(remote_cmd), timeout) == 0:
return
raise errors.DeviceConnectionError(
"Ssh isn't ready in the remote instance.")
@utils.TimeExecute(function_description="Waiting for SSH server")
def WaitForSsh(self, timeout=_WAIT_FOR_SSH_MAX_TIMEOUT,
sleep_for_retry=_SSH_CMD_RETRY_SLEEP,
max_retry=_SSH_CMD_MAX_RETRY):
"""Wait until the remote instance is ready to accept commands over SSH.
Args:
timeout: Integer, the maximum time in seconds to wait for the
command to respond.
sleep_for_retry: Integer, the sleep time in seconds for retry.
max_retry: Integer, the maximum number of retry.
Raises:
errors.DeviceConnectionError: Ssh isn't ready in the remote instance.
"""
utils.RetryExceptionType(
exception_types=errors.DeviceConnectionError,
max_retries=max_retry,
functor=self.CheckSshConnection,
sleep_multiplier=sleep_for_retry,
retry_backoff_factor=utils.DEFAULT_RETRY_BACKOFF_FACTOR,
timeout=timeout)
def ScpPushFile(self, src_file, dst_file):
"""Scp push file to remote.
Args:
src_file: The source file path to be pulled.
dst_file: The destination file path the file is pulled to.
"""
scp_command = [self.GetBaseCmd(constants.SCP_BIN)]
scp_command.append(src_file)
scp_command.append("%s@%s:%s" %(self._gce_user, self._ip, dst_file))
ShellCmdWithRetry(" ".join(scp_command))
def ScpPullFile(self, src_file, dst_file):
"""Scp pull file from remote.
Args:
src_file: The source file path to be pulled.
dst_file: The destination file path the file is pulled to.
"""
scp_command = [self.GetBaseCmd(constants.SCP_BIN)]
scp_command.append("%s@%s:%s" %(self._gce_user, self._ip, src_file))
scp_command.append(dst_file)
ShellCmdWithRetry(" ".join(scp_command))