pyinfra 3.1__py2.py3-none-any.whl → 3.2__py2.py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyinfra/api/arguments.py +10 -3
- pyinfra/api/deploy.py +12 -2
- pyinfra/api/host.py +7 -4
- pyinfra/connectors/chroot.py +1 -1
- pyinfra/connectors/docker.py +17 -6
- pyinfra/connectors/local.py +1 -1
- pyinfra/connectors/ssh.py +3 -0
- pyinfra/connectors/sshuserclient/client.py +26 -14
- pyinfra/facts/apk.py +3 -1
- pyinfra/facts/apt.py +62 -2
- pyinfra/facts/crontab.py +190 -0
- pyinfra/facts/docker.py +6 -0
- pyinfra/facts/efibootmgr.py +108 -0
- pyinfra/facts/files.py +93 -6
- pyinfra/facts/git.py +3 -2
- pyinfra/facts/hardware.py +1 -0
- pyinfra/facts/mysql.py +1 -2
- pyinfra/facts/opkg.py +233 -0
- pyinfra/facts/pipx.py +74 -0
- pyinfra/facts/podman.py +47 -0
- pyinfra/facts/postgres.py +2 -0
- pyinfra/facts/selinux.py +3 -1
- pyinfra/facts/server.py +39 -77
- pyinfra/facts/util/units.py +30 -0
- pyinfra/facts/zfs.py +22 -19
- pyinfra/local.py +3 -2
- pyinfra/operations/apt.py +29 -21
- pyinfra/operations/crontab.py +189 -0
- pyinfra/operations/docker.py +13 -12
- pyinfra/operations/files.py +20 -2
- pyinfra/operations/git.py +48 -9
- pyinfra/operations/opkg.py +88 -0
- pyinfra/operations/pip.py +3 -2
- pyinfra/operations/pipx.py +90 -0
- pyinfra/operations/postgres.py +15 -11
- pyinfra/operations/runit.py +2 -0
- pyinfra/operations/server.py +4 -178
- pyinfra/operations/zfs.py +14 -14
- {pyinfra-3.1.dist-info → pyinfra-3.2.dist-info}/METADATA +11 -12
- {pyinfra-3.1.dist-info → pyinfra-3.2.dist-info}/RECORD +52 -43
- pyinfra_cli/inventory.py +26 -9
- pyinfra_cli/prints.py +18 -3
- pyinfra_cli/util.py +5 -2
- tests/test_cli/test_cli_deploy.py +15 -13
- tests/test_cli/test_cli_exceptions.py +2 -2
- tests/test_cli/test_cli_inventory.py +53 -0
- tests/test_cli/test_cli_util.py +2 -4
- tests/test_connectors/test_sshuserclient.py +68 -1
- {pyinfra-3.1.dist-info → pyinfra-3.2.dist-info}/LICENSE.md +0 -0
- {pyinfra-3.1.dist-info → pyinfra-3.2.dist-info}/WHEEL +0 -0
- {pyinfra-3.1.dist-info → pyinfra-3.2.dist-info}/entry_points.txt +0 -0
- {pyinfra-3.1.dist-info → pyinfra-3.2.dist-info}/top_level.txt +0 -0
pyinfra_cli/prints.py
CHANGED
|
@@ -281,6 +281,8 @@ def print_results(state: "State"):
|
|
|
281
281
|
(logger.info, ["Operation", "Hosts", "Success", "Error", "No Change"]),
|
|
282
282
|
]
|
|
283
283
|
|
|
284
|
+
totals = {"hosts": 0, "success": 0, "error": 0, "no_change": 0}
|
|
285
|
+
|
|
284
286
|
for op_hash in state.get_op_order():
|
|
285
287
|
hosts_in_op = 0
|
|
286
288
|
hosts_in_op_success: list[str] = []
|
|
@@ -306,19 +308,32 @@ def print_results(state: "State"):
|
|
|
306
308
|
str(hosts_in_op),
|
|
307
309
|
]
|
|
308
310
|
|
|
311
|
+
totals["hosts"] += hosts_in_op
|
|
312
|
+
|
|
309
313
|
if hosts_in_op_success:
|
|
310
|
-
|
|
314
|
+
num_hosts_in_op_success = len(hosts_in_op_success)
|
|
315
|
+
row.append(str(num_hosts_in_op_success))
|
|
316
|
+
totals["success"] += num_hosts_in_op_success
|
|
311
317
|
else:
|
|
312
318
|
row.append("-")
|
|
319
|
+
|
|
313
320
|
if hosts_in_op_error:
|
|
314
|
-
|
|
321
|
+
num_hosts_in_op_error = len(hosts_in_op_error)
|
|
322
|
+
row.append(str(num_hosts_in_op_error))
|
|
323
|
+
totals["error"] += num_hosts_in_op_error
|
|
315
324
|
else:
|
|
316
325
|
row.append("-")
|
|
326
|
+
|
|
317
327
|
if hosts_in_op_no_change:
|
|
318
|
-
|
|
328
|
+
num_hosts_in_op_no_change = len(hosts_in_op_no_change)
|
|
329
|
+
row.append(str(num_hosts_in_op_no_change))
|
|
330
|
+
totals["no_change"] += num_hosts_in_op_no_change
|
|
319
331
|
else:
|
|
320
332
|
row.append("-")
|
|
321
333
|
|
|
322
334
|
rows.append((logger.info, row))
|
|
323
335
|
|
|
336
|
+
totals_row = ["Grand total"] + [str(i) if i else "-" for i in totals.values()]
|
|
337
|
+
rows.append((logger.info, totals_row))
|
|
338
|
+
|
|
324
339
|
print_rows(rows)
|
pyinfra_cli/util.py
CHANGED
|
@@ -124,6 +124,9 @@ def json_encode(obj):
|
|
|
124
124
|
if isinstance(obj, bytes):
|
|
125
125
|
return obj.decode()
|
|
126
126
|
|
|
127
|
+
if hasattr(obj, "to_json"):
|
|
128
|
+
return obj.to_json()
|
|
129
|
+
|
|
127
130
|
raise TypeError("Cannot serialize: {0} ({1})".format(type(obj), obj))
|
|
128
131
|
|
|
129
132
|
|
|
@@ -180,13 +183,13 @@ def try_import_module_attribute(path, prefix=None, raise_for_none=True):
|
|
|
180
183
|
|
|
181
184
|
if module is None:
|
|
182
185
|
if raise_for_none:
|
|
183
|
-
raise CliError(f"No such module: {possible_modules[
|
|
186
|
+
raise CliError(f"No such module: {possible_modules[0]}")
|
|
184
187
|
return
|
|
185
188
|
|
|
186
189
|
attr = getattr(module, attr_name, None)
|
|
187
190
|
if attr is None:
|
|
188
191
|
if raise_for_none:
|
|
189
|
-
raise CliError(f"No such attribute in module {possible_modules[
|
|
192
|
+
raise CliError(f"No such attribute in module {possible_modules[0]}: {attr_name}")
|
|
190
193
|
return
|
|
191
194
|
|
|
192
195
|
return attr
|
|
@@ -43,26 +43,28 @@ class TestCliDeployState(PatchSSHTestCase):
|
|
|
43
43
|
assert executed is False
|
|
44
44
|
|
|
45
45
|
def test_deploy(self):
|
|
46
|
-
|
|
46
|
+
a_task_file_path = path.join("tasks", "a_task.py")
|
|
47
|
+
b_task_file_path = path.join("tasks", "b_task.py")
|
|
47
48
|
nested_task_path = path.join("tasks", "another_task.py")
|
|
48
49
|
correct_op_name_and_host_names = [
|
|
49
50
|
("First main operation", True), # true for all hosts
|
|
50
51
|
("Second main operation", ("somehost",)),
|
|
51
|
-
("{0} | First task operation".format(
|
|
52
|
-
("{0} | Task order loop 1".format(
|
|
53
|
-
("{0} | 2nd Task order loop 1".format(
|
|
54
|
-
("{0} | Task order loop 2".format(
|
|
55
|
-
("{0} | 2nd Task order loop 2".format(
|
|
52
|
+
("{0} | First task operation".format(a_task_file_path), ("anotherhost",)),
|
|
53
|
+
("{0} | Task order loop 1".format(a_task_file_path), ("anotherhost",)),
|
|
54
|
+
("{0} | 2nd Task order loop 1".format(a_task_file_path), ("anotherhost",)),
|
|
55
|
+
("{0} | Task order loop 2".format(a_task_file_path), ("anotherhost",)),
|
|
56
|
+
("{0} | 2nd Task order loop 2".format(a_task_file_path), ("anotherhost",)),
|
|
56
57
|
(
|
|
57
|
-
"{0} | {1} | Second task operation".format(
|
|
58
|
+
"{0} | {1} | Second task operation".format(a_task_file_path, nested_task_path),
|
|
58
59
|
("anotherhost",),
|
|
59
60
|
),
|
|
60
|
-
("{0} | First task operation".format(
|
|
61
|
-
("{0} | Task order loop 1".format(
|
|
62
|
-
("{0} | 2nd Task order loop 1".format(
|
|
63
|
-
("{0} | Task order loop 2".format(
|
|
64
|
-
("{0} | 2nd Task order loop 2".format(
|
|
65
|
-
("{0} | {1} | Second task operation".format(
|
|
61
|
+
("{0} | First task operation".format(a_task_file_path), True),
|
|
62
|
+
("{0} | Task order loop 1".format(a_task_file_path), True),
|
|
63
|
+
("{0} | 2nd Task order loop 1".format(a_task_file_path), True),
|
|
64
|
+
("{0} | Task order loop 2".format(a_task_file_path), True),
|
|
65
|
+
("{0} | 2nd Task order loop 2".format(a_task_file_path), True),
|
|
66
|
+
("{0} | {1} | Second task operation".format(a_task_file_path, nested_task_path), True),
|
|
67
|
+
("{0} | Important task operation".format(b_task_file_path), True),
|
|
66
68
|
("My deploy | First deploy operation", True),
|
|
67
69
|
("My deploy | My nested deploy | First nested deploy operation", True),
|
|
68
70
|
("My deploy | Second deploy operation", True),
|
|
@@ -38,13 +38,13 @@ class TestCliExceptions(TestCase):
|
|
|
38
38
|
def test_no_fact_module(self):
|
|
39
39
|
self.assert_cli_exception(
|
|
40
40
|
["my-server.net", "fact", "not_a_module.SomeFact"],
|
|
41
|
-
"No such module:
|
|
41
|
+
"No such module: not_a_module",
|
|
42
42
|
)
|
|
43
43
|
|
|
44
44
|
def test_no_fact_cls(self):
|
|
45
45
|
self.assert_cli_exception(
|
|
46
46
|
["my-server.net", "fact", "server.NotAFact"],
|
|
47
|
-
"No such attribute in module
|
|
47
|
+
"No such attribute in module server: NotAFact",
|
|
48
48
|
)
|
|
49
49
|
|
|
50
50
|
|
|
@@ -64,3 +64,56 @@ class TestCliInventory(PatchSSHTestCase):
|
|
|
64
64
|
assert "leftover_data" in inventory.group_data
|
|
65
65
|
assert inventory.group_data["leftover_data"].get("still_parsed") == "never_used"
|
|
66
66
|
assert inventory.group_data["leftover_data"].get("_global_arg") == "gets_parsed"
|
|
67
|
+
|
|
68
|
+
def test_ignores_variables_with_leading_underscore(self):
|
|
69
|
+
ctx_state.reset()
|
|
70
|
+
ctx_inventory.reset()
|
|
71
|
+
|
|
72
|
+
result = run_cli(
|
|
73
|
+
path.join("tests", "test_cli", "inventories", "invalid.py"),
|
|
74
|
+
"exec",
|
|
75
|
+
"--debug",
|
|
76
|
+
"--",
|
|
77
|
+
"echo hi",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
assert result.exit_code == 0, result.stdout
|
|
81
|
+
assert (
|
|
82
|
+
'Ignoring variable "_hosts" in inventory file since it starts with a leading underscore'
|
|
83
|
+
in result.stdout
|
|
84
|
+
)
|
|
85
|
+
assert inventory.hosts == {}
|
|
86
|
+
|
|
87
|
+
def test_only_supports_list_and_tuples(self):
|
|
88
|
+
ctx_state.reset()
|
|
89
|
+
ctx_inventory.reset()
|
|
90
|
+
|
|
91
|
+
result = run_cli(
|
|
92
|
+
path.join("tests", "test_cli", "inventories", "invalid.py"),
|
|
93
|
+
"exec",
|
|
94
|
+
"--debug",
|
|
95
|
+
"--",
|
|
96
|
+
"echo hi",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
assert result.exit_code == 0, result.stdout
|
|
100
|
+
assert 'Ignoring variable "dict_hosts" in inventory file' in result.stdout, result.stdout
|
|
101
|
+
assert (
|
|
102
|
+
'Ignoring variable "generator_hosts" in inventory file' in result.stdout
|
|
103
|
+
), result.stdout
|
|
104
|
+
assert inventory.hosts == {}
|
|
105
|
+
|
|
106
|
+
def test_host_groups_may_only_contain_strings_or_tuples(self):
|
|
107
|
+
ctx_state.reset()
|
|
108
|
+
ctx_inventory.reset()
|
|
109
|
+
|
|
110
|
+
result = run_cli(
|
|
111
|
+
path.join("tests", "test_cli", "inventories", "invalid.py"),
|
|
112
|
+
"exec",
|
|
113
|
+
"--",
|
|
114
|
+
"echo hi",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
assert result.exit_code == 0, result.stdout
|
|
118
|
+
assert 'Ignoring host group "issue_662"' in result.stdout, result.stdout
|
|
119
|
+
assert inventory.hosts == {}
|
tests/test_cli/test_cli_util.py
CHANGED
|
@@ -30,15 +30,13 @@ class TestCliUtil(TestCase):
|
|
|
30
30
|
def test_setup_no_module(self):
|
|
31
31
|
with self.assertRaises(CliError) as context:
|
|
32
32
|
get_func_and_args(("no.op",))
|
|
33
|
-
assert context.exception.message == "No such module:
|
|
33
|
+
assert context.exception.message == "No such module: no"
|
|
34
34
|
|
|
35
35
|
def test_setup_no_op(self):
|
|
36
36
|
with self.assertRaises(CliError) as context:
|
|
37
37
|
get_func_and_args(("server.no",))
|
|
38
38
|
|
|
39
|
-
assert
|
|
40
|
-
context.exception.message == "No such attribute in module pyinfra.operations.server: no"
|
|
41
|
-
)
|
|
39
|
+
assert context.exception.message == "No such attribute in module server: no"
|
|
42
40
|
|
|
43
41
|
def test_setup_op_and_args(self):
|
|
44
42
|
commands = ("pyinfra.operations.server.user", "one", "two", "hello=world")
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
from base64 import b64decode
|
|
1
2
|
from unittest import TestCase
|
|
2
3
|
from unittest.mock import mock_open, patch
|
|
3
4
|
|
|
4
|
-
from paramiko import ProxyCommand
|
|
5
|
+
from paramiko import PKey, ProxyCommand, SSHException
|
|
5
6
|
|
|
6
7
|
from pyinfra.connectors.sshuserclient import SSHClient
|
|
7
8
|
from pyinfra.connectors.sshuserclient.client import AskPolicy, get_ssh_config
|
|
@@ -41,6 +42,30 @@ LOOPING_SSH_CONFIG_DATA = """
|
|
|
41
42
|
Include other_file
|
|
42
43
|
"""
|
|
43
44
|
|
|
45
|
+
# To ensure that we don't remove things from users hostfiles
|
|
46
|
+
# we should test that all modifications only append to the
|
|
47
|
+
# hostfile, and don't delete any data or comments.
|
|
48
|
+
EXAMPLE_KEY_1 = (
|
|
49
|
+
"AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+"
|
|
50
|
+
"VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/"
|
|
51
|
+
"C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXk"
|
|
52
|
+
"E2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMj"
|
|
53
|
+
"A2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIE"
|
|
54
|
+
"s4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+Ej"
|
|
55
|
+
"qoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/Wnw"
|
|
56
|
+
"H6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk="
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
KNOWN_HOSTS_EXAMPLE_DATA = f"""
|
|
60
|
+
# this is an important comment
|
|
61
|
+
|
|
62
|
+
# another comment after the newline
|
|
63
|
+
|
|
64
|
+
@cert-authority example-domain.lan ssh-rsa {EXAMPLE_KEY_1}
|
|
65
|
+
|
|
66
|
+
192.168.1.222 ssh-rsa {EXAMPLE_KEY_1}
|
|
67
|
+
"""
|
|
68
|
+
|
|
44
69
|
|
|
45
70
|
class TestSSHUserConfigMissing(TestCase):
|
|
46
71
|
def setUp(self):
|
|
@@ -199,3 +224,45 @@ class TestSSHUserConfig(TestCase):
|
|
|
199
224
|
port=22,
|
|
200
225
|
test="kwarg",
|
|
201
226
|
)
|
|
227
|
+
|
|
228
|
+
def test_missing_hostkey(self):
|
|
229
|
+
client = SSHClient()
|
|
230
|
+
policy = AskPolicy()
|
|
231
|
+
example_hostname = "new_host"
|
|
232
|
+
example_keytype = "ecdsa-sha2-nistp256"
|
|
233
|
+
example_key = (
|
|
234
|
+
"AAAAE2VjZHNhLXNoYTItbmlzdHAyNT"
|
|
235
|
+
"YAAAAIbmlzdHAyNTYAAABBBHNp1NM"
|
|
236
|
+
"ZjxPBuuKwIPfkVJqWaH3oUtW137kIW"
|
|
237
|
+
"P4PlCyACt8zVIIimFhIpwRUidcf7jw"
|
|
238
|
+
"VWPAJvfBjEPqewDApnZQ="
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
key = PKey.from_type_string(
|
|
242
|
+
example_keytype,
|
|
243
|
+
b64decode(example_key),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# Check if AskPolicy respects not importing and properly raises SSHException
|
|
247
|
+
with self.subTest("Check user 'no'"):
|
|
248
|
+
with patch("builtins.input", return_value="n"):
|
|
249
|
+
self.assertRaises(
|
|
250
|
+
SSHException, lambda: policy.missing_host_key(client, example_hostname, key)
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Check if AskPolicy properly appends to hostfile
|
|
254
|
+
with self.subTest("Check user 'yes'"):
|
|
255
|
+
mock_data = mock_open(read_data=KNOWN_HOSTS_EXAMPLE_DATA)
|
|
256
|
+
# Read mock hostfile
|
|
257
|
+
with patch("pyinfra.connectors.sshuserclient.client.open", mock_data):
|
|
258
|
+
with patch("paramiko.hostkeys.open", mock_data):
|
|
259
|
+
with patch("builtins.input", return_value="y"):
|
|
260
|
+
policy.missing_host_key(client, "new_host", key)
|
|
261
|
+
|
|
262
|
+
# Assert that we appended correctly to the file
|
|
263
|
+
write_call_args = mock_data.return_value.write.call_args
|
|
264
|
+
# Ensure we only wrote once and then closed the handle.
|
|
265
|
+
assert len(write_call_args) == 2
|
|
266
|
+
# Ensure we wrote the correct content
|
|
267
|
+
correct_output = f"{example_hostname} {example_keytype} {example_key}\n"
|
|
268
|
+
assert write_call_args[0][0] == correct_output
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|