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.
- pyinfra/__init__.py +9 -12
- pyinfra/__main__.py +4 -0
- pyinfra/api/__init__.py +19 -3
- pyinfra/api/arguments.py +413 -0
- pyinfra/api/arguments_typed.py +79 -0
- pyinfra/api/command.py +274 -0
- pyinfra/api/config.py +222 -28
- pyinfra/api/connect.py +33 -13
- pyinfra/api/connectors.py +27 -0
- pyinfra/api/deploy.py +65 -66
- pyinfra/api/exceptions.py +73 -18
- pyinfra/api/facts.py +267 -200
- pyinfra/api/host.py +416 -50
- pyinfra/api/inventory.py +121 -160
- pyinfra/api/metadata.py +69 -0
- pyinfra/api/operation.py +432 -262
- pyinfra/api/operations.py +273 -260
- pyinfra/api/state.py +302 -248
- pyinfra/api/util.py +309 -369
- pyinfra/connectors/base.py +173 -0
- pyinfra/connectors/chroot.py +212 -0
- pyinfra/connectors/docker.py +405 -0
- pyinfra/connectors/dockerssh.py +297 -0
- pyinfra/connectors/local.py +238 -0
- pyinfra/connectors/scp/__init__.py +1 -0
- pyinfra/connectors/scp/client.py +204 -0
- pyinfra/connectors/ssh.py +727 -0
- pyinfra/connectors/ssh_util.py +114 -0
- pyinfra/connectors/sshuserclient/client.py +309 -0
- pyinfra/connectors/sshuserclient/config.py +102 -0
- pyinfra/connectors/terraform.py +135 -0
- pyinfra/connectors/util.py +417 -0
- pyinfra/connectors/vagrant.py +183 -0
- pyinfra/context.py +145 -0
- pyinfra/facts/__init__.py +7 -6
- pyinfra/facts/apk.py +22 -7
- pyinfra/facts/apt.py +117 -60
- pyinfra/facts/brew.py +100 -15
- pyinfra/facts/bsdinit.py +23 -0
- pyinfra/facts/cargo.py +37 -0
- pyinfra/facts/choco.py +47 -0
- pyinfra/facts/crontab.py +195 -0
- pyinfra/facts/deb.py +94 -0
- pyinfra/facts/dnf.py +48 -0
- pyinfra/facts/docker.py +96 -23
- pyinfra/facts/efibootmgr.py +113 -0
- pyinfra/facts/files.py +629 -58
- pyinfra/facts/flatpak.py +77 -0
- pyinfra/facts/freebsd.py +70 -0
- pyinfra/facts/gem.py +19 -6
- pyinfra/facts/git.py +59 -14
- pyinfra/facts/gpg.py +150 -0
- pyinfra/facts/hardware.py +313 -167
- pyinfra/facts/iptables.py +72 -62
- pyinfra/facts/launchd.py +44 -0
- pyinfra/facts/lxd.py +17 -4
- pyinfra/facts/mysql.py +122 -86
- pyinfra/facts/npm.py +17 -9
- pyinfra/facts/openrc.py +71 -0
- pyinfra/facts/opkg.py +246 -0
- pyinfra/facts/pacman.py +50 -7
- pyinfra/facts/pip.py +24 -7
- pyinfra/facts/pipx.py +82 -0
- pyinfra/facts/pkg.py +15 -6
- pyinfra/facts/pkgin.py +35 -0
- pyinfra/facts/podman.py +54 -0
- pyinfra/facts/postgres.py +178 -0
- pyinfra/facts/postgresql.py +6 -147
- pyinfra/facts/rpm.py +105 -0
- pyinfra/facts/runit.py +77 -0
- pyinfra/facts/selinux.py +161 -0
- pyinfra/facts/server.py +762 -285
- pyinfra/facts/snap.py +88 -0
- pyinfra/facts/systemd.py +139 -0
- pyinfra/facts/sysvinit.py +59 -0
- pyinfra/facts/upstart.py +35 -0
- pyinfra/facts/util/__init__.py +17 -0
- pyinfra/facts/util/databases.py +4 -6
- pyinfra/facts/util/packaging.py +37 -6
- pyinfra/facts/util/units.py +30 -0
- pyinfra/facts/util/win_files.py +99 -0
- pyinfra/facts/vzctl.py +20 -13
- pyinfra/facts/xbps.py +35 -0
- pyinfra/facts/yum.py +34 -40
- pyinfra/facts/zfs.py +77 -0
- pyinfra/facts/zypper.py +42 -0
- pyinfra/local.py +45 -83
- pyinfra/operations/__init__.py +12 -0
- pyinfra/operations/apk.py +99 -0
- pyinfra/operations/apt.py +496 -0
- pyinfra/operations/brew.py +232 -0
- pyinfra/operations/bsdinit.py +59 -0
- pyinfra/operations/cargo.py +45 -0
- pyinfra/operations/choco.py +61 -0
- pyinfra/operations/crontab.py +194 -0
- pyinfra/operations/dnf.py +213 -0
- pyinfra/operations/docker.py +492 -0
- pyinfra/operations/files.py +2014 -0
- pyinfra/operations/flatpak.py +95 -0
- pyinfra/operations/freebsd/__init__.py +12 -0
- pyinfra/operations/freebsd/freebsd_update.py +70 -0
- pyinfra/operations/freebsd/pkg.py +219 -0
- pyinfra/operations/freebsd/service.py +116 -0
- pyinfra/operations/freebsd/sysrc.py +92 -0
- pyinfra/operations/gem.py +48 -0
- pyinfra/operations/git.py +420 -0
- pyinfra/operations/iptables.py +312 -0
- pyinfra/operations/launchd.py +45 -0
- pyinfra/operations/lxd.py +69 -0
- pyinfra/operations/mysql.py +610 -0
- pyinfra/operations/npm.py +57 -0
- pyinfra/operations/openrc.py +63 -0
- pyinfra/operations/opkg.py +89 -0
- pyinfra/operations/pacman.py +82 -0
- pyinfra/operations/pip.py +206 -0
- pyinfra/operations/pipx.py +103 -0
- pyinfra/operations/pkg.py +71 -0
- pyinfra/operations/pkgin.py +92 -0
- pyinfra/operations/postgres.py +437 -0
- pyinfra/operations/postgresql.py +30 -0
- pyinfra/operations/puppet.py +41 -0
- pyinfra/operations/python.py +73 -0
- pyinfra/operations/runit.py +184 -0
- pyinfra/operations/selinux.py +190 -0
- pyinfra/operations/server.py +1100 -0
- pyinfra/operations/snap.py +118 -0
- pyinfra/operations/ssh.py +217 -0
- pyinfra/operations/systemd.py +150 -0
- pyinfra/operations/sysvinit.py +142 -0
- pyinfra/operations/upstart.py +68 -0
- pyinfra/operations/util/__init__.py +12 -0
- pyinfra/operations/util/docker.py +407 -0
- pyinfra/operations/util/files.py +247 -0
- pyinfra/operations/util/packaging.py +338 -0
- pyinfra/operations/util/service.py +46 -0
- pyinfra/operations/vzctl.py +137 -0
- pyinfra/operations/xbps.py +78 -0
- pyinfra/operations/yum.py +213 -0
- pyinfra/operations/zfs.py +176 -0
- pyinfra/operations/zypper.py +193 -0
- pyinfra/progress.py +44 -32
- pyinfra/py.typed +0 -0
- pyinfra/version.py +9 -1
- pyinfra-3.6.dist-info/METADATA +142 -0
- pyinfra-3.6.dist-info/RECORD +160 -0
- {pyinfra-0.11.dev3.dist-info → pyinfra-3.6.dist-info}/WHEEL +1 -2
- pyinfra-3.6.dist-info/entry_points.txt +12 -0
- {pyinfra-0.11.dev3.dist-info → pyinfra-3.6.dist-info/licenses}/LICENSE.md +1 -1
- pyinfra_cli/__init__.py +1 -0
- pyinfra_cli/cli.py +793 -0
- pyinfra_cli/commands.py +66 -0
- pyinfra_cli/exceptions.py +155 -65
- pyinfra_cli/inventory.py +233 -89
- pyinfra_cli/log.py +39 -43
- pyinfra_cli/main.py +26 -495
- pyinfra_cli/prints.py +215 -156
- pyinfra_cli/util.py +172 -105
- pyinfra_cli/virtualenv.py +25 -20
- pyinfra/api/connectors/__init__.py +0 -21
- pyinfra/api/connectors/ansible.py +0 -99
- pyinfra/api/connectors/docker.py +0 -178
- pyinfra/api/connectors/local.py +0 -169
- pyinfra/api/connectors/ssh.py +0 -402
- pyinfra/api/connectors/sshuserclient/client.py +0 -105
- pyinfra/api/connectors/sshuserclient/config.py +0 -90
- pyinfra/api/connectors/util.py +0 -63
- pyinfra/api/connectors/vagrant.py +0 -155
- pyinfra/facts/init.py +0 -176
- pyinfra/facts/util/files.py +0 -102
- pyinfra/hook.py +0 -41
- pyinfra/modules/__init__.py +0 -11
- pyinfra/modules/apk.py +0 -64
- pyinfra/modules/apt.py +0 -272
- pyinfra/modules/brew.py +0 -122
- pyinfra/modules/files.py +0 -711
- pyinfra/modules/gem.py +0 -30
- pyinfra/modules/git.py +0 -115
- pyinfra/modules/init.py +0 -344
- pyinfra/modules/iptables.py +0 -271
- pyinfra/modules/lxd.py +0 -45
- pyinfra/modules/mysql.py +0 -347
- pyinfra/modules/npm.py +0 -47
- pyinfra/modules/pacman.py +0 -60
- pyinfra/modules/pip.py +0 -99
- pyinfra/modules/pkg.py +0 -43
- pyinfra/modules/postgresql.py +0 -245
- pyinfra/modules/puppet.py +0 -20
- pyinfra/modules/python.py +0 -37
- pyinfra/modules/server.py +0 -524
- pyinfra/modules/ssh.py +0 -150
- pyinfra/modules/util/files.py +0 -52
- pyinfra/modules/util/packaging.py +0 -118
- pyinfra/modules/vzctl.py +0 -133
- pyinfra/modules/yum.py +0 -171
- pyinfra/pseudo_modules.py +0 -64
- pyinfra-0.11.dev3.dist-info/.DS_Store +0 -0
- pyinfra-0.11.dev3.dist-info/METADATA +0 -135
- pyinfra-0.11.dev3.dist-info/RECORD +0 -95
- pyinfra-0.11.dev3.dist-info/entry_points.txt +0 -3
- pyinfra-0.11.dev3.dist-info/top_level.txt +0 -2
- pyinfra_cli/__main__.py +0 -40
- pyinfra_cli/config.py +0 -92
- /pyinfra/{modules/util → connectors}/__init__.py +0 -0
- /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
|