diff --git a/labgrid/driver/sshdriver.py b/labgrid/driver/sshdriver.py index 110a4f707..1a2930bd5 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: @@ -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(): @@ -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""" diff --git a/labgrid/remote/client.py b/labgrid/remote/client.py index 4d2eb0bfa..163c60908 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() @@ -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: @@ -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) @@ -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)