| #!/usr/bin/env python |
| # remote-run - Runs a command on another machine, for testing -----*- python -*- |
| # |
| # This source file is part of the Swift.org open source project |
| # |
| # Copyright (c) 2018 Apple Inc. and the Swift project authors |
| # Licensed under Apache License v2.0 with Runtime Library Exception |
| # |
| # See https://swift.org/LICENSE.txt for license information |
| # See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors |
| # |
| # ---------------------------------------------------------------------------- |
| |
| from __future__ import print_function |
| |
| import argparse |
| import os.path |
| import subprocess |
| import sys |
| |
| def quote(arg): |
| return repr(arg) |
| |
| class CommandRunner(object): |
| def __init__(self): |
| self.verbose = False |
| self.dry_run = False |
| |
| @staticmethod |
| def _dirnames(files): |
| return sorted(set(os.path.dirname(f) for f in files)) |
| |
| def popen(self, command, **kwargs): |
| if self.verbose: |
| print(' '.join(command), file=sys.stderr) |
| if self.dry_run: |
| return None |
| return subprocess.Popen(command, **kwargs) |
| |
| def send(self, local_to_remote_files): |
| # Prepare the remote directory structure. |
| # FIXME: This could be folded into the sftp connection below. |
| dirs_to_make = self._dirnames(local_to_remote_files.viewvalues()) |
| self.run_remote(['/bin/mkdir', '-p'] + dirs_to_make) |
| |
| # Send the local files. |
| sftp_commands = ("-put {0} {1}".format(quote(local_file), |
| quote(remote_file)) |
| for local_file, remote_file |
| in local_to_remote_files.viewitems()) |
| self.run_sftp(sftp_commands) |
| |
| def fetch(self, local_to_remote_files): |
| # Prepare the local directory structure. |
| dirs_to_make = self._dirnames(local_to_remote_files.viewkeys()) |
| mkdir_command = ['/bin/mkdir', '-p'] + dirs_to_make |
| if self.verbose: |
| print(' '.join(mkdir_command), file=sys.stderr) |
| if not self.dry_run: |
| subprocess.check_call(mkdir_command) |
| |
| # Fetch the remote files. |
| sftp_commands = ("-get {0} {1}".format(quote(remote_file), |
| quote(local_file)) |
| for local_file, remote_file |
| in local_to_remote_files.viewitems()) |
| self.run_sftp(sftp_commands) |
| |
| def run_remote(self, command, remote_env={}): |
| env_strings = ['{0}={1}'.format(k,v) for k,v in remote_env.viewitems()] |
| remote_invocation = self.remote_invocation( |
| ['/usr/bin/env'] + env_strings + command) |
| remote_proc = self.popen(remote_invocation, stdin=subprocess.PIPE, |
| stdout=None, stderr=None) |
| if self.dry_run: |
| return |
| _, _ = remote_proc.communicate() |
| if remote_proc.returncode: |
| # FIXME: We may still want to fetch the output files to see what |
| # went wrong. |
| sys.exit(remote_proc.returncode) |
| |
| def run_sftp(self, commands): |
| sftp_proc = self.popen(self.sftp_invocation(), stdin=subprocess.PIPE, |
| stdout=subprocess.PIPE, stderr=None) |
| concatenated_commands = '\n'.join(commands) |
| if self.verbose: |
| print(concatenated_commands, file=sys.stderr) |
| if self.dry_run: |
| return |
| _, _ = sftp_proc.communicate(concatenated_commands) |
| if sftp_proc.returncode: |
| sys.exit(sftp_proc.returncode) |
| |
| class RemoteCommandRunner(CommandRunner): |
| def __init__(self, host, identity_path, ssh_options): |
| if ':' in host: |
| (self.remote_host, self.port) = host.rsplit(':', 1) |
| else: |
| self.remote_host = host |
| self.port = None |
| self.identity_path = identity_path |
| self.ssh_options = ssh_options |
| |
| def common_options(self, port_flag): |
| port_option = [port_flag, self.port] if self.port else [] |
| identity_option = ( |
| ['-i', self.identity_path] if self.identity_path else []) |
| # Interleave '-o' with each custom option. |
| # From https://stackoverflow.com/a/8168526, |
| # with explanatory help from |
| # https://spapas.github.io/2016/04/27/python-nested-list-comprehensions/ |
| extra_options = [arg for option in self.ssh_options |
| for arg in ["-o", option]] |
| return port_option + identity_option + extra_options |
| |
| def remote_invocation(self, command): |
| return (['/usr/bin/ssh', '-n'] + |
| self.common_options(port_flag='-p') + |
| [self.remote_host, '--'] + |
| [quote(arg) for arg in command]) |
| |
| def sftp_invocation(self): |
| return (['/usr/bin/sftp', '-b', '-', '-q'] + |
| self.common_options(port_flag='-P') + |
| [self.remote_host]) |
| |
| class LocalCommandRunner(CommandRunner): |
| def __init__(self, sftp_server_path): |
| self.sftp_server_path = sftp_server_path |
| |
| def remote_invocation(self, command): |
| return command |
| |
| def sftp_invocation(self): |
| return ['/usr/bin/sftp', '-b', '-', '-q', '-D', self.sftp_server_path] |
| |
| def find_transfers(args, source_prefix, dest_prefix): |
| if source_prefix.endswith(os.path.sep): |
| source_prefix = source_prefix[:-len(os.path.sep)] |
| return dict((arg, dest_prefix + arg[len(source_prefix):]) |
| for arg in args if arg.startswith(source_prefix)) |
| |
| def collect_remote_env(local_env=os.environ, prefix='REMOTE_RUN_CHILD_'): |
| return dict((key[len(prefix):], value) |
| for key, value in local_env.items() if key.startswith(prefix)) |
| |
| def main(): |
| parser = argparse.ArgumentParser() |
| |
| parser.add_argument('-v', '--verbose', action='store_true', dest='verbose', |
| help='print commands as they are run') |
| parser.add_argument('-n', '--dry-run', action='store_true', dest='dry_run', |
| help="print the commands that would have been run, but " |
| "don't actually run them") |
| |
| parser.add_argument('--remote-dir', required=True, metavar='PATH', |
| help='(required) a writable temporary path on the ' |
| 'remote machine') |
| parser.add_argument('--input-prefix', |
| help='arguments matching this prefix will be uploaded') |
| parser.add_argument('--output-prefix', |
| help='arguments matching this prefix will be both ' |
| 'uploaded and downloaded') |
| parser.add_argument('--remote-input-prefix', default='input', |
| help='input arguments use this prefix on the remote ' |
| 'machine') |
| parser.add_argument('--remote-output-prefix', default='output', |
| help='output arguments use this prefix on the remote ' |
| 'machine') |
| |
| parser.add_argument('-i', '--identity', dest='identity', metavar='FILE', |
| help='an SSH identity file (private key) to use') |
| parser.add_argument('-o', '--ssh-option', action='append', default=[], |
| dest='ssh_options', metavar='OPTION', |
| help='extra SSH config options (man ssh_config)') |
| parser.add_argument('--debug-as-local', metavar='/PATH/TO/SFTP-SERVER', |
| help='run commands locally instead of over SSH, for ' |
| 'debugging purposes. The "host" argument is ' |
| 'omitted.') |
| |
| parser.add_argument('host', |
| help='the host to connect to, in the form ' |
| '[user@]host[:port]') |
| parser.add_argument('command', nargs=argparse.REMAINDER, |
| help='the command to run', metavar='command...') |
| args = parser.parse_args() |
| |
| if args.debug_as_local: |
| runner = LocalCommandRunner(args.debug_as_local) |
| args.command.insert(0, args.host) |
| del args.host |
| else: |
| runner = RemoteCommandRunner(args.host, args.identity, args.ssh_options) |
| runner.dry_run = args.dry_run |
| runner.verbose = args.verbose or args.dry_run |
| |
| assert not args.remote_dir == '/' |
| |
| upload_files = dict() |
| download_files = dict() |
| remote_test_specific_dir = None |
| if args.input_prefix: |
| assert not args.remote_input_prefix.startswith("..") |
| remote_dir = os.path.join(args.remote_dir, args.remote_input_prefix) |
| input_files = find_transfers(args.command, args.input_prefix, |
| remote_dir) |
| assert not any(upload_files.has_key(f) for f in input_files) |
| upload_files.update(input_files) |
| if args.output_prefix: |
| assert not args.remote_output_prefix.startswith("..") |
| remote_dir = os.path.join(args.remote_dir, args.remote_output_prefix) |
| test_files = find_transfers(args.command, args.output_prefix, |
| remote_dir) |
| assert not any(upload_files.has_key(f) for f in test_files) |
| upload_files.update(test_files) |
| assert not any(download_files.has_key(f) for f in test_files) |
| download_files.update(test_files) |
| remote_test_specific_dir = remote_dir |
| |
| if remote_test_specific_dir: |
| assert remote_test_specific_dir.startswith(args.remote_dir) |
| runner.run_remote(['/bin/rm', '-rf', remote_test_specific_dir]) |
| if upload_files: |
| runner.send(upload_files) |
| |
| remote_env = collect_remote_env() |
| |
| translated_command = [upload_files.get(arg, download_files.get(arg, arg)) |
| for arg in args.command] |
| runner.run_remote(translated_command, remote_env) |
| |
| if download_files: |
| runner.fetch(download_files) |
| |
| if __name__ == "__main__": |
| main() |