pyinfra 0.11.dev3__py3-none-any.whl → 3.5.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyinfra/__init__.py +9 -12
- pyinfra/__main__.py +4 -0
- pyinfra/api/__init__.py +18 -3
- pyinfra/api/arguments.py +406 -0
- pyinfra/api/arguments_typed.py +79 -0
- pyinfra/api/command.py +274 -0
- pyinfra/api/config.py +222 -28
- pyinfra/api/connect.py +33 -13
- pyinfra/api/connectors.py +27 -0
- pyinfra/api/deploy.py +65 -66
- pyinfra/api/exceptions.py +67 -18
- pyinfra/api/facts.py +253 -202
- pyinfra/api/host.py +413 -50
- pyinfra/api/inventory.py +121 -160
- pyinfra/api/operation.py +432 -262
- pyinfra/api/operations.py +273 -260
- pyinfra/api/state.py +302 -248
- pyinfra/api/util.py +291 -368
- pyinfra/connectors/base.py +173 -0
- pyinfra/connectors/chroot.py +212 -0
- pyinfra/connectors/docker.py +381 -0
- pyinfra/connectors/dockerssh.py +297 -0
- pyinfra/connectors/local.py +238 -0
- pyinfra/connectors/scp/__init__.py +1 -0
- pyinfra/connectors/scp/client.py +204 -0
- pyinfra/connectors/ssh.py +670 -0
- pyinfra/connectors/ssh_util.py +114 -0
- pyinfra/connectors/sshuserclient/client.py +309 -0
- pyinfra/connectors/sshuserclient/config.py +102 -0
- pyinfra/connectors/terraform.py +135 -0
- pyinfra/connectors/util.py +410 -0
- pyinfra/connectors/vagrant.py +183 -0
- pyinfra/context.py +145 -0
- pyinfra/facts/__init__.py +7 -6
- pyinfra/facts/apk.py +22 -7
- pyinfra/facts/apt.py +117 -60
- pyinfra/facts/brew.py +100 -15
- pyinfra/facts/bsdinit.py +23 -0
- pyinfra/facts/cargo.py +37 -0
- pyinfra/facts/choco.py +47 -0
- pyinfra/facts/crontab.py +195 -0
- pyinfra/facts/deb.py +94 -0
- pyinfra/facts/dnf.py +48 -0
- pyinfra/facts/docker.py +96 -23
- pyinfra/facts/efibootmgr.py +113 -0
- pyinfra/facts/files.py +630 -58
- pyinfra/facts/flatpak.py +77 -0
- pyinfra/facts/freebsd.py +70 -0
- pyinfra/facts/gem.py +19 -6
- pyinfra/facts/git.py +59 -14
- pyinfra/facts/gpg.py +150 -0
- pyinfra/facts/hardware.py +313 -167
- pyinfra/facts/iptables.py +72 -62
- pyinfra/facts/launchd.py +44 -0
- pyinfra/facts/lxd.py +17 -4
- pyinfra/facts/mysql.py +122 -86
- pyinfra/facts/npm.py +17 -9
- pyinfra/facts/openrc.py +71 -0
- pyinfra/facts/opkg.py +246 -0
- pyinfra/facts/pacman.py +50 -7
- pyinfra/facts/pip.py +24 -7
- pyinfra/facts/pipx.py +82 -0
- pyinfra/facts/pkg.py +15 -6
- pyinfra/facts/pkgin.py +35 -0
- pyinfra/facts/podman.py +54 -0
- pyinfra/facts/postgres.py +178 -0
- pyinfra/facts/postgresql.py +6 -147
- pyinfra/facts/rpm.py +105 -0
- pyinfra/facts/runit.py +77 -0
- pyinfra/facts/selinux.py +161 -0
- pyinfra/facts/server.py +746 -285
- pyinfra/facts/snap.py +88 -0
- pyinfra/facts/systemd.py +139 -0
- pyinfra/facts/sysvinit.py +59 -0
- pyinfra/facts/upstart.py +35 -0
- pyinfra/facts/util/__init__.py +17 -0
- pyinfra/facts/util/databases.py +4 -6
- pyinfra/facts/util/packaging.py +37 -6
- pyinfra/facts/util/units.py +30 -0
- pyinfra/facts/util/win_files.py +99 -0
- pyinfra/facts/vzctl.py +20 -13
- pyinfra/facts/xbps.py +35 -0
- pyinfra/facts/yum.py +34 -40
- pyinfra/facts/zfs.py +77 -0
- pyinfra/facts/zypper.py +42 -0
- pyinfra/local.py +45 -83
- pyinfra/operations/__init__.py +12 -0
- pyinfra/operations/apk.py +98 -0
- pyinfra/operations/apt.py +488 -0
- pyinfra/operations/brew.py +231 -0
- pyinfra/operations/bsdinit.py +59 -0
- pyinfra/operations/cargo.py +45 -0
- pyinfra/operations/choco.py +61 -0
- pyinfra/operations/crontab.py +191 -0
- pyinfra/operations/dnf.py +210 -0
- pyinfra/operations/docker.py +446 -0
- pyinfra/operations/files.py +1939 -0
- pyinfra/operations/flatpak.py +94 -0
- pyinfra/operations/freebsd/__init__.py +12 -0
- pyinfra/operations/freebsd/freebsd_update.py +70 -0
- pyinfra/operations/freebsd/pkg.py +219 -0
- pyinfra/operations/freebsd/service.py +116 -0
- pyinfra/operations/freebsd/sysrc.py +92 -0
- pyinfra/operations/gem.py +47 -0
- pyinfra/operations/git.py +419 -0
- pyinfra/operations/iptables.py +311 -0
- pyinfra/operations/launchd.py +45 -0
- pyinfra/operations/lxd.py +68 -0
- pyinfra/operations/mysql.py +609 -0
- pyinfra/operations/npm.py +57 -0
- pyinfra/operations/openrc.py +63 -0
- pyinfra/operations/opkg.py +88 -0
- pyinfra/operations/pacman.py +81 -0
- pyinfra/operations/pip.py +205 -0
- pyinfra/operations/pipx.py +102 -0
- pyinfra/operations/pkg.py +70 -0
- pyinfra/operations/pkgin.py +91 -0
- pyinfra/operations/postgres.py +436 -0
- pyinfra/operations/postgresql.py +30 -0
- pyinfra/operations/puppet.py +40 -0
- pyinfra/operations/python.py +72 -0
- pyinfra/operations/runit.py +184 -0
- pyinfra/operations/selinux.py +189 -0
- pyinfra/operations/server.py +1099 -0
- pyinfra/operations/snap.py +117 -0
- pyinfra/operations/ssh.py +216 -0
- pyinfra/operations/systemd.py +149 -0
- pyinfra/operations/sysvinit.py +141 -0
- pyinfra/operations/upstart.py +68 -0
- pyinfra/operations/util/__init__.py +12 -0
- pyinfra/operations/util/docker.py +251 -0
- pyinfra/operations/util/files.py +247 -0
- pyinfra/operations/util/packaging.py +336 -0
- pyinfra/operations/util/service.py +46 -0
- pyinfra/operations/vzctl.py +137 -0
- pyinfra/operations/xbps.py +77 -0
- pyinfra/operations/yum.py +210 -0
- pyinfra/operations/zfs.py +175 -0
- pyinfra/operations/zypper.py +192 -0
- pyinfra/progress.py +44 -32
- pyinfra/py.typed +0 -0
- pyinfra/version.py +9 -1
- pyinfra-3.5.1.dist-info/METADATA +141 -0
- pyinfra-3.5.1.dist-info/RECORD +159 -0
- {pyinfra-0.11.dev3.dist-info → pyinfra-3.5.1.dist-info}/WHEEL +1 -2
- pyinfra-3.5.1.dist-info/entry_points.txt +12 -0
- {pyinfra-0.11.dev3.dist-info → pyinfra-3.5.1.dist-info/licenses}/LICENSE.md +1 -1
- pyinfra_cli/__init__.py +1 -0
- pyinfra_cli/cli.py +780 -0
- pyinfra_cli/commands.py +66 -0
- pyinfra_cli/exceptions.py +155 -65
- pyinfra_cli/inventory.py +233 -89
- pyinfra_cli/log.py +39 -43
- pyinfra_cli/main.py +26 -495
- pyinfra_cli/prints.py +215 -156
- pyinfra_cli/util.py +172 -105
- pyinfra_cli/virtualenv.py +25 -20
- pyinfra/api/connectors/__init__.py +0 -21
- pyinfra/api/connectors/ansible.py +0 -99
- pyinfra/api/connectors/docker.py +0 -178
- pyinfra/api/connectors/local.py +0 -169
- pyinfra/api/connectors/ssh.py +0 -402
- pyinfra/api/connectors/sshuserclient/client.py +0 -105
- pyinfra/api/connectors/sshuserclient/config.py +0 -90
- pyinfra/api/connectors/util.py +0 -63
- pyinfra/api/connectors/vagrant.py +0 -155
- pyinfra/facts/init.py +0 -176
- pyinfra/facts/util/files.py +0 -102
- pyinfra/hook.py +0 -41
- pyinfra/modules/__init__.py +0 -11
- pyinfra/modules/apk.py +0 -64
- pyinfra/modules/apt.py +0 -272
- pyinfra/modules/brew.py +0 -122
- pyinfra/modules/files.py +0 -711
- pyinfra/modules/gem.py +0 -30
- pyinfra/modules/git.py +0 -115
- pyinfra/modules/init.py +0 -344
- pyinfra/modules/iptables.py +0 -271
- pyinfra/modules/lxd.py +0 -45
- pyinfra/modules/mysql.py +0 -347
- pyinfra/modules/npm.py +0 -47
- pyinfra/modules/pacman.py +0 -60
- pyinfra/modules/pip.py +0 -99
- pyinfra/modules/pkg.py +0 -43
- pyinfra/modules/postgresql.py +0 -245
- pyinfra/modules/puppet.py +0 -20
- pyinfra/modules/python.py +0 -37
- pyinfra/modules/server.py +0 -524
- pyinfra/modules/ssh.py +0 -150
- pyinfra/modules/util/files.py +0 -52
- pyinfra/modules/util/packaging.py +0 -118
- pyinfra/modules/vzctl.py +0 -133
- pyinfra/modules/yum.py +0 -171
- pyinfra/pseudo_modules.py +0 -64
- pyinfra-0.11.dev3.dist-info/.DS_Store +0 -0
- pyinfra-0.11.dev3.dist-info/METADATA +0 -135
- pyinfra-0.11.dev3.dist-info/RECORD +0 -95
- pyinfra-0.11.dev3.dist-info/entry_points.txt +0 -3
- pyinfra-0.11.dev3.dist-info/top_level.txt +0 -2
- pyinfra_cli/__main__.py +0 -40
- pyinfra_cli/config.py +0 -92
- /pyinfra/{modules/util → connectors}/__init__.py +0 -0
- /pyinfra/{api/connectors → connectors}/sshuserclient/__init__.py +0 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from getpass import getpass
|
|
2
|
+
from os import path
|
|
3
|
+
from typing import TYPE_CHECKING, Type, Union
|
|
4
|
+
|
|
5
|
+
from paramiko import (
|
|
6
|
+
DSSKey,
|
|
7
|
+
ECDSAKey,
|
|
8
|
+
Ed25519Key,
|
|
9
|
+
PasswordRequiredException,
|
|
10
|
+
PKey,
|
|
11
|
+
RSAKey,
|
|
12
|
+
SSHException,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
import pyinfra
|
|
16
|
+
from pyinfra.api.exceptions import ConnectError, PyinfraError
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from pyinfra.api.host import Host
|
|
20
|
+
from pyinfra.api.state import State
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def raise_connect_error(host: "Host", message, data):
|
|
24
|
+
message = "{0} ({1})".format(message, data)
|
|
25
|
+
raise ConnectError(message)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _load_private_key_file(filename: str, key_filename: str, key_password: str):
|
|
29
|
+
exception: Union[PyinfraError, SSHException] = PyinfraError("Invalid key: {0}".format(filename))
|
|
30
|
+
|
|
31
|
+
key_cls: Union[Type[RSAKey], Type[DSSKey], Type[ECDSAKey], Type[Ed25519Key]]
|
|
32
|
+
|
|
33
|
+
for key_cls in (RSAKey, DSSKey, ECDSAKey, Ed25519Key):
|
|
34
|
+
try:
|
|
35
|
+
return key_cls.from_private_key_file(
|
|
36
|
+
filename=filename,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
except PasswordRequiredException:
|
|
40
|
+
if not key_password:
|
|
41
|
+
# If password is not provided, but we're in CLI mode, ask for it. I'm not a
|
|
42
|
+
# huge fan of having CLI specific code in here, but it doesn't really fit
|
|
43
|
+
# anywhere else without duplicating lots of key related code into cli.py.
|
|
44
|
+
if pyinfra.is_cli:
|
|
45
|
+
key_password = getpass(
|
|
46
|
+
"Enter password for private key: {0}: ".format(
|
|
47
|
+
key_filename,
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# API mode and no password? We can't continue!
|
|
52
|
+
else:
|
|
53
|
+
raise PyinfraError(
|
|
54
|
+
"Private key file ({0}) is encrypted, set ssh_key_password to "
|
|
55
|
+
"use this key".format(key_filename),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
return key_cls.from_private_key_file(
|
|
60
|
+
filename=filename,
|
|
61
|
+
password=key_password,
|
|
62
|
+
)
|
|
63
|
+
except SSHException as e: # key does not match key_cls type
|
|
64
|
+
exception = e
|
|
65
|
+
except SSHException as e: # key does not match key_cls type
|
|
66
|
+
exception = e
|
|
67
|
+
raise exception
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_private_key(state: "State", key_filename: str, key_password: str) -> PKey:
|
|
71
|
+
if key_filename in state.private_keys:
|
|
72
|
+
return state.private_keys[key_filename]
|
|
73
|
+
|
|
74
|
+
ssh_key_filenames = [
|
|
75
|
+
# Global from executed directory
|
|
76
|
+
path.expanduser(key_filename),
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
if state.cwd:
|
|
80
|
+
# Relative to the CWD
|
|
81
|
+
path.join(state.cwd, key_filename)
|
|
82
|
+
|
|
83
|
+
key = None
|
|
84
|
+
key_file_exists = False
|
|
85
|
+
|
|
86
|
+
for filename in ssh_key_filenames:
|
|
87
|
+
if not path.isfile(filename):
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
key_file_exists = True
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
key = _load_private_key_file(filename, key_filename, key_password)
|
|
94
|
+
break
|
|
95
|
+
except SSHException:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
# No break, so no key found
|
|
99
|
+
if not key:
|
|
100
|
+
if not key_file_exists:
|
|
101
|
+
raise PyinfraError("No such private key file: {0}".format(key_filename))
|
|
102
|
+
raise PyinfraError("Invalid private key file: {0}".format(key_filename))
|
|
103
|
+
|
|
104
|
+
# Load any certificate, names from OpenSSH:
|
|
105
|
+
# https://github.com/openssh/openssh-portable/blob/049297de975b92adcc2db77e3fb7046c0e3c695d/ssh-keygen.c#L2453 # noqa: E501
|
|
106
|
+
for certificate_filename in (
|
|
107
|
+
"{0}-cert.pub".format(key_filename),
|
|
108
|
+
"{0}.pub".format(key_filename),
|
|
109
|
+
):
|
|
110
|
+
if path.isfile(certificate_filename):
|
|
111
|
+
key.load_certificate(certificate_filename)
|
|
112
|
+
|
|
113
|
+
state.private_keys[key_filename] = key
|
|
114
|
+
return key
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This file as originally part of the "sshuserclient" pypi package. The GitHub
|
|
3
|
+
source has now vanished (https://github.com/tobald/sshuserclient).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from os import path
|
|
7
|
+
|
|
8
|
+
from gevent.lock import BoundedSemaphore
|
|
9
|
+
from paramiko import (
|
|
10
|
+
HostKeys,
|
|
11
|
+
MissingHostKeyPolicy,
|
|
12
|
+
ProxyCommand,
|
|
13
|
+
SSHClient as ParamikoClient,
|
|
14
|
+
SSHException,
|
|
15
|
+
)
|
|
16
|
+
from paramiko.agent import AgentRequestHandler
|
|
17
|
+
from paramiko.hostkeys import HostKeyEntry
|
|
18
|
+
from typing_extensions import override
|
|
19
|
+
|
|
20
|
+
from pyinfra import logger
|
|
21
|
+
from pyinfra.api.util import memoize
|
|
22
|
+
|
|
23
|
+
from .config import SSHConfig
|
|
24
|
+
|
|
25
|
+
HOST_KEYS_LOCK = BoundedSemaphore()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class StrictPolicy(MissingHostKeyPolicy):
|
|
29
|
+
@override
|
|
30
|
+
def missing_host_key(self, client, hostname, key):
|
|
31
|
+
logger.error("No host key for {0} found in known_hosts".format(hostname))
|
|
32
|
+
raise SSHException(
|
|
33
|
+
"StrictPolicy: No host key for {0} found in known_hosts".format(hostname),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def append_hostkey(client, hostname, key):
|
|
38
|
+
"""Append hostname to the clients host_keys_file"""
|
|
39
|
+
|
|
40
|
+
with HOST_KEYS_LOCK:
|
|
41
|
+
# The paramiko client saves host keys incorrectly whereas the host keys object does
|
|
42
|
+
# this correctly, so use that with the client filename variable.
|
|
43
|
+
# See: https://github.com/paramiko/paramiko/pull/1989
|
|
44
|
+
host_key_entry = HostKeyEntry([hostname], key)
|
|
45
|
+
if host_key_entry is None:
|
|
46
|
+
raise SSHException(
|
|
47
|
+
"Append Hostkey: Failed to parse host {0}, could not append to hostfile".format(
|
|
48
|
+
hostname
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
with open(client._host_keys_filename, "a") as host_keys_file:
|
|
52
|
+
hk_entry = host_key_entry.to_line()
|
|
53
|
+
if hk_entry is None:
|
|
54
|
+
raise SSHException(f"Append Hostkey: Failed to append hostkey ({host_key_entry})")
|
|
55
|
+
|
|
56
|
+
host_keys_file.write(hk_entry)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AcceptNewPolicy(MissingHostKeyPolicy):
|
|
60
|
+
@override
|
|
61
|
+
def missing_host_key(self, client, hostname, key):
|
|
62
|
+
logger.warning(
|
|
63
|
+
(
|
|
64
|
+
f"No host key for {hostname} found in known_hosts, "
|
|
65
|
+
"accepting & adding to host keys file"
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
append_hostkey(client, hostname, key)
|
|
70
|
+
logger.warning("Added host key for {0} to known_hosts".format(hostname))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class AskPolicy(MissingHostKeyPolicy):
|
|
74
|
+
@override
|
|
75
|
+
def missing_host_key(self, client, hostname, key):
|
|
76
|
+
should_continue = input(
|
|
77
|
+
"No host key for {0} found in known_hosts, do you want to continue [y/n] ".format(
|
|
78
|
+
hostname,
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
if should_continue.lower() != "y":
|
|
82
|
+
raise SSHException(
|
|
83
|
+
"AskPolicy: No host key for {0} found in known_hosts".format(hostname),
|
|
84
|
+
)
|
|
85
|
+
append_hostkey(client, hostname, key)
|
|
86
|
+
logger.warning("Added host key for {0} to known_hosts".format(hostname))
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class WarningPolicy(MissingHostKeyPolicy):
|
|
91
|
+
@override
|
|
92
|
+
def missing_host_key(self, client, hostname, key):
|
|
93
|
+
logger.warning("No host key for {0} found in known_hosts".format(hostname))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_missing_host_key_policy(policy):
|
|
97
|
+
if policy is None or policy == "ask":
|
|
98
|
+
return AskPolicy()
|
|
99
|
+
if policy == "no" or policy == "off":
|
|
100
|
+
return WarningPolicy()
|
|
101
|
+
if policy == "yes":
|
|
102
|
+
return StrictPolicy()
|
|
103
|
+
if policy == "accept-new":
|
|
104
|
+
return AcceptNewPolicy()
|
|
105
|
+
raise SSHException("Invalid value StrictHostKeyChecking={}".format(policy))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@memoize
|
|
109
|
+
def get_ssh_config(user_config_file=None):
|
|
110
|
+
logger.debug("Loading SSH config: %s", user_config_file)
|
|
111
|
+
|
|
112
|
+
if user_config_file is None:
|
|
113
|
+
user_config_file = path.expanduser("~/.ssh/config")
|
|
114
|
+
|
|
115
|
+
if path.exists(user_config_file):
|
|
116
|
+
with open(user_config_file, encoding="utf-8") as f:
|
|
117
|
+
ssh_config = SSHConfig()
|
|
118
|
+
ssh_config.parse(f)
|
|
119
|
+
return ssh_config
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@memoize
|
|
123
|
+
def get_host_keys(filename):
|
|
124
|
+
with HOST_KEYS_LOCK:
|
|
125
|
+
host_keys = HostKeys()
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
host_keys.load(filename)
|
|
129
|
+
# When paramiko encounters a bad host keys line it sometimes bails the
|
|
130
|
+
# entire load incorrectly.
|
|
131
|
+
# See: https://github.com/paramiko/paramiko/pull/1990
|
|
132
|
+
except Exception as e:
|
|
133
|
+
logger.warning("Failed to load host keys from {0}: {1}".format(filename, e))
|
|
134
|
+
|
|
135
|
+
return host_keys
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class SSHClient(ParamikoClient):
|
|
139
|
+
"""
|
|
140
|
+
An SSHClient which honors ssh_config and supports proxyjumping
|
|
141
|
+
original idea at http://bitprophet.org/blog/2012/11/05/gateway-solutions/.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
@override
|
|
145
|
+
def connect( # type: ignore[override]
|
|
146
|
+
self,
|
|
147
|
+
hostname,
|
|
148
|
+
_pyinfra_ssh_forward_agent=None,
|
|
149
|
+
_pyinfra_ssh_config_file=None,
|
|
150
|
+
_pyinfra_ssh_known_hosts_file=None,
|
|
151
|
+
_pyinfra_ssh_strict_host_key_checking=None,
|
|
152
|
+
_pyinfra_ssh_paramiko_connect_kwargs=None,
|
|
153
|
+
**kwargs,
|
|
154
|
+
):
|
|
155
|
+
(
|
|
156
|
+
hostname,
|
|
157
|
+
config,
|
|
158
|
+
forward_agent,
|
|
159
|
+
missing_host_key_policy,
|
|
160
|
+
host_keys_file,
|
|
161
|
+
keep_alive,
|
|
162
|
+
) = self.parse_config(
|
|
163
|
+
hostname,
|
|
164
|
+
kwargs,
|
|
165
|
+
ssh_config_file=_pyinfra_ssh_config_file,
|
|
166
|
+
strict_host_key_checking=_pyinfra_ssh_strict_host_key_checking,
|
|
167
|
+
)
|
|
168
|
+
self.set_missing_host_key_policy(missing_host_key_policy)
|
|
169
|
+
config.update(kwargs)
|
|
170
|
+
|
|
171
|
+
if _pyinfra_ssh_known_hosts_file:
|
|
172
|
+
host_keys_file = _pyinfra_ssh_known_hosts_file
|
|
173
|
+
|
|
174
|
+
# Overwrite paramiko empty defaults with @memoize-d host keys object
|
|
175
|
+
self._host_keys = get_host_keys(host_keys_file)
|
|
176
|
+
self._host_keys_filename = host_keys_file
|
|
177
|
+
|
|
178
|
+
if _pyinfra_ssh_paramiko_connect_kwargs:
|
|
179
|
+
config.update(_pyinfra_ssh_paramiko_connect_kwargs)
|
|
180
|
+
|
|
181
|
+
self._ssh_config = config
|
|
182
|
+
super().connect(hostname, **config)
|
|
183
|
+
|
|
184
|
+
if _pyinfra_ssh_forward_agent is not None:
|
|
185
|
+
forward_agent = _pyinfra_ssh_forward_agent
|
|
186
|
+
|
|
187
|
+
if keep_alive:
|
|
188
|
+
transport = self.get_transport()
|
|
189
|
+
assert transport is not None, "No transport"
|
|
190
|
+
transport.set_keepalive(keep_alive)
|
|
191
|
+
|
|
192
|
+
if forward_agent:
|
|
193
|
+
transport = self.get_transport()
|
|
194
|
+
assert transport is not None, "No transport"
|
|
195
|
+
session = transport.open_session()
|
|
196
|
+
AgentRequestHandler(session)
|
|
197
|
+
|
|
198
|
+
def gateway(self, hostname, host_port, target, target_port):
|
|
199
|
+
transport = self.get_transport()
|
|
200
|
+
assert transport is not None, "No transport"
|
|
201
|
+
return transport.open_channel(
|
|
202
|
+
"direct-tcpip",
|
|
203
|
+
(target, target_port),
|
|
204
|
+
(hostname, host_port),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def parse_config(
|
|
208
|
+
self,
|
|
209
|
+
hostname,
|
|
210
|
+
initial_cfg=None,
|
|
211
|
+
ssh_config_file=None,
|
|
212
|
+
strict_host_key_checking=None,
|
|
213
|
+
):
|
|
214
|
+
cfg: dict = {"port": 22}
|
|
215
|
+
cfg.update(initial_cfg or {})
|
|
216
|
+
|
|
217
|
+
keep_alive = 0
|
|
218
|
+
forward_agent = False
|
|
219
|
+
missing_host_key_policy = get_missing_host_key_policy(strict_host_key_checking)
|
|
220
|
+
host_keys_file = path.expanduser("~/.ssh/known_hosts") # OpenSSH default
|
|
221
|
+
|
|
222
|
+
ssh_config = get_ssh_config(ssh_config_file)
|
|
223
|
+
if not ssh_config:
|
|
224
|
+
return hostname, cfg, forward_agent, missing_host_key_policy, host_keys_file, keep_alive
|
|
225
|
+
|
|
226
|
+
host_config = ssh_config.lookup(hostname)
|
|
227
|
+
forward_agent = host_config.get("forwardagent") == "yes"
|
|
228
|
+
|
|
229
|
+
# If not overridden, apply any StrictHostKeyChecking
|
|
230
|
+
if strict_host_key_checking is None and "stricthostkeychecking" in host_config:
|
|
231
|
+
missing_host_key_policy = get_missing_host_key_policy(
|
|
232
|
+
host_config["stricthostkeychecking"],
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
if "userknownhostsfile" in host_config:
|
|
236
|
+
host_keys_file = path.expanduser(host_config["userknownhostsfile"])
|
|
237
|
+
|
|
238
|
+
if "hostname" in host_config:
|
|
239
|
+
hostname = host_config["hostname"]
|
|
240
|
+
|
|
241
|
+
if "user" in host_config:
|
|
242
|
+
cfg["username"] = host_config["user"]
|
|
243
|
+
|
|
244
|
+
if "identityfile" in host_config:
|
|
245
|
+
cfg["key_filename"] = host_config["identityfile"]
|
|
246
|
+
|
|
247
|
+
if "port" in host_config:
|
|
248
|
+
cfg["port"] = int(host_config["port"])
|
|
249
|
+
|
|
250
|
+
if "serveraliveinterval" in host_config:
|
|
251
|
+
keep_alive = int(host_config["serveraliveinterval"])
|
|
252
|
+
|
|
253
|
+
if "proxycommand" in host_config:
|
|
254
|
+
cfg["sock"] = ProxyCommand(host_config["proxycommand"])
|
|
255
|
+
|
|
256
|
+
elif "proxyjump" in host_config:
|
|
257
|
+
hops = host_config["proxyjump"].split(",")
|
|
258
|
+
sock = None
|
|
259
|
+
|
|
260
|
+
for i, hop in enumerate(hops):
|
|
261
|
+
hop_hostname, hop_config = self.derive_shorthand(ssh_config, hop)
|
|
262
|
+
logger.debug("SSH ProxyJump through %s:%s", hop_hostname, hop_config["port"])
|
|
263
|
+
|
|
264
|
+
c = SSHClient()
|
|
265
|
+
c.connect(
|
|
266
|
+
hop_hostname, _pyinfra_ssh_config_file=ssh_config_file, sock=sock, **hop_config
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
if i == len(hops) - 1:
|
|
270
|
+
target = hostname
|
|
271
|
+
target_config = {"port": cfg["port"]}
|
|
272
|
+
else:
|
|
273
|
+
target, target_config = self.derive_shorthand(ssh_config, hops[i + 1])
|
|
274
|
+
|
|
275
|
+
sock = c.gateway(hostname, cfg["port"], target, target_config["port"])
|
|
276
|
+
cfg["sock"] = sock
|
|
277
|
+
|
|
278
|
+
return hostname, cfg, forward_agent, missing_host_key_policy, host_keys_file, keep_alive
|
|
279
|
+
|
|
280
|
+
@staticmethod
|
|
281
|
+
def derive_shorthand(ssh_config, host_string):
|
|
282
|
+
shorthand_config = {}
|
|
283
|
+
user_hostport = host_string.rsplit("@", 1)
|
|
284
|
+
hostport = user_hostport.pop()
|
|
285
|
+
user = user_hostport[0] if user_hostport and user_hostport[0] else None
|
|
286
|
+
if user:
|
|
287
|
+
shorthand_config["username"] = user
|
|
288
|
+
|
|
289
|
+
# IPv6: can't reliably tell where addr ends and port begins, so don't
|
|
290
|
+
# try (and don't bother adding special syntax either, user should avoid
|
|
291
|
+
# this situation by using port=).
|
|
292
|
+
if hostport.count(":") > 1:
|
|
293
|
+
hostname = hostport
|
|
294
|
+
# IPv4: can split on ':' reliably.
|
|
295
|
+
else:
|
|
296
|
+
host_port = hostport.rsplit(":", 1)
|
|
297
|
+
hostname = host_port.pop(0) or None
|
|
298
|
+
if host_port and host_port[0]:
|
|
299
|
+
shorthand_config["port"] = int(host_port[0])
|
|
300
|
+
|
|
301
|
+
base_config = ssh_config.lookup(hostname)
|
|
302
|
+
|
|
303
|
+
config = {
|
|
304
|
+
"port": base_config.get("port", 22),
|
|
305
|
+
"username": base_config.get("user"),
|
|
306
|
+
}
|
|
307
|
+
config.update(shorthand_config)
|
|
308
|
+
|
|
309
|
+
return hostname, config
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This file as originally part of the "sshuserclient" pypi package. The GitHub
|
|
3
|
+
source has now vanished (https://github.com/tobald/sshuserclient).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import glob
|
|
7
|
+
import re
|
|
8
|
+
from os import environ, path
|
|
9
|
+
|
|
10
|
+
import paramiko.config
|
|
11
|
+
from gevent.subprocess import CalledProcessError, check_call
|
|
12
|
+
from paramiko import SSHConfig as ParamikoSSHConfig
|
|
13
|
+
from typing_extensions import override
|
|
14
|
+
|
|
15
|
+
from pyinfra import logger
|
|
16
|
+
|
|
17
|
+
SETTINGS_REGEX = re.compile(r"(\w+)(?:\s*=\s*|\s+)(.+)")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FakeInvokeResult:
|
|
21
|
+
ok = False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FakeInvoke:
|
|
25
|
+
@staticmethod
|
|
26
|
+
def run(cmd, *args, **kwargs):
|
|
27
|
+
result = FakeInvokeResult()
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
cmd = [environ["SHELL"], cmd]
|
|
31
|
+
try:
|
|
32
|
+
code = check_call(cmd)
|
|
33
|
+
except CalledProcessError as e:
|
|
34
|
+
code = e.returncode
|
|
35
|
+
result.ok = code == 0
|
|
36
|
+
except Exception as e:
|
|
37
|
+
logger.warning(
|
|
38
|
+
("pyinfra encountered an error loading SSH config match exec {0}: {1}").format(
|
|
39
|
+
cmd,
|
|
40
|
+
e,
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return result
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
paramiko.config.invoke = FakeInvoke # type: ignore
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _expand_include_statements(file_obj, parsed_files=None):
|
|
51
|
+
parsed_lines = []
|
|
52
|
+
|
|
53
|
+
for line in file_obj.readlines():
|
|
54
|
+
line = line.strip()
|
|
55
|
+
if not line or line.startswith("#"):
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
match = re.match(SETTINGS_REGEX, line)
|
|
59
|
+
if not match:
|
|
60
|
+
parsed_lines.append(line)
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
key = match.group(1).lower()
|
|
64
|
+
value = match.group(2)
|
|
65
|
+
|
|
66
|
+
if key != "include":
|
|
67
|
+
parsed_lines.append(line)
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
if parsed_files is None:
|
|
71
|
+
parsed_files = []
|
|
72
|
+
|
|
73
|
+
# The path can be relative to its parent configuration file
|
|
74
|
+
if path.isabs(value) is False and value[0] != "~":
|
|
75
|
+
folder = path.dirname(file_obj.name)
|
|
76
|
+
value = path.join(folder, value)
|
|
77
|
+
|
|
78
|
+
value = path.expanduser(value)
|
|
79
|
+
|
|
80
|
+
for filename in glob.iglob(value):
|
|
81
|
+
if path.isfile(filename):
|
|
82
|
+
if filename in parsed_files:
|
|
83
|
+
raise Exception(
|
|
84
|
+
"Include loop detected in ssh config file: %s" % filename,
|
|
85
|
+
)
|
|
86
|
+
with open(filename, encoding="utf-8") as fd:
|
|
87
|
+
parsed_files.append(filename)
|
|
88
|
+
parsed_lines.extend(_expand_include_statements(fd, parsed_files))
|
|
89
|
+
|
|
90
|
+
return parsed_lines
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class SSHConfig(ParamikoSSHConfig):
|
|
94
|
+
"""
|
|
95
|
+
an SSHConfig that supports includes directives
|
|
96
|
+
https://github.com/paramiko/paramiko/pull/1194
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
@override
|
|
100
|
+
def parse(self, file_obj):
|
|
101
|
+
file_obj = _expand_include_statements(file_obj)
|
|
102
|
+
return super().parse(file_obj)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from typing_extensions import override
|
|
4
|
+
|
|
5
|
+
from pyinfra import local, logger
|
|
6
|
+
from pyinfra.api.exceptions import InventoryError
|
|
7
|
+
from pyinfra.api.util import memoize
|
|
8
|
+
from pyinfra.progress import progress_spinner
|
|
9
|
+
|
|
10
|
+
from .base import BaseConnector
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@memoize
|
|
14
|
+
def show_warning() -> None:
|
|
15
|
+
logger.warning("The @terraform connector is in beta!")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _flatten_dict_gen(d, parent_key, sep):
|
|
19
|
+
for k, v in d.items():
|
|
20
|
+
new_key = parent_key + sep + k if parent_key else k
|
|
21
|
+
yield new_key, v
|
|
22
|
+
if isinstance(v, dict):
|
|
23
|
+
yield from _flatten_dict(v, new_key, sep=sep).items()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _flatten_dict(d: dict, parent_key: str = "", sep: str = "."):
|
|
27
|
+
return dict(_flatten_dict_gen(d, parent_key, sep))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TerraformInventoryConnector(BaseConnector):
|
|
31
|
+
"""
|
|
32
|
+
Generate one or more SSH hosts from a Terraform output variable. The variable
|
|
33
|
+
must be a list of hostnames or dictionaries.
|
|
34
|
+
|
|
35
|
+
Output is fetched from a flattened JSON dictionary output from ``terraform output
|
|
36
|
+
-json``. For example the following object:
|
|
37
|
+
|
|
38
|
+
.. code:: json
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
"server_group": {
|
|
42
|
+
"value": {
|
|
43
|
+
"server_group_node_ips": [
|
|
44
|
+
"1.2.3.4",
|
|
45
|
+
"1.2.3.5",
|
|
46
|
+
"1.2.3.6"
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
The IP list ``server_group_node_ips`` would be used like so:
|
|
53
|
+
|
|
54
|
+
.. code:: sh
|
|
55
|
+
|
|
56
|
+
pyinfra @terraform/server_group.value.server_group_node_ips ...
|
|
57
|
+
|
|
58
|
+
You can also specify dictionaries to include extra data with hosts:
|
|
59
|
+
|
|
60
|
+
.. code:: json
|
|
61
|
+
|
|
62
|
+
{
|
|
63
|
+
"server_group": {
|
|
64
|
+
"value": {
|
|
65
|
+
"server_group_node_ips": [
|
|
66
|
+
{
|
|
67
|
+
"ssh_hostname": "1.2.3.4",
|
|
68
|
+
"ssh_user": "ssh-user"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"ssh_hostname": "1.2.3.5",
|
|
72
|
+
"ssh_user": "ssh-user"
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
@override
|
|
82
|
+
@staticmethod
|
|
83
|
+
def make_names_data(name=None):
|
|
84
|
+
show_warning()
|
|
85
|
+
|
|
86
|
+
if not name:
|
|
87
|
+
# This is the default which allows one to create a Terraform output
|
|
88
|
+
# "pyinfra" and directly call: pyinfra @terraform ...
|
|
89
|
+
name = "pyinfra_inventory.value"
|
|
90
|
+
|
|
91
|
+
with progress_spinner({"fetch terraform output"}):
|
|
92
|
+
tf_output_raw = local.shell("terraform output -json")
|
|
93
|
+
|
|
94
|
+
assert isinstance(tf_output_raw, str)
|
|
95
|
+
tf_output = json.loads(tf_output_raw)
|
|
96
|
+
tf_output = _flatten_dict(tf_output)
|
|
97
|
+
|
|
98
|
+
tf_output_value = tf_output.get(name)
|
|
99
|
+
if tf_output_value is None:
|
|
100
|
+
keys = "\n".join(f" - {k}" for k in tf_output.keys())
|
|
101
|
+
raise InventoryError(f"No Terraform output with key: `{name}`, valid keys:\n{keys}")
|
|
102
|
+
|
|
103
|
+
if not isinstance(tf_output_value, (list, dict)):
|
|
104
|
+
raise InventoryError(
|
|
105
|
+
"Invalid Terraform output type, should be `list`, got "
|
|
106
|
+
f"`{type(tf_output_value).__name__}`",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if isinstance(tf_output_value, list):
|
|
110
|
+
tf_output_value = {
|
|
111
|
+
"all": tf_output_value,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for group_name, hosts in tf_output_value.items():
|
|
115
|
+
if not isinstance(hosts, list):
|
|
116
|
+
raise InventoryError(
|
|
117
|
+
"Invalid Terraform map value type, all values should be `list`, got "
|
|
118
|
+
f"`{type(hosts).__name__}`",
|
|
119
|
+
)
|
|
120
|
+
for host in hosts:
|
|
121
|
+
if isinstance(host, dict):
|
|
122
|
+
name = host.pop("name", host.get("ssh_hostname"))
|
|
123
|
+
if name is None:
|
|
124
|
+
raise InventoryError(
|
|
125
|
+
"Invalid Terraform list item, missing `name` or `ssh_hostname` keys",
|
|
126
|
+
)
|
|
127
|
+
yield f"@terraform/{name}", host, ["@terraform", group_name]
|
|
128
|
+
elif isinstance(host, str):
|
|
129
|
+
data = {"ssh_hostname": host}
|
|
130
|
+
yield f"@terraform/{host}", data, ["@terraform", group_name]
|
|
131
|
+
else:
|
|
132
|
+
raise InventoryError(
|
|
133
|
+
"Invalid Terraform list item, should be `dict` or `str` got "
|
|
134
|
+
f"`{type(host).__name__}`",
|
|
135
|
+
)
|