pyinfra 2.9.2__py2.py3-none-any.whl → 3.0b1__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. pyinfra/api/__init__.py +3 -0
  2. pyinfra/api/arguments.py +261 -255
  3. pyinfra/api/arguments_typed.py +77 -0
  4. pyinfra/api/command.py +66 -53
  5. pyinfra/api/config.py +27 -22
  6. pyinfra/api/connect.py +1 -1
  7. pyinfra/api/connectors.py +2 -24
  8. pyinfra/api/deploy.py +21 -52
  9. pyinfra/api/exceptions.py +33 -8
  10. pyinfra/api/facts.py +77 -113
  11. pyinfra/api/host.py +150 -82
  12. pyinfra/api/inventory.py +17 -25
  13. pyinfra/api/operation.py +232 -198
  14. pyinfra/api/operations.py +102 -148
  15. pyinfra/api/state.py +137 -79
  16. pyinfra/api/util.py +55 -70
  17. pyinfra/connectors/base.py +150 -0
  18. pyinfra/connectors/chroot.py +160 -169
  19. pyinfra/connectors/docker.py +227 -237
  20. pyinfra/connectors/dockerssh.py +231 -253
  21. pyinfra/connectors/local.py +195 -207
  22. pyinfra/connectors/ssh.py +528 -615
  23. pyinfra/connectors/ssh_util.py +114 -0
  24. pyinfra/connectors/sshuserclient/client.py +5 -3
  25. pyinfra/connectors/terraform.py +86 -65
  26. pyinfra/connectors/util.py +212 -137
  27. pyinfra/connectors/vagrant.py +55 -48
  28. pyinfra/context.py +3 -2
  29. pyinfra/facts/docker.py +1 -0
  30. pyinfra/facts/files.py +45 -32
  31. pyinfra/facts/git.py +3 -1
  32. pyinfra/facts/gpg.py +1 -1
  33. pyinfra/facts/hardware.py +4 -2
  34. pyinfra/facts/iptables.py +5 -3
  35. pyinfra/facts/mysql.py +1 -0
  36. pyinfra/facts/postgres.py +168 -0
  37. pyinfra/facts/postgresql.py +5 -161
  38. pyinfra/facts/selinux.py +3 -1
  39. pyinfra/facts/server.py +77 -30
  40. pyinfra/facts/systemd.py +29 -12
  41. pyinfra/facts/sysvinit.py +10 -10
  42. pyinfra/facts/util/packaging.py +4 -2
  43. pyinfra/local.py +4 -5
  44. pyinfra/operations/apk.py +3 -3
  45. pyinfra/operations/apt.py +25 -47
  46. pyinfra/operations/brew.py +7 -14
  47. pyinfra/operations/bsdinit.py +4 -4
  48. pyinfra/operations/cargo.py +1 -1
  49. pyinfra/operations/choco.py +1 -1
  50. pyinfra/operations/dnf.py +4 -4
  51. pyinfra/operations/files.py +108 -321
  52. pyinfra/operations/gem.py +1 -1
  53. pyinfra/operations/git.py +6 -37
  54. pyinfra/operations/iptables.py +2 -10
  55. pyinfra/operations/launchd.py +1 -1
  56. pyinfra/operations/lxd.py +1 -9
  57. pyinfra/operations/mysql.py +5 -28
  58. pyinfra/operations/npm.py +1 -1
  59. pyinfra/operations/openrc.py +1 -1
  60. pyinfra/operations/pacman.py +3 -3
  61. pyinfra/operations/pip.py +14 -15
  62. pyinfra/operations/pkg.py +1 -1
  63. pyinfra/operations/pkgin.py +3 -3
  64. pyinfra/operations/postgres.py +347 -0
  65. pyinfra/operations/postgresql.py +17 -380
  66. pyinfra/operations/python.py +2 -17
  67. pyinfra/operations/selinux.py +5 -28
  68. pyinfra/operations/server.py +59 -84
  69. pyinfra/operations/snap.py +1 -3
  70. pyinfra/operations/ssh.py +8 -23
  71. pyinfra/operations/systemd.py +7 -7
  72. pyinfra/operations/sysvinit.py +3 -12
  73. pyinfra/operations/upstart.py +4 -4
  74. pyinfra/operations/util/__init__.py +12 -0
  75. pyinfra/operations/util/files.py +2 -2
  76. pyinfra/operations/util/packaging.py +6 -24
  77. pyinfra/operations/util/service.py +18 -37
  78. pyinfra/operations/vzctl.py +2 -2
  79. pyinfra/operations/xbps.py +3 -3
  80. pyinfra/operations/yum.py +4 -4
  81. pyinfra/operations/zypper.py +4 -4
  82. {pyinfra-2.9.2.dist-info → pyinfra-3.0b1.dist-info}/METADATA +19 -22
  83. pyinfra-3.0b1.dist-info/RECORD +163 -0
  84. pyinfra-3.0b1.dist-info/entry_points.txt +11 -0
  85. pyinfra_cli/__main__.py +2 -0
  86. pyinfra_cli/commands.py +7 -2
  87. pyinfra_cli/exceptions.py +83 -42
  88. pyinfra_cli/inventory.py +19 -4
  89. pyinfra_cli/log.py +17 -3
  90. pyinfra_cli/main.py +133 -90
  91. pyinfra_cli/prints.py +93 -129
  92. pyinfra_cli/util.py +60 -29
  93. tests/test_api/test_api.py +2 -0
  94. tests/test_api/test_api_arguments.py +13 -13
  95. tests/test_api/test_api_deploys.py +28 -29
  96. tests/test_api/test_api_facts.py +60 -98
  97. tests/test_api/test_api_operations.py +100 -200
  98. tests/test_cli/test_cli.py +18 -49
  99. tests/test_cli/test_cli_deploy.py +11 -37
  100. tests/test_cli/test_cli_exceptions.py +50 -19
  101. tests/test_cli/util.py +1 -1
  102. tests/test_connectors/test_chroot.py +6 -6
  103. tests/test_connectors/test_docker.py +4 -4
  104. tests/test_connectors/test_dockerssh.py +38 -50
  105. tests/test_connectors/test_local.py +11 -12
  106. tests/test_connectors/test_ssh.py +66 -107
  107. tests/test_connectors/test_terraform.py +9 -15
  108. tests/test_connectors/test_util.py +24 -46
  109. tests/test_connectors/test_vagrant.py +4 -4
  110. pyinfra/api/operation.pyi +0 -117
  111. pyinfra/connectors/ansible.py +0 -171
  112. pyinfra/connectors/mech.py +0 -186
  113. pyinfra/connectors/pyinfrawinrmsession/__init__.py +0 -28
  114. pyinfra/connectors/winrm.py +0 -320
  115. pyinfra/facts/windows.py +0 -366
  116. pyinfra/facts/windows_files.py +0 -90
  117. pyinfra/operations/windows.py +0 -59
  118. pyinfra/operations/windows_files.py +0 -551
  119. pyinfra-2.9.2.dist-info/RECORD +0 -170
  120. pyinfra-2.9.2.dist-info/entry_points.txt +0 -14
  121. tests/test_connectors/test_ansible.py +0 -64
  122. tests/test_connectors/test_mech.py +0 -126
  123. tests/test_connectors/test_winrm.py +0 -76
  124. {pyinfra-2.9.2.dist-info → pyinfra-3.0b1.dist-info}/LICENSE.md +0 -0
  125. {pyinfra-2.9.2.dist-info → pyinfra-3.0b1.dist-info}/WHEEL +0 -0
  126. {pyinfra-2.9.2.dist-info → pyinfra-3.0b1.dist-info}/top_level.txt +0 -0
pyinfra/connectors/ssh.py CHANGED
@@ -1,711 +1,624 @@
1
- """
2
- Connect to hosts over SSH. This is the default connector and all targets default
3
- to this meaning you do not need to specify it - ie the following two commands
4
- are identical:
1
+ from __future__ import annotations
5
2
 
6
- .. code:: shell
7
-
8
- pyinfra my-host.net ...
9
- pyinfra @ssh/my-host.net ...
10
- """
11
3
  import shlex
12
4
  from distutils.spawn import find_executable
13
- from getpass import getpass
14
- from os import path
15
5
  from random import uniform
16
6
  from socket import error as socket_error, gaierror
17
7
  from time import sleep
18
- from typing import TYPE_CHECKING, Type, Union
8
+ from typing import TYPE_CHECKING, Any, Iterable, Optional, Tuple
19
9
 
20
10
  import click
21
- from paramiko import (
22
- AuthenticationException,
23
- BadHostKeyException,
24
- DSSKey,
25
- ECDSAKey,
26
- Ed25519Key,
27
- PasswordRequiredException,
28
- RSAKey,
29
- SFTPClient,
30
- SSHException,
31
- )
11
+ from paramiko import AuthenticationException, BadHostKeyException, SFTPClient, SSHException
12
+ from typing_extensions import TypedDict, Unpack
32
13
 
33
- import pyinfra
34
14
  from pyinfra import logger
35
15
  from pyinfra.api.command import QuoteString, StringCommand
36
- from pyinfra.api.connectors import BaseConnectorMeta
37
- from pyinfra.api.exceptions import ConnectError, PyinfraError
16
+ from pyinfra.api.exceptions import ConnectError
38
17
  from pyinfra.api.util import get_file_io, memoize
39
18
 
19
+ from .base import BaseConnector, DataMeta
20
+ from .ssh_util import get_private_key, raise_connect_error
40
21
  from .sshuserclient import SSHClient
41
22
  from .util import (
23
+ CommandOutput,
42
24
  execute_command_with_sudo_retry,
43
25
  make_unix_command_for_host,
44
- read_buffers_into_queue,
26
+ read_output_buffers,
45
27
  run_local_process,
46
- split_combined_output,
47
28
  write_stdin,
48
29
  )
49
30
 
50
31
  if TYPE_CHECKING:
51
- from pyinfra.api.host import Host
52
- from pyinfra.api.state import State
32
+ from pyinfra.api.arguments import ConnectorArguments
33
+
34
+
35
+ class ConnectorData(TypedDict):
36
+ ssh_hostname: str
37
+ ssh_port: int
38
+ ssh_user: str
39
+ ssh_password: str
40
+ ssh_key: str
41
+ ssh_key_password: str
42
+
43
+ ssh_allow_agent: bool
44
+ ssh_look_for_keys: bool
45
+ ssh_forward_agent: bool
46
+
47
+ ssh_config_file: str
48
+ ssh_known_hosts_file: str
49
+ ssh_strict_host_key_checking: str
50
+
51
+ ssh_paramiko_connect_kwargs: dict
52
+
53
+ ssh_connect_retries: int
54
+ ssh_connect_retry_min_delay: float
55
+ ssh_connect_retry_max_delay: float
56
+
57
+
58
+ connector_data_meta: dict[str, DataMeta] = {
59
+ "ssh_hostname": DataMeta("SSH hostname"),
60
+ "ssh_port": DataMeta("SSH port"),
61
+ "ssh_user": DataMeta("SSH user"),
62
+ "ssh_password": DataMeta("SSH password"),
63
+ "ssh_key": DataMeta("SSH key filename"),
64
+ "ssh_key_password": DataMeta("SSH key password"),
65
+ "ssh_allow_agent": DataMeta(
66
+ "Whether to use any active SSH agent",
67
+ True,
68
+ ),
69
+ "ssh_look_for_keys": DataMeta(
70
+ "Whether to look for private keys",
71
+ True,
72
+ ),
73
+ "ssh_forward_agent": DataMeta(
74
+ "Whether to enable SSH forward agent",
75
+ False,
76
+ ),
77
+ "ssh_config_file": DataMeta("SSH config filename"),
78
+ "ssh_known_hosts_file": DataMeta("SSH known_hosts filename"),
79
+ "ssh_strict_host_key_checking": DataMeta(
80
+ "SSH strict host key checking",
81
+ "accept-new",
82
+ ),
83
+ "ssh_paramiko_connect_kwargs": DataMeta(
84
+ "Override keyword arguments passed into Paramiko's ``SSHClient.connect``"
85
+ ),
86
+ "ssh_connect_retries": DataMeta("Number of tries to connect via ssh", 0),
87
+ "ssh_connect_retry_min_delay": DataMeta(
88
+ "Lower bound for random delay between retries",
89
+ 0.1,
90
+ ),
91
+ "ssh_connect_retry_max_delay": DataMeta(
92
+ "Upper bound for random delay between retries",
93
+ 0.5,
94
+ ),
95
+ }
96
+
97
+
98
+ class SSHConnector(BaseConnector):
99
+ """
100
+ Connect to hosts over SSH. This is the default connector and all targets default
101
+ to this meaning you do not need to specify it - ie the following two commands
102
+ are identical:
53
103
 
104
+ .. code:: shell
54
105
 
55
- class Meta(BaseConnectorMeta):
56
- handles_execution = True
57
- keys_prefix = "ssh"
106
+ pyinfra my-host.net ...
107
+ pyinfra @ssh/my-host.net ...
108
+ """
58
109
 
59
- class DataKeys:
60
- hostname = "SSH hostname"
61
- port = "SSH port"
110
+ __examples_doc__ = """
111
+ An inventory file (``inventory.py``) containing a single SSH target with SSH
112
+ forward agent enabled:
62
113
 
63
- user = "User to SSH as"
64
- password = "Password to use for authentication"
65
- key = "Key file to use for authentication"
66
- key_password = "Key file password"
114
+ .. code:: python
67
115
 
68
- allow_agent = "Allow using SSH agent"
69
- look_for_keys = "Allow looking up users keys"
116
+ hosts = [
117
+ ("my-host.net", {"ssh_forward_agent": True}),
118
+ ]
70
119
 
71
- forward_agent = "Enable SSH forward agent"
72
- config_file = "Custom SSH config file"
73
- known_hosts_file = "Custom SSH known hosts file"
74
- strict_host_key_checking = "Override strict host keys check setting"
120
+ Multiple hosts sharing the same SSH username:
75
121
 
76
- connect_retries = "Number of tries to connect via ssh"
77
- connect_retry_min_delay = "Lower bound for random delay between retries"
78
- connect_retry_max_delay = "Upper bound for random delay between retries"
122
+ .. code:: python
79
123
 
80
- paramiko_connect_kwargs = (
81
- "Override keyword arguments passed into paramiko's `SSHClient.connect`"
124
+ hosts = (
125
+ [
126
+ "my-host-1.net",
127
+ "my-host-2.net",
128
+ ],
129
+ {
130
+ "ssh_username": "ssh-user",
131
+ },
82
132
  )
133
+ """
83
134
 
135
+ handles_execution = True
84
136
 
85
- DATA_KEYS = Meta.keys()
86
-
87
-
88
- def make_names_data(hostname):
89
- yield "@ssh/{0}".format(hostname), {DATA_KEYS.hostname: hostname}, []
90
-
91
-
92
- def _raise_connect_error(host: "Host", message, data):
93
- message = "{0} ({1})".format(message, data)
94
- raise ConnectError(message)
95
-
96
-
97
- def _load_private_key_file(filename: str, key_filename: str, key_password: str):
98
- exception: Union[PyinfraError, SSHException] = PyinfraError("Invalid key: {0}".format(filename))
99
-
100
- key_cls: Union[Type[RSAKey], Type[DSSKey], Type[ECDSAKey], Type[Ed25519Key]]
101
-
102
- for key_cls in (RSAKey, DSSKey, ECDSAKey, Ed25519Key):
103
- try:
104
- return key_cls.from_private_key_file(
105
- filename=filename,
137
+ data_cls = ConnectorData
138
+ data_meta = connector_data_meta
139
+ data: ConnectorData
140
+
141
+ client: Optional[SSHClient] = None
142
+
143
+ @staticmethod
144
+ def make_names_data(name):
145
+ yield "@ssh/{0}".format(name), {"ssh_hostname": name}, []
146
+
147
+ def make_paramiko_kwargs(self) -> dict[str, Any]:
148
+ kwargs = {
149
+ "allow_agent": False,
150
+ "look_for_keys": False,
151
+ "hostname": self.data["ssh_hostname"] or self.host.name,
152
+ # Overrides of SSH config via pyinfra host data
153
+ "_pyinfra_ssh_forward_agent": self.data["ssh_forward_agent"],
154
+ "_pyinfra_ssh_config_file": self.data["ssh_config_file"],
155
+ "_pyinfra_ssh_known_hosts_file": self.data["ssh_known_hosts_file"],
156
+ "_pyinfra_ssh_strict_host_key_checking": self.data["ssh_strict_host_key_checking"],
157
+ "_pyinfra_ssh_paramiko_connect_kwargs": self.data["ssh_paramiko_connect_kwargs"],
158
+ }
159
+
160
+ for key, value in (
161
+ ("username", self.data["ssh_user"]),
162
+ ("port", int(self.data["ssh_port"] or 0)),
163
+ ("timeout", self.state.config.CONNECT_TIMEOUT),
164
+ ):
165
+ if value:
166
+ kwargs[key] = value
167
+
168
+ # Password auth (boo!)
169
+ ssh_password = self.data["ssh_password"]
170
+ if ssh_password:
171
+ kwargs["password"] = ssh_password
172
+
173
+ # Key auth!
174
+ ssh_key = self.data["ssh_key"]
175
+ if ssh_key:
176
+ kwargs["pkey"] = get_private_key(
177
+ self.state,
178
+ key_filename=ssh_key,
179
+ key_password=self.data["ssh_key_password"],
106
180
  )
107
181
 
108
- except PasswordRequiredException:
109
- if not key_password:
110
- # If password is not provided, but we're in CLI mode, ask for it. I'm not a
111
- # huge fan of having CLI specific code in here, but it doesn't really fit
112
- # anywhere else without duplicating lots of key related code into cli.py.
113
- if pyinfra.is_cli:
114
- key_password = getpass(
115
- "Enter password for private key: {0}: ".format(
116
- key_filename,
117
- ),
118
- )
119
-
120
- # API mode and no password? We can't continue!
121
- else:
122
- raise PyinfraError(
123
- "Private key file ({0}) is encrypted, set ssh_key_password to "
124
- "use this key".format(key_filename),
125
- )
126
-
127
- try:
128
- return key_cls.from_private_key_file(
129
- filename=filename,
130
- password=key_password,
131
- )
132
- except SSHException as e: # key does not match key_cls type
133
- exception = e
134
- except SSHException as e: # key does not match key_cls type
135
- exception = e
136
- raise exception
137
-
138
-
139
- def _get_private_key(state: "State", key_filename: str, key_password: str):
140
- if key_filename in state.private_keys:
141
- return state.private_keys[key_filename]
142
-
143
- ssh_key_filenames = [
144
- # Global from executed directory
145
- path.expanduser(key_filename),
146
- ]
147
-
148
- if state.cwd:
149
- # Relative to the CWD
150
- path.join(state.cwd, key_filename)
151
-
152
- key = None
153
- key_file_exists = False
182
+ # No key or password, so let's have paramiko look for SSH agents and user keys
183
+ # unless disabled by the user.
184
+ else:
185
+ kwargs["allow_agent"] = self.data["ssh_allow_agent"]
186
+ kwargs["look_for_keys"] = self.data["ssh_look_for_keys"]
154
187
 
155
- for filename in ssh_key_filenames:
156
- if not path.isfile(filename):
157
- continue
188
+ return kwargs
158
189
 
159
- key_file_exists = True
190
+ def connect(self) -> None:
191
+ retries = self.data["ssh_connect_retries"]
160
192
 
161
193
  try:
162
- key = _load_private_key_file(filename, key_filename, key_password)
163
- break
164
- except SSHException:
165
- pass
166
-
167
- # No break, so no key found
168
- if not key:
169
- if not key_file_exists:
170
- raise PyinfraError("No such private key file: {0}".format(key_filename))
171
- raise PyinfraError("Invalid private key file: {0}".format(key_filename))
172
-
173
- # Load any certificate, names from OpenSSH:
174
- # https://github.com/openssh/openssh-portable/blob/049297de975b92adcc2db77e3fb7046c0e3c695d/ssh-keygen.c#L2453 # noqa: E501
175
- for certificate_filename in (
176
- "{0}-cert.pub".format(key_filename),
177
- "{0}.pub".format(key_filename),
178
- ):
179
- if path.isfile(certificate_filename):
180
- key.load_certificate(certificate_filename)
181
-
182
- state.private_keys[key_filename] = key
183
- return key
184
-
185
-
186
- def _make_paramiko_kwargs(state: "State", host: "Host"):
187
- kwargs = {
188
- "allow_agent": False,
189
- "look_for_keys": False,
190
- "hostname": host.data.get(DATA_KEYS.hostname, host.name),
191
- # Overrides of SSH config via pyinfra host data
192
- "_pyinfra_ssh_forward_agent": host.data.get(DATA_KEYS.forward_agent),
193
- "_pyinfra_ssh_config_file": host.data.get(DATA_KEYS.config_file),
194
- "_pyinfra_ssh_known_hosts_file": host.data.get(DATA_KEYS.known_hosts_file),
195
- "_pyinfra_ssh_strict_host_key_checking": host.data.get(DATA_KEYS.strict_host_key_checking),
196
- "_pyinfra_ssh_paramiko_connect_kwargs": host.data.get(DATA_KEYS.paramiko_connect_kwargs),
197
- }
198
-
199
- for key, value in (
200
- ("username", host.data.get(DATA_KEYS.user)),
201
- ("port", int(host.data.get(DATA_KEYS.port, 0))),
202
- ("timeout", state.config.CONNECT_TIMEOUT),
203
- ):
204
- if value:
205
- kwargs[key] = value
206
-
207
- # Password auth (boo!)
208
- ssh_password = host.data.get(DATA_KEYS.password)
209
- if ssh_password:
210
- kwargs["password"] = ssh_password
211
-
212
- # Key auth!
213
- ssh_key = host.data.get(DATA_KEYS.key)
214
- if ssh_key:
215
- kwargs["pkey"] = _get_private_key(
216
- state,
217
- key_filename=ssh_key,
218
- key_password=host.data.get(DATA_KEYS.key_password),
219
- )
194
+ while True:
195
+ try:
196
+ return self._connect()
197
+ except (SSHException, gaierror, socket_error, EOFError):
198
+ if retries == 0:
199
+ raise
200
+ retries -= 1
201
+ min_delay = self.data["ssh_connect_retry_min_delay"]
202
+ max_delay = self.data["ssh_connect_retry_max_delay"]
203
+ sleep(uniform(min_delay, max_delay))
204
+ except SSHException as e:
205
+ raise_connect_error(self.host, "SSH error", e)
206
+ except gaierror as e:
207
+ raise_connect_error(self.host, "Could not resolve hostname", e)
208
+ except socket_error as e:
209
+ raise_connect_error(self.host, "Could not connect", e)
210
+ except EOFError as e:
211
+ raise_connect_error(self.host, "EOF error", e)
212
+
213
+ def _connect(self) -> None:
214
+ """
215
+ Connect to a single host. Returns the SSH client if successful. Stateless by
216
+ design so can be run in parallel.
217
+ """
218
+
219
+ kwargs = self.make_paramiko_kwargs()
220
+ hostname = kwargs.pop("hostname")
221
+ logger.debug("Connecting to: %s (%r)", hostname, kwargs)
222
+
223
+ self.client = SSHClient()
220
224
 
221
- # No key or password, so let's have paramiko look for SSH agents and user keys
222
- # unless disabled by the user.
223
- else:
224
- kwargs["allow_agent"] = host.data.get(DATA_KEYS.allow_agent, True)
225
- kwargs["look_for_keys"] = host.data.get(DATA_KEYS.look_for_keys, True)
226
-
227
- return kwargs
225
+ try:
226
+ self.client.connect(hostname, **kwargs)
227
+ except AuthenticationException as e:
228
+ auth_kwargs = {}
228
229
 
230
+ for key, value in kwargs.items():
231
+ if key in ("username", "password"):
232
+ auth_kwargs[key] = value
233
+ continue
229
234
 
230
- def connect(state: "State", host: "Host"):
231
- """
232
- Connect to a single host. Returns the SSH client if successful. Stateless by
233
- design so can be run in parallel.
234
- """
235
- retries = host.data.get(DATA_KEYS.connect_retries, 0)
235
+ if key == "pkey" and value:
236
+ auth_kwargs["key"] = self.data["ssh_key"]
236
237
 
237
- for tries_left in range(retries, -1, -1):
238
- con = _connect(state, host, tries_left)
239
- if con:
240
- return con
238
+ auth_args = ", ".join(
239
+ "{0}={1}".format(key, value) for key, value in auth_kwargs.items()
240
+ )
241
241
 
242
+ raise_connect_error(self.host, "Authentication error ({0})".format(auth_args), e)
242
243
 
243
- def _connect(state: "State", host: "Host", tries_left: int):
244
- kwargs = _make_paramiko_kwargs(state, host)
245
- logger.debug("Connecting to: %s (%r)", host.name, kwargs)
244
+ except BadHostKeyException as e:
245
+ remove_entry = e.hostname
246
+ port = self.client._ssh_config.get("port", 22)
247
+ if port != 22:
248
+ remove_entry = f"[{e.hostname}]:{port}"
246
249
 
247
- hostname = kwargs.pop("hostname")
250
+ logger.warning("WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!")
251
+ logger.warning(
252
+ ("Someone could be eavesdropping on you right now " "(man-in-the-middle attack)!"),
253
+ )
254
+ logger.warning("If this is expected, you can remove the bad key using:")
255
+ logger.warning(f" ssh-keygen -R {remove_entry}")
248
256
 
249
- try:
250
- # Create new client & connect to the host
251
- client = SSHClient()
252
- client.connect(hostname, **kwargs)
253
- return client
257
+ raise_connect_error(
258
+ self.host,
259
+ "SSH host key error",
260
+ f"Host key for {e.hostname} does not match.",
261
+ )
254
262
 
255
- except AuthenticationException as e:
256
- auth_kwargs = {}
263
+ def run_shell_command(
264
+ self,
265
+ command: StringCommand,
266
+ print_output: bool = False,
267
+ print_input: bool = False,
268
+ **arguments: Unpack["ConnectorArguments"],
269
+ ) -> Tuple[bool, CommandOutput]:
270
+ """
271
+ Execute a command on the specified host.
272
+
273
+ Args:
274
+ state (``pyinfra.api.State`` obj): state object for this command
275
+ hostname (string): hostname of the target
276
+ command (string): actual command to execute
277
+ sudo (boolean): whether to wrap the command with sudo
278
+ sudo_user (string): user to sudo to
279
+ get_pty (boolean): whether to get a PTY before executing the command
280
+ env (dict): environment variables to set
281
+ timeout (int): timeout for this command to complete before erroring
282
+
283
+ Returns:
284
+ tuple: (exit_code, stdout, stderr)
285
+ stdout and stderr are both lists of strings from each buffer.
286
+ """
287
+
288
+ _get_pty = arguments.pop("_get_pty", False)
289
+ _timeout = arguments.pop("_timeout", None)
290
+ _stdin = arguments.pop("_stdin", None)
291
+ _success_exit_codes = arguments.pop("_success_exit_codes", None)
292
+
293
+ def execute_command() -> Tuple[int, CommandOutput]:
294
+ unix_command = make_unix_command_for_host(self.state, self.host, command, **arguments)
295
+ actual_command = unix_command.get_raw_value()
296
+
297
+ logger.debug(
298
+ "Running command on %s: (pty=%s) %s",
299
+ self.host.name,
300
+ _get_pty,
301
+ unix_command,
302
+ )
257
303
 
258
- for key, value in kwargs.items():
259
- if key in ("username", "password"):
260
- auth_kwargs[key] = value
261
- continue
304
+ if print_input:
305
+ click.echo("{0}>>> {1}".format(self.host.print_prefix, unix_command), err=True)
262
306
 
263
- if key == "pkey" and value:
264
- auth_kwargs["key"] = host.data.get(DATA_KEYS.key)
307
+ # Run it! Get stdout, stderr & the underlying channel
308
+ assert self.client is not None
309
+ stdin_buffer, stdout_buffer, stderr_buffer = self.client.exec_command(
310
+ actual_command,
311
+ get_pty=_get_pty,
312
+ )
265
313
 
266
- auth_args = ", ".join("{0}={1}".format(key, value) for key, value in auth_kwargs.items())
314
+ if _stdin:
315
+ write_stdin(_stdin, stdin_buffer)
267
316
 
268
- _raise_connect_error(host, "Authentication error ({0})".format(auth_args), e)
317
+ combined_output = read_output_buffers(
318
+ stdout_buffer,
319
+ stderr_buffer,
320
+ timeout=_timeout,
321
+ print_output=print_output,
322
+ print_prefix=self.host.print_prefix,
323
+ )
269
324
 
270
- except BadHostKeyException as e:
271
- remove_entry = e.hostname
272
- port = client._ssh_config.get("port", 22)
273
- if port != 22:
274
- remove_entry = f"[{e.hostname}]:{port}"
325
+ logger.debug("Waiting for exit status...")
326
+ exit_status = stdout_buffer.channel.recv_exit_status()
327
+ logger.debug("Command exit status: %i", exit_status)
275
328
 
276
- logger.warning("WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!")
277
- logger.warning(
278
- ("Someone could be eavesdropping on you right now " "(man-in-the-middle attack)!"),
279
- )
280
- logger.warning("If this is expected, you can remove the bad key using:")
281
- logger.warning(f" ssh-keygen -R {remove_entry}")
329
+ return exit_status, combined_output
282
330
 
283
- _raise_connect_error(
284
- host,
285
- "SSH host key error",
286
- f"Host key for {e.hostname} does not match.",
331
+ return_code, combined_output = execute_command_with_sudo_retry(
332
+ self.host,
333
+ arguments,
334
+ execute_command,
287
335
  )
288
336
 
289
- except SSHException as e:
290
- if tries_left == 0:
291
- _raise_connect_error(host, "SSH error", e)
292
-
293
- except gaierror:
294
- if tries_left == 0:
295
- _raise_connect_error(host, "Could not resolve hostname", hostname)
296
-
297
- except socket_error as e:
298
- if tries_left == 0:
299
- _raise_connect_error(host, "Could not connect", e)
300
-
301
- except EOFError as e:
302
- if tries_left == 0:
303
- _raise_connect_error(host, "EOF error", e)
304
-
305
- min_delay = host.data.get(DATA_KEYS.connect_retry_min_delay, 0.1)
306
- max_delay = host.data.get(DATA_KEYS.connect_retry_max_delay, 0.5)
307
- sleep(uniform(min_delay, max_delay))
308
-
309
- return None
310
-
311
-
312
- def run_shell_command(
313
- state: "State",
314
- host: "Host",
315
- command,
316
- get_pty: bool = False,
317
- timeout=None,
318
- stdin=None,
319
- success_exit_codes=None,
320
- print_output: bool = False,
321
- print_input: bool = False,
322
- return_combined_output: bool = False,
323
- **command_kwargs,
324
- ):
325
- """
326
- Execute a command on the specified host.
327
-
328
- Args:
329
- state (``pyinfra.api.State`` obj): state object for this command
330
- hostname (string): hostname of the target
331
- command (string): actual command to execute
332
- sudo (boolean): whether to wrap the command with sudo
333
- sudo_user (string): user to sudo to
334
- get_pty (boolean): whether to get a PTY before executing the command
335
- env (dict): environment variables to set
336
- timeout (int): timeout for this command to complete before erroring
337
-
338
- Returns:
339
- tuple: (exit_code, stdout, stderr)
340
- stdout and stderr are both lists of strings from each buffer.
341
- """
342
-
343
- def execute_command():
344
- unix_command = make_unix_command_for_host(state, host, command, **command_kwargs)
345
- actual_command = unix_command.get_raw_value()
337
+ if _success_exit_codes:
338
+ status = return_code in _success_exit_codes
339
+ else:
340
+ status = return_code == 0
346
341
 
347
- logger.debug(
348
- "Running command on %s: (pty=%s) %s",
349
- host.name,
350
- get_pty,
351
- unix_command,
352
- )
353
-
354
- if print_input:
355
- click.echo("{0}>>> {1}".format(host.print_prefix, unix_command), err=True)
342
+ return status, combined_output
356
343
 
357
- # Run it! Get stdout, stderr & the underlying channel
358
- stdin_buffer, stdout_buffer, stderr_buffer = host.connection.exec_command(
359
- actual_command,
360
- get_pty=get_pty,
361
- )
344
+ @memoize
345
+ def get_sftp_connection(self):
346
+ assert self.client is not None
347
+ transport = self.client.get_transport()
348
+ assert transport is not None, "No transport"
349
+ try:
350
+ return SFTPClient.from_transport(transport)
351
+ except SSHException as e:
352
+ raise ConnectError(
353
+ (
354
+ "Unable to establish SFTP connection. Check that the SFTP subsystem "
355
+ "for the SSH service at {0} is enabled."
356
+ ).format(self.host),
357
+ ) from e
358
+
359
+ def _get_file(self, remote_filename: str, filename_or_io):
360
+ with get_file_io(filename_or_io, "wb") as file_io:
361
+ sftp = self.get_sftp_connection()
362
+ sftp.getfo(remote_filename, file_io)
363
+
364
+ def get_file(
365
+ self,
366
+ remote_filename: str,
367
+ filename_or_io,
368
+ remote_temp_filename=None,
369
+ print_output: bool = False,
370
+ print_input: bool = False,
371
+ **arguments: Unpack["ConnectorArguments"],
372
+ ) -> bool:
373
+ """
374
+ Download a file from the remote host using SFTP. Supports download files
375
+ with sudo by copying to a temporary directory with read permissions,
376
+ downloading and then removing the copy.
377
+ """
378
+
379
+ _sudo = arguments.get("_sudo", False)
380
+ _su_user = arguments.get("_su_user", None)
381
+
382
+ if _sudo or _su_user:
383
+ # Get temp file location
384
+ temp_file = remote_temp_filename or self.host.get_temp_filename(remote_filename)
385
+
386
+ # Copy the file to the tempfile location and add read permissions
387
+ command = StringCommand(
388
+ "cp", remote_filename, temp_file, "&&", "chmod", "+r", temp_file
389
+ )
362
390
 
363
- if stdin:
364
- write_stdin(stdin, stdin_buffer)
391
+ copy_status, output = self.run_shell_command(
392
+ command,
393
+ print_output=print_output,
394
+ print_input=print_input,
395
+ **arguments,
396
+ )
365
397
 
366
- combined_output = read_buffers_into_queue(
367
- stdout_buffer,
368
- stderr_buffer,
369
- timeout=timeout,
370
- print_output=print_output,
371
- print_prefix=host.print_prefix,
372
- )
398
+ if copy_status is False:
399
+ logger.error("File download copy temp error: {0}".format(output.stderr))
400
+ return False
373
401
 
374
- logger.debug("Waiting for exit status...")
375
- exit_status = stdout_buffer.channel.recv_exit_status()
376
- logger.debug("Command exit status: %i", exit_status)
402
+ try:
403
+ self._get_file(temp_file, filename_or_io)
404
+
405
+ # Ensure that, even if we encounter an error, we (attempt to) remove the
406
+ # temporary copy of the file.
407
+ finally:
408
+ remove_status, output = self.run_shell_command(
409
+ StringCommand("rm", "-f", temp_file),
410
+ print_output=print_output,
411
+ print_input=print_input,
412
+ **arguments,
413
+ )
377
414
 
378
- return exit_status, combined_output
415
+ if remove_status is False:
416
+ logger.error("File download remove temp error: {0}".format(output.stderr))
417
+ return False
379
418
 
380
- return_code, combined_output = execute_command_with_sudo_retry(
381
- host,
382
- command_kwargs,
383
- execute_command,
384
- )
419
+ else:
420
+ self._get_file(remote_filename, filename_or_io)
385
421
 
386
- if success_exit_codes:
387
- status = return_code in success_exit_codes
388
- else:
389
- status = return_code == 0
422
+ if print_output:
423
+ click.echo(
424
+ "{0}file downloaded: {1}".format(self.host.print_prefix, remote_filename),
425
+ err=True,
426
+ )
390
427
 
391
- if return_combined_output:
392
- return status, combined_output
428
+ return True
393
429
 
394
- stdout, stderr = split_combined_output(combined_output)
395
- return status, stdout, stderr
396
-
397
-
398
- @memoize
399
- def _get_sftp_connection(host: "Host"):
400
- assert host.connection is not None
401
- transport = host.connection.get_transport()
402
-
403
- try:
404
- return SFTPClient.from_transport(transport)
405
- except SSHException as e:
406
- raise ConnectError(
407
- (
408
- "Unable to establish SFTP connection. Check that the SFTP subsystem "
409
- "for the SSH service at {0} is enabled."
410
- ).format(host),
411
- ) from e
412
-
413
-
414
- def _get_file(host: "Host", remote_filename: str, filename_or_io):
415
- with get_file_io(filename_or_io, "wb") as file_io:
416
- sftp = _get_sftp_connection(host)
417
- sftp.getfo(remote_filename, file_io)
418
-
419
-
420
- def get_file(
421
- state: "State",
422
- host: "Host",
423
- remote_filename: str,
424
- filename_or_io,
425
- remote_temp_filename=None,
426
- sudo: bool = False,
427
- sudo_user=None,
428
- su_user=None,
429
- print_output: bool = False,
430
- print_input: bool = False,
431
- **command_kwargs,
432
- ):
433
- """
434
- Download a file from the remote host using SFTP. Supports download files
435
- with sudo by copying to a temporary directory with read permissions,
436
- downloading and then removing the copy.
437
- """
430
+ def _put_file(self, filename_or_io, remote_location):
431
+ logger.debug("Attempting upload of %s to %s", filename_or_io, remote_location)
438
432
 
439
- if sudo or su_user:
440
- # Get temp file location
441
- temp_file = remote_temp_filename or state.get_temp_filename(remote_filename)
433
+ attempts = 0
434
+ last_e = None
442
435
 
443
- # Copy the file to the tempfile location and add read permissions
444
- command = "cp {0} {1} && chmod +r {0}".format(remote_filename, temp_file)
436
+ while attempts < 3:
437
+ try:
438
+ with get_file_io(filename_or_io) as file_io:
439
+ sftp = self.get_sftp_connection()
440
+ sftp.putfo(file_io, remote_location)
441
+ return
442
+ except OSError as e:
443
+ logger.warning(f"Failed to upload file, retrying: {e}")
444
+ attempts += 1
445
+ last_e = e
446
+
447
+ if last_e is not None:
448
+ raise last_e
449
+
450
+ def put_file(
451
+ self,
452
+ filename_or_io,
453
+ remote_filename,
454
+ remote_temp_filename=None,
455
+ print_output: bool = False,
456
+ print_input: bool = False,
457
+ **arguments: Unpack["ConnectorArguments"],
458
+ ) -> bool:
459
+ """
460
+ Upload file-ios to the specified host using SFTP. Supports uploading files
461
+ with sudo by uploading to a temporary directory then moving & chowning.
462
+ """
463
+
464
+ original_arguments = arguments.copy()
465
+
466
+ _sudo = arguments.pop("_sudo", False)
467
+ _sudo_user = arguments.pop("_sudo_user", False)
468
+ _doas = arguments.pop("_doas", False)
469
+ _doas_user = arguments.pop("_doas_user", False)
470
+ _su_user = arguments.pop("_su_user", None)
471
+
472
+ # sudo/su are a little more complicated, as you can only sftp with the SSH
473
+ # user connected, so upload to tmp and copy/chown w/sudo and/or su_user
474
+ if _sudo or _doas or _su_user:
475
+ # Get temp file location
476
+ temp_file = remote_temp_filename or self.host.get_temp_filename(remote_filename)
477
+ self._put_file(filename_or_io, temp_file)
478
+
479
+ # Make sure our sudo/su user can access the file
480
+ other_user = _su_user or _sudo_user or _doas_user
481
+ if other_user:
482
+ status, output = self.run_shell_command(
483
+ StringCommand("setfacl", "-m", f"u:{other_user}:r", temp_file),
484
+ print_output=print_output,
485
+ print_input=print_input,
486
+ **arguments,
487
+ )
445
488
 
446
- copy_status, _, stderr = run_shell_command(
447
- state,
448
- host,
449
- command,
450
- sudo=sudo,
451
- sudo_user=sudo_user,
452
- su_user=su_user,
453
- print_output=print_output,
454
- print_input=print_input,
455
- **command_kwargs,
456
- )
489
+ if status is False:
490
+ logger.error("Error on handover to sudo/su user: {0}".format(output.stderr))
491
+ return False
457
492
 
458
- if copy_status is False:
459
- logger.error("File download copy temp error: {0}".format("\n".join(stderr)))
460
- return False
493
+ # Execute run_shell_command w/sudo, etc
494
+ command = StringCommand("cp", temp_file, QuoteString(remote_filename))
461
495
 
462
- try:
463
- _get_file(host, temp_file, filename_or_io)
464
-
465
- # Ensure that, even if we encounter an error, we (attempt to) remove the
466
- # temporary copy of the file.
467
- finally:
468
- remove_status, _, stderr = run_shell_command(
469
- state,
470
- host,
471
- "rm -f {0}".format(temp_file),
472
- sudo=sudo,
473
- sudo_user=sudo_user,
474
- su_user=su_user,
496
+ status, output = self.run_shell_command(
497
+ command,
475
498
  print_output=print_output,
476
499
  print_input=print_input,
477
- **command_kwargs,
500
+ **original_arguments,
478
501
  )
479
502
 
480
- if remove_status is False:
481
- logger.error("File download remove temp error: {0}".format("\n".join(stderr)))
482
- return False
483
-
484
- else:
485
- _get_file(host, remote_filename, filename_or_io)
486
-
487
- if print_output:
488
- click.echo(
489
- "{0}file downloaded: {1}".format(host.print_prefix, remote_filename),
490
- err=True,
491
- )
492
-
493
- return True
494
-
495
-
496
- def _put_file(host: "Host", filename_or_io, remote_location):
497
- logger.debug("Attempting upload of %s to %s", filename_or_io, remote_location)
498
-
499
- attempts = 0
500
- last_e = None
503
+ if status is False:
504
+ logger.error("File upload error: {0}".format(output.stderr))
505
+ return False
501
506
 
502
- while attempts < 3:
503
- try:
504
- with get_file_io(filename_or_io) as file_io:
505
- sftp = _get_sftp_connection(host)
506
- sftp.putfo(file_io, remote_location)
507
- return
508
- except OSError as e:
509
- logger.warning(f"Failed to upload file, retrying: {e}")
510
- attempts += 1
511
- last_e = e
512
-
513
- if last_e is not None:
514
- raise last_e
515
-
516
-
517
- def put_file(
518
- state: "State",
519
- host: "Host",
520
- filename_or_io,
521
- remote_filename,
522
- remote_temp_filename=None,
523
- sudo: bool = False,
524
- sudo_user=None,
525
- doas: bool = False,
526
- doas_user=None,
527
- su_user=None,
528
- print_output: bool = False,
529
- print_input: bool = False,
530
- **command_kwargs,
531
- ):
532
- """
533
- Upload file-ios to the specified host using SFTP. Supports uploading files
534
- with sudo by uploading to a temporary directory then moving & chowning.
535
- """
507
+ # Delete the temporary file now that we've successfully copied it
508
+ command = StringCommand("rm", "-f", temp_file)
536
509
 
537
- # sudo/su are a little more complicated, as you can only sftp with the SSH
538
- # user connected, so upload to tmp and copy/chown w/sudo and/or su_user
539
- if sudo or doas or su_user:
540
- # Get temp file location
541
- temp_file = remote_temp_filename or state.get_temp_filename(remote_filename)
542
- _put_file(host, filename_or_io, temp_file)
543
-
544
- # Make sure our sudo/su user can access the file
545
- if su_user:
546
- command = StringCommand("setfacl", "-m", "u:{0}:r".format(su_user), temp_file)
547
- elif sudo_user:
548
- command = StringCommand("setfacl", "-m", "u:{0}:r".format(sudo_user), temp_file)
549
- elif doas_user:
550
- command = StringCommand("setfacl", "-m", "u:{0}:r".format(doas_user), temp_file)
551
-
552
- if su_user or sudo_user or doas_user:
553
- status, _, stderr = run_shell_command(
554
- state,
555
- host,
510
+ status, output = self.run_shell_command(
556
511
  command,
557
- sudo=False,
558
512
  print_output=print_output,
559
513
  print_input=print_input,
560
- **command_kwargs,
514
+ **arguments,
561
515
  )
562
516
 
563
517
  if status is False:
564
- logger.error("Error on handover to sudo/su user: {0}".format("\n".join(stderr)))
518
+ logger.error("Unable to remove temporary file: {0}".format(output.stderr))
565
519
  return False
566
520
 
567
- # Execute run_shell_command w/sudo and/or su_user
568
- command = StringCommand("cp", temp_file, QuoteString(remote_filename))
569
-
570
- status, _, stderr = run_shell_command(
571
- state,
572
- host,
573
- command,
574
- sudo=sudo,
575
- sudo_user=sudo_user,
576
- doas=doas,
577
- doas_user=doas_user,
578
- su_user=su_user,
579
- print_output=print_output,
580
- print_input=print_input,
581
- **command_kwargs,
582
- )
521
+ # No sudo and no su_user, so just upload it!
522
+ else:
523
+ self._put_file(filename_or_io, remote_filename)
583
524
 
584
- if status is False:
585
- logger.error("File upload error: {0}".format("\n".join(stderr)))
586
- return False
525
+ if print_output:
526
+ click.echo(
527
+ "{0}file uploaded: {1}".format(self.host.print_prefix, remote_filename),
528
+ err=True,
529
+ )
587
530
 
588
- # Delete the temporary file now that we've successfully copied it
589
- command = StringCommand("rm", "-f", temp_file)
531
+ return True
590
532
 
591
- status, _, stderr = run_shell_command(
592
- state,
593
- host,
594
- command,
595
- sudo=False,
596
- doas=False,
597
- print_output=print_output,
598
- print_input=print_input,
599
- **command_kwargs,
600
- )
533
+ def check_can_rsync(self):
534
+ if self.data["ssh_key_password"]:
535
+ raise NotImplementedError(
536
+ "Rsync does not currently work with SSH keys needing passwords."
537
+ )
538
+
539
+ if self.data["ssh_password"]:
540
+ raise NotImplementedError("Rsync does not currently work with SSH passwords.")
601
541
 
602
- if status is False:
603
- logger.error("Unable to remove temporary file: {0}".format("\n".join(stderr)))
604
- return False
542
+ if not find_executable("rsync"):
543
+ raise NotImplementedError("The `rsync` binary is not available on this system.")
605
544
 
606
- # No sudo and no su_user, so just upload it!
607
- else:
608
- _put_file(host, filename_or_io, remote_filename)
545
+ def rsync(
546
+ self,
547
+ src: str,
548
+ dest: str,
549
+ flags: Iterable[str],
550
+ print_output: bool = False,
551
+ print_input: bool = False,
552
+ **arguments: Unpack["ConnectorArguments"],
553
+ ):
554
+ _sudo = arguments.pop("_sudo", False)
555
+ _sudo_user = arguments.pop("_sudo_user", False)
556
+
557
+ hostname = self.data["ssh_hostname"] or self.host.name
558
+ user = self.data["ssh_user"]
559
+ if user:
560
+ user = "{0}@".format(user)
561
+
562
+ ssh_flags = []
563
+ # To avoid asking for interactive input, specify BatchMode=yes
564
+ ssh_flags.append("-o BatchMode=yes")
565
+
566
+ known_hosts_file = self.data["ssh_known_hosts_file"]
567
+ if known_hosts_file:
568
+ ssh_flags.append(
569
+ '-o \\"UserKnownHostsFile={0}\\"'.format(shlex.quote(known_hosts_file))
570
+ ) # never trust users
571
+
572
+ strict_host_key_checking = self.data["ssh_strict_host_key_checking"]
573
+ if strict_host_key_checking:
574
+ ssh_flags.append(
575
+ '-o \\"StrictHostKeyChecking={0}\\"'.format(shlex.quote(strict_host_key_checking))
576
+ )
609
577
 
610
- if print_output:
611
- click.echo(
612
- "{0}file uploaded: {1}".format(host.print_prefix, remote_filename),
613
- err=True,
578
+ ssh_config_file = self.data["ssh_config_file"]
579
+ if ssh_config_file:
580
+ ssh_flags.append("-F {0}".format(shlex.quote(ssh_config_file)))
581
+
582
+ port = self.data["ssh_port"]
583
+ if port:
584
+ ssh_flags.append("-p {0}".format(port))
585
+
586
+ ssh_key = self.data["ssh_key"]
587
+ if ssh_key:
588
+ ssh_flags.append("-i {0}".format(ssh_key))
589
+
590
+ remote_rsync_command = "rsync"
591
+ if _sudo:
592
+ remote_rsync_command = "sudo rsync"
593
+ if _sudo_user:
594
+ remote_rsync_command = "sudo -u {0} rsync".format(_sudo_user)
595
+
596
+ rsync_command = (
597
+ "rsync {rsync_flags} "
598
+ '--rsh "ssh {ssh_flags}" '
599
+ "--rsync-path '{remote_rsync_command}' "
600
+ "{src} {user}{hostname}:{dest}"
601
+ ).format(
602
+ rsync_flags=" ".join(flags),
603
+ ssh_flags=" ".join(ssh_flags),
604
+ remote_rsync_command=remote_rsync_command,
605
+ user=user,
606
+ hostname=hostname,
607
+ src=src,
608
+ dest=dest,
614
609
  )
615
610
 
616
- return True
617
-
618
-
619
- def check_can_rsync(host: "Host"):
620
- if host.data.get(DATA_KEYS.key_password):
621
- raise NotImplementedError("Rsync does not currently work with SSH keys needing passwords.")
622
-
623
- if host.data.get(DATA_KEYS.password):
624
- raise NotImplementedError("Rsync does not currently work with SSH passwords.")
625
-
626
- if not find_executable("rsync"):
627
- raise NotImplementedError("The `rsync` binary is not available on this system.")
628
-
629
-
630
- def rsync(
631
- state: "State",
632
- host: "Host",
633
- src: str,
634
- dest: str,
635
- flags,
636
- print_output: bool = False,
637
- print_input: bool = False,
638
- sudo: bool = False,
639
- sudo_user=None,
640
- **ignored_kwargs,
641
- ):
642
- hostname = host.data.get(DATA_KEYS.hostname, host.name)
643
- user = host.data.get(DATA_KEYS.user, "")
644
- if user:
645
- user = "{0}@".format(user)
646
-
647
- ssh_flags = []
648
- # To avoid asking for interactive input, specify BatchMode=yes
649
- ssh_flags.append("-o BatchMode=yes")
650
-
651
- known_hosts_file = host.data.get(DATA_KEYS.known_hosts_file, "")
652
- if known_hosts_file:
653
- ssh_flags.append(
654
- '-o \\"UserKnownHostsFile={0}\\"'.format(shlex.quote(known_hosts_file))
655
- ) # never trust users
656
-
657
- strict_host_key_checking = host.data.get(DATA_KEYS.strict_host_key_checking, "")
658
- if strict_host_key_checking:
659
- ssh_flags.append(
660
- '-o \\"StrictHostKeyChecking={0}\\"'.format(shlex.quote(strict_host_key_checking))
611
+ if print_input:
612
+ click.echo("{0}>>> {1}".format(self.host.print_prefix, rsync_command), err=True)
613
+
614
+ return_code, output = run_local_process(
615
+ rsync_command,
616
+ print_output=print_output,
617
+ print_prefix=self.host.print_prefix,
661
618
  )
662
619
 
663
- ssh_config_file = host.data.get(DATA_KEYS.config_file, "")
664
- if ssh_config_file:
665
- ssh_flags.append("-F {0}".format(shlex.quote(ssh_config_file)))
666
-
667
- port = host.data.get(DATA_KEYS.port)
668
- if port:
669
- ssh_flags.append("-p {0}".format(port))
670
-
671
- ssh_key = host.data.get(DATA_KEYS.key)
672
- if ssh_key:
673
- ssh_flags.append("-i {0}".format(ssh_key))
674
-
675
- remote_rsync_command = "rsync"
676
- if sudo:
677
- remote_rsync_command = "sudo rsync"
678
- if sudo_user:
679
- remote_rsync_command = "sudo -u {0} rsync".format(sudo_user)
680
-
681
- rsync_command = (
682
- "rsync {rsync_flags} "
683
- '--rsh "ssh {ssh_flags}" '
684
- "--rsync-path '{remote_rsync_command}' "
685
- "{src} {user}{hostname}:{dest}"
686
- ).format(
687
- rsync_flags=" ".join(flags),
688
- ssh_flags=" ".join(ssh_flags),
689
- remote_rsync_command=remote_rsync_command,
690
- user=user,
691
- hostname=hostname,
692
- src=src,
693
- dest=dest,
694
- )
695
-
696
- if print_input:
697
- click.echo("{0}>>> {1}".format(host.print_prefix, rsync_command), err=True)
698
-
699
- return_code, combined_output = run_local_process(
700
- rsync_command,
701
- print_output=print_output,
702
- print_prefix=host.print_prefix,
703
- )
704
-
705
- status = return_code == 0
706
-
707
- if not status:
708
- _, stderr = split_combined_output(combined_output)
709
- raise IOError("\n".join(stderr))
710
-
711
- return True
620
+ status = return_code == 0
621
+ if not status:
622
+ raise IOError(output.stderr)
623
+
624
+ return True