From 08f2b3d29fc729df2856b35fb6ff00e4dea828a3 Mon Sep 17 00:00:00 2001 From: Martin Kalcok Date: Wed, 25 Dec 2024 22:06:51 +0100 Subject: [PATCH 1/2] docs: Update wording regarding Connectors Signed-off-by: Martin Kalcok --- README.md | 8 +++++--- microovn_rebuilder/cli.py | 9 ++++++--- tests/unit/test_cli.py | 8 ++++---- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b6fd4f0..103f2da 100644 --- a/README.md +++ b/README.md @@ -195,8 +195,11 @@ Press 'Enter' to rebuild and deploy OVN. (Ctrl-C for exit) ## Supported remote connectors This tool is primarily meant to sync OVN binaries to the remote hosts running the -cluster. Following connectors are currently supported: - * LXD +cluster. It supports multiple types of connection types called "Connectors". Each +connector has specific syntax for defining remote targets expected in the `-H/--host` +argument. Following connectors are currently supported: + + * LXD - `lxd:` ## Caveats @@ -250,7 +253,6 @@ Note that this command may "fail" if the coverage is not sufficient. * Support execution of arbitrary scripts aside from just restarting services on file updates. This will enable syncing things like OVSDB schemas as they require migration execution of a migration script instead of just a service restart. -* Add SSH connector * Add automation for bootstrapping local OVN source repository * Add automation for bootstrap remote cluster * Add command that lists supported remote connectors diff --git a/microovn_rebuilder/cli.py b/microovn_rebuilder/cli.py index 301f20b..0fe8d92 100644 --- a/microovn_rebuilder/cli.py +++ b/microovn_rebuilder/cli.py @@ -81,10 +81,13 @@ def parse_args() -> argparse.Namespace: # pragma: no cover ) parser.add_argument( "-H", - "--host", + "--hosts", type=str, required=True, - help="Remote host specification. Expected format: ':'", + help="Comma-separated list of remote host to which changes will be synced. For " + "details on supported connectors and their syntax, please see " + "documentation. Generally, the format is:" + "':'", ) parser.add_argument( "-j", @@ -106,7 +109,7 @@ def main() -> None: sys.exit(1) try: - connector = create_connector(args.host) + connector = create_connector(args.hosts) connector.check_remote(args.remote_path) except ConnectorException as exc: print(f"Failed to create connection to remote host: {exc}") diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 16a14a1..69b93ac 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -166,7 +166,7 @@ def test_main_connector_fail(mocker, default_targets): mock_args.config = MagicMock() mock_args.ovn_src = MagicMock() mock_args.remote_path = MagicMock() - mock_args.host = MagicMock() + mock_args.hosts = MagicMock() mock_arg_parse = mocker.patch.object(cli, "parse_args", return_value=mock_args) mock_parse_config = mocker.patch.object( @@ -190,7 +190,7 @@ def test_main_connector_fail(mocker, default_targets): mock_parse_config.assert_called_with( mock_args.config, mock_args.ovn_src, mock_args.remote_path ) - mock_create_connector.assert_called_once_with(mock_args.host) + mock_create_connector.assert_called_once_with(mock_args.hosts) mock_connector.check_remote.assert_called_once_with(mock_args.remote_path) mock_print.assert_called_with( @@ -205,7 +205,7 @@ def test_main_ok(mocker, default_targets): mock_args.config = MagicMock() mock_args.ovn_src = MagicMock() mock_args.remote_path = MagicMock() - mock_args.host = MagicMock() + mock_args.hosts = MagicMock() mock_args.jobs = MagicMock() mock_arg_parse = mocker.patch.object(cli, "parse_args", return_value=mock_args) @@ -227,7 +227,7 @@ def test_main_ok(mocker, default_targets): mock_parse_config.assert_called_with( mock_args.config, mock_args.ovn_src, mock_args.remote_path ) - mock_create_connector.assert_called_once_with(mock_args.host) + mock_create_connector.assert_called_once_with(mock_args.hosts) mock_connector.check_remote.assert_called_once_with(mock_args.remote_path) mock_watch.assert_called_with( From 36c8032901b08f642cc3e9fac2517e344bf8a347 Mon Sep 17 00:00:00 2001 From: Martin Kalcok Date: Wed, 25 Dec 2024 22:09:58 +0100 Subject: [PATCH 2/2] remotes: Add SSH connector. Signed-off-by: Martin Kalcok --- README.md | 1 + microovn_rebuilder/cli.py | 1 + microovn_rebuilder/remote/__init__.py | 15 +- microovn_rebuilder/remote/base.py | 8 + microovn_rebuilder/remote/lxd.py | 8 + microovn_rebuilder/remote/ssh.py | 77 +++++++++ poetry.lock | 226 +++++++++++++++++++++++++- pyproject.toml | 1 + tests/unit/conftest.py | 10 +- tests/unit/remote/test_init.py | 10 +- tests/unit/remote/test_ssh.py | 139 ++++++++++++++++ tests/unit/test_cli.py | 3 + 12 files changed, 490 insertions(+), 9 deletions(-) create mode 100644 microovn_rebuilder/remote/ssh.py create mode 100644 tests/unit/remote/test_ssh.py diff --git a/README.md b/README.md index 103f2da..ecc8044 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,7 @@ connector has specific syntax for defining remote targets expected in the `-H/- argument. Following connectors are currently supported: * LXD - `lxd:` + * SSH - `ssh:[@]` ## Caveats diff --git a/microovn_rebuilder/cli.py b/microovn_rebuilder/cli.py index 0fe8d92..0410dc2 100644 --- a/microovn_rebuilder/cli.py +++ b/microovn_rebuilder/cli.py @@ -59,6 +59,7 @@ def watch( print("[local] No changes in watched files") except KeyboardInterrupt: print() + connector.teardown() break diff --git a/microovn_rebuilder/remote/__init__.py b/microovn_rebuilder/remote/__init__.py index 66967c7..e0e3c48 100644 --- a/microovn_rebuilder/remote/__init__.py +++ b/microovn_rebuilder/remote/__init__.py @@ -1,7 +1,11 @@ from .base import BaseConnector, ConnectorException from .lxd import LXDConnector +from .ssh import SSHConnector -_CONNECTORS = {"lxd": LXDConnector} +_CONNECTORS = { + "lxd": LXDConnector, + "ssh": SSHConnector, +} def create_connector(remote_spec: str) -> BaseConnector: @@ -23,10 +27,13 @@ def create_connector(remote_spec: str) -> BaseConnector: ) connector_type = types.pop() - connector = _CONNECTORS.get(connector_type) - if connector is None: + connector_class = _CONNECTORS.get(connector_type) + if connector_class is None: raise ConnectorException( f"{connector_type} is not a valid connector type. Available types: {", ".join(_CONNECTORS.keys())}" ) - return connector(remotes) + connector = connector_class(remotes) + connector.initialize() + + return connector diff --git a/microovn_rebuilder/remote/base.py b/microovn_rebuilder/remote/base.py index 8406c3d..00cc879 100644 --- a/microovn_rebuilder/remote/base.py +++ b/microovn_rebuilder/remote/base.py @@ -13,6 +13,14 @@ class BaseConnector(ABC): def __init__(self, remotes: List[str]) -> None: self.remotes = remotes + @abstractmethod + def initialize(self) -> None: + pass # pragma: no cover + + @abstractmethod + def teardown(self) -> None: + pass # pragma: no cover + @abstractmethod def check_remote(self, remote_dst: str) -> None: pass # pragma: no cover diff --git a/microovn_rebuilder/remote/lxd.py b/microovn_rebuilder/remote/lxd.py index 1659e62..1286c12 100644 --- a/microovn_rebuilder/remote/lxd.py +++ b/microovn_rebuilder/remote/lxd.py @@ -10,6 +10,14 @@ class LXDConnector(BaseConnector): + def initialize(self) -> None: + # LXDConnector does not require any special initialization + pass # pragma: no cover + + def teardown(self) -> None: + # LXDConnector does not require any special teardown + pass # pragma: no cover + def update(self, target: Target) -> None: for remote in self.remotes: print(f"{os.linesep}[{remote}] Removing remote file {target.remote_path}") diff --git a/microovn_rebuilder/remote/ssh.py b/microovn_rebuilder/remote/ssh.py new file mode 100644 index 0000000..2799013 --- /dev/null +++ b/microovn_rebuilder/remote/ssh.py @@ -0,0 +1,77 @@ +import os +from typing import Dict, List + +from paramiko import SSHClient, SSHException + +from microovn_rebuilder.remote.base import BaseConnector, ConnectorException +from microovn_rebuilder.target import Target + + +class SSHConnector(BaseConnector): + def __init__(self, remotes: List[str]) -> None: + super().__init__(remotes=remotes) + + self.connections: Dict[str, SSHClient] = {} + + def initialize(self) -> None: + for remote in self.remotes: + username, found, host = remote.partition("@") + try: + ssh = SSHClient() + ssh.load_system_host_keys() + if found: + ssh.connect(hostname=host, username=username) + else: + ssh.connect(hostname=remote) + self.connections[remote] = ssh + except SSHException as exc: + raise ConnectorException( + f"Failed to connect to {remote}: {exc}" + ) from exc + + def update(self, target: Target) -> None: + for remote, ssh in self.connections.items(): + try: + with ssh.open_sftp() as sftp: + local_stat = os.stat(str(target.local_path)) + print( + f"{os.linesep}[{remote}] Removing remote file {target.remote_path}" + ) + sftp.remove(str(target.remote_path)) + + print( + f"[{remote}] Uploading file {target.local_path} to {target.remote_path}" + ) + sftp.put(target.local_path, str(target.remote_path)) + sftp.chmod(str(target.remote_path), local_stat.st_mode) + if target.service: + print(f"[{remote}] Restarting {target.service}") + self._run_command(ssh, remote, f"snap restart {target.service}") + except SSHException as exc: + raise ConnectorException( + f"[{remote}] Failed to upload file: {exc}" + ) from exc + + def check_remote(self, remote_dst: str) -> None: + for remote, ssh in self.connections.items(): + self._run_command(ssh, remote, f"test -d {remote_dst}") + + @staticmethod + def _run_command(ssh: SSHClient, remote: str, command: str) -> None: + try: + _, stdout, stderr = ssh.exec_command(command) + ret_code = stdout.channel.recv_exit_status() + if ret_code != 0: + error = stderr.read().decode("utf-8") + raise ConnectorException( + f"[{remote}] Failed to execute command: {error}]" + ) + except SSHException as exc: + raise ConnectorException( + f"[{remote}] Failed to execute command: {exc}" + ) from exc + + def teardown(self) -> None: + for host, ssh in self.connections.items(): + ssh.close() + self.connections.clear() diff --git a/poetry.lock b/poetry.lock index f2159eb..4bcaf53 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,43 @@ # This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. +[[package]] +name = "bcrypt" +version = "4.2.1" +description = "Modern password hashing for your software and your servers" +optional = false +python-versions = ">=3.7" +files = [ + {file = "bcrypt-4.2.1-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:1340411a0894b7d3ef562fb233e4b6ed58add185228650942bdc885362f32c17"}, + {file = "bcrypt-4.2.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1ee315739bc8387aa36ff127afc99120ee452924e0df517a8f3e4c0187a0f5f"}, + {file = "bcrypt-4.2.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dbd0747208912b1e4ce730c6725cb56c07ac734b3629b60d4398f082ea718ad"}, + {file = "bcrypt-4.2.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:aaa2e285be097050dba798d537b6efd9b698aa88eef52ec98d23dcd6d7cf6fea"}, + {file = "bcrypt-4.2.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:76d3e352b32f4eeb34703370e370997065d28a561e4a18afe4fef07249cb4396"}, + {file = "bcrypt-4.2.1-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:b7703ede632dc945ed1172d6f24e9f30f27b1b1a067f32f68bf169c5f08d0425"}, + {file = "bcrypt-4.2.1-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:89df2aea2c43be1e1fa066df5f86c8ce822ab70a30e4c210968669565c0f4685"}, + {file = "bcrypt-4.2.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:04e56e3fe8308a88b77e0afd20bec516f74aecf391cdd6e374f15cbed32783d6"}, + {file = "bcrypt-4.2.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cfdf3d7530c790432046c40cda41dfee8c83e29482e6a604f8930b9930e94139"}, + {file = "bcrypt-4.2.1-cp37-abi3-win32.whl", hash = "sha256:adadd36274510a01f33e6dc08f5824b97c9580583bd4487c564fc4617b328005"}, + {file = "bcrypt-4.2.1-cp37-abi3-win_amd64.whl", hash = "sha256:8c458cd103e6c5d1d85cf600e546a639f234964d0228909d8f8dbeebff82d526"}, + {file = "bcrypt-4.2.1-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:8ad2f4528cbf0febe80e5a3a57d7a74e6635e41af1ea5675282a33d769fba413"}, + {file = "bcrypt-4.2.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:909faa1027900f2252a9ca5dfebd25fc0ef1417943824783d1c8418dd7d6df4a"}, + {file = "bcrypt-4.2.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cde78d385d5e93ece5479a0a87f73cd6fa26b171c786a884f955e165032b262c"}, + {file = "bcrypt-4.2.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:533e7f3bcf2f07caee7ad98124fab7499cb3333ba2274f7a36cf1daee7409d99"}, + {file = "bcrypt-4.2.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:687cf30e6681eeda39548a93ce9bfbb300e48b4d445a43db4298d2474d2a1e54"}, + {file = "bcrypt-4.2.1-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:041fa0155c9004eb98a232d54da05c0b41d4b8e66b6fc3cb71b4b3f6144ba837"}, + {file = "bcrypt-4.2.1-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f85b1ffa09240c89aa2e1ae9f3b1c687104f7b2b9d2098da4e923f1b7082d331"}, + {file = "bcrypt-4.2.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c6f5fa3775966cca251848d4d5393ab016b3afed251163c1436fefdec3b02c84"}, + {file = "bcrypt-4.2.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:807261df60a8b1ccd13e6599c779014a362ae4e795f5c59747f60208daddd96d"}, + {file = "bcrypt-4.2.1-cp39-abi3-win32.whl", hash = "sha256:b588af02b89d9fad33e5f98f7838bf590d6d692df7153647724a7f20c186f6bf"}, + {file = "bcrypt-4.2.1-cp39-abi3-win_amd64.whl", hash = "sha256:e84e0e6f8e40a242b11bce56c313edc2be121cec3e0ec2d76fce01f6af33c07c"}, + {file = "bcrypt-4.2.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:76132c176a6d9953cdc83c296aeaed65e1a708485fd55abf163e0d9f8f16ce0e"}, + {file = "bcrypt-4.2.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e158009a54c4c8bc91d5e0da80920d048f918c61a581f0a63e4e93bb556d362f"}, + {file = "bcrypt-4.2.1.tar.gz", hash = "sha256:6765386e3ab87f569b276988742039baab087b2cdb01e809d74e74503c2faafe"}, +] + +[package.extras] +tests = ["pytest (>=3.2.1,!=3.3.0)"] +typecheck = ["mypy"] + [[package]] name = "black" version = "24.10.0" @@ -55,6 +93,85 @@ files = [ {file = "cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a"}, ] +[[package]] +name = "cffi" +version = "1.17.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, + {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, + {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, + {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, + {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, + {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, + {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, + {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, + {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, + {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, + {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, + {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, + {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, + {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, + {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, +] + +[package.dependencies] +pycparser = "*" + [[package]] name = "chardet" version = "5.2.0" @@ -165,6 +282,55 @@ files = [ [package.extras] toml = ["tomli"] +[[package]] +name = "cryptography" +version = "44.0.0" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.7" +files = [ + {file = "cryptography-44.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:84111ad4ff3f6253820e6d3e58be2cc2a00adb29335d4cacb5ab4d4d34f2a123"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15492a11f9e1b62ba9d73c210e2416724633167de94607ec6069ef724fad092"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:831c3c4d0774e488fdc83a1923b49b9957d33287de923d58ebd3cec47a0ae43f"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:761817a3377ef15ac23cd7834715081791d4ec77f9297ee694ca1ee9c2c7e5eb"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3c672a53c0fb4725a29c303be906d3c1fa99c32f58abe008a82705f9ee96f40b"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4ac4c9f37eba52cb6fbeaf5b59c152ea976726b865bd4cf87883a7e7006cc543"}, + {file = "cryptography-44.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ed3534eb1090483c96178fcb0f8893719d96d5274dfde98aa6add34614e97c8e"}, + {file = "cryptography-44.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f3f6fdfa89ee2d9d496e2c087cebef9d4fcbb0ad63c40e821b39f74bf48d9c5e"}, + {file = "cryptography-44.0.0-cp37-abi3-win32.whl", hash = "sha256:eb33480f1bad5b78233b0ad3e1b0be21e8ef1da745d8d2aecbb20671658b9053"}, + {file = "cryptography-44.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:abc998e0c0eee3c8a1904221d3f67dcfa76422b23620173e28c11d3e626c21bd"}, + {file = "cryptography-44.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:660cb7312a08bc38be15b696462fa7cc7cd85c3ed9c576e81f4dc4d8b2b31591"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1923cb251c04be85eec9fda837661c67c1049063305d6be5721643c22dd4e2b7"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:404fdc66ee5f83a1388be54300ae978b2efd538018de18556dde92575e05defc"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5eb858beed7835e5ad1faba59e865109f3e52b3783b9ac21e7e47dc5554e289"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f53c2c87e0fb4b0c00fa9571082a057e37690a8f12233306161c8f4b819960b7"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e6fc8a08e116fb7c7dd1f040074c9d7b51d74a8ea40d4df2fc7aa08b76b9e6c"}, + {file = "cryptography-44.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d2436114e46b36d00f8b72ff57e598978b37399d2786fd39793c36c6d5cb1c64"}, + {file = "cryptography-44.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a01956ddfa0a6790d594f5b34fc1bfa6098aca434696a03cfdbe469b8ed79285"}, + {file = "cryptography-44.0.0-cp39-abi3-win32.whl", hash = "sha256:eca27345e1214d1b9f9490d200f9db5a874479be914199194e746c893788d417"}, + {file = "cryptography-44.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:708ee5f1bafe76d041b53a4f95eb28cdeb8d18da17e597d46d7833ee59b97ede"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:37d76e6863da3774cd9db5b409a9ecfd2c71c981c38788d3fcfaf177f447b731"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:f677e1268c4e23420c3acade68fac427fffcb8d19d7df95ed7ad17cdef8404f4"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f5e7cb1e5e56ca0933b4873c0220a78b773b24d40d186b6738080b73d3d0a756"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:8b3e6eae66cf54701ee7d9c83c30ac0a1e3fa17be486033000f2a73a12ab507c"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:be4ce505894d15d5c5037167ffb7f0ae90b7be6f2a98f9a5c3442395501c32fa"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:62901fb618f74d7d81bf408c8719e9ec14d863086efe4185afd07c352aee1d2c"}, + {file = "cryptography-44.0.0.tar.gz", hash = "sha256:cd4e834f340b4293430701e772ec543b0fbe6c2dea510a5286fe0acabe153a02"}, +] + +[package.dependencies] +cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0)"] +docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] +nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2)"] +pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] +sdist = ["build (>=1.0.0)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi (>=2024)", "cryptography-vectors (==44.0.0)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test-randomorder = ["pytest-randomly"] + [[package]] name = "distlib" version = "0.3.9" @@ -291,6 +457,27 @@ files = [ {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, ] +[[package]] +name = "paramiko" +version = "3.5.0" +description = "SSH2 protocol library" +optional = false +python-versions = ">=3.6" +files = [ + {file = "paramiko-3.5.0-py3-none-any.whl", hash = "sha256:1fedf06b085359051cd7d0d270cebe19e755a8a921cc2ddbfa647fb0cd7d68f9"}, + {file = "paramiko-3.5.0.tar.gz", hash = "sha256:ad11e540da4f55cedda52931f1a3f812a8238a7af7f62a60de538cd80bb28124"}, +] + +[package.dependencies] +bcrypt = ">=3.2" +cryptography = ">=3.3" +pynacl = ">=1.5" + +[package.extras] +all = ["gssapi (>=1.4.1)", "invoke (>=2.0)", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8)"] +gssapi = ["gssapi (>=1.4.1)", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8)"] +invoke = ["invoke (>=2.0)"] + [[package]] name = "pathspec" version = "0.12.1" @@ -333,6 +520,43 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "pycparser" +version = "2.22" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] + +[[package]] +name = "pynacl" +version = "1.5.0" +description = "Python binding to the Networking and Cryptography (NaCl) library" +optional = false +python-versions = ">=3.6" +files = [ + {file = "PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1"}, + {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92"}, + {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394"}, + {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d"}, + {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858"}, + {file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b"}, + {file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff"}, + {file = "PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543"}, + {file = "PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93"}, + {file = "PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba"}, +] + +[package.dependencies] +cffi = ">=1.4.1" + +[package.extras] +docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"] +tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"] + [[package]] name = "pyproject-api" version = "1.8.0" @@ -527,4 +751,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "89c8ef1407c3b58d86ff0fdc8b394d0204825f5990de2f3cfc89c460f7bbc438" +content-hash = "d21cd9943a64cf82dde01e847858c07fd7a3f89970d63265df4b3ab8727bf6e7" diff --git a/pyproject.toml b/pyproject.toml index 1cfac2b..4cc194e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ include = ["default_config.yaml"] [tool.poetry.dependencies] python = "^3.12" pyyaml = "^6.0.2" +paramiko = "^3.5.0" [tool.poetry.scripts] microovn-rebuilder = "microovn_rebuilder.cli:main" diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 2198b65..f2d2b15 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -4,7 +4,7 @@ import pytest import microovn_rebuilder -from microovn_rebuilder.remote import lxd +from microovn_rebuilder.remote import lxd, ssh from microovn_rebuilder.target import Target, parse_config @@ -33,3 +33,11 @@ def default_targets( @pytest.fixture(scope="session") def lxd_connector() -> lxd.LXDConnector: return lxd.LXDConnector(["vm1", "vm2"]) + + +@pytest.fixture(scope="function") +def ssh_connector(mocker) -> ssh.SSHConnector: + mocker.patch("microovn_rebuilder.remote.ssh.SSHClient", side_effect=mocker.MagicMock) + connector = ssh.SSHConnector(["root@vm1", "vm2"]) + connector.initialize() + return connector diff --git a/tests/unit/remote/test_init.py b/tests/unit/remote/test_init.py index 57c39f5..f130aa3 100644 --- a/tests/unit/remote/test_init.py +++ b/tests/unit/remote/test_init.py @@ -11,10 +11,14 @@ def test_create_connector_invalid_spec(bad_spec): create_connector(bad_spec) -def test_create_connector(): - connector = create_connector("lxd:vm1,lxd:vm2") +@pytest.mark.parametrize("connector_type", ["lxd", "ssh"]) +def test_create_connector(mocker, connector_type): expected_remotes = ["vm1", "vm2"] - expected_type = _CONNECTORS["lxd"] + expected_type = _CONNECTORS[connector_type] + mock_initialize = mocker.patch.object(expected_type, "initialize") + + connector = create_connector(f"{connector_type}:vm1,{connector_type}:vm2") assert isinstance(connector, expected_type) assert connector.remotes == expected_remotes + mock_initialize.assert_called_once() diff --git a/tests/unit/remote/test_ssh.py b/tests/unit/remote/test_ssh.py new file mode 100644 index 0000000..57ff6f6 --- /dev/null +++ b/tests/unit/remote/test_ssh.py @@ -0,0 +1,139 @@ +from os import stat_result +from unittest.mock import MagicMock, call + +import pytest +from paramiko.channel import ChannelFile, ChannelStderrFile +from paramiko.client import SSHClient +from paramiko.sftp_client import SFTPClient +from paramiko.ssh_exception import SSHException + +from microovn_rebuilder.remote import ConnectorException, SSHConnector + + +def test_initialize(mocker): + expected_remotes = { + "vm1": MagicMock(autospec=SSHClient), + "root@vm2": MagicMock(autospec=SSHClient), + } + mocker.patch( + "microovn_rebuilder.remote.ssh.SSHClient", + side_effect=list(expected_remotes.values()), + ) + + connector = SSHConnector(list(expected_remotes.keys())) + connector.initialize() + + expected_remotes["vm1"].connect.assert_called_once_with(hostname="vm1") + expected_remotes["root@vm2"].connect.assert_called_once_with( + hostname="vm2", username="root" + ) + assert connector.connections == expected_remotes + + +def test_initialize_fail(mocker): + mocker.patch("microovn_rebuilder.remote.ssh.SSHClient", side_effect=SSHException()) + + connector = SSHConnector(["vm1", "vm2"]) + with pytest.raises(ConnectorException): + connector.initialize() + + +def test_update_ssh_err(mocker, ssh_connector, default_targets): + target = list(default_targets)[0] + mocker.patch("microovn_rebuilder.remote.ssh.os.stat") + + for connection in ssh_connector.connections.values(): + connection.open_sftp.side_effect = SSHException + + with pytest.raises(ConnectorException): + ssh_connector.update(target) + + +def test_update(mocker, ssh_connector, default_targets): + target = list(default_targets)[0] + + file_stats = MagicMock(autospec=stat_result) + mocker.patch("microovn_rebuilder.remote.ssh.os.stat", return_value=file_stats) + + expected_run_commands = [] + for remote, client in ssh_connector.connections.items(): + expected_run_commands.append( + call(client, remote, f"snap restart {target.service}") + ) + mock_run_command = mocker.patch.object(ssh_connector, "_run_command") + + mock_sftp_ctx = [] + mock_sftps = [] + for connection in ssh_connector.connections.values(): + sftp = MagicMock(autospec=SFTPClient) + sftp_ctx = MagicMock(autospec=SFTPClient) + sftp_ctx.__enter__ = MagicMock(return_value=sftp) + + connection.open_sftp.return_value = sftp_ctx + mock_sftp_ctx.append(sftp_ctx) + mock_sftps.append(sftp) + + ssh_connector.update(target) + + for sftp in mock_sftps: + sftp.remove.assert_called_once_with(str(target.remote_path)) + sftp.put.assert_called_once_with(target.local_path, str(target.remote_path)) + sftp.chmod.assert_called_once_with(str(target.remote_path), file_stats.st_mode) + + mock_run_command.assert_has_calls(expected_run_commands) + + +def test_check_remote(mocker, ssh_connector, remote_deployment_path): + mock_run_command = mocker.patch.object(ssh_connector, "_run_command") + + expected_calls = [] + for remote, client in ssh_connector.connections.items(): + expected_calls.append(call(client, remote, f"test -d {remote_deployment_path}")) + + ssh_connector.check_remote(remote_deployment_path) + mock_run_command.assert_has_calls(expected_calls) + + +def test_run_command_rc_zero(ssh_connector): + remote, client = next(iter(ssh_connector.connections.items())) + mock_stdout = MagicMock(autospec=ChannelFile) + mock_stdout.channel.recv_exit_status.return_value = 0 + client.exec_command.return_value = (None, mock_stdout, None) + + ssh_connector._run_command(client, remote, "foo") + + client.exec_command.assert_called_once_with("foo") + + +def test_run_command_rc_one(ssh_connector): + remote, client = next(iter(ssh_connector.connections.items())) + mock_stderr = MagicMock(autospec=ChannelStderrFile) + mock_stdout = MagicMock(autospec=ChannelFile) + mock_stdout.channel.recv_exit_status.return_value = 1 + client.exec_command.return_value = (None, mock_stdout, mock_stderr) + + with pytest.raises(ConnectorException): + ssh_connector._run_command(client, remote, "foo") + + client.exec_command.assert_called_once_with("foo") + + +def test_run_command_ssh_err(ssh_connector): + remote, client = next(iter(ssh_connector.connections.items())) + client.exec_command.side_effect = SSHException() + + with pytest.raises(ConnectorException): + ssh_connector._run_command(client, remote, "foo") + + client.exec_command.assert_called_once_with("foo") + + +def test_teardown(ssh_connector): + assert len(ssh_connector.connections) != 0 + + ssh_connector.teardown() + + ssh_clients = [client for client in ssh_connector.connections.values()] + assert len(ssh_connector.connections) == 0 + for client in ssh_clients: + client.close.assert_called_once() diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 69b93ac..2b81e31 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -109,6 +109,8 @@ def test_watch(mocker, default_targets, local_ovn_path, targets_changed): print_calls.append(call()) mock_print.assert_has_calls([call()]) + connector.teardown.assert_called_once() + def test_watch_rebuild_failed(mocker, default_targets, local_ovn_path): mock_get_file_timestamps = mocker.patch.object(cli, "get_file_timestamps") @@ -131,6 +133,7 @@ def test_watch_rebuild_failed(mocker, default_targets, local_ovn_path): # After rebuild returns False, no updates should occur. mock_get_changed_targets.assert_not_called() mock_update_targets.assert_not_called() + connector.teardown.assert_called_once() def test_main_parse_config_fail(mocker):