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