pyinfra 3.0.dev0__py2.py3-none-any.whl → 3.0.2__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 (148) hide show
  1. pyinfra/api/__init__.py +3 -0
  2. pyinfra/api/arguments.py +115 -97
  3. pyinfra/api/arguments_typed.py +80 -0
  4. pyinfra/api/command.py +5 -3
  5. pyinfra/api/config.py +139 -39
  6. pyinfra/api/connectors.py +5 -2
  7. pyinfra/api/deploy.py +19 -19
  8. pyinfra/api/exceptions.py +35 -4
  9. pyinfra/api/facts.py +62 -86
  10. pyinfra/api/host.py +102 -15
  11. pyinfra/api/inventory.py +4 -0
  12. pyinfra/api/operation.py +188 -120
  13. pyinfra/api/operations.py +66 -113
  14. pyinfra/api/state.py +53 -34
  15. pyinfra/api/util.py +64 -33
  16. pyinfra/connectors/base.py +65 -20
  17. pyinfra/connectors/chroot.py +15 -13
  18. pyinfra/connectors/docker.py +62 -72
  19. pyinfra/connectors/dockerssh.py +20 -19
  20. pyinfra/connectors/local.py +32 -22
  21. pyinfra/connectors/ssh.py +162 -86
  22. pyinfra/connectors/sshuserclient/client.py +1 -1
  23. pyinfra/connectors/terraform.py +57 -39
  24. pyinfra/connectors/util.py +26 -27
  25. pyinfra/connectors/vagrant.py +27 -26
  26. pyinfra/context.py +1 -0
  27. pyinfra/facts/apk.py +7 -2
  28. pyinfra/facts/apt.py +15 -7
  29. pyinfra/facts/brew.py +28 -13
  30. pyinfra/facts/bsdinit.py +9 -6
  31. pyinfra/facts/cargo.py +6 -3
  32. pyinfra/facts/choco.py +8 -4
  33. pyinfra/facts/deb.py +21 -9
  34. pyinfra/facts/dnf.py +11 -6
  35. pyinfra/facts/docker.py +30 -5
  36. pyinfra/facts/files.py +49 -33
  37. pyinfra/facts/gem.py +7 -2
  38. pyinfra/facts/git.py +14 -21
  39. pyinfra/facts/gpg.py +4 -1
  40. pyinfra/facts/hardware.py +186 -138
  41. pyinfra/facts/launchd.py +7 -2
  42. pyinfra/facts/lxd.py +8 -2
  43. pyinfra/facts/mysql.py +19 -12
  44. pyinfra/facts/npm.py +3 -1
  45. pyinfra/facts/openrc.py +8 -2
  46. pyinfra/facts/pacman.py +13 -5
  47. pyinfra/facts/pip.py +2 -0
  48. pyinfra/facts/pkg.py +5 -1
  49. pyinfra/facts/pkgin.py +7 -2
  50. pyinfra/facts/postgres.py +170 -0
  51. pyinfra/facts/postgresql.py +5 -162
  52. pyinfra/facts/rpm.py +21 -15
  53. pyinfra/facts/runit.py +70 -0
  54. pyinfra/facts/selinux.py +12 -4
  55. pyinfra/facts/server.py +240 -82
  56. pyinfra/facts/snap.py +8 -2
  57. pyinfra/facts/systemd.py +37 -13
  58. pyinfra/facts/sysvinit.py +7 -4
  59. pyinfra/facts/upstart.py +7 -2
  60. pyinfra/facts/util/packaging.py +3 -2
  61. pyinfra/facts/vzctl.py +8 -4
  62. pyinfra/facts/xbps.py +7 -2
  63. pyinfra/facts/yum.py +10 -5
  64. pyinfra/facts/zypper.py +9 -4
  65. pyinfra/operations/apk.py +5 -3
  66. pyinfra/operations/apt.py +28 -25
  67. pyinfra/operations/brew.py +60 -29
  68. pyinfra/operations/bsdinit.py +6 -4
  69. pyinfra/operations/cargo.py +3 -1
  70. pyinfra/operations/choco.py +3 -1
  71. pyinfra/operations/dnf.py +16 -20
  72. pyinfra/operations/docker.py +339 -0
  73. pyinfra/operations/files.py +187 -168
  74. pyinfra/operations/gem.py +3 -1
  75. pyinfra/operations/git.py +23 -25
  76. pyinfra/operations/iptables.py +33 -25
  77. pyinfra/operations/launchd.py +5 -6
  78. pyinfra/operations/lxd.py +7 -4
  79. pyinfra/operations/mysql.py +59 -55
  80. pyinfra/operations/npm.py +8 -1
  81. pyinfra/operations/openrc.py +5 -3
  82. pyinfra/operations/pacman.py +6 -7
  83. pyinfra/operations/pip.py +19 -12
  84. pyinfra/operations/pkg.py +3 -1
  85. pyinfra/operations/pkgin.py +5 -3
  86. pyinfra/operations/postgres.py +349 -0
  87. pyinfra/operations/postgresql.py +18 -335
  88. pyinfra/operations/puppet.py +3 -1
  89. pyinfra/operations/python.py +8 -19
  90. pyinfra/operations/runit.py +182 -0
  91. pyinfra/operations/selinux.py +47 -29
  92. pyinfra/operations/server.py +138 -67
  93. pyinfra/operations/snap.py +3 -1
  94. pyinfra/operations/ssh.py +18 -16
  95. pyinfra/operations/systemd.py +18 -12
  96. pyinfra/operations/sysvinit.py +7 -5
  97. pyinfra/operations/upstart.py +7 -5
  98. pyinfra/operations/util/__init__.py +12 -0
  99. pyinfra/operations/util/docker.py +177 -0
  100. pyinfra/operations/util/files.py +24 -16
  101. pyinfra/operations/util/packaging.py +54 -38
  102. pyinfra/operations/util/service.py +39 -47
  103. pyinfra/operations/vzctl.py +12 -10
  104. pyinfra/operations/xbps.py +5 -3
  105. pyinfra/operations/yum.py +15 -19
  106. pyinfra/operations/zypper.py +9 -10
  107. pyinfra/version.py +5 -2
  108. {pyinfra-3.0.dev0.dist-info → pyinfra-3.0.2.dist-info}/METADATA +51 -58
  109. pyinfra-3.0.2.dist-info/RECORD +168 -0
  110. {pyinfra-3.0.dev0.dist-info → pyinfra-3.0.2.dist-info}/WHEEL +1 -1
  111. {pyinfra-3.0.dev0.dist-info → pyinfra-3.0.2.dist-info}/entry_points.txt +0 -3
  112. pyinfra_cli/__main__.py +4 -3
  113. pyinfra_cli/commands.py +3 -2
  114. pyinfra_cli/exceptions.py +75 -43
  115. pyinfra_cli/inventory.py +52 -31
  116. pyinfra_cli/log.py +10 -2
  117. pyinfra_cli/main.py +88 -65
  118. pyinfra_cli/prints.py +37 -109
  119. pyinfra_cli/util.py +15 -10
  120. tests/test_api/test_api.py +2 -0
  121. tests/test_api/test_api_arguments.py +9 -9
  122. tests/test_api/test_api_deploys.py +15 -19
  123. tests/test_api/test_api_facts.py +4 -5
  124. tests/test_api/test_api_operations.py +18 -20
  125. tests/test_api/test_api_util.py +41 -2
  126. tests/test_cli/test_cli.py +14 -50
  127. tests/test_cli/test_cli_deploy.py +17 -14
  128. tests/test_cli/test_cli_exceptions.py +50 -19
  129. tests/test_cli/test_cli_inventory.py +66 -0
  130. tests/test_cli/util.py +1 -1
  131. tests/test_connectors/test_dockerssh.py +11 -8
  132. tests/test_connectors/test_ssh.py +88 -23
  133. tests/test_connectors/test_sshuserclient.py +1 -1
  134. tests/test_connectors/test_terraform.py +11 -8
  135. tests/test_connectors/test_vagrant.py +6 -6
  136. pyinfra/connectors/ansible.py +0 -175
  137. pyinfra/connectors/mech.py +0 -189
  138. pyinfra/connectors/pyinfrawinrmsession/__init__.py +0 -28
  139. pyinfra/connectors/winrm.py +0 -312
  140. pyinfra/facts/windows.py +0 -366
  141. pyinfra/facts/windows_files.py +0 -90
  142. pyinfra/operations/windows.py +0 -59
  143. pyinfra/operations/windows_files.py +0 -538
  144. pyinfra-3.0.dev0.dist-info/RECORD +0 -170
  145. tests/test_connectors/test_ansible.py +0 -64
  146. tests/test_connectors/test_mech.py +0 -126
  147. {pyinfra-3.0.dev0.dist-info → pyinfra-3.0.2.dist-info}/LICENSE.md +0 -0
  148. {pyinfra-3.0.dev0.dist-info → pyinfra-3.0.2.dist-info}/top_level.txt +0 -0
pyinfra/connectors/ssh.py CHANGED
@@ -1,31 +1,22 @@
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:
5
-
6
- .. code:: shell
7
-
8
- pyinfra my-host.net ...
9
- pyinfra @ssh/my-host.net ...
10
- """
11
-
12
1
  from __future__ import annotations
13
2
 
14
3
  import shlex
15
- from distutils.spawn import find_executable
4
+ from random import uniform
5
+ from shutil import which
16
6
  from socket import error as socket_error, gaierror
7
+ from time import sleep
17
8
  from typing import TYPE_CHECKING, Any, Iterable, Optional, Tuple
18
9
 
19
10
  import click
20
11
  from paramiko import AuthenticationException, BadHostKeyException, SFTPClient, SSHException
21
- from typing_extensions import Unpack
12
+ from typing_extensions import TypedDict, Unpack
22
13
 
23
14
  from pyinfra import logger
24
15
  from pyinfra.api.command import QuoteString, StringCommand
25
16
  from pyinfra.api.exceptions import ConnectError
26
17
  from pyinfra.api.util import get_file_io, memoize
27
18
 
28
- from .base import BaseConnector, make_keys
19
+ from .base import BaseConnector, DataMeta
29
20
  from .ssh_util import get_private_key, raise_connect_error
30
21
  from .sshuserclient import SSHClient
31
22
  from .util import (
@@ -41,94 +32,197 @@ if TYPE_CHECKING:
41
32
  from pyinfra.api.arguments import ConnectorArguments
42
33
 
43
34
 
44
- class DataKeys:
45
- hostname = "SSH hostname"
46
- port = "SSH port"
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
+ }
47
96
 
48
- user = "User to SSH as"
49
- password = "Password to use for authentication"
50
- key = "Key file to use for authentication"
51
- key_password = "Key file password"
52
97
 
53
- allow_agent = "Allow using SSH agent"
54
- look_for_keys = "Allow looking up users keys"
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:
55
103
 
56
- forward_agent = "Enable SSH forward agent"
57
- config_file = "Custom SSH config file"
58
- known_hosts_file = "Custom SSH known hosts file"
59
- strict_host_key_checking = "Override strict host keys check setting"
104
+ .. code:: shell
60
105
 
61
- paramiko_connect_kwargs = (
62
- "Override keyword arguments passed into paramiko's `SSHClient.connect`"
63
- )
106
+ pyinfra my-host.net ...
107
+ pyinfra @ssh/my-host.net ...
108
+ """
64
109
 
110
+ __examples_doc__ = """
111
+ An inventory file (``inventory.py``) containing a single SSH target with SSH
112
+ forward agent enabled:
65
113
 
66
- DATA_KEYS = make_keys("ssh", DataKeys)
114
+ .. code:: python
67
115
 
116
+ hosts = [
117
+ ("my-host.net", {"ssh_forward_agent": True}),
118
+ ]
119
+
120
+ Multiple hosts sharing the same SSH username:
121
+
122
+ .. code:: python
123
+
124
+ hosts = (
125
+ ["my-host-1.net", "my-host-2.net"],
126
+ {"ssh_user": "ssh-user"},
127
+ )
128
+
129
+ Multiple hosts with different SSH usernames:
130
+
131
+ .. code:: python
132
+
133
+ hosts = [
134
+ ("my-host-1.net", {"ssh_user": "ssh-user"}),
135
+ ("my-host-2.net", {"ssh_user": "other-user"}),
136
+ ]
137
+ """
68
138
 
69
- class SSHConnector(BaseConnector):
70
139
  handles_execution = True
140
+
141
+ data_cls = ConnectorData
142
+ data_meta = connector_data_meta
143
+ data: ConnectorData
144
+
71
145
  client: Optional[SSHClient] = None
72
146
 
73
- def make_names_data(hostname):
74
- yield "@ssh/{0}".format(hostname), {DATA_KEYS.hostname: hostname}, []
147
+ @staticmethod
148
+ def make_names_data(name):
149
+ yield "@ssh/{0}".format(name), {"ssh_hostname": name}, []
75
150
 
76
151
  def make_paramiko_kwargs(self) -> dict[str, Any]:
77
152
  kwargs = {
78
153
  "allow_agent": False,
79
154
  "look_for_keys": False,
80
- "hostname": self.host.data.get(DATA_KEYS.hostname, self.host.name),
155
+ "hostname": self.data["ssh_hostname"] or self.host.name,
81
156
  # Overrides of SSH config via pyinfra host data
82
- "_pyinfra_ssh_forward_agent": self.host.data.get(DATA_KEYS.forward_agent),
83
- "_pyinfra_ssh_config_file": self.host.data.get(DATA_KEYS.config_file),
84
- "_pyinfra_ssh_known_hosts_file": self.host.data.get(DATA_KEYS.known_hosts_file),
85
- "_pyinfra_ssh_strict_host_key_checking": self.host.data.get(
86
- DATA_KEYS.strict_host_key_checking
87
- ),
88
- "_pyinfra_ssh_paramiko_connect_kwargs": self.host.data.get(
89
- DATA_KEYS.paramiko_connect_kwargs
90
- ),
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"],
91
162
  }
92
163
 
93
164
  for key, value in (
94
- ("username", self.host.data.get(DATA_KEYS.user)),
95
- ("port", int(self.host.data.get(DATA_KEYS.port, 0))),
165
+ ("username", self.data["ssh_user"]),
166
+ ("port", int(self.data["ssh_port"] or 0)),
96
167
  ("timeout", self.state.config.CONNECT_TIMEOUT),
97
168
  ):
98
169
  if value:
99
170
  kwargs[key] = value
100
171
 
101
172
  # Password auth (boo!)
102
- ssh_password = self.host.data.get(DATA_KEYS.password)
173
+ ssh_password = self.data["ssh_password"]
103
174
  if ssh_password:
104
175
  kwargs["password"] = ssh_password
105
176
 
106
177
  # Key auth!
107
- ssh_key = self.host.data.get(DATA_KEYS.key)
178
+ ssh_key = self.data["ssh_key"]
108
179
  if ssh_key:
109
180
  kwargs["pkey"] = get_private_key(
110
181
  self.state,
111
182
  key_filename=ssh_key,
112
- key_password=self.host.data.get(DATA_KEYS.key_password),
183
+ key_password=self.data["ssh_key_password"],
113
184
  )
114
185
 
115
186
  # No key or password, so let's have paramiko look for SSH agents and user keys
116
187
  # unless disabled by the user.
117
188
  else:
118
- kwargs["allow_agent"] = self.host.data.get(DATA_KEYS.allow_agent, True)
119
- kwargs["look_for_keys"] = self.host.data.get(DATA_KEYS.look_for_keys, True)
189
+ kwargs["allow_agent"] = self.data["ssh_allow_agent"]
190
+ kwargs["look_for_keys"] = self.data["ssh_look_for_keys"]
120
191
 
121
192
  return kwargs
122
193
 
123
194
  def connect(self) -> None:
195
+ retries = self.data["ssh_connect_retries"]
196
+
197
+ try:
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:
124
218
  """
125
219
  Connect to a single host. Returns the SSH client if successful. Stateless by
126
220
  design so can be run in parallel.
127
221
  """
128
222
 
129
223
  kwargs = self.make_paramiko_kwargs()
130
- logger.debug("Connecting to: %s (%r)", self.host.name, kwargs)
131
224
  hostname = kwargs.pop("hostname")
225
+ logger.debug("Connecting to: %s (%r)", hostname, kwargs)
132
226
 
133
227
  self.client = SSHClient()
134
228
 
@@ -143,7 +237,7 @@ class SSHConnector(BaseConnector):
143
237
  continue
144
238
 
145
239
  if key == "pkey" and value:
146
- auth_kwargs["key"] = self.host.data.get(DATA_KEYS.key)
240
+ auth_kwargs["key"] = self.data["ssh_key"]
147
241
 
148
242
  auth_args = ", ".join(
149
243
  "{0}={1}".format(key, value) for key, value in auth_kwargs.items()
@@ -170,18 +264,6 @@ class SSHConnector(BaseConnector):
170
264
  f"Host key for {e.hostname} does not match.",
171
265
  )
172
266
 
173
- except SSHException as e:
174
- raise_connect_error(self.host, "SSH error", e)
175
-
176
- except gaierror:
177
- raise_connect_error(self.host, "Could not resolve hostname", hostname)
178
-
179
- except socket_error as e:
180
- raise_connect_error(self.host, "Could not connect", e)
181
-
182
- except EOFError as e:
183
- raise_connect_error(self.host, "EOF error", e)
184
-
185
267
  def run_shell_command(
186
268
  self,
187
269
  command: StringCommand,
@@ -303,7 +385,7 @@ class SSHConnector(BaseConnector):
303
385
 
304
386
  if _sudo or _su_user:
305
387
  # Get temp file location
306
- temp_file = remote_temp_filename or self.state.get_temp_filename(remote_filename)
388
+ temp_file = remote_temp_filename or self.host.get_temp_filename(remote_filename)
307
389
 
308
390
  # Copy the file to the tempfile location and add read permissions
309
391
  command = StringCommand(
@@ -395,20 +477,14 @@ class SSHConnector(BaseConnector):
395
477
  # user connected, so upload to tmp and copy/chown w/sudo and/or su_user
396
478
  if _sudo or _doas or _su_user:
397
479
  # Get temp file location
398
- temp_file = remote_temp_filename or self.state.get_temp_filename(remote_filename)
480
+ temp_file = remote_temp_filename or self.host.get_temp_filename(remote_filename)
399
481
  self._put_file(filename_or_io, temp_file)
400
482
 
401
483
  # Make sure our sudo/su user can access the file
402
- if _su_user:
403
- command = StringCommand("setfacl", "-m", "u:{0}:r".format(_su_user), temp_file)
404
- elif _sudo_user:
405
- command = StringCommand("setfacl", "-m", "u:{0}:r".format(_sudo_user), temp_file)
406
- elif _doas_user:
407
- command = StringCommand("setfacl", "-m", "u:{0}:r".format(_doas_user), temp_file)
408
-
409
- if _su_user or _sudo_user or _doas_user:
484
+ other_user = _su_user or _sudo_user or _doas_user
485
+ if other_user:
410
486
  status, output = self.run_shell_command(
411
- command,
487
+ StringCommand("setfacl", "-m", f"u:{other_user}:r", temp_file),
412
488
  print_output=print_output,
413
489
  print_input=print_input,
414
490
  **arguments,
@@ -459,15 +535,15 @@ class SSHConnector(BaseConnector):
459
535
  return True
460
536
 
461
537
  def check_can_rsync(self):
462
- if self.host.data.get(DATA_KEYS.key_password):
538
+ if self.data["ssh_key_password"]:
463
539
  raise NotImplementedError(
464
540
  "Rsync does not currently work with SSH keys needing passwords."
465
541
  )
466
542
 
467
- if self.host.data.get(DATA_KEYS.password):
543
+ if self.data["ssh_password"]:
468
544
  raise NotImplementedError("Rsync does not currently work with SSH passwords.")
469
545
 
470
- if not find_executable("rsync"):
546
+ if not which("rsync"):
471
547
  raise NotImplementedError("The `rsync` binary is not available on this system.")
472
548
 
473
549
  def rsync(
@@ -482,8 +558,8 @@ class SSHConnector(BaseConnector):
482
558
  _sudo = arguments.pop("_sudo", False)
483
559
  _sudo_user = arguments.pop("_sudo_user", False)
484
560
 
485
- hostname = self.host.data.get(DATA_KEYS.hostname, self.host.name)
486
- user = self.host.data.get(DATA_KEYS.user, "")
561
+ hostname = self.data["ssh_hostname"] or self.host.name
562
+ user = self.data["ssh_user"]
487
563
  if user:
488
564
  user = "{0}@".format(user)
489
565
 
@@ -491,27 +567,27 @@ class SSHConnector(BaseConnector):
491
567
  # To avoid asking for interactive input, specify BatchMode=yes
492
568
  ssh_flags.append("-o BatchMode=yes")
493
569
 
494
- known_hosts_file = self.host.data.get(DATA_KEYS.known_hosts_file, "")
570
+ known_hosts_file = self.data["ssh_known_hosts_file"]
495
571
  if known_hosts_file:
496
572
  ssh_flags.append(
497
573
  '-o \\"UserKnownHostsFile={0}\\"'.format(shlex.quote(known_hosts_file))
498
574
  ) # never trust users
499
575
 
500
- strict_host_key_checking = self.host.data.get(DATA_KEYS.strict_host_key_checking, "")
576
+ strict_host_key_checking = self.data["ssh_strict_host_key_checking"]
501
577
  if strict_host_key_checking:
502
578
  ssh_flags.append(
503
579
  '-o \\"StrictHostKeyChecking={0}\\"'.format(shlex.quote(strict_host_key_checking))
504
580
  )
505
581
 
506
- ssh_config_file = self.host.data.get(DATA_KEYS.config_file, "")
582
+ ssh_config_file = self.data["ssh_config_file"]
507
583
  if ssh_config_file:
508
584
  ssh_flags.append("-F {0}".format(shlex.quote(ssh_config_file)))
509
585
 
510
- port = self.host.data.get(DATA_KEYS.port)
586
+ port = self.data["ssh_port"]
511
587
  if port:
512
588
  ssh_flags.append("-p {0}".format(port))
513
589
 
514
- ssh_key = self.host.data.get(DATA_KEYS.key)
590
+ ssh_key = self.data["ssh_key"]
515
591
  if ssh_key:
516
592
  ssh_flags.append("-i {0}".format(ssh_key))
517
593
 
@@ -124,7 +124,7 @@ class SSHClient(ParamikoClient):
124
124
  original idea at http://bitprophet.org/blog/2012/11/05/gateway-solutions/.
125
125
  """
126
126
 
127
- def connect(
127
+ def connect( # type: ignore[override]
128
128
  self,
129
129
  hostname,
130
130
  _pyinfra_ssh_forward_agent=None,
@@ -1,37 +1,3 @@
1
- """
2
- .. warning::
3
- This connector is in alpha and may change in future releases.
4
-
5
- Generate one or more SSH hosts from a Terraform output variable. The variable
6
- must be a list of hostnames or IP addresses that ``pyinfra`` can connect to
7
- over SSH. Currently there is no support for specifying SSH user/pass/port/key
8
- from Terraform, these must be provided via ``pyinfra`` group data or ``--data``
9
- CLI flags.
10
-
11
- Output is fetched from a flattened JSON dictionary output from ``terraform output
12
- -json``. For example the following object:
13
-
14
- .. code:: json
15
-
16
- {
17
- "server_group": {
18
- "value": {
19
- "server_group_node_ips": [
20
- "1.2.3.4",
21
- "1.2.3.5",
22
- "1.2.3.6"
23
- ]
24
- }
25
- }
26
- }
27
-
28
- The IP list ``server_group_node_ips`` would be used like so:
29
-
30
- .. code:: python
31
-
32
- pyinfra @terraform/server_group.value.server_group_node_ips ...
33
- """
34
-
35
1
  import json
36
2
 
37
3
  from pyinfra import local, logger
@@ -61,22 +27,74 @@ def _flatten_dict(d: dict, parent_key: str = "", sep: str = "."):
61
27
 
62
28
 
63
29
  class TerraformInventoryConnector(BaseConnector):
30
+ """
31
+ Generate one or more SSH hosts from a Terraform output variable. The variable
32
+ must be a list of hostnames or dictionaries.
33
+
34
+ Output is fetched from a flattened JSON dictionary output from ``terraform output
35
+ -json``. For example the following object:
36
+
37
+ .. code:: json
38
+
39
+ {
40
+ "server_group": {
41
+ "value": {
42
+ "server_group_node_ips": [
43
+ "1.2.3.4",
44
+ "1.2.3.5",
45
+ "1.2.3.6"
46
+ ]
47
+ }
48
+ }
49
+ }
50
+
51
+ The IP list ``server_group_node_ips`` would be used like so:
52
+
53
+ .. code:: sh
54
+
55
+ pyinfra @terraform/server_group.value.server_group_node_ips ...
56
+
57
+ You can also specify dictionaries to include extra data with hosts:
58
+
59
+ .. code:: json
60
+
61
+ {
62
+ "server_group": {
63
+ "value": {
64
+ "server_group_node_ips": [
65
+ {
66
+ "ssh_hostname": "1.2.3.4",
67
+ "ssh_user": "ssh-user"
68
+ },
69
+ {
70
+ "ssh_hostname": "1.2.3.5",
71
+ "ssh_user": "ssh-user"
72
+ }
73
+ ]
74
+ }
75
+ }
76
+ }
77
+
78
+ """
79
+
64
80
  @staticmethod
65
- def make_names_data(output_key=None):
81
+ def make_names_data(name=None):
66
82
  show_warning()
67
83
 
68
- if not output_key:
69
- raise InventoryError("No Terraform output key!")
84
+ if not name:
85
+ name = ""
70
86
 
71
87
  with progress_spinner({"fetch terraform output"}):
72
88
  tf_output_raw = local.shell("terraform output -json")
73
89
 
90
+ assert isinstance(tf_output_raw, str)
74
91
  tf_output = json.loads(tf_output_raw)
75
92
  tf_output = _flatten_dict(tf_output)
76
93
 
77
- tf_output_value = tf_output.get(output_key)
94
+ tf_output_value = tf_output.get(name)
78
95
  if tf_output_value is None:
79
- raise InventoryError(f"No Terraform output with key: `{output_key}`")
96
+ keys = "\n".join(f" - {k}" for k in tf_output.keys())
97
+ raise InventoryError(f"No Terraform output with key: `{name}`, valid keys:\n{keys}")
80
98
 
81
99
  if not isinstance(tf_output_value, list):
82
100
  raise InventoryError(
@@ -40,7 +40,7 @@ def run_local_process(
40
40
  stdin=None,
41
41
  timeout: Optional[int] = None,
42
42
  print_output: bool = False,
43
- print_prefix=None,
43
+ print_prefix: str = "",
44
44
  ) -> tuple[int, "CommandOutput"]:
45
45
  process = Popen(command, shell=True, stdout=PIPE, stderr=PIPE, stdin=PIPE)
46
46
 
@@ -201,7 +201,10 @@ def execute_command_with_sudo_retry(
201
201
  if return_code != 0 and output and output.combined_lines:
202
202
  last_line = output.combined_lines[-1].line
203
203
  if last_line.strip() == "sudo: a password is required":
204
- command_arguments["_use_sudo_password"] = True # ask for the password
204
+ # If we need a password, ask the user for it and attach to the host
205
+ # internal connector data for use when executing future commands.
206
+ sudo_password = getpass("{0}sudo password: ".format(host.print_prefix))
207
+ host.connector_data["prompted_sudo_password"] = sudo_password
205
208
  return_code, output = execute_command()
206
209
 
207
210
  return return_code, output
@@ -221,25 +224,6 @@ def write_stdin(stdin, buffer):
221
224
  buffer.close()
222
225
 
223
226
 
224
- def _get_sudo_password(host: "Host", use_sudo_password) -> str:
225
- if not host.connector_data.get("sudo_askpass_path"):
226
- _, output = host.run_shell_command(SUDO_ASKPASS_COMMAND)
227
- host.connector_data["sudo_askpass_path"] = shlex.quote(output.stdout_lines[0])
228
-
229
- if use_sudo_password is True:
230
- sudo_password = host.connector_data.get("sudo_password")
231
- if not sudo_password:
232
- sudo_password = getpass("{0}sudo password: ".format(host.print_prefix))
233
- host.connector_data["sudo_password"] = sudo_password
234
- sudo_password = sudo_password
235
- elif callable(use_sudo_password):
236
- sudo_password = use_sudo_password()
237
- else:
238
- sudo_password = use_sudo_password
239
-
240
- return shlex.quote(sudo_password)
241
-
242
-
243
227
  def remove_any_sudo_askpass_file(host) -> None:
244
228
  sudo_askpass_path = host.connector_data.get("sudo_askpass_path")
245
229
  if sudo_askpass_path:
@@ -273,17 +257,32 @@ def extract_control_arguments(arguments: "ConnectorArguments") -> "ConnectorArgu
273
257
  return control_arguments
274
258
 
275
259
 
260
+ def _ensure_sudo_askpass_set_for_host(host: "Host"):
261
+ if host.connector_data.get("sudo_askpass_path"):
262
+ return
263
+ _, output = host.run_shell_command(SUDO_ASKPASS_COMMAND)
264
+ host.connector_data["sudo_askpass_path"] = shlex.quote(output.stdout_lines[0])
265
+
266
+
276
267
  def make_unix_command_for_host(
277
268
  state: "State",
278
269
  host: "Host",
279
270
  command: StringCommand,
280
271
  **command_arguments,
281
272
  ) -> StringCommand:
282
- use_sudo_password = command_arguments.pop("_use_sudo_password", None)
283
- if use_sudo_password:
284
- command_arguments["_sudo_password"] = _get_sudo_password(host, use_sudo_password)
285
- command_arguments["_sudo_askpass_path"] = host.connector_data.get("sudo_askpass_path")
286
-
273
+ if not command_arguments.get("_sudo"):
274
+ # If no sudo, we've nothing to do here
275
+ return make_unix_command(command, **command_arguments)
276
+
277
+ # If the sudo password is not set in the direct arguments,
278
+ # set it from the connector data value.
279
+ if "_sudo_password" not in command_arguments or not command_arguments["_sudo_password"]:
280
+ command_arguments["_sudo_password"] = host.connector_data.get("prompted_sudo_password")
281
+
282
+ if command_arguments["_sudo_password"]:
283
+ # Ensure the askpass path is correctly set and passed through
284
+ _ensure_sudo_askpass_set_for_host(host)
285
+ command_arguments["_sudo_askpass_path"] = host.connector_data["sudo_askpass_path"]
287
286
  return make_unix_command(command, **command_arguments)
288
287
 
289
288
 
@@ -339,7 +338,7 @@ def make_unix_command(
339
338
  [
340
339
  "env",
341
340
  "SUDO_ASKPASS={0}".format(_sudo_askpass_path),
342
- MaskString("{0}={1}".format(SUDO_ASKPASS_ENV_VAR, _sudo_password)),
341
+ MaskString("{0}={1}".format(SUDO_ASKPASS_ENV_VAR, shlex.quote(_sudo_password))),
343
342
  ],
344
343
  )
345
344