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