pyinfra 3.0.dev0__py2.py3-none-any.whl → 3.0.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.
Files changed (148) hide show
  1. pyinfra/api/__init__.py +3 -0
  2. pyinfra/api/arguments.py +115 -97
  3. pyinfra/api/arguments_typed.py +80 -0
  4. pyinfra/api/command.py +5 -3
  5. pyinfra/api/config.py +139 -39
  6. pyinfra/api/connectors.py +5 -2
  7. pyinfra/api/deploy.py +19 -19
  8. pyinfra/api/exceptions.py +35 -4
  9. pyinfra/api/facts.py +62 -86
  10. pyinfra/api/host.py +102 -15
  11. pyinfra/api/inventory.py +4 -0
  12. pyinfra/api/operation.py +188 -120
  13. pyinfra/api/operations.py +66 -113
  14. pyinfra/api/state.py +53 -34
  15. pyinfra/api/util.py +64 -33
  16. pyinfra/connectors/base.py +65 -20
  17. pyinfra/connectors/chroot.py +15 -13
  18. pyinfra/connectors/docker.py +62 -72
  19. pyinfra/connectors/dockerssh.py +20 -19
  20. pyinfra/connectors/local.py +32 -22
  21. pyinfra/connectors/ssh.py +162 -86
  22. pyinfra/connectors/sshuserclient/client.py +1 -1
  23. pyinfra/connectors/terraform.py +57 -39
  24. pyinfra/connectors/util.py +26 -27
  25. pyinfra/connectors/vagrant.py +27 -26
  26. pyinfra/context.py +1 -0
  27. pyinfra/facts/apk.py +7 -2
  28. pyinfra/facts/apt.py +15 -7
  29. pyinfra/facts/brew.py +28 -13
  30. pyinfra/facts/bsdinit.py +9 -6
  31. pyinfra/facts/cargo.py +6 -3
  32. pyinfra/facts/choco.py +8 -4
  33. pyinfra/facts/deb.py +21 -9
  34. pyinfra/facts/dnf.py +11 -6
  35. pyinfra/facts/docker.py +30 -5
  36. pyinfra/facts/files.py +49 -33
  37. pyinfra/facts/gem.py +7 -2
  38. pyinfra/facts/git.py +14 -21
  39. pyinfra/facts/gpg.py +4 -1
  40. pyinfra/facts/hardware.py +186 -138
  41. pyinfra/facts/launchd.py +7 -2
  42. pyinfra/facts/lxd.py +8 -2
  43. pyinfra/facts/mysql.py +19 -12
  44. pyinfra/facts/npm.py +3 -1
  45. pyinfra/facts/openrc.py +8 -2
  46. pyinfra/facts/pacman.py +13 -5
  47. pyinfra/facts/pip.py +2 -0
  48. pyinfra/facts/pkg.py +5 -1
  49. pyinfra/facts/pkgin.py +7 -2
  50. pyinfra/facts/postgres.py +170 -0
  51. pyinfra/facts/postgresql.py +5 -162
  52. pyinfra/facts/rpm.py +21 -15
  53. pyinfra/facts/runit.py +70 -0
  54. pyinfra/facts/selinux.py +12 -4
  55. pyinfra/facts/server.py +240 -82
  56. pyinfra/facts/snap.py +8 -2
  57. pyinfra/facts/systemd.py +37 -13
  58. pyinfra/facts/sysvinit.py +7 -4
  59. pyinfra/facts/upstart.py +7 -2
  60. pyinfra/facts/util/packaging.py +3 -2
  61. pyinfra/facts/vzctl.py +8 -4
  62. pyinfra/facts/xbps.py +7 -2
  63. pyinfra/facts/yum.py +10 -5
  64. pyinfra/facts/zypper.py +9 -4
  65. pyinfra/operations/apk.py +5 -3
  66. pyinfra/operations/apt.py +28 -25
  67. pyinfra/operations/brew.py +60 -29
  68. pyinfra/operations/bsdinit.py +6 -4
  69. pyinfra/operations/cargo.py +3 -1
  70. pyinfra/operations/choco.py +3 -1
  71. pyinfra/operations/dnf.py +16 -20
  72. pyinfra/operations/docker.py +339 -0
  73. pyinfra/operations/files.py +187 -168
  74. pyinfra/operations/gem.py +3 -1
  75. pyinfra/operations/git.py +23 -25
  76. pyinfra/operations/iptables.py +33 -25
  77. pyinfra/operations/launchd.py +5 -6
  78. pyinfra/operations/lxd.py +7 -4
  79. pyinfra/operations/mysql.py +59 -55
  80. pyinfra/operations/npm.py +8 -1
  81. pyinfra/operations/openrc.py +5 -3
  82. pyinfra/operations/pacman.py +6 -7
  83. pyinfra/operations/pip.py +19 -12
  84. pyinfra/operations/pkg.py +3 -1
  85. pyinfra/operations/pkgin.py +5 -3
  86. pyinfra/operations/postgres.py +349 -0
  87. pyinfra/operations/postgresql.py +18 -335
  88. pyinfra/operations/puppet.py +3 -1
  89. pyinfra/operations/python.py +8 -19
  90. pyinfra/operations/runit.py +182 -0
  91. pyinfra/operations/selinux.py +47 -29
  92. pyinfra/operations/server.py +138 -67
  93. pyinfra/operations/snap.py +3 -1
  94. pyinfra/operations/ssh.py +18 -16
  95. pyinfra/operations/systemd.py +18 -12
  96. pyinfra/operations/sysvinit.py +7 -5
  97. pyinfra/operations/upstart.py +7 -5
  98. pyinfra/operations/util/__init__.py +12 -0
  99. pyinfra/operations/util/docker.py +177 -0
  100. pyinfra/operations/util/files.py +24 -16
  101. pyinfra/operations/util/packaging.py +54 -38
  102. pyinfra/operations/util/service.py +39 -47
  103. pyinfra/operations/vzctl.py +12 -10
  104. pyinfra/operations/xbps.py +5 -3
  105. pyinfra/operations/yum.py +15 -19
  106. pyinfra/operations/zypper.py +9 -10
  107. pyinfra/version.py +5 -2
  108. {pyinfra-3.0.dev0.dist-info → pyinfra-3.0.2.dist-info}/METADATA +51 -58
  109. pyinfra-3.0.2.dist-info/RECORD +168 -0
  110. {pyinfra-3.0.dev0.dist-info → pyinfra-3.0.2.dist-info}/WHEEL +1 -1
  111. {pyinfra-3.0.dev0.dist-info → pyinfra-3.0.2.dist-info}/entry_points.txt +0 -3
  112. pyinfra_cli/__main__.py +4 -3
  113. pyinfra_cli/commands.py +3 -2
  114. pyinfra_cli/exceptions.py +75 -43
  115. pyinfra_cli/inventory.py +52 -31
  116. pyinfra_cli/log.py +10 -2
  117. pyinfra_cli/main.py +88 -65
  118. pyinfra_cli/prints.py +37 -109
  119. pyinfra_cli/util.py +15 -10
  120. tests/test_api/test_api.py +2 -0
  121. tests/test_api/test_api_arguments.py +9 -9
  122. tests/test_api/test_api_deploys.py +15 -19
  123. tests/test_api/test_api_facts.py +4 -5
  124. tests/test_api/test_api_operations.py +18 -20
  125. tests/test_api/test_api_util.py +41 -2
  126. tests/test_cli/test_cli.py +14 -50
  127. tests/test_cli/test_cli_deploy.py +17 -14
  128. tests/test_cli/test_cli_exceptions.py +50 -19
  129. tests/test_cli/test_cli_inventory.py +66 -0
  130. tests/test_cli/util.py +1 -1
  131. tests/test_connectors/test_dockerssh.py +11 -8
  132. tests/test_connectors/test_ssh.py +88 -23
  133. tests/test_connectors/test_sshuserclient.py +1 -1
  134. tests/test_connectors/test_terraform.py +11 -8
  135. tests/test_connectors/test_vagrant.py +6 -6
  136. pyinfra/connectors/ansible.py +0 -175
  137. pyinfra/connectors/mech.py +0 -189
  138. pyinfra/connectors/pyinfrawinrmsession/__init__.py +0 -28
  139. pyinfra/connectors/winrm.py +0 -312
  140. pyinfra/facts/windows.py +0 -366
  141. pyinfra/facts/windows_files.py +0 -90
  142. pyinfra/operations/windows.py +0 -59
  143. pyinfra/operations/windows_files.py +0 -538
  144. pyinfra-3.0.dev0.dist-info/RECORD +0 -170
  145. tests/test_connectors/test_ansible.py +0 -64
  146. tests/test_connectors/test_mech.py +0 -126
  147. {pyinfra-3.0.dev0.dist-info → pyinfra-3.0.2.dist-info}/LICENSE.md +0 -0
  148. {pyinfra-3.0.dev0.dist-info → pyinfra-3.0.2.dist-info}/top_level.txt +0 -0
@@ -1,21 +1,36 @@
1
1
  """
2
2
  Provides operations to set SELinux file contexts, booleans and port types.
3
3
  """
4
+
5
+ from __future__ import annotations
6
+
7
+ from enum import Enum
8
+
4
9
  from pyinfra import host
5
- from pyinfra.api import QuoteString, StringCommand, operation
10
+ from pyinfra.api import OperationValueError, QuoteString, StringCommand, operation
6
11
  from pyinfra.facts.selinux import FileContext, FileContextMapping, SEBoolean, SEPort, SEPorts
7
12
  from pyinfra.facts.server import Which
8
13
 
9
14
 
10
- @operation(
11
- pipeline_facts={"seboolean": "bool_name"},
12
- )
13
- def boolean(bool_name, value, persistent=False):
15
+ class Boolean(Enum):
16
+ ON = "on"
17
+ OFF = "off"
18
+
19
+
20
+ class Protocol(Enum):
21
+ UDP = "udp"
22
+ TCP = "tcp"
23
+ SCTP = "sctp"
24
+ DCCP = "dccp"
25
+
26
+
27
+ @operation()
28
+ def boolean(bool_name: str, value: Boolean, persistent=False):
14
29
  """
15
30
  Set the specified SELinux boolean to the desired state.
16
31
 
17
32
  + boolean: name of an SELinux boolean
18
- + state: 'on' or 'off'
33
+ + value: desired state of the boolean
19
34
  + persistent: whether to write updated policy or not
20
35
 
21
36
  Note: This operation requires root privileges.
@@ -27,28 +42,31 @@ def boolean(bool_name, value, persistent=False):
27
42
  selinux.boolean(
28
43
  name='Allow Apache to connect to LDAP server',
29
44
  'httpd_can_network_connect',
30
- 'on',
45
+ Boolean.ON,
31
46
  persistent=True
32
47
  )
33
48
  """
34
- _valid_states = ["on", "off"]
35
49
 
36
- if value not in _valid_states:
37
- raise ValueError(
38
- f'\'value\' must be one of \'{",".join(_valid_states)}\' but found \'{value}\'',
39
- )
50
+ value_str: str
51
+ if value in ["on", "off"]: # compatibility with the old version
52
+ assert isinstance(value, str)
53
+ value_str = value
54
+ elif value is Boolean.ON:
55
+ value_str = "on"
56
+ elif value is Boolean.OFF:
57
+ value_str = "off"
58
+ else:
59
+ raise OperationValueError(f"Invalid value '{value}' for boolean operation")
40
60
 
41
- if host.get_fact(SEBoolean, boolean=bool_name) != value:
61
+ if host.get_fact(SEBoolean, boolean=bool_name) != value_str:
42
62
  persist = "-P " if persistent else ""
43
- yield StringCommand("setsebool", f"{persist}{bool_name}", value)
63
+ yield StringCommand("setsebool", f"{persist}{bool_name}", value_str)
44
64
  else:
45
- host.noop(f"boolean '{bool_name}' already had the value '{value}'")
65
+ host.noop(f"boolean '{bool_name}' already had the value '{value_str}'")
46
66
 
47
67
 
48
- @operation(
49
- pipeline_facts={"filecontext": "path"},
50
- )
51
- def file_context(path, se_type):
68
+ @operation()
69
+ def file_context(path: str, se_type: str):
52
70
  """
53
71
  Set the SELinux type for the specified path to the specified value.
54
72
 
@@ -73,10 +91,8 @@ def file_context(path, se_type):
73
91
  host.noop(f"file_context: '{path}' already had type '{se_type}'")
74
92
 
75
93
 
76
- @operation(
77
- pipeline_facts={"filecontextmapping": "target"},
78
- )
79
- def file_context_mapping(target, se_type=None, present=True):
94
+ @operation()
95
+ def file_context_mapping(target: str, se_type: str | None = None, present=True):
80
96
  """
81
97
  Set the SELinux file context mapping for paths matching the target.
82
98
 
@@ -115,10 +131,8 @@ def file_context_mapping(target, se_type=None, present=True):
115
131
  host.noop(f"no existing mapping for '{target}'")
116
132
 
117
133
 
118
- @operation(
119
- pipeline_facts={"which": "sepolicy"},
120
- )
121
- def port(protocol, port_num, se_type=None, present=True):
134
+ @operation()
135
+ def port(protocol: Protocol | str, port_num: int, se_type: str | None = None, present=True):
122
136
  """
123
137
  Set the SELinux type for the specified protocol and port.
124
138
 
@@ -135,17 +149,21 @@ def port(protocol, port_num, se_type=None, present=True):
135
149
 
136
150
  selinux.port(
137
151
  name='Allow Apache to provide service on port 2222',
138
- 'tcp',
152
+ Protocol.TCP,
139
153
  2222,
140
154
  'http_port_t',
141
155
  )
142
156
  """
143
157
 
158
+ if protocol is Protocol:
159
+ assert isinstance(protocol, Protocol)
160
+ protocol = protocol.value
161
+
144
162
  if present and (se_type is None):
145
163
  raise ValueError("se_type must have a valid value if present is set")
146
164
 
147
165
  new_type = se_type if present else ""
148
- direct_get = len(host.get_fact(Which, command=SEPort.requires_command) or "") > 0
166
+ direct_get = len(host.get_fact(Which, command="sepolicy") or "") > 0
149
167
  if direct_get:
150
168
  current = host.get_fact(SEPort, protocol=protocol, port=port_num)
151
169
  else:
@@ -3,12 +3,14 @@ The server module takes care of os-level state. Targets POSIX compatibility, tes
3
3
  Linux/BSD.
4
4
  """
5
5
 
6
+ from __future__ import annotations
7
+
6
8
  import shlex
7
9
  from io import StringIO
8
10
  from itertools import filterfalse, tee
9
11
  from os import path
10
12
  from time import sleep
11
- from typing import Callable
13
+ from typing import TYPE_CHECKING
12
14
 
13
15
  from pyinfra import host, logger, state
14
16
  from pyinfra.api import FunctionCommand, OperationError, StringCommand, operation
@@ -18,6 +20,7 @@ from pyinfra.facts.files import Directory, FindInFile, Link
18
20
  from pyinfra.facts.server import (
19
21
  Crontab,
20
22
  Groups,
23
+ Home,
21
24
  Hostname,
22
25
  KernelModules,
23
26
  Locales,
@@ -38,6 +41,7 @@ from . import (
38
41
  openrc,
39
42
  pacman,
40
43
  pkg,
44
+ runit,
41
45
  systemd,
42
46
  sysvinit,
43
47
  upstart,
@@ -47,6 +51,9 @@ from . import (
47
51
  )
48
52
  from .util.files import chmod, sed_replace
49
53
 
54
+ if TYPE_CHECKING:
55
+ from pyinfra.api.arguments_typed import PyinfraOperation
56
+
50
57
 
51
58
  @operation(is_idempotent=False)
52
59
  def reboot(delay=10, interval=1, reboot_timeout=300):
@@ -100,6 +107,12 @@ def reboot(delay=10, interval=1, reboot_timeout=300):
100
107
 
101
108
  yield FunctionCommand(wait_and_reconnect, (), {})
102
109
 
110
+ # On certain systems sudo files are lost on reboot
111
+ def clean_sudo_info(state, host):
112
+ host.connector_data["sudo_askpass_path"] = None
113
+
114
+ yield FunctionCommand(clean_sudo_info, (), {})
115
+
103
116
 
104
117
  @operation(is_idempotent=False)
105
118
  def wait(port: int):
@@ -130,7 +143,7 @@ def wait(port: int):
130
143
 
131
144
 
132
145
  @operation(is_idempotent=False)
133
- def shell(commands):
146
+ def shell(commands: str | list[str]):
134
147
  """
135
148
  Run raw shell code on server during a deploy. If the command would
136
149
  modify data that would be in a fact, the fact would not be updated
@@ -157,7 +170,7 @@ def shell(commands):
157
170
 
158
171
 
159
172
  @operation(is_idempotent=False)
160
- def script(src, args=()):
173
+ def script(src: str, args=()):
161
174
  """
162
175
  Upload and execute a local script on the remote host.
163
176
 
@@ -182,15 +195,15 @@ def script(src, args=()):
182
195
  )
183
196
  """
184
197
 
185
- temp_file = state.get_temp_filename()
186
- yield from files.put(src=src, dest=temp_file)
198
+ temp_file = host.get_temp_filename()
199
+ yield from files.put._inner(src=src, dest=temp_file)
187
200
 
188
201
  yield chmod(temp_file, "+x")
189
202
  yield StringCommand(temp_file, *args)
190
203
 
191
204
 
192
205
  @operation(is_idempotent=False)
193
- def script_template(src, args=(), **data):
206
+ def script_template(src: str, args=(), **data):
194
207
  """
195
208
  Generate, upload and execute a local script template on the remote host.
196
209
 
@@ -212,15 +225,15 @@ def script_template(src, args=(), **data):
212
225
  )
213
226
  """
214
227
 
215
- temp_file = state.get_temp_filename("{0}{1}".format(src, data))
216
- yield from files.template(src, temp_file, **data)
228
+ temp_file = host.get_temp_filename("{0}{1}".format(src, data))
229
+ yield from files.template._inner(src, temp_file, **data)
217
230
 
218
231
  yield chmod(temp_file, "+x")
219
232
  yield StringCommand(temp_file, *args)
220
233
 
221
234
 
222
235
  @operation()
223
- def modprobe(module, present=True, force=False):
236
+ def modprobe(module: str, present=True, force=False):
224
237
  """
225
238
  Load/unload kernel modules.
226
239
 
@@ -272,11 +285,11 @@ def modprobe(module, present=True, force=False):
272
285
 
273
286
  @operation()
274
287
  def mount(
275
- path,
288
+ path: str,
276
289
  mounted=True,
277
- options=None,
278
- device=None,
279
- fs_type=None,
290
+ options: list[str] | None = None,
291
+ device: str | None = None,
292
+ fs_type: str | None = None,
280
293
  # TODO: do we want to manage fstab here?
281
294
  # update_fstab=False,
282
295
  ):
@@ -335,7 +348,7 @@ def mount(
335
348
 
336
349
 
337
350
  @operation()
338
- def hostname(hostname, hostname_file=None):
351
+ def hostname(hostname: str, hostname_file: str | None = None):
339
352
  """
340
353
  Set the system hostname using ``hostnamectl`` or ``hostname`` on older systems.
341
354
 
@@ -388,13 +401,13 @@ def hostname(hostname, hostname_file=None):
388
401
  file = StringIO("{0}\n".format(hostname))
389
402
 
390
403
  # And ensure it exists
391
- yield from files.put(src=file, dest=hostname_file)
404
+ yield from files.put._inner(src=file, dest=hostname_file)
392
405
 
393
406
 
394
407
  @operation()
395
408
  def sysctl(
396
- key,
397
- value,
409
+ key: str,
410
+ value: str | int | list[str | int],
398
411
  persist=False,
399
412
  persist_file="/etc/sysctl.conf",
400
413
  ):
@@ -422,16 +435,16 @@ def sysctl(
422
435
 
423
436
  value = [try_int(v) for v in value] if isinstance(value, list) else try_int(value)
424
437
 
425
- existing_sysctls = host.get_fact(Sysctl)
426
-
438
+ existing_sysctls = host.get_fact(Sysctl, keys=[key])
427
439
  existing_value = existing_sysctls.get(key)
440
+
428
441
  if not existing_value or existing_value != value:
429
442
  yield "sysctl {0}='{1}'".format(key, string_value)
430
443
  else:
431
444
  host.noop("sysctl {0} is set to {1}".format(key, string_value))
432
445
 
433
446
  if persist:
434
- yield from files.line(
447
+ yield from files.line._inner(
435
448
  path=persist_file,
436
449
  line="{0}[[:space:]]*=[[:space:]]*{1}".format(key, string_value),
437
450
  replace="{0} = {1}".format(key, string_value),
@@ -440,16 +453,16 @@ def sysctl(
440
453
 
441
454
  @operation()
442
455
  def service(
443
- service,
456
+ service: str,
444
457
  running=True,
445
458
  restarted=False,
446
459
  reloaded=False,
447
- command=None,
448
- enabled=None,
460
+ command: str | None = None,
461
+ enabled: bool | None = None,
449
462
  ):
450
463
  """
451
464
  Manage the state of services. This command checks for the presence of all the
452
- Linux init systems ``pyinfra`` can handle and executes the relevant operation.
465
+ Linux init systems pyinfra can handle and executes the relevant operation.
453
466
 
454
467
  + service: name of the service to manage
455
468
  + running: whether the service should be running
@@ -469,7 +482,7 @@ def service(
469
482
  )
470
483
  """
471
484
 
472
- service_operation: Callable
485
+ service_operation: "PyinfraOperation"
473
486
 
474
487
  if host.get_fact(Which, command="systemctl"):
475
488
  service_operation = systemd.service
@@ -480,6 +493,9 @@ def service(
480
493
  elif host.get_fact(Which, command="initctl"):
481
494
  service_operation = upstart.service
482
495
 
496
+ elif host.get_fact(Which, command="sv"):
497
+ service_operation = runit.service
498
+
483
499
  elif (
484
500
  host.get_fact(Which, command="service")
485
501
  or host.get_fact(Link, path="/etc/init.d")
@@ -489,7 +505,7 @@ def service(
489
505
 
490
506
  # NOTE: important that we are not Linux here because /etc/rc.d will exist but checking it's
491
507
  # contents may trigger things (like a reboot: https://github.com/Fizzadar/pyinfra/issues/819)
492
- elif host.get_fact(Os) != "Linux" and host.get_fact(Directory, path="/etc/rc.d"):
508
+ elif host.get_fact(Os) != "Linux" and bool(host.get_fact(Directory, path="/etc/rc.d")):
493
509
  service_operation = bsdinit.service
494
510
 
495
511
  else:
@@ -497,7 +513,7 @@ def service(
497
513
  ("No init system found " "(no systemctl, initctl, /etc/init.d or /etc/rc.d found)"),
498
514
  )
499
515
 
500
- yield from service_operation(
516
+ yield from service_operation._inner(
501
517
  service=service,
502
518
  running=running,
503
519
  restarted=restarted,
@@ -509,12 +525,12 @@ def service(
509
525
 
510
526
  @operation()
511
527
  def packages(
512
- packages,
528
+ packages: str | list[str],
513
529
  present=True,
514
530
  ):
515
531
  """
516
532
  Add or remove system packages. This command checks for the presence of all the
517
- system package managers ``pyinfra`` can handle and executes the relevant operation.
533
+ system package managers pyinfra can handle and executes the relevant operation.
518
534
 
519
535
  + packages: list of packages to ensure
520
536
  + present: whether the packages should be installed
@@ -529,7 +545,7 @@ def packages(
529
545
  )
530
546
  """
531
547
 
532
- package_operation: Callable
548
+ package_operation: "PyinfraOperation"
533
549
 
534
550
  # TODO: improve this - use LinuxDistribution fact + mapping with fallback below?
535
551
  # Here to be preferred on openSUSE which also provides aptitude
@@ -552,7 +568,7 @@ def packages(
552
568
  elif host.get_fact(Which, command="pacman"):
553
569
  package_operation = pacman.packages
554
570
 
555
- elif host.get_fact(Which, command="xbps"):
571
+ elif host.get_fact(Which, command="xbps-install") or host.get_fact(Which, command="xbps"):
556
572
  package_operation = xbps.packages
557
573
 
558
574
  elif host.get_fact(Which, command="yum"):
@@ -569,21 +585,21 @@ def packages(
569
585
  ),
570
586
  )
571
587
 
572
- yield from package_operation(packages=packages, present=present)
588
+ yield from package_operation._inner(packages=packages, present=present)
573
589
 
574
590
 
575
591
  @operation()
576
592
  def crontab(
577
- command,
593
+ command: str,
578
594
  present=True,
579
- user=None,
580
- cron_name=None,
595
+ user: str | None = None,
596
+ cron_name: str | None = None,
581
597
  minute="*",
582
598
  hour="*",
583
599
  month="*",
584
600
  day_of_week="*",
585
601
  day_of_month="*",
586
- special_time=None,
602
+ special_time: str | None = None,
587
603
  interpolate_variables=False,
588
604
  ):
589
605
  """
@@ -645,6 +661,8 @@ def crontab(
645
661
 
646
662
  if not existing_crontab and cron_name: # find the crontab by name if provided
647
663
  for cmd, details in crontab.items():
664
+ if not details["comments"]:
665
+ continue
648
666
  if name_comment in details["comments"]:
649
667
  existing_crontab = details
650
668
  existing_crontab_match = cmd
@@ -652,8 +670,8 @@ def crontab(
652
670
 
653
671
  exists = existing_crontab is not None
654
672
 
655
- edit_commands = []
656
- temp_filename = state.get_temp_filename()
673
+ edit_commands: list[str | StringCommand] = []
674
+ temp_filename = host.get_temp_filename()
657
675
 
658
676
  if special_time:
659
677
  new_crontab_line = "{0} {1}".format(special_time, command)
@@ -701,6 +719,7 @@ def crontab(
701
719
 
702
720
  # We have the cron and it exists, do it's details? If not, replace the line
703
721
  elif present and exists:
722
+ assert existing_crontab is not None
704
723
  if any(
705
724
  (
706
725
  special_time != existing_crontab.get("special_time"),
@@ -746,7 +765,7 @@ def crontab(
746
765
 
747
766
 
748
767
  @operation()
749
- def group(group, present=True, system=False, gid=None):
768
+ def group(group: str, present=True, system=False, gid: int | str | None = None):
750
769
  """
751
770
  Add/remove system groups.
752
771
 
@@ -815,12 +834,12 @@ def group(group, present=True, system=False, gid=None):
815
834
 
816
835
  @operation()
817
836
  def user_authorized_keys(
818
- user,
819
- public_keys,
820
- group=None,
837
+ user: str,
838
+ public_keys: str | list[str],
839
+ group: str | None = None,
821
840
  delete_keys=False,
822
- authorized_key_directory=None,
823
- authorized_key_filename=None,
841
+ authorized_key_directory: str | None = None,
842
+ authorized_key_filename: str | None = None,
824
843
  ):
825
844
  """
826
845
  Manage `authorized_keys` of system users.
@@ -832,7 +851,7 @@ def user_authorized_keys(
832
851
 
833
852
  Public keys:
834
853
  These can be provided as strings containing the public key or as a path to
835
- a public key file which ``pyinfra`` will read.
854
+ a public key file which pyinfra will read.
836
855
 
837
856
  **Examples:**
838
857
 
@@ -844,8 +863,11 @@ def user_authorized_keys(
844
863
  public_keys=["ed25519..."],
845
864
  )
846
865
  """
866
+
847
867
  if not authorized_key_directory:
848
- authorized_key_directory = f"/home/{user}/.ssh/"
868
+ home = host.get_fact(Home, user=user)
869
+ authorized_key_directory = f"{home}/.ssh"
870
+
849
871
  if not authorized_key_filename:
850
872
  authorized_key_filename = "authorized_keys"
851
873
 
@@ -861,14 +883,14 @@ def user_authorized_keys(
861
883
  with open(try_path, "r") as f:
862
884
  return f.read().strip()
863
885
 
864
- return key
886
+ return key.strip()
865
887
 
866
888
  public_keys = list(map(read_any_pub_key_file, public_keys))
867
889
 
868
890
  # Ensure .ssh directory
869
891
  # note that this always outputs commands unless the SSH user has access to the
870
892
  # authorized_keys file, ie the SSH user is the user defined in this function
871
- yield from files.directory(
893
+ yield from files.directory._inner(
872
894
  path=authorized_key_directory,
873
895
  user=user,
874
896
  group=group or user,
@@ -886,7 +908,7 @@ def user_authorized_keys(
886
908
  )
887
909
 
888
910
  # And ensure it exists
889
- yield from files.put(
911
+ yield from files.put._inner(
890
912
  src=keys_file,
891
913
  dest=authorized_key_file,
892
914
  user=user,
@@ -896,7 +918,7 @@ def user_authorized_keys(
896
918
 
897
919
  else:
898
920
  # Ensure authorized_keys exists
899
- yield from files.file(
921
+ yield from files.file._inner(
900
922
  path=authorized_key_file,
901
923
  user=user,
902
924
  group=group or user,
@@ -905,26 +927,27 @@ def user_authorized_keys(
905
927
 
906
928
  # And every public key is present
907
929
  for key in public_keys:
908
- yield from files.line(path=authorized_key_file, line=key, ensure_newline=True)
930
+ yield from files.line._inner(path=authorized_key_file, line=key, ensure_newline=True)
909
931
 
910
932
 
911
933
  @operation()
912
934
  def user(
913
- user,
935
+ user: str,
914
936
  present=True,
915
- home=None,
916
- shell=None,
917
- group=None,
918
- groups=None,
919
- public_keys=None,
937
+ home: str | None = None,
938
+ shell: str | None = None,
939
+ group: str | None = None,
940
+ groups: list[str] | None = None,
941
+ public_keys: str | list[str] | None = None,
920
942
  delete_keys=False,
921
943
  ensure_home=True,
922
944
  create_home=False,
923
945
  system=False,
924
- uid=None,
925
- comment=None,
946
+ uid: int | None = None,
947
+ comment: str | None = None,
926
948
  add_deploy_dir=True,
927
949
  unique=True,
950
+ password: str | None = None,
928
951
  ):
929
952
  """
930
953
  Add/remove/update system users & their ssh `authorized_keys`.
@@ -944,6 +967,7 @@ def user(
944
967
  + comment: the user GECOS comment
945
968
  + add_deploy_dir: any public_key filenames are relative to the deploy directory
946
969
  + unique: prevent creating users with duplicate UID
970
+ + password: set the encrypted password for the user
947
971
 
948
972
  Home directory:
949
973
  When ``ensure_home`` or ``public_keys`` are provided, ``home`` defaults to
@@ -953,7 +977,7 @@ def user(
953
977
 
954
978
  Public keys:
955
979
  These can be provided as strings containing the public key or as a path to
956
- a public key file which ``pyinfra`` will read.
980
+ a public key file which pyinfra will read.
957
981
 
958
982
  **Examples:**
959
983
 
@@ -1040,6 +1064,11 @@ def user(
1040
1064
 
1041
1065
  if create_home:
1042
1066
  args.append("-m")
1067
+ else:
1068
+ args.append("-M")
1069
+
1070
+ if password:
1071
+ args.append("-p '{0}'".format(password))
1043
1072
 
1044
1073
  # Users are often added by other operations (package installs), so check
1045
1074
  # for the user at runtime before adding.
@@ -1058,7 +1087,7 @@ def user(
1058
1087
  user,
1059
1088
  )
1060
1089
 
1061
- # User exists and we want them, check home/shell/keys
1090
+ # User exists and we want them, check home/shell/keys/password
1062
1091
  else:
1063
1092
  args = []
1064
1093
 
@@ -1081,6 +1110,9 @@ def user(
1081
1110
  if comment and existing_user["comment"] != comment:
1082
1111
  args.append("-c '{0}'".format(comment))
1083
1112
 
1113
+ if password and existing_user["password"] != password:
1114
+ args.append("-p '{0}'".format(password))
1115
+
1084
1116
  # Need to mod the user?
1085
1117
  if args:
1086
1118
  if os_type == "FreeBSD":
@@ -1097,10 +1129,12 @@ def user(
1097
1129
  existing_user["group"] = group
1098
1130
  if groups:
1099
1131
  existing_user["groups"] = groups
1132
+ if password:
1133
+ existing_user["password"] = password
1100
1134
 
1101
1135
  # Ensure home directory ownership
1102
- if ensure_home:
1103
- yield from files.directory(
1136
+ if ensure_home and home:
1137
+ yield from files.directory._inner(
1104
1138
  path=home,
1105
1139
  user=user,
1106
1140
  group=group or user,
@@ -1110,7 +1144,7 @@ def user(
1110
1144
 
1111
1145
  # Add SSH keys
1112
1146
  if public_keys is not None:
1113
- yield from user_authorized_keys(
1147
+ yield from user_authorized_keys._inner(
1114
1148
  user=user,
1115
1149
  public_keys=public_keys,
1116
1150
  group=group,
@@ -1122,7 +1156,7 @@ def user(
1122
1156
 
1123
1157
  @operation()
1124
1158
  def locale(
1125
- locale,
1159
+ locale: str,
1126
1160
  present=True,
1127
1161
  ):
1128
1162
  """
@@ -1171,7 +1205,7 @@ def locale(
1171
1205
  if not present and locale in locales:
1172
1206
  logger.debug(f"Removing locale {locale}")
1173
1207
 
1174
- yield from files.line(
1208
+ yield from files.line._inner(
1175
1209
  path=locales_definitions_file, line=f"^{matching_line}$", replace=f"# {matching_line}"
1176
1210
  )
1177
1211
 
@@ -1181,10 +1215,47 @@ def locale(
1181
1215
  if present and locale not in locales:
1182
1216
  logger.debug(f"Adding locale {locale}")
1183
1217
 
1184
- yield from files.replace(
1218
+ yield from files.replace._inner(
1185
1219
  path=locales_definitions_file,
1186
1220
  text=f"^{matching_line}$",
1187
1221
  replace=f"{matching_line}".replace("# ", ""),
1188
1222
  )
1189
1223
 
1190
1224
  yield "locale-gen"
1225
+
1226
+
1227
+ @operation()
1228
+ def security_limit(
1229
+ domain: str,
1230
+ limit_type: str,
1231
+ item: str,
1232
+ value: int,
1233
+ ):
1234
+ """
1235
+ Edit /etc/security/limits.conf configuration.
1236
+
1237
+ + domain: the domain (user, group, or wildcard) for the limit
1238
+ + limit_type: the type of limit (hard or soft)
1239
+ + item: the item to limit (e.g., nofile, nproc)
1240
+ + value: the value for the limit
1241
+
1242
+ **Example:**
1243
+
1244
+ .. code:: python
1245
+
1246
+ security_limit(
1247
+ name="Set nofile limit for all users",
1248
+ domain='*',
1249
+ limit_type='soft',
1250
+ item='nofile',
1251
+ value=1024,
1252
+ )
1253
+ """
1254
+
1255
+ line_format = f"{domain}\t{limit_type}\t{item}\t{value}"
1256
+
1257
+ yield from files.line._inner(
1258
+ path="/etc/security/limits.conf",
1259
+ line=f"^{domain}[[:space:]]+{limit_type}[[:space:]]+{item}",
1260
+ replace=line_format,
1261
+ )