-
Notifications
You must be signed in to change notification settings - Fork 1.1k
SSH runner: created RemoteConnection and copy conan config to remote #18599
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop2
Are you sure you want to change the base?
Changes from 7 commits
c0e2849
4a58573
b3b6059
50f4fa2
b3f3853
419b2bf
08a9a32
f457721
5d79916
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,6 @@ | ||
| from pathlib import Path | ||
| from typing import Iterable | ||
| import fnmatch | ||
| import pathlib | ||
| import tempfile | ||
|
|
||
|
|
@@ -9,6 +11,8 @@ | |
| from io import BytesIO | ||
| import sys | ||
|
|
||
| from conan.internal.runner.output import RunnerOutput | ||
|
|
||
| def ssh_info(msg, error=False): | ||
| fg=Color.BRIGHT_MAGENTA | ||
| if error: | ||
|
|
@@ -51,9 +55,14 @@ def __init__(self, conan_api, command, host_profile, build_profile, args, raw_ar | |
| if ssh_config and ssh_config.lookup(hostname): | ||
| hostname = ssh_config.lookup(hostname)['hostname'] | ||
|
|
||
| self.client = SSHClient() | ||
| self.client.load_system_host_keys() | ||
| self.client.connect(hostname) | ||
| # this self.client manages the main ssh connection | ||
| self.ssh_client = SSHClient() | ||
| self.ssh_client.load_system_host_keys() | ||
| self.ssh_client.connect(hostname) | ||
| self.runner_output = RunnerOutput(hostname) | ||
| # This client manages just the sftp transfers | ||
| # TODO: Integrate both client calls in one | ||
| self.sftp_client = RemoteConnection(self.ssh_client, self.runner_output) | ||
|
|
||
|
|
||
| def run(self, use_cache=True): | ||
|
|
@@ -92,10 +101,10 @@ def ensure_runner_environment(self): | |
| has_python3_command = False | ||
| python_is_python3 = False | ||
|
|
||
| _, _stdout, _stderr = self.client.exec_command("python3 --version") | ||
| _, _stdout, _stderr = self.ssh_client.exec_command("python3 --version") | ||
| has_python3_command = _stdout.channel.recv_exit_status() == 0 | ||
| if not has_python3_command: | ||
| _, _stdout, _stderr = self.client.exec_command("python --version") | ||
| _, _stdout, _stderr = self.ssh_client.exec_command("python --version") | ||
| if _stdout.channel.recv_exit_status() == 0 and "Python 3" in _stdout.read().decode(): | ||
| python_is_python3 = True | ||
|
|
||
|
|
@@ -106,14 +115,14 @@ def ensure_runner_environment(self): | |
| raise ConanException("Unable to locate working Python 3 executable in remote SSH environment") | ||
|
|
||
| # Determine if remote host is Windows | ||
| _, _stdout, _ = self.client.exec_command(f'{python_command} -c "import os; print(os.name)"') | ||
| _, _stdout, _ = self.ssh_client.exec_command(f'{python_command} -c "import os; print(os.name)"') | ||
| if _stdout.channel.recv_exit_status() != 0: | ||
| raise ConanException("Unable to determine remote OS type") | ||
| is_windows = _stdout.read().decode().strip() == "nt" | ||
| self.remote_is_windows = is_windows | ||
|
|
||
| # Get remote user home folder | ||
| _, _stdout, _ = self.client.exec_command(f'{python_command} -c "from pathlib import Path; print(Path.home())"') | ||
| _, _stdout, _ = self.ssh_client.exec_command(f'{python_command} -c "from pathlib import Path; print(Path.home())"') | ||
| if _stdout.channel.recv_exit_status() != 0: | ||
| raise ConanException("Unable to determine remote home user folder") | ||
| home_folder = _stdout.read().decode().strip() | ||
|
|
@@ -129,7 +138,7 @@ def ensure_runner_environment(self): | |
|
|
||
| # Ensure remote folders exist | ||
| for folder in [remote_folder, remote_conan_home]: | ||
| _, _stdout, _stderr = self.client.exec_command(f"""{python_command} -c "import os; os.makedirs('{folder}', exist_ok=True)""") | ||
| _, _stdout, _stderr = self.ssh_client.exec_command(f"""{python_command} -c "import os; os.makedirs('{folder}', exist_ok=True)""") | ||
| if _stdout.channel.recv_exit_status() != 0: | ||
| ssh_info(f"Error creating remote folder: {_stderr.read().decode()}") | ||
| raise ConanException(f"Unable to create remote workfolder at {folder}") | ||
|
|
@@ -144,17 +153,10 @@ def ensure_runner_environment(self): | |
| ssh_info(f"Expected remote conan command: {conan_cmd}") | ||
|
|
||
| # Check if remote Conan executable exists, otherwise invoke pip inside venv | ||
| sftp = self.client.open_sftp() | ||
| try: | ||
| sftp.stat(conan_cmd) | ||
| has_remote_conan = True | ||
| except FileNotFoundError: | ||
| has_remote_conan = False | ||
| finally: | ||
| sftp.close() | ||
| has_remote_conan = self.sftp_client.check_file_exists(conan_cmd) | ||
|
|
||
| if not has_remote_conan: | ||
| _, _stdout, _stderr = self.client.exec_command(f"{python_command} -m venv {conan_venv}") | ||
| _, _stdout, _stderr = self.ssh_client.exec_command(f"{python_command} -m venv {conan_venv}") | ||
| if _stdout.channel.recv_exit_status() != 0: | ||
| ssh_info(f"Unable to create remote venv: {_stderr.read().decode().strip()}") | ||
|
|
||
|
|
@@ -163,7 +165,7 @@ def ensure_runner_environment(self): | |
| else: | ||
| python_command = remote_folder + "/venv" + "/bin" + "/python" | ||
|
|
||
| _, _stdout, _stderr = self.client.exec_command(f"{python_command} -m pip install git+https://github.com/conan-io/conan@feature/docker_wrapper") | ||
| _, _stdout, _stderr = self.ssh_client.exec_command(f"{python_command} -m pip install git+https://github.com/conan-io/conan@feature/docker_wrapper") | ||
| if _stdout.channel.recv_exit_status() != 0: | ||
| # Note: this may fail on windows | ||
| ssh_info(f"Unable to install conan in venv: {_stderr.read().decode().strip()}") | ||
|
|
@@ -178,37 +180,33 @@ def ensure_runner_environment(self): | |
| conan_bat_contents = f"""@echo off\n{env_lines}\n{conan_cmd} %*\n""" | ||
| conan_bat = remote_folder + "/conan.bat" | ||
| try: | ||
| sftp = self.client.open_sftp() | ||
| sftp = self.ssh_client.open_sftp() | ||
| sftp.putfo(BytesIO(conan_bat_contents.encode()), conan_bat) | ||
| except: | ||
| raise ConanException("unable to set up Conan remote script") | ||
| finally: | ||
| sftp.close() | ||
|
|
||
| self.remote_conan = conan_bat | ||
| _, _stdout, _stderr = self.client.exec_command(f"{self.remote_conan} config home") | ||
| _, _stdout, _stderr = self.ssh_client.exec_command(f"{self.remote_conan} config home") | ||
| ssh_info(f"Remote conan config home returned: {_stdout.read().decode().strip()}") | ||
| _, _stdout, _stderr = self.client.exec_command(f"{self.remote_conan} profile detect --force") | ||
| self._copy_profiles() | ||
|
|
||
|
|
||
| def _copy_profiles(self): | ||
| sftp = self.client.open_sftp() | ||
|
|
||
| # TODO: very questionable choices here | ||
| try: | ||
| profiles = { | ||
| self.args.profile_host[0]: self.host_profile.dumps(), | ||
| self.args.profile_build[0]: self.build_profile.dumps() | ||
| } | ||
|
|
||
| for name, contents in profiles.items(): | ||
| dest_filename = self.remote_conan_home + f"/profiles/{name}" | ||
| sftp.putfo(BytesIO(contents.encode()), dest_filename) | ||
| except: | ||
| raise ConanException("Unable to copy profiles to remote") | ||
| finally: | ||
| sftp.close() | ||
| _, _stdout, _stderr = self.ssh_client.exec_command(f"{self.remote_conan} profile detect --force") | ||
| self._sync_conan_config() | ||
|
|
||
| def _sync_conan_config(self): | ||
| # Transfer conan config to remote | ||
| cache_home = Path(self.conan_api.config.home()) | ||
| cache_remote = Path(self.remote_conan_home) | ||
| # TODO: inspect each file and determine if references to local paths are present -> inform | ||
| # user that that profile/configuration file will not work on remote | ||
| self.sftp_client.put_dir( | ||
| cache_home / "profiles", | ||
| cache_remote / "profiles", | ||
| exclude_patterns=(".conan.db", "*.pyc", "__pycache__", ".DS_Store", ".git") | ||
| ) | ||
| for file in ("settings.yml", "settings_user.yml", "global.conf", "remotes.json"): | ||
| if (cache_home / file).exists(): | ||
| self.sftp_client.put(cache_home / file, cache_remote / file) | ||
|
|
||
| def copy_working_conanfile_path(self): | ||
| resolved_path = Path(self.args.path).resolve() | ||
|
|
@@ -220,14 +218,15 @@ def copy_working_conanfile_path(self): | |
|
|
||
| # Create temporary destination directory | ||
| temp_dir_create_cmd = f"""{self.remote_python_command} -c "import tempfile; print(tempfile.mkdtemp(dir='{self.remote_workspace}'))""" | ||
| _, _stdout, _ = self.client.exec_command(temp_dir_create_cmd) | ||
| _, _stdout, _ = self.ssh_client.exec_command(temp_dir_create_cmd) | ||
| if _stdout.channel.recv_exit_status() != 0: | ||
| raise ConanException("Unable to create remote temporary directory") | ||
| self.remote_create_dir = _stdout.read().decode().strip().replace("\\", '/') | ||
|
|
||
| # Copy current folder to destination using sftp | ||
| # self.remote_conn.put_dir(resolved_path.as_posix(), self.remote_create_dir) | ||
| _Path = pathlib.PureWindowsPath if self.remote_is_windows else pathlib.PurePath | ||
| sftp = self.client.open_sftp() | ||
| sftp = self.ssh_client.open_sftp() | ||
| for root, dirs, files in os.walk(resolved_path.as_posix()): | ||
| relative_root = Path(root).relative_to(resolved_path) | ||
| for dir in dirs: | ||
|
|
@@ -245,7 +244,7 @@ def _run_command(self, command): | |
| ensure we pass width and height that matches the current | ||
| terminal | ||
| ''' | ||
| channel = self.client.get_transport().open_session() | ||
| channel = self.ssh_client.get_transport().open_session() | ||
| if sys.stdout.isatty(): | ||
| width, height = os.get_terminal_size() | ||
| channel.get_pty(width=width, height=height) | ||
|
|
@@ -261,18 +260,69 @@ def update_local_cache(self, json_result): | |
| _Path = pathlib.PureWindowsPath if self.remote_is_windows else pathlib.PurePath | ||
| pkg_list_json = _Path(self.remote_create_dir).joinpath("pkg_list.json").as_posix() | ||
| pkg_list_command = f"{self.remote_conan} list --graph={json_result} --graph-binaries=build --format=json > {pkg_list_json}" | ||
| _, stdout, _ = self.client.exec_command(pkg_list_command) | ||
| _, stdout, _ = self.ssh_client.exec_command(pkg_list_command) | ||
| if stdout.channel.recv_exit_status() != 0: | ||
| raise ConanException("Unable to generate remote package list") | ||
|
|
||
| conan_cache_tgz = _Path(self.remote_create_dir).joinpath("cache.tgz").as_posix() | ||
| cache_save_command = f"{self.remote_conan} cache save --list {pkg_list_json} --file {conan_cache_tgz}" | ||
| _, stdout, _ = self.client.exec_command(cache_save_command) | ||
| _, stdout, _ = self.ssh_client.exec_command(cache_save_command) | ||
| if stdout.channel.recv_exit_status() != 0: | ||
| raise ConanException("Unable to save remote conan cache state") | ||
|
|
||
| sftp = self.client.open_sftp() | ||
| with tempfile.TemporaryDirectory() as tmp: | ||
| local_cache_tgz = os.path.join(tmp, 'cache.tgz') | ||
| sftp.get(conan_cache_tgz, local_cache_tgz) | ||
| package_list = self.conan_api.cache.restore(local_cache_tgz) | ||
| self.sftp_client.get(conan_cache_tgz, local_cache_tgz) | ||
| self.conan_api.cache.restore(local_cache_tgz) | ||
|
|
||
|
|
||
| class RemoteConnection: | ||
| def __init__(self, ssh_client, runner_output: RunnerOutput): | ||
| from paramiko.client import SSHClient | ||
| self.client: SSHClient = ssh_client | ||
| # No need to close connection as it will be closed on destroy of RemoteConnection | ||
| self.sftp = self.client.open_sftp() | ||
| self.runner_output = runner_output | ||
|
|
||
| def put(self, src: Path, dst: Path) -> None: | ||
| try: | ||
| self.sftp.put(src.as_posix(), dst.as_posix()) | ||
| self.runner_output.verbose(f"Copying file {src.as_posix()} to {dst}") | ||
| except IOError as e: | ||
| self.runner_output.error(f"Unable to copy {src} to {dst}:\n{e}") | ||
|
|
||
| def put_dir(self, source_folder: Path, destination_folder: Path, exclude_patterns: Iterable[str] | None = None) -> None: | ||
perseoGI marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| for item in source_folder.iterdir(): | ||
| dest_item = destination_folder / item.name | ||
| # Check if item matches any exclude pattern | ||
| if exclude_patterns and any(fnmatch.fnmatch(item.name, pattern) for pattern in exclude_patterns): | ||
| continue | ||
| if item.is_file(): | ||
| self.runner_output.verbose(f"Copying file {item.as_posix()} to {dest_item}") | ||
| self.put(item, dest_item) | ||
| elif item.is_dir(): | ||
| self.runner_output.verbose(f"Copying directory {item.as_posix()} to {dest_item}") | ||
| self.mkdir(dest_item, ignore_existing=True) | ||
| self.put_dir(item, dest_item, exclude_patterns) | ||
|
|
||
| def get(self, src: str, dst: str) -> None: | ||
| try: | ||
| self.sftp.get(src, dst) | ||
| except IOError as e: | ||
| self.runner_output.error(f"Unable to copy from remote {src} to {dst}:\n{e}") | ||
|
|
||
| def mkdir(self, folder: Path, ignore_existing=False) -> None: | ||
| try: | ||
| self.sftp.mkdir(folder.as_posix()) | ||
| except IOError: | ||
| if ignore_existing: | ||
|
||
| pass | ||
| else: | ||
| raise | ||
|
|
||
| def check_file_exists(self, file: str) -> bool: | ||
| try: | ||
| self.sftp.stat(file) | ||
| return True | ||
| except FileNotFoundError: | ||
| return False | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
do we want to allow Python annotations in this class?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I cannot answer this to you, IMHO I prefer annotations