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