Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 127 additions & 22 deletions labgrid/driver/sshdriver.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def __attrs_post_init__(self):
self._scp = self._get_tool("scp")
self._sshfs = self._get_tool("sshfs")
self._rsync = self._get_tool("rsync")
self._fusermount = self._get_tool("fusermount")

def _get_tool(self, name):
if self.target.env:
Expand Down Expand Up @@ -377,7 +378,7 @@ def scp(self, *, src, dst):
"-o", f"ControlPath={self.control.replace('%', '%%')}",
src, dst,
]

if self.explicit_sftp_mode and self._scp_supports_explicit_sftp_mode():
complete_cmd.insert(1, "-s")
if self.explicit_scp_mode and self._scp_supports_explicit_scp_mode():
Expand Down Expand Up @@ -422,33 +423,137 @@ def rsync(self, *, src, dst, extra=[]):
)
return sub.wait()

# development remote mounting/unmounting options will require:
# - sshd up and running on host
# - fusermount available on Exporter
# - sshfs available on Exporter
@Driver.check_active
@step(args=['path', 'mountpoint'])
def sshfs(self, *, path, mountpoint):
if not self._check_keepalive():
raise ExecutionError("Keepalive no longer running")

complete_cmd = [self._sshfs,
"-F", "none",
"-f",
def sshfs(self, *, path=None, mountpoint=None, mount=False, unmount=False):
import os, subprocess

# remote path logic
mount_is_remote = mountpoint.startswith(':') if mountpoint else False
path_is_remote = True if not mount_is_remote else False

# unmount
if unmount:
target = mountpoint.lstrip(':')
if mount_is_remote:
self.logger.info("Unmounting remote %s", target)
self.run(f"fusermount -u {target} || umount {target} || sudo umount {target}")
else:
if not os.path.ismount(target):
self.logger.info("Skipping, %s is not a mountpoint.", target)
return
subprocess.run([self._fusermount, "-u", target], check=True)
return

# checks
path = path.lstrip(':')
mountpoint = mountpoint.lstrip(':')

if not path:
raise ExecutionError("Remote path is required for mounting")

if mount_is_remote:
if not mount:
raise ExecutionError("Remote mountpoints require the --mount flag.")

# arg: local IP
local_ip_address = ""
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect((f"{self.networkservice.address}", 1))
local_ip_address = s.getsockname()[0]
except Exception:
raise ExecutionError("Could not identify used local IP address")
finally:
s.close()

# arg: local user
local_user = os.getlogin()

# arg: local path
from pathlib import Path
local_path = Path(path).resolve()
if not os.path.exists(local_path):
raise ExecutionError(f"Local path {local_path} is invalid")

# arg: remote mountpoint
# - check if remote mountpoint is a valid absolute path
remote_mountpoint = mountpoint
res, stderr, ret = self.run(f"cd / && test -d {remote_mountpoint} ; echo $?")
if res[0] != '0':
raise ExecutionError(f"Remote mountpoint {remote_mountpoint} is not a valid absolute path")

res, stderr, ret = self.run(f"test -r {remote_mountpoint} -a -w {remote_mountpoint} ; echo $?")
if res[0] != '0':
raise ExecutionError(f"Permission denied: user '{self._get_username()}' cannot access '{remote_mountpoint}'")

# - check if remote mountpoint is already mounted
res, stderr, ret = self.run(f"grep -qs ' {remote_mountpoint} ' /proc/mounts &> /dev/null; echo $?")
if res[0] == '0':
self.logger.info("Remote destination %s is already mounted. Skipping", remote_mountpoint)
return

# build remote mount command
session_bg_time = 5
# Note:
# Remotely mounting sshfs is supposed to be a development feature.
# Thus we execute ssh in terminal mode to allow for interactivly
# asking for a password, rather than assuming credentials on the
# DUT (which could be setup equally outside labgrid). After that
# sshfs puts itself to the background.
# The sshfs command needs an execution shell when being in
# foreground. Even when executed with nohup a missing shell will
# shut sshfs down immediately when the shell access is gone. The
# trick here is to execute sshfs with nohup (to avoid closing on
# HUP) piping the (empty) nohup log file to /dev/null, then sleep
# some seconds, to allow sshfs moving into the background. Being in
# the background the sshfs mount will persist closing the ssh
# session.
inner_cmd = f"nohup sshfs {local_user}@{local_ip_address}:{local_path} {remote_mountpoint} > /dev/null 2>&1 && sleep {session_bg_time}"
remote_cmd = [
self._ssh,
"-o", f"ControlPath={self.control.replace('%', '%%')}",
f":{path}",
mountpoint,
]
"-t",
"-l", self._get_username(),
self.networkservice.address,
inner_cmd # The remote shell will execute this
]
self.logger.info("Running command: %s", remote_cmd)
sub = subprocess.Popen(remote_cmd)
return sub.wait()

if os.path.ismount(mountpoint):
self.logger.info("Destination %s is already mounted. Skipping", mountpoint)
return

# mount, build command
complete_cmd = [self._sshfs]
if mount:
remote_src = f"{self._get_username()}@{self.networkservice.address}:{path}"
complete_cmd.extend(["-o", "reconnect,ServerAliveInterval=15", remote_src, mountpoint])
else:
complete_cmd.extend(["-F", "none", "-f", "-o", f"ControlPath={self.control.replace('%', '%%')}", f":{path}", mountpoint])

self.logger.debug("Running command: %s", complete_cmd)
sub = subprocess.Popen(
complete_cmd,
)
sub = subprocess.Popen(complete_cmd)
try:
sub.wait(1)
raise ExecutionError(
f"error executing command: {complete_cmd}"
)
except subprocess.TimeoutExpired: # still running
self.logger.info("Started SSHFS on %s. Press CTRL-C to stop.", mountpoint)

sub.wait()
exit_code = sub.wait(1)
if exit_code != 0:
raise ExecutionError(f"error executing command: {complete_cmd}")
except subprocess.TimeoutExpired:
if mount:
self.logger.info("SSHFS mounted in background: %s (Unmount manually)", mountpoint)
else:
if not self._check_keepalive():
raise ExecutionError("Keepalive no longer running")

self.logger.info("Started SSHFS on %s. Press CTRL-C to stop.", mountpoint)
sub.wait()

def get_status(self):
"""The SSHDriver is always connected, return 1"""
Expand Down
35 changes: 28 additions & 7 deletions labgrid/remote/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1316,10 +1316,20 @@ def ssh(self):

def scp(self):
drv = self._get_ssh()

res = drv.scp(src=self.args.src, dst=self.args.dst)
if res:
raise InteractiveCommandError("scp error", res)
import glob
sources = []
for s in self.args.src:
expanded = glob.glob(s)
if expanded:
sources.extend(expanded)
else:
sources.append(s)
for src_file in sources:
res = drv.scp(src=src_file, dst=self.args.dst)
if res:
exc = InteractiveCommandError("scp error", res)
exc.exitcode = res
raise exc

def rsync(self):
drv = self._get_ssh()
Expand All @@ -1329,9 +1339,15 @@ def rsync(self):
raise InteractiveCommandError("rsync error", res)

def sshfs(self):
if self.args.mount and self.args.unmount:
raise MarsError("Cannot use --mount and --unmount at the same time.")

if (self.args.path and self.args.path.startswith(':') and
self.args.mountpoint.startswith(':')):
raise ExecutionError("Ambiguous: Both path and mountpoint cannot start with ':'.")
drv = self._get_ssh()

drv.sshfs(path=self.args.path, mountpoint=self.args.mountpoint)
drv.sshfs(path=self.args.path, mountpoint=self.args.mountpoint, mount=self.args.mount, unmount=self.args.unmount)

def forward(self):
if not self.args.local and not self.args.remote:
Expand Down Expand Up @@ -2052,7 +2068,7 @@ def get_parser(auto_doc_mode=False) -> "argparse.ArgumentParser | AutoProgramArg

subparser = subparsers.add_parser("scp", help="transfer file via scp")
subparser.add_argument("--name", "-n", help="optional resource name")
subparser.add_argument("src", help="source path (use :dir/file for remote side)")
subparser.add_argument("src", nargs='+', help="source path(s) (use :dir/file for remote side)")
subparser.add_argument("dst", help="destination path (use :dir/file for remote side)")
subparser.set_defaults(func=ClientSession.scp)

Expand All @@ -2066,7 +2082,12 @@ def get_parser(auto_doc_mode=False) -> "argparse.ArgumentParser | AutoProgramArg

subparser = subparsers.add_parser("sshfs", help="mount via sshfs (blocking)")
subparser.add_argument("--name", "-n", help="optional resource name")
subparser.add_argument("path", help="remote path on the target")
# create a group to make "--mount" and "--unmount" mutually exclusive
group = subparser.add_mutually_exclusive_group()
group.add_argument("--mount", action="store_true", help="mount daemonized (manual unmount required)")
group.add_argument("--unmount", action="store_true", help="unmount the local path")
# mountpoint is always needed, path is only for mounting
subparser.add_argument("path", nargs="?", help="remote path on the target (only for mounting)")
subparser.add_argument("mountpoint", help="local path")
subparser.set_defaults(func=ClientSession.sshfs)

Expand Down
Loading