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