pyinfra 2.9.2__py2.py3-none-any.whl → 3.0b1__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 +261 -255
- pyinfra/api/arguments_typed.py +77 -0
- pyinfra/api/command.py +66 -53
- pyinfra/api/config.py +27 -22
- pyinfra/api/connect.py +1 -1
- pyinfra/api/connectors.py +2 -24
- pyinfra/api/deploy.py +21 -52
- pyinfra/api/exceptions.py +33 -8
- pyinfra/api/facts.py +77 -113
- pyinfra/api/host.py +150 -82
- pyinfra/api/inventory.py +17 -25
- pyinfra/api/operation.py +232 -198
- pyinfra/api/operations.py +102 -148
- pyinfra/api/state.py +137 -79
- pyinfra/api/util.py +55 -70
- pyinfra/connectors/base.py +150 -0
- pyinfra/connectors/chroot.py +160 -169
- pyinfra/connectors/docker.py +227 -237
- pyinfra/connectors/dockerssh.py +231 -253
- pyinfra/connectors/local.py +195 -207
- pyinfra/connectors/ssh.py +528 -615
- pyinfra/connectors/ssh_util.py +114 -0
- pyinfra/connectors/sshuserclient/client.py +5 -3
- pyinfra/connectors/terraform.py +86 -65
- pyinfra/connectors/util.py +212 -137
- pyinfra/connectors/vagrant.py +55 -48
- pyinfra/context.py +3 -2
- pyinfra/facts/docker.py +1 -0
- pyinfra/facts/files.py +45 -32
- pyinfra/facts/git.py +3 -1
- pyinfra/facts/gpg.py +1 -1
- pyinfra/facts/hardware.py +4 -2
- pyinfra/facts/iptables.py +5 -3
- pyinfra/facts/mysql.py +1 -0
- pyinfra/facts/postgres.py +168 -0
- pyinfra/facts/postgresql.py +5 -161
- pyinfra/facts/selinux.py +3 -1
- pyinfra/facts/server.py +77 -30
- pyinfra/facts/systemd.py +29 -12
- pyinfra/facts/sysvinit.py +10 -10
- pyinfra/facts/util/packaging.py +4 -2
- pyinfra/local.py +4 -5
- pyinfra/operations/apk.py +3 -3
- pyinfra/operations/apt.py +25 -47
- pyinfra/operations/brew.py +7 -14
- pyinfra/operations/bsdinit.py +4 -4
- pyinfra/operations/cargo.py +1 -1
- pyinfra/operations/choco.py +1 -1
- pyinfra/operations/dnf.py +4 -4
- pyinfra/operations/files.py +108 -321
- pyinfra/operations/gem.py +1 -1
- pyinfra/operations/git.py +6 -37
- pyinfra/operations/iptables.py +2 -10
- pyinfra/operations/launchd.py +1 -1
- pyinfra/operations/lxd.py +1 -9
- pyinfra/operations/mysql.py +5 -28
- pyinfra/operations/npm.py +1 -1
- pyinfra/operations/openrc.py +1 -1
- pyinfra/operations/pacman.py +3 -3
- pyinfra/operations/pip.py +14 -15
- pyinfra/operations/pkg.py +1 -1
- pyinfra/operations/pkgin.py +3 -3
- pyinfra/operations/postgres.py +347 -0
- pyinfra/operations/postgresql.py +17 -380
- pyinfra/operations/python.py +2 -17
- pyinfra/operations/selinux.py +5 -28
- pyinfra/operations/server.py +59 -84
- pyinfra/operations/snap.py +1 -3
- pyinfra/operations/ssh.py +8 -23
- pyinfra/operations/systemd.py +7 -7
- pyinfra/operations/sysvinit.py +3 -12
- pyinfra/operations/upstart.py +4 -4
- pyinfra/operations/util/__init__.py +12 -0
- pyinfra/operations/util/files.py +2 -2
- pyinfra/operations/util/packaging.py +6 -24
- pyinfra/operations/util/service.py +18 -37
- pyinfra/operations/vzctl.py +2 -2
- pyinfra/operations/xbps.py +3 -3
- pyinfra/operations/yum.py +4 -4
- pyinfra/operations/zypper.py +4 -4
- {pyinfra-2.9.2.dist-info → pyinfra-3.0b1.dist-info}/METADATA +19 -22
- pyinfra-3.0b1.dist-info/RECORD +163 -0
- pyinfra-3.0b1.dist-info/entry_points.txt +11 -0
- pyinfra_cli/__main__.py +2 -0
- pyinfra_cli/commands.py +7 -2
- pyinfra_cli/exceptions.py +83 -42
- pyinfra_cli/inventory.py +19 -4
- pyinfra_cli/log.py +17 -3
- pyinfra_cli/main.py +133 -90
- pyinfra_cli/prints.py +93 -129
- pyinfra_cli/util.py +60 -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 +100 -200
- 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 +66 -107
- tests/test_connectors/test_terraform.py +9 -15
- tests/test_connectors/test_util.py +24 -46
- tests/test_connectors/test_vagrant.py +4 -4
- 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.2.dist-info/RECORD +0 -170
- pyinfra-2.9.2.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.2.dist-info → pyinfra-3.0b1.dist-info}/LICENSE.md +0 -0
- {pyinfra-2.9.2.dist-info → pyinfra-3.0b1.dist-info}/WHEEL +0 -0
- {pyinfra-2.9.2.dist-info → pyinfra-3.0b1.dist-info}/top_level.txt +0 -0
pyinfra/connectors/ssh.py
CHANGED
|
@@ -1,711 +1,624 @@
|
|
|
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
4
|
from distutils.spawn import find_executable
|
|
13
|
-
from getpass import getpass
|
|
14
|
-
from os import path
|
|
15
5
|
from random import uniform
|
|
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
|
+
[
|
|
126
|
+
"my-host-1.net",
|
|
127
|
+
"my-host-2.net",
|
|
128
|
+
],
|
|
129
|
+
{
|
|
130
|
+
"ssh_username": "ssh-user",
|
|
131
|
+
},
|
|
82
132
|
)
|
|
133
|
+
"""
|
|
83
134
|
|
|
135
|
+
handles_execution = True
|
|
84
136
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
137
|
+
data_cls = ConnectorData
|
|
138
|
+
data_meta = connector_data_meta
|
|
139
|
+
data: ConnectorData
|
|
140
|
+
|
|
141
|
+
client: Optional[SSHClient] = None
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def make_names_data(name):
|
|
145
|
+
yield "@ssh/{0}".format(name), {"ssh_hostname": name}, []
|
|
146
|
+
|
|
147
|
+
def make_paramiko_kwargs(self) -> dict[str, Any]:
|
|
148
|
+
kwargs = {
|
|
149
|
+
"allow_agent": False,
|
|
150
|
+
"look_for_keys": False,
|
|
151
|
+
"hostname": self.data["ssh_hostname"] or self.host.name,
|
|
152
|
+
# Overrides of SSH config via pyinfra host data
|
|
153
|
+
"_pyinfra_ssh_forward_agent": self.data["ssh_forward_agent"],
|
|
154
|
+
"_pyinfra_ssh_config_file": self.data["ssh_config_file"],
|
|
155
|
+
"_pyinfra_ssh_known_hosts_file": self.data["ssh_known_hosts_file"],
|
|
156
|
+
"_pyinfra_ssh_strict_host_key_checking": self.data["ssh_strict_host_key_checking"],
|
|
157
|
+
"_pyinfra_ssh_paramiko_connect_kwargs": self.data["ssh_paramiko_connect_kwargs"],
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for key, value in (
|
|
161
|
+
("username", self.data["ssh_user"]),
|
|
162
|
+
("port", int(self.data["ssh_port"] or 0)),
|
|
163
|
+
("timeout", self.state.config.CONNECT_TIMEOUT),
|
|
164
|
+
):
|
|
165
|
+
if value:
|
|
166
|
+
kwargs[key] = value
|
|
167
|
+
|
|
168
|
+
# Password auth (boo!)
|
|
169
|
+
ssh_password = self.data["ssh_password"]
|
|
170
|
+
if ssh_password:
|
|
171
|
+
kwargs["password"] = ssh_password
|
|
172
|
+
|
|
173
|
+
# Key auth!
|
|
174
|
+
ssh_key = self.data["ssh_key"]
|
|
175
|
+
if ssh_key:
|
|
176
|
+
kwargs["pkey"] = get_private_key(
|
|
177
|
+
self.state,
|
|
178
|
+
key_filename=ssh_key,
|
|
179
|
+
key_password=self.data["ssh_key_password"],
|
|
106
180
|
)
|
|
107
181
|
|
|
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]
|
|
142
|
-
|
|
143
|
-
ssh_key_filenames = [
|
|
144
|
-
# Global from executed directory
|
|
145
|
-
path.expanduser(key_filename),
|
|
146
|
-
]
|
|
147
|
-
|
|
148
|
-
if state.cwd:
|
|
149
|
-
# Relative to the CWD
|
|
150
|
-
path.join(state.cwd, key_filename)
|
|
151
|
-
|
|
152
|
-
key = None
|
|
153
|
-
key_file_exists = False
|
|
182
|
+
# No key or password, so let's have paramiko look for SSH agents and user keys
|
|
183
|
+
# unless disabled by the user.
|
|
184
|
+
else:
|
|
185
|
+
kwargs["allow_agent"] = self.data["ssh_allow_agent"]
|
|
186
|
+
kwargs["look_for_keys"] = self.data["ssh_look_for_keys"]
|
|
154
187
|
|
|
155
|
-
|
|
156
|
-
if not path.isfile(filename):
|
|
157
|
-
continue
|
|
188
|
+
return kwargs
|
|
158
189
|
|
|
159
|
-
|
|
190
|
+
def connect(self) -> None:
|
|
191
|
+
retries = self.data["ssh_connect_retries"]
|
|
160
192
|
|
|
161
193
|
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
|
-
)
|
|
194
|
+
while True:
|
|
195
|
+
try:
|
|
196
|
+
return self._connect()
|
|
197
|
+
except (SSHException, gaierror, socket_error, EOFError):
|
|
198
|
+
if retries == 0:
|
|
199
|
+
raise
|
|
200
|
+
retries -= 1
|
|
201
|
+
min_delay = self.data["ssh_connect_retry_min_delay"]
|
|
202
|
+
max_delay = self.data["ssh_connect_retry_max_delay"]
|
|
203
|
+
sleep(uniform(min_delay, max_delay))
|
|
204
|
+
except SSHException as e:
|
|
205
|
+
raise_connect_error(self.host, "SSH error", e)
|
|
206
|
+
except gaierror as e:
|
|
207
|
+
raise_connect_error(self.host, "Could not resolve hostname", e)
|
|
208
|
+
except socket_error as e:
|
|
209
|
+
raise_connect_error(self.host, "Could not connect", e)
|
|
210
|
+
except EOFError as e:
|
|
211
|
+
raise_connect_error(self.host, "EOF error", e)
|
|
212
|
+
|
|
213
|
+
def _connect(self) -> None:
|
|
214
|
+
"""
|
|
215
|
+
Connect to a single host. Returns the SSH client if successful. Stateless by
|
|
216
|
+
design so can be run in parallel.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
kwargs = self.make_paramiko_kwargs()
|
|
220
|
+
hostname = kwargs.pop("hostname")
|
|
221
|
+
logger.debug("Connecting to: %s (%r)", hostname, kwargs)
|
|
222
|
+
|
|
223
|
+
self.client = SSHClient()
|
|
220
224
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
kwargs["look_for_keys"] = host.data.get(DATA_KEYS.look_for_keys, True)
|
|
226
|
-
|
|
227
|
-
return kwargs
|
|
225
|
+
try:
|
|
226
|
+
self.client.connect(hostname, **kwargs)
|
|
227
|
+
except AuthenticationException as e:
|
|
228
|
+
auth_kwargs = {}
|
|
228
229
|
|
|
230
|
+
for key, value in kwargs.items():
|
|
231
|
+
if key in ("username", "password"):
|
|
232
|
+
auth_kwargs[key] = value
|
|
233
|
+
continue
|
|
229
234
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
Connect to a single host. Returns the SSH client if successful. Stateless by
|
|
233
|
-
design so can be run in parallel.
|
|
234
|
-
"""
|
|
235
|
-
retries = host.data.get(DATA_KEYS.connect_retries, 0)
|
|
235
|
+
if key == "pkey" and value:
|
|
236
|
+
auth_kwargs["key"] = self.data["ssh_key"]
|
|
236
237
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
return con
|
|
238
|
+
auth_args = ", ".join(
|
|
239
|
+
"{0}={1}".format(key, value) for key, value in auth_kwargs.items()
|
|
240
|
+
)
|
|
241
241
|
|
|
242
|
+
raise_connect_error(self.host, "Authentication error ({0})".format(auth_args), e)
|
|
242
243
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
244
|
+
except BadHostKeyException as e:
|
|
245
|
+
remove_entry = e.hostname
|
|
246
|
+
port = self.client._ssh_config.get("port", 22)
|
|
247
|
+
if port != 22:
|
|
248
|
+
remove_entry = f"[{e.hostname}]:{port}"
|
|
246
249
|
|
|
247
|
-
|
|
250
|
+
logger.warning("WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!")
|
|
251
|
+
logger.warning(
|
|
252
|
+
("Someone could be eavesdropping on you right now " "(man-in-the-middle attack)!"),
|
|
253
|
+
)
|
|
254
|
+
logger.warning("If this is expected, you can remove the bad key using:")
|
|
255
|
+
logger.warning(f" ssh-keygen -R {remove_entry}")
|
|
248
256
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
257
|
+
raise_connect_error(
|
|
258
|
+
self.host,
|
|
259
|
+
"SSH host key error",
|
|
260
|
+
f"Host key for {e.hostname} does not match.",
|
|
261
|
+
)
|
|
254
262
|
|
|
255
|
-
|
|
256
|
-
|
|
263
|
+
def run_shell_command(
|
|
264
|
+
self,
|
|
265
|
+
command: StringCommand,
|
|
266
|
+
print_output: bool = False,
|
|
267
|
+
print_input: bool = False,
|
|
268
|
+
**arguments: Unpack["ConnectorArguments"],
|
|
269
|
+
) -> Tuple[bool, CommandOutput]:
|
|
270
|
+
"""
|
|
271
|
+
Execute a command on the specified host.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
state (``pyinfra.api.State`` obj): state object for this command
|
|
275
|
+
hostname (string): hostname of the target
|
|
276
|
+
command (string): actual command to execute
|
|
277
|
+
sudo (boolean): whether to wrap the command with sudo
|
|
278
|
+
sudo_user (string): user to sudo to
|
|
279
|
+
get_pty (boolean): whether to get a PTY before executing the command
|
|
280
|
+
env (dict): environment variables to set
|
|
281
|
+
timeout (int): timeout for this command to complete before erroring
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
tuple: (exit_code, stdout, stderr)
|
|
285
|
+
stdout and stderr are both lists of strings from each buffer.
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
_get_pty = arguments.pop("_get_pty", False)
|
|
289
|
+
_timeout = arguments.pop("_timeout", None)
|
|
290
|
+
_stdin = arguments.pop("_stdin", None)
|
|
291
|
+
_success_exit_codes = arguments.pop("_success_exit_codes", None)
|
|
292
|
+
|
|
293
|
+
def execute_command() -> Tuple[int, CommandOutput]:
|
|
294
|
+
unix_command = make_unix_command_for_host(self.state, self.host, command, **arguments)
|
|
295
|
+
actual_command = unix_command.get_raw_value()
|
|
296
|
+
|
|
297
|
+
logger.debug(
|
|
298
|
+
"Running command on %s: (pty=%s) %s",
|
|
299
|
+
self.host.name,
|
|
300
|
+
_get_pty,
|
|
301
|
+
unix_command,
|
|
302
|
+
)
|
|
257
303
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
auth_kwargs[key] = value
|
|
261
|
-
continue
|
|
304
|
+
if print_input:
|
|
305
|
+
click.echo("{0}>>> {1}".format(self.host.print_prefix, unix_command), err=True)
|
|
262
306
|
|
|
263
|
-
|
|
264
|
-
|
|
307
|
+
# Run it! Get stdout, stderr & the underlying channel
|
|
308
|
+
assert self.client is not None
|
|
309
|
+
stdin_buffer, stdout_buffer, stderr_buffer = self.client.exec_command(
|
|
310
|
+
actual_command,
|
|
311
|
+
get_pty=_get_pty,
|
|
312
|
+
)
|
|
265
313
|
|
|
266
|
-
|
|
314
|
+
if _stdin:
|
|
315
|
+
write_stdin(_stdin, stdin_buffer)
|
|
267
316
|
|
|
268
|
-
|
|
317
|
+
combined_output = read_output_buffers(
|
|
318
|
+
stdout_buffer,
|
|
319
|
+
stderr_buffer,
|
|
320
|
+
timeout=_timeout,
|
|
321
|
+
print_output=print_output,
|
|
322
|
+
print_prefix=self.host.print_prefix,
|
|
323
|
+
)
|
|
269
324
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
if port != 22:
|
|
274
|
-
remove_entry = f"[{e.hostname}]:{port}"
|
|
325
|
+
logger.debug("Waiting for exit status...")
|
|
326
|
+
exit_status = stdout_buffer.channel.recv_exit_status()
|
|
327
|
+
logger.debug("Command exit status: %i", exit_status)
|
|
275
328
|
|
|
276
|
-
|
|
277
|
-
logger.warning(
|
|
278
|
-
("Someone could be eavesdropping on you right now " "(man-in-the-middle attack)!"),
|
|
279
|
-
)
|
|
280
|
-
logger.warning("If this is expected, you can remove the bad key using:")
|
|
281
|
-
logger.warning(f" ssh-keygen -R {remove_entry}")
|
|
329
|
+
return exit_status, combined_output
|
|
282
330
|
|
|
283
|
-
|
|
284
|
-
host,
|
|
285
|
-
|
|
286
|
-
|
|
331
|
+
return_code, combined_output = execute_command_with_sudo_retry(
|
|
332
|
+
self.host,
|
|
333
|
+
arguments,
|
|
334
|
+
execute_command,
|
|
287
335
|
)
|
|
288
336
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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()
|
|
337
|
+
if _success_exit_codes:
|
|
338
|
+
status = return_code in _success_exit_codes
|
|
339
|
+
else:
|
|
340
|
+
status = return_code == 0
|
|
346
341
|
|
|
347
|
-
|
|
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)
|
|
342
|
+
return status, combined_output
|
|
356
343
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
344
|
+
@memoize
|
|
345
|
+
def get_sftp_connection(self):
|
|
346
|
+
assert self.client is not None
|
|
347
|
+
transport = self.client.get_transport()
|
|
348
|
+
assert transport is not None, "No transport"
|
|
349
|
+
try:
|
|
350
|
+
return SFTPClient.from_transport(transport)
|
|
351
|
+
except SSHException as e:
|
|
352
|
+
raise ConnectError(
|
|
353
|
+
(
|
|
354
|
+
"Unable to establish SFTP connection. Check that the SFTP subsystem "
|
|
355
|
+
"for the SSH service at {0} is enabled."
|
|
356
|
+
).format(self.host),
|
|
357
|
+
) from e
|
|
358
|
+
|
|
359
|
+
def _get_file(self, remote_filename: str, filename_or_io):
|
|
360
|
+
with get_file_io(filename_or_io, "wb") as file_io:
|
|
361
|
+
sftp = self.get_sftp_connection()
|
|
362
|
+
sftp.getfo(remote_filename, file_io)
|
|
363
|
+
|
|
364
|
+
def get_file(
|
|
365
|
+
self,
|
|
366
|
+
remote_filename: str,
|
|
367
|
+
filename_or_io,
|
|
368
|
+
remote_temp_filename=None,
|
|
369
|
+
print_output: bool = False,
|
|
370
|
+
print_input: bool = False,
|
|
371
|
+
**arguments: Unpack["ConnectorArguments"],
|
|
372
|
+
) -> bool:
|
|
373
|
+
"""
|
|
374
|
+
Download a file from the remote host using SFTP. Supports download files
|
|
375
|
+
with sudo by copying to a temporary directory with read permissions,
|
|
376
|
+
downloading and then removing the copy.
|
|
377
|
+
"""
|
|
378
|
+
|
|
379
|
+
_sudo = arguments.get("_sudo", False)
|
|
380
|
+
_su_user = arguments.get("_su_user", None)
|
|
381
|
+
|
|
382
|
+
if _sudo or _su_user:
|
|
383
|
+
# Get temp file location
|
|
384
|
+
temp_file = remote_temp_filename or self.host.get_temp_filename(remote_filename)
|
|
385
|
+
|
|
386
|
+
# Copy the file to the tempfile location and add read permissions
|
|
387
|
+
command = StringCommand(
|
|
388
|
+
"cp", remote_filename, temp_file, "&&", "chmod", "+r", temp_file
|
|
389
|
+
)
|
|
362
390
|
|
|
363
|
-
|
|
364
|
-
|
|
391
|
+
copy_status, output = self.run_shell_command(
|
|
392
|
+
command,
|
|
393
|
+
print_output=print_output,
|
|
394
|
+
print_input=print_input,
|
|
395
|
+
**arguments,
|
|
396
|
+
)
|
|
365
397
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
timeout=timeout,
|
|
370
|
-
print_output=print_output,
|
|
371
|
-
print_prefix=host.print_prefix,
|
|
372
|
-
)
|
|
398
|
+
if copy_status is False:
|
|
399
|
+
logger.error("File download copy temp error: {0}".format(output.stderr))
|
|
400
|
+
return False
|
|
373
401
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
402
|
+
try:
|
|
403
|
+
self._get_file(temp_file, filename_or_io)
|
|
404
|
+
|
|
405
|
+
# Ensure that, even if we encounter an error, we (attempt to) remove the
|
|
406
|
+
# temporary copy of the file.
|
|
407
|
+
finally:
|
|
408
|
+
remove_status, output = self.run_shell_command(
|
|
409
|
+
StringCommand("rm", "-f", temp_file),
|
|
410
|
+
print_output=print_output,
|
|
411
|
+
print_input=print_input,
|
|
412
|
+
**arguments,
|
|
413
|
+
)
|
|
377
414
|
|
|
378
|
-
|
|
415
|
+
if remove_status is False:
|
|
416
|
+
logger.error("File download remove temp error: {0}".format(output.stderr))
|
|
417
|
+
return False
|
|
379
418
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
command_kwargs,
|
|
383
|
-
execute_command,
|
|
384
|
-
)
|
|
419
|
+
else:
|
|
420
|
+
self._get_file(remote_filename, filename_or_io)
|
|
385
421
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
422
|
+
if print_output:
|
|
423
|
+
click.echo(
|
|
424
|
+
"{0}file downloaded: {1}".format(self.host.print_prefix, remote_filename),
|
|
425
|
+
err=True,
|
|
426
|
+
)
|
|
390
427
|
|
|
391
|
-
|
|
392
|
-
return status, combined_output
|
|
428
|
+
return True
|
|
393
429
|
|
|
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
|
-
"""
|
|
430
|
+
def _put_file(self, filename_or_io, remote_location):
|
|
431
|
+
logger.debug("Attempting upload of %s to %s", filename_or_io, remote_location)
|
|
438
432
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
temp_file = remote_temp_filename or state.get_temp_filename(remote_filename)
|
|
433
|
+
attempts = 0
|
|
434
|
+
last_e = None
|
|
442
435
|
|
|
443
|
-
|
|
444
|
-
|
|
436
|
+
while attempts < 3:
|
|
437
|
+
try:
|
|
438
|
+
with get_file_io(filename_or_io) as file_io:
|
|
439
|
+
sftp = self.get_sftp_connection()
|
|
440
|
+
sftp.putfo(file_io, remote_location)
|
|
441
|
+
return
|
|
442
|
+
except OSError as e:
|
|
443
|
+
logger.warning(f"Failed to upload file, retrying: {e}")
|
|
444
|
+
attempts += 1
|
|
445
|
+
last_e = e
|
|
446
|
+
|
|
447
|
+
if last_e is not None:
|
|
448
|
+
raise last_e
|
|
449
|
+
|
|
450
|
+
def put_file(
|
|
451
|
+
self,
|
|
452
|
+
filename_or_io,
|
|
453
|
+
remote_filename,
|
|
454
|
+
remote_temp_filename=None,
|
|
455
|
+
print_output: bool = False,
|
|
456
|
+
print_input: bool = False,
|
|
457
|
+
**arguments: Unpack["ConnectorArguments"],
|
|
458
|
+
) -> bool:
|
|
459
|
+
"""
|
|
460
|
+
Upload file-ios to the specified host using SFTP. Supports uploading files
|
|
461
|
+
with sudo by uploading to a temporary directory then moving & chowning.
|
|
462
|
+
"""
|
|
463
|
+
|
|
464
|
+
original_arguments = arguments.copy()
|
|
465
|
+
|
|
466
|
+
_sudo = arguments.pop("_sudo", False)
|
|
467
|
+
_sudo_user = arguments.pop("_sudo_user", False)
|
|
468
|
+
_doas = arguments.pop("_doas", False)
|
|
469
|
+
_doas_user = arguments.pop("_doas_user", False)
|
|
470
|
+
_su_user = arguments.pop("_su_user", None)
|
|
471
|
+
|
|
472
|
+
# sudo/su are a little more complicated, as you can only sftp with the SSH
|
|
473
|
+
# user connected, so upload to tmp and copy/chown w/sudo and/or su_user
|
|
474
|
+
if _sudo or _doas or _su_user:
|
|
475
|
+
# Get temp file location
|
|
476
|
+
temp_file = remote_temp_filename or self.host.get_temp_filename(remote_filename)
|
|
477
|
+
self._put_file(filename_or_io, temp_file)
|
|
478
|
+
|
|
479
|
+
# Make sure our sudo/su user can access the file
|
|
480
|
+
other_user = _su_user or _sudo_user or _doas_user
|
|
481
|
+
if other_user:
|
|
482
|
+
status, output = self.run_shell_command(
|
|
483
|
+
StringCommand("setfacl", "-m", f"u:{other_user}:r", temp_file),
|
|
484
|
+
print_output=print_output,
|
|
485
|
+
print_input=print_input,
|
|
486
|
+
**arguments,
|
|
487
|
+
)
|
|
445
488
|
|
|
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
|
-
)
|
|
489
|
+
if status is False:
|
|
490
|
+
logger.error("Error on handover to sudo/su user: {0}".format(output.stderr))
|
|
491
|
+
return False
|
|
457
492
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
return False
|
|
493
|
+
# Execute run_shell_command w/sudo, etc
|
|
494
|
+
command = StringCommand("cp", temp_file, QuoteString(remote_filename))
|
|
461
495
|
|
|
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,
|
|
496
|
+
status, output = self.run_shell_command(
|
|
497
|
+
command,
|
|
475
498
|
print_output=print_output,
|
|
476
499
|
print_input=print_input,
|
|
477
|
-
**
|
|
500
|
+
**original_arguments,
|
|
478
501
|
)
|
|
479
502
|
|
|
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
|
|
503
|
+
if status is False:
|
|
504
|
+
logger.error("File upload error: {0}".format(output.stderr))
|
|
505
|
+
return False
|
|
501
506
|
|
|
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
|
-
"""
|
|
507
|
+
# Delete the temporary file now that we've successfully copied it
|
|
508
|
+
command = StringCommand("rm", "-f", temp_file)
|
|
536
509
|
|
|
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,
|
|
510
|
+
status, output = self.run_shell_command(
|
|
556
511
|
command,
|
|
557
|
-
sudo=False,
|
|
558
512
|
print_output=print_output,
|
|
559
513
|
print_input=print_input,
|
|
560
|
-
**
|
|
514
|
+
**arguments,
|
|
561
515
|
)
|
|
562
516
|
|
|
563
517
|
if status is False:
|
|
564
|
-
logger.error("
|
|
518
|
+
logger.error("Unable to remove temporary file: {0}".format(output.stderr))
|
|
565
519
|
return False
|
|
566
520
|
|
|
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
|
-
)
|
|
521
|
+
# No sudo and no su_user, so just upload it!
|
|
522
|
+
else:
|
|
523
|
+
self._put_file(filename_or_io, remote_filename)
|
|
583
524
|
|
|
584
|
-
if
|
|
585
|
-
|
|
586
|
-
|
|
525
|
+
if print_output:
|
|
526
|
+
click.echo(
|
|
527
|
+
"{0}file uploaded: {1}".format(self.host.print_prefix, remote_filename),
|
|
528
|
+
err=True,
|
|
529
|
+
)
|
|
587
530
|
|
|
588
|
-
|
|
589
|
-
command = StringCommand("rm", "-f", temp_file)
|
|
531
|
+
return True
|
|
590
532
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
**command_kwargs,
|
|
600
|
-
)
|
|
533
|
+
def check_can_rsync(self):
|
|
534
|
+
if self.data["ssh_key_password"]:
|
|
535
|
+
raise NotImplementedError(
|
|
536
|
+
"Rsync does not currently work with SSH keys needing passwords."
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
if self.data["ssh_password"]:
|
|
540
|
+
raise NotImplementedError("Rsync does not currently work with SSH passwords.")
|
|
601
541
|
|
|
602
|
-
if
|
|
603
|
-
|
|
604
|
-
return False
|
|
542
|
+
if not find_executable("rsync"):
|
|
543
|
+
raise NotImplementedError("The `rsync` binary is not available on this system.")
|
|
605
544
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
545
|
+
def rsync(
|
|
546
|
+
self,
|
|
547
|
+
src: str,
|
|
548
|
+
dest: str,
|
|
549
|
+
flags: Iterable[str],
|
|
550
|
+
print_output: bool = False,
|
|
551
|
+
print_input: bool = False,
|
|
552
|
+
**arguments: Unpack["ConnectorArguments"],
|
|
553
|
+
):
|
|
554
|
+
_sudo = arguments.pop("_sudo", False)
|
|
555
|
+
_sudo_user = arguments.pop("_sudo_user", False)
|
|
556
|
+
|
|
557
|
+
hostname = self.data["ssh_hostname"] or self.host.name
|
|
558
|
+
user = self.data["ssh_user"]
|
|
559
|
+
if user:
|
|
560
|
+
user = "{0}@".format(user)
|
|
561
|
+
|
|
562
|
+
ssh_flags = []
|
|
563
|
+
# To avoid asking for interactive input, specify BatchMode=yes
|
|
564
|
+
ssh_flags.append("-o BatchMode=yes")
|
|
565
|
+
|
|
566
|
+
known_hosts_file = self.data["ssh_known_hosts_file"]
|
|
567
|
+
if known_hosts_file:
|
|
568
|
+
ssh_flags.append(
|
|
569
|
+
'-o \\"UserKnownHostsFile={0}\\"'.format(shlex.quote(known_hosts_file))
|
|
570
|
+
) # never trust users
|
|
571
|
+
|
|
572
|
+
strict_host_key_checking = self.data["ssh_strict_host_key_checking"]
|
|
573
|
+
if strict_host_key_checking:
|
|
574
|
+
ssh_flags.append(
|
|
575
|
+
'-o \\"StrictHostKeyChecking={0}\\"'.format(shlex.quote(strict_host_key_checking))
|
|
576
|
+
)
|
|
609
577
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
"{0}
|
|
613
|
-
|
|
578
|
+
ssh_config_file = self.data["ssh_config_file"]
|
|
579
|
+
if ssh_config_file:
|
|
580
|
+
ssh_flags.append("-F {0}".format(shlex.quote(ssh_config_file)))
|
|
581
|
+
|
|
582
|
+
port = self.data["ssh_port"]
|
|
583
|
+
if port:
|
|
584
|
+
ssh_flags.append("-p {0}".format(port))
|
|
585
|
+
|
|
586
|
+
ssh_key = self.data["ssh_key"]
|
|
587
|
+
if ssh_key:
|
|
588
|
+
ssh_flags.append("-i {0}".format(ssh_key))
|
|
589
|
+
|
|
590
|
+
remote_rsync_command = "rsync"
|
|
591
|
+
if _sudo:
|
|
592
|
+
remote_rsync_command = "sudo rsync"
|
|
593
|
+
if _sudo_user:
|
|
594
|
+
remote_rsync_command = "sudo -u {0} rsync".format(_sudo_user)
|
|
595
|
+
|
|
596
|
+
rsync_command = (
|
|
597
|
+
"rsync {rsync_flags} "
|
|
598
|
+
'--rsh "ssh {ssh_flags}" '
|
|
599
|
+
"--rsync-path '{remote_rsync_command}' "
|
|
600
|
+
"{src} {user}{hostname}:{dest}"
|
|
601
|
+
).format(
|
|
602
|
+
rsync_flags=" ".join(flags),
|
|
603
|
+
ssh_flags=" ".join(ssh_flags),
|
|
604
|
+
remote_rsync_command=remote_rsync_command,
|
|
605
|
+
user=user,
|
|
606
|
+
hostname=hostname,
|
|
607
|
+
src=src,
|
|
608
|
+
dest=dest,
|
|
614
609
|
)
|
|
615
610
|
|
|
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))
|
|
611
|
+
if print_input:
|
|
612
|
+
click.echo("{0}>>> {1}".format(self.host.print_prefix, rsync_command), err=True)
|
|
613
|
+
|
|
614
|
+
return_code, output = run_local_process(
|
|
615
|
+
rsync_command,
|
|
616
|
+
print_output=print_output,
|
|
617
|
+
print_prefix=self.host.print_prefix,
|
|
661
618
|
)
|
|
662
619
|
|
|
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
|
|
620
|
+
status = return_code == 0
|
|
621
|
+
if not status:
|
|
622
|
+
raise IOError(output.stderr)
|
|
623
|
+
|
|
624
|
+
return True
|