From 95f736b82d6abb86bff7672feafbd63c6bd2f7dd Mon Sep 17 00:00:00 2001 From: Lothar Rubusch Date: Wed, 8 Apr 2026 22:48:28 +0200 Subject: [PATCH 1/4] client: scp - add globbing Add file globbing, e.g. writing `*.txt` to copy multiple files at once. Signed-off-by: Lothar Rubusch --- labgrid/remote/client.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/labgrid/remote/client.py b/labgrid/remote/client.py index 4d2eb0bfa..3f2fd2c43 100755 --- a/labgrid/remote/client.py +++ b/labgrid/remote/client.py @@ -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() @@ -2052,7 +2062,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) From 2736386d11efeace035f0aa7f349ac42fae8aecc Mon Sep 17 00:00:00 2001 From: Lothar Rubusch Date: Wed, 8 Apr 2026 22:53:02 +0200 Subject: [PATCH 2/4] client: sshfs - mount and unmount daemonized for development Add functionality to moung and unmount remote directorys via sshfs on a local mount point. The labgrid prompt returns, the sshfs share stays mounted until unmounted. Signed-off-by: Lothar Rubusch --- labgrid/driver/sshdriver.py | 65 ++++++++++++++++++++++++------------- labgrid/remote/client.py | 11 +++++-- 2 files changed, 52 insertions(+), 24 deletions(-) diff --git a/labgrid/driver/sshdriver.py b/labgrid/driver/sshdriver.py index 110a4f707..40974f148 100644 --- a/labgrid/driver/sshdriver.py +++ b/labgrid/driver/sshdriver.py @@ -377,7 +377,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(): @@ -424,31 +424,52 @@ def rsync(self, *, src, dst, extra=[]): @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", - "-o", f"ControlPath={self.control.replace('%', '%%')}", - f":{path}", - mountpoint, - ] + def sshfs(self, *, path=None, mountpoint=None, mount=False, unmount=False): + import os, subprocess + + # unmount + if unmount: + if not os.path.ismount(mountpoint): + self.logger.info("Skipping, %s is not a mountpoint.", mountpoint) + return + + self.logger.info("Unmounting %s", mountpoint) + subprocess.run(["fusermount", "-u", mountpoint], check=True) + return + + # mount, checks + if not path: + raise ExecutionError("Remote path is required for mounting") + + 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: + complete_cmd.extend(["-o", "reconnect,ServerAliveInterval=15,ServerAliveCountMax=3", f"{self._get_username()}@{self.networkservice.address}:{path}", 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}" - ) + exit_code = sub.wait(1) + if exit_code != 0: + 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() + 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""" diff --git a/labgrid/remote/client.py b/labgrid/remote/client.py index 3f2fd2c43..79935d279 100755 --- a/labgrid/remote/client.py +++ b/labgrid/remote/client.py @@ -1339,9 +1339,11 @@ 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.") 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: @@ -2076,7 +2078,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) From ab264d86ff2b6c9d19eacb6cd6c64092a81a1cc6 Mon Sep 17 00:00:00 2001 From: Lothar Rubusch Date: Wed, 8 Apr 2026 23:05:58 +0200 Subject: [PATCH 3/4] client: sshfs - remote mount for development Add the possibility to mount a local folder on a remote host. Open a ssh connection to the configured SSH target, to issue a remote sshfs mount command. A running sshd (locally) and sshfs is required to be installed for this functionality. Then use the ':' to indicate the remote side. Available commands are then: ---------------------------- $ labgrid-client remote_dir local_mnt or $ labgrid-client :remote_dir local_mnt mounts in the foreground and does not come back until signalled $ labgrid-client --mount remote_dir local_dir or $ labgrid-client --mount :remote_dir local_dir mounts in the background, prompt returns, share stays mounted $ labgrid-client --unmount local_dir or $ labgrid-client --unmount remote_dir local_dir or $ labgrid-client --unmount :remote_dir local_dir unmounts local sshfs share, when mounted with '--mount' before $ labgrid-client --mount local_dir :remote_mnt mounts a local dir on a remote mountpoint $ labgrid-client --unmount :remote_mnt or $ labgrid-client --unmount local_dir :remote_mnt unmount a remote mountpoint if mounted before using :remote_mnt Signed-off-by: Lothar Rubusch --- labgrid/driver/sshdriver.py | 106 +++++++++++++++++++++++++++++++----- labgrid/remote/client.py | 4 ++ 2 files changed, 97 insertions(+), 13 deletions(-) diff --git a/labgrid/driver/sshdriver.py b/labgrid/driver/sshdriver.py index 40974f148..f0f6f197b 100644 --- a/labgrid/driver/sshdriver.py +++ b/labgrid/driver/sshdriver.py @@ -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: @@ -422,25 +423,106 @@ 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=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: - if not os.path.ismount(mountpoint): - self.logger.info("Skipping, %s is not a mountpoint.", mountpoint) - return - - self.logger.info("Unmounting %s", mountpoint) - subprocess.run(["fusermount", "-u", mountpoint], check=True) + 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 - # mount, checks + # 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") + + # - 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('%', '%%')}", + "-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 @@ -448,20 +530,18 @@ def sshfs(self, *, path=None, mountpoint=None, mount=False, unmount=False): # mount, build command complete_cmd = [self._sshfs] if mount: - complete_cmd.extend(["-o", "reconnect,ServerAliveInterval=15,ServerAliveCountMax=3", f"{self._get_username()}@{self.networkservice.address}:{path}", mountpoint]) + 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) try: exit_code = sub.wait(1) if exit_code != 0: - raise ExecutionError( - f"error executing command: {complete_cmd}" - ) - except subprocess.TimeoutExpired: # still running + 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: diff --git a/labgrid/remote/client.py b/labgrid/remote/client.py index 79935d279..163c60908 100755 --- a/labgrid/remote/client.py +++ b/labgrid/remote/client.py @@ -1341,6 +1341,10 @@ def rsync(self): 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, mount=self.args.mount, unmount=self.args.unmount) From 4371d9b623b39099a8fb79e44c561b8ed3d5fd57 Mon Sep 17 00:00:00 2001 From: Lothar Rubusch Date: Mon, 18 May 2026 23:03:19 +0200 Subject: [PATCH 4/4] client: sshfs - check permissons of mountpoint Add a warning, when permissions of mount point prevent a mounting. Signed-off-by: Lothar Rubusch --- labgrid/driver/sshdriver.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/labgrid/driver/sshdriver.py b/labgrid/driver/sshdriver.py index f0f6f197b..1a2930bd5 100644 --- a/labgrid/driver/sshdriver.py +++ b/labgrid/driver/sshdriver.py @@ -488,6 +488,10 @@ def sshfs(self, *, path=None, mountpoint=None, mount=False, unmount=False): 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':