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.
Files changed (203) hide show
  1. pyinfra/__init__.py +9 -12
  2. pyinfra/__main__.py +4 -0
  3. pyinfra/api/__init__.py +18 -3
  4. pyinfra/api/arguments.py +406 -0
  5. pyinfra/api/arguments_typed.py +79 -0
  6. pyinfra/api/command.py +274 -0
  7. pyinfra/api/config.py +222 -28
  8. pyinfra/api/connect.py +33 -13
  9. pyinfra/api/connectors.py +27 -0
  10. pyinfra/api/deploy.py +65 -66
  11. pyinfra/api/exceptions.py +67 -18
  12. pyinfra/api/facts.py +253 -202
  13. pyinfra/api/host.py +413 -50
  14. pyinfra/api/inventory.py +121 -160
  15. pyinfra/api/operation.py +432 -262
  16. pyinfra/api/operations.py +273 -260
  17. pyinfra/api/state.py +302 -248
  18. pyinfra/api/util.py +291 -368
  19. pyinfra/connectors/base.py +173 -0
  20. pyinfra/connectors/chroot.py +212 -0
  21. pyinfra/connectors/docker.py +381 -0
  22. pyinfra/connectors/dockerssh.py +297 -0
  23. pyinfra/connectors/local.py +238 -0
  24. pyinfra/connectors/scp/__init__.py +1 -0
  25. pyinfra/connectors/scp/client.py +204 -0
  26. pyinfra/connectors/ssh.py +670 -0
  27. pyinfra/connectors/ssh_util.py +114 -0
  28. pyinfra/connectors/sshuserclient/client.py +309 -0
  29. pyinfra/connectors/sshuserclient/config.py +102 -0
  30. pyinfra/connectors/terraform.py +135 -0
  31. pyinfra/connectors/util.py +410 -0
  32. pyinfra/connectors/vagrant.py +183 -0
  33. pyinfra/context.py +145 -0
  34. pyinfra/facts/__init__.py +7 -6
  35. pyinfra/facts/apk.py +22 -7
  36. pyinfra/facts/apt.py +117 -60
  37. pyinfra/facts/brew.py +100 -15
  38. pyinfra/facts/bsdinit.py +23 -0
  39. pyinfra/facts/cargo.py +37 -0
  40. pyinfra/facts/choco.py +47 -0
  41. pyinfra/facts/crontab.py +195 -0
  42. pyinfra/facts/deb.py +94 -0
  43. pyinfra/facts/dnf.py +48 -0
  44. pyinfra/facts/docker.py +96 -23
  45. pyinfra/facts/efibootmgr.py +113 -0
  46. pyinfra/facts/files.py +630 -58
  47. pyinfra/facts/flatpak.py +77 -0
  48. pyinfra/facts/freebsd.py +70 -0
  49. pyinfra/facts/gem.py +19 -6
  50. pyinfra/facts/git.py +59 -14
  51. pyinfra/facts/gpg.py +150 -0
  52. pyinfra/facts/hardware.py +313 -167
  53. pyinfra/facts/iptables.py +72 -62
  54. pyinfra/facts/launchd.py +44 -0
  55. pyinfra/facts/lxd.py +17 -4
  56. pyinfra/facts/mysql.py +122 -86
  57. pyinfra/facts/npm.py +17 -9
  58. pyinfra/facts/openrc.py +71 -0
  59. pyinfra/facts/opkg.py +246 -0
  60. pyinfra/facts/pacman.py +50 -7
  61. pyinfra/facts/pip.py +24 -7
  62. pyinfra/facts/pipx.py +82 -0
  63. pyinfra/facts/pkg.py +15 -6
  64. pyinfra/facts/pkgin.py +35 -0
  65. pyinfra/facts/podman.py +54 -0
  66. pyinfra/facts/postgres.py +178 -0
  67. pyinfra/facts/postgresql.py +6 -147
  68. pyinfra/facts/rpm.py +105 -0
  69. pyinfra/facts/runit.py +77 -0
  70. pyinfra/facts/selinux.py +161 -0
  71. pyinfra/facts/server.py +746 -285
  72. pyinfra/facts/snap.py +88 -0
  73. pyinfra/facts/systemd.py +139 -0
  74. pyinfra/facts/sysvinit.py +59 -0
  75. pyinfra/facts/upstart.py +35 -0
  76. pyinfra/facts/util/__init__.py +17 -0
  77. pyinfra/facts/util/databases.py +4 -6
  78. pyinfra/facts/util/packaging.py +37 -6
  79. pyinfra/facts/util/units.py +30 -0
  80. pyinfra/facts/util/win_files.py +99 -0
  81. pyinfra/facts/vzctl.py +20 -13
  82. pyinfra/facts/xbps.py +35 -0
  83. pyinfra/facts/yum.py +34 -40
  84. pyinfra/facts/zfs.py +77 -0
  85. pyinfra/facts/zypper.py +42 -0
  86. pyinfra/local.py +45 -83
  87. pyinfra/operations/__init__.py +12 -0
  88. pyinfra/operations/apk.py +98 -0
  89. pyinfra/operations/apt.py +488 -0
  90. pyinfra/operations/brew.py +231 -0
  91. pyinfra/operations/bsdinit.py +59 -0
  92. pyinfra/operations/cargo.py +45 -0
  93. pyinfra/operations/choco.py +61 -0
  94. pyinfra/operations/crontab.py +191 -0
  95. pyinfra/operations/dnf.py +210 -0
  96. pyinfra/operations/docker.py +446 -0
  97. pyinfra/operations/files.py +1939 -0
  98. pyinfra/operations/flatpak.py +94 -0
  99. pyinfra/operations/freebsd/__init__.py +12 -0
  100. pyinfra/operations/freebsd/freebsd_update.py +70 -0
  101. pyinfra/operations/freebsd/pkg.py +219 -0
  102. pyinfra/operations/freebsd/service.py +116 -0
  103. pyinfra/operations/freebsd/sysrc.py +92 -0
  104. pyinfra/operations/gem.py +47 -0
  105. pyinfra/operations/git.py +419 -0
  106. pyinfra/operations/iptables.py +311 -0
  107. pyinfra/operations/launchd.py +45 -0
  108. pyinfra/operations/lxd.py +68 -0
  109. pyinfra/operations/mysql.py +609 -0
  110. pyinfra/operations/npm.py +57 -0
  111. pyinfra/operations/openrc.py +63 -0
  112. pyinfra/operations/opkg.py +88 -0
  113. pyinfra/operations/pacman.py +81 -0
  114. pyinfra/operations/pip.py +205 -0
  115. pyinfra/operations/pipx.py +102 -0
  116. pyinfra/operations/pkg.py +70 -0
  117. pyinfra/operations/pkgin.py +91 -0
  118. pyinfra/operations/postgres.py +436 -0
  119. pyinfra/operations/postgresql.py +30 -0
  120. pyinfra/operations/puppet.py +40 -0
  121. pyinfra/operations/python.py +72 -0
  122. pyinfra/operations/runit.py +184 -0
  123. pyinfra/operations/selinux.py +189 -0
  124. pyinfra/operations/server.py +1099 -0
  125. pyinfra/operations/snap.py +117 -0
  126. pyinfra/operations/ssh.py +216 -0
  127. pyinfra/operations/systemd.py +149 -0
  128. pyinfra/operations/sysvinit.py +141 -0
  129. pyinfra/operations/upstart.py +68 -0
  130. pyinfra/operations/util/__init__.py +12 -0
  131. pyinfra/operations/util/docker.py +251 -0
  132. pyinfra/operations/util/files.py +247 -0
  133. pyinfra/operations/util/packaging.py +336 -0
  134. pyinfra/operations/util/service.py +46 -0
  135. pyinfra/operations/vzctl.py +137 -0
  136. pyinfra/operations/xbps.py +77 -0
  137. pyinfra/operations/yum.py +210 -0
  138. pyinfra/operations/zfs.py +175 -0
  139. pyinfra/operations/zypper.py +192 -0
  140. pyinfra/progress.py +44 -32
  141. pyinfra/py.typed +0 -0
  142. pyinfra/version.py +9 -1
  143. pyinfra-3.5.1.dist-info/METADATA +141 -0
  144. pyinfra-3.5.1.dist-info/RECORD +159 -0
  145. {pyinfra-0.11.dev3.dist-info → pyinfra-3.5.1.dist-info}/WHEEL +1 -2
  146. pyinfra-3.5.1.dist-info/entry_points.txt +12 -0
  147. {pyinfra-0.11.dev3.dist-info → pyinfra-3.5.1.dist-info/licenses}/LICENSE.md +1 -1
  148. pyinfra_cli/__init__.py +1 -0
  149. pyinfra_cli/cli.py +780 -0
  150. pyinfra_cli/commands.py +66 -0
  151. pyinfra_cli/exceptions.py +155 -65
  152. pyinfra_cli/inventory.py +233 -89
  153. pyinfra_cli/log.py +39 -43
  154. pyinfra_cli/main.py +26 -495
  155. pyinfra_cli/prints.py +215 -156
  156. pyinfra_cli/util.py +172 -105
  157. pyinfra_cli/virtualenv.py +25 -20
  158. pyinfra/api/connectors/__init__.py +0 -21
  159. pyinfra/api/connectors/ansible.py +0 -99
  160. pyinfra/api/connectors/docker.py +0 -178
  161. pyinfra/api/connectors/local.py +0 -169
  162. pyinfra/api/connectors/ssh.py +0 -402
  163. pyinfra/api/connectors/sshuserclient/client.py +0 -105
  164. pyinfra/api/connectors/sshuserclient/config.py +0 -90
  165. pyinfra/api/connectors/util.py +0 -63
  166. pyinfra/api/connectors/vagrant.py +0 -155
  167. pyinfra/facts/init.py +0 -176
  168. pyinfra/facts/util/files.py +0 -102
  169. pyinfra/hook.py +0 -41
  170. pyinfra/modules/__init__.py +0 -11
  171. pyinfra/modules/apk.py +0 -64
  172. pyinfra/modules/apt.py +0 -272
  173. pyinfra/modules/brew.py +0 -122
  174. pyinfra/modules/files.py +0 -711
  175. pyinfra/modules/gem.py +0 -30
  176. pyinfra/modules/git.py +0 -115
  177. pyinfra/modules/init.py +0 -344
  178. pyinfra/modules/iptables.py +0 -271
  179. pyinfra/modules/lxd.py +0 -45
  180. pyinfra/modules/mysql.py +0 -347
  181. pyinfra/modules/npm.py +0 -47
  182. pyinfra/modules/pacman.py +0 -60
  183. pyinfra/modules/pip.py +0 -99
  184. pyinfra/modules/pkg.py +0 -43
  185. pyinfra/modules/postgresql.py +0 -245
  186. pyinfra/modules/puppet.py +0 -20
  187. pyinfra/modules/python.py +0 -37
  188. pyinfra/modules/server.py +0 -524
  189. pyinfra/modules/ssh.py +0 -150
  190. pyinfra/modules/util/files.py +0 -52
  191. pyinfra/modules/util/packaging.py +0 -118
  192. pyinfra/modules/vzctl.py +0 -133
  193. pyinfra/modules/yum.py +0 -171
  194. pyinfra/pseudo_modules.py +0 -64
  195. pyinfra-0.11.dev3.dist-info/.DS_Store +0 -0
  196. pyinfra-0.11.dev3.dist-info/METADATA +0 -135
  197. pyinfra-0.11.dev3.dist-info/RECORD +0 -95
  198. pyinfra-0.11.dev3.dist-info/entry_points.txt +0 -3
  199. pyinfra-0.11.dev3.dist-info/top_level.txt +0 -2
  200. pyinfra_cli/__main__.py +0 -40
  201. pyinfra_cli/config.py +0 -92
  202. /pyinfra/{modules/util → connectors}/__init__.py +0 -0
  203. /pyinfra/{api/connectors → connectors}/sshuserclient/__init__.py +0 -0
@@ -0,0 +1,1099 @@
1
+ """
2
+ The server module takes care of os-level state. Targets POSIX compatibility, tested on
3
+ Linux/BSD.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from io import StringIO
9
+ from itertools import filterfalse, tee
10
+ from os import path
11
+ from time import sleep
12
+ from typing import TYPE_CHECKING
13
+
14
+ from pyinfra import host, logger, state
15
+ from pyinfra.api import FunctionCommand, OperationError, StringCommand, operation
16
+ from pyinfra.api.util import try_int
17
+ from pyinfra.connectors.util import remove_any_sudo_askpass_file
18
+ from pyinfra.facts.files import Directory, FindInFile, Link
19
+ from pyinfra.facts.server import (
20
+ Groups,
21
+ Home,
22
+ Hostname,
23
+ Kernel,
24
+ KernelModules,
25
+ Locales,
26
+ Mounts,
27
+ Os,
28
+ Sysctl,
29
+ Users,
30
+ Which,
31
+ )
32
+ from pyinfra.operations import crontab as crontab_
33
+
34
+ from . import (
35
+ apk,
36
+ apt,
37
+ brew,
38
+ bsdinit,
39
+ dnf,
40
+ files,
41
+ openrc,
42
+ pacman,
43
+ pkg,
44
+ runit,
45
+ systemd,
46
+ sysvinit,
47
+ upstart,
48
+ xbps,
49
+ yum,
50
+ zypper,
51
+ )
52
+ from .util.files import chmod
53
+
54
+ if TYPE_CHECKING:
55
+ from pyinfra.api.arguments_typed import PyinfraOperation
56
+
57
+
58
+ @operation(is_idempotent=False)
59
+ def reboot(delay=10, interval=1, reboot_timeout=300):
60
+ """
61
+ Reboot the server and wait for reconnection.
62
+
63
+ + delay: number of seconds to wait before attempting reconnect
64
+ + interval: interval (s) between reconnect attempts
65
+ + reboot_timeout: total time before giving up reconnecting
66
+
67
+ **Example:**
68
+
69
+ .. code:: python
70
+
71
+ server.reboot(
72
+ name="Reboot the server and wait to reconnect",
73
+ delay=60,
74
+ reboot_timeout=600,
75
+ )
76
+ """
77
+
78
+ # Remove this now, before we reboot the server - if the reboot fails (expected or
79
+ # not) we'll error if we don't clean this up now. Will simply be re-uploaded if
80
+ # needed later.
81
+ def remove_any_askpass_file(state, host):
82
+ remove_any_sudo_askpass_file(host)
83
+
84
+ yield FunctionCommand(remove_any_askpass_file, (), {})
85
+
86
+ yield StringCommand("reboot", _success_exit_codes=[0, -1]) # -1 being error/disconnected
87
+
88
+ def wait_and_reconnect(state, host): # pragma: no cover
89
+ sleep(delay)
90
+ max_retries = round(reboot_timeout / interval)
91
+
92
+ host.disconnect() # make sure we are properly disconnected
93
+ retries = 0
94
+
95
+ while True:
96
+ host.connect(show_errors=False)
97
+ if host.connected:
98
+ break
99
+
100
+ if retries > max_retries:
101
+ raise Exception(
102
+ ("Server did not reboot in time (reboot_timeout={0}s)").format(reboot_timeout),
103
+ )
104
+
105
+ sleep(interval)
106
+ retries += 1
107
+
108
+ yield FunctionCommand(wait_and_reconnect, (), {})
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
+
116
+
117
+ @operation(is_idempotent=False)
118
+ def wait(port: int):
119
+ """
120
+ Waits for a port to come active on the target machine. Requires netstat, checks every
121
+ second.
122
+
123
+ + port: port number to wait for
124
+
125
+ **Example:**
126
+
127
+ .. code:: python
128
+
129
+ server.wait(
130
+ name="Wait for webserver to start",
131
+ port=80,
132
+ )
133
+ """
134
+
135
+ yield r"""
136
+ while ! (netstat -an | grep LISTEN | grep -e "\.{0}" -e ":{0}"); do
137
+ echo "waiting for port {0}..."
138
+ sleep 1
139
+ done
140
+ """.format(
141
+ port,
142
+ )
143
+
144
+
145
+ @operation(is_idempotent=False)
146
+ def shell(commands: str | list[str]):
147
+ """
148
+ Run raw shell code on server during a deploy. If the command would
149
+ modify data that would be in a fact, the fact would not be updated
150
+ since facts are only run at the start of a deploy.
151
+
152
+ + commands: command or list of commands to execute on the remote server
153
+
154
+ **Example:**
155
+
156
+ .. code:: python
157
+
158
+ server.shell(
159
+ name="Run lxd auto init",
160
+ commands=["lxd init --auto"],
161
+ )
162
+ """
163
+
164
+ # Ensure we have a list
165
+ if isinstance(commands, str):
166
+ commands = [commands]
167
+
168
+ for command in commands:
169
+ yield command
170
+
171
+
172
+ @operation(is_idempotent=False)
173
+ def script(src: str, args=()):
174
+ """
175
+ Upload and execute a local script on the remote host.
176
+
177
+ + src: local script filename to upload & execute
178
+ + args: iterable to pass as arguments to the script
179
+
180
+ **Example:**
181
+
182
+ .. code:: python
183
+
184
+ # Note: This assumes there is a file in files/hello.bash locally.
185
+ server.script(
186
+ name="Hello",
187
+ src="files/hello.bash",
188
+ )
189
+
190
+ # Example passing arguments to the script
191
+ server.script(
192
+ name="Hello",
193
+ src="files/hello.bash",
194
+ args=("do-something", "with-this"),
195
+ )
196
+ """
197
+
198
+ temp_file = host.get_temp_filename()
199
+ yield from files.put._inner(src=src, dest=temp_file)
200
+
201
+ yield chmod(temp_file, "+x")
202
+ yield StringCommand(temp_file, *args)
203
+
204
+
205
+ @operation(is_idempotent=False)
206
+ def script_template(src: str, args=(), **data):
207
+ """
208
+ Generate, upload and execute a local script template on the remote host.
209
+
210
+ + src: local script template filename
211
+
212
+ **Example:**
213
+
214
+ .. code:: python
215
+
216
+ # Example showing how to pass python variable to a script template file.
217
+ # The .j2 file can use `{{ some_var }}` to be interpolated.
218
+ # To see output need to run pyinfra with '-v'
219
+ # Note: This assumes there is a file in templates/hello2.bash.j2 locally.
220
+ some_var = 'blah blah blah '
221
+ server.script_template(
222
+ name="Hello from script",
223
+ src="templates/hello2.bash.j2",
224
+ some_var=some_var,
225
+ )
226
+ """
227
+
228
+ temp_file = host.get_temp_filename("{0}{1}".format(src, data))
229
+ yield from files.template._inner(src, temp_file, **data)
230
+
231
+ yield chmod(temp_file, "+x")
232
+ yield StringCommand(temp_file, *args)
233
+
234
+
235
+ @operation()
236
+ def modprobe(module: str, present=True, force=False):
237
+ """
238
+ Load/unload kernel modules.
239
+
240
+ + module: name of the module to manage
241
+ + present: whether the module should be loaded or not
242
+ + force: whether to force any add/remove modules
243
+
244
+ **Example:**
245
+
246
+ .. code:: python
247
+
248
+ server.modprobe(
249
+ name="Silly example for modprobe",
250
+ module="floppy",
251
+ )
252
+ """
253
+ list_value = [module] if isinstance(module, str) else module
254
+
255
+ # NOTE: https://docs.python.org/3/library/itertools.html#itertools-recipes
256
+ def partition(predicate, iterable):
257
+ t1, t2 = tee(iterable)
258
+ return list(filter(predicate, t2)), list(filterfalse(predicate, t1))
259
+
260
+ modules = host.get_fact(KernelModules)
261
+ present_mods, missing_mods = partition(lambda mod: mod in modules, list_value)
262
+
263
+ args = ""
264
+ if force:
265
+ args = " -f"
266
+
267
+ # Module is loaded and we don't want it?
268
+ if not present and present_mods:
269
+ yield "modprobe{0} -r -a {1}".format(args, " ".join(present_mods))
270
+
271
+ # Module isn't loaded and we want it?
272
+ elif present and missing_mods:
273
+ yield "modprobe{0} -a {1}".format(args, " ".join(missing_mods))
274
+
275
+ else:
276
+ host.noop(
277
+ "{0} {1} {2} {3}".format(
278
+ "modules" if len(list_value) > 1 else "module",
279
+ "/".join(list_value),
280
+ "are" if len(list_value) > 1 else "is",
281
+ "loaded" if present else "not loaded",
282
+ ),
283
+ )
284
+
285
+
286
+ @operation()
287
+ def mount(
288
+ path: str,
289
+ mounted=True,
290
+ options: list[str] | None = None,
291
+ device: str | None = None,
292
+ fs_type: str | None = None,
293
+ # TODO: do we want to manage fstab here?
294
+ # update_fstab=False,
295
+ ):
296
+ """
297
+ Manage mounted filesystems.
298
+
299
+ + path: the path of the mounted filesystem
300
+ + mounted: whether the filesystem should be mounted
301
+ + options: the mount options
302
+
303
+ Options:
304
+ If the currently mounted filesystem does not have all of the provided
305
+ options it will be remounted with the options provided.
306
+
307
+ ``/etc/fstab``:
308
+ This operation does not attempt to modify the on disk fstab file - for
309
+ that you should use the `files.line operation <./files.html#files-line>`_.
310
+ """
311
+ options = options or []
312
+ options_string = ",".join(options)
313
+
314
+ mounts = host.get_fact(Mounts)
315
+ is_mounted = path in mounts
316
+
317
+ # Want mount but don't have?
318
+ if mounted and not is_mounted:
319
+ args = []
320
+ if fs_type:
321
+ args.extend(["-t", fs_type])
322
+ if options_string:
323
+ args.extend(["-o", options_string])
324
+ if device:
325
+ args.append(device)
326
+ args.append(path)
327
+
328
+ yield StringCommand("mount", *args)
329
+
330
+ # Want no mount but mounted?
331
+ elif mounted is False and is_mounted:
332
+ yield "umount {0}".format(path)
333
+
334
+ # Want mount and is mounted! Check the options
335
+ elif is_mounted and mounted and options:
336
+ mounted_options = mounts[path]["options"]
337
+ needed_options = set(options) - set(mounted_options)
338
+ if needed_options:
339
+ if host.get_fact(Kernel).strip() == "FreeBSD":
340
+ fs_type = mounts[path]["type"]
341
+ device = mounts[path]["device"]
342
+
343
+ yield "mount -o update,{options} -t {fs_type} {device} {path}".format(
344
+ options=options_string, fs_type=fs_type, device=device, path=path
345
+ )
346
+ else:
347
+ yield "mount -o remount,{0} {1}".format(options_string, path)
348
+
349
+ else:
350
+ host.noop(
351
+ "filesystem {0} is {1}".format(
352
+ path,
353
+ "mounted" if mounted else "not mounted",
354
+ ),
355
+ )
356
+
357
+
358
+ @operation()
359
+ def hostname(hostname: str, hostname_file: str | None = None):
360
+ """
361
+ Set the system hostname using ``hostnamectl`` or ``hostname`` on older systems.
362
+
363
+ + hostname: the hostname that should be set
364
+ + hostname_file: the file that permanently sets the hostname
365
+
366
+ Hostname file:
367
+ The hostname file only matters no systems that do not have ``hostnamectl``,
368
+ which is part of ``systemd``.
369
+
370
+ By default pyinfra will auto detect this by targeting ``/etc/hostname``
371
+ on Linux and ``/etc/myname`` on OpenBSD.
372
+
373
+ To completely disable writing the hostname file, set ``hostname_file=False``.
374
+
375
+ **Example:**
376
+
377
+ .. code:: python
378
+
379
+ server.hostname(
380
+ name="Set the hostname",
381
+ hostname="server1.example.com",
382
+ )
383
+ """
384
+
385
+ current_hostname = host.get_fact(Hostname)
386
+
387
+ if host.get_fact(Which, command="hostnamectl"):
388
+ if current_hostname != hostname:
389
+ yield "hostnamectl set-hostname {0}".format(hostname)
390
+ else:
391
+ host.noop("hostname is set")
392
+ return
393
+
394
+ if hostname_file is None:
395
+ os = host.get_fact(Os)
396
+
397
+ if os == "Linux":
398
+ hostname_file = "/etc/hostname"
399
+ elif os == "OpenBSD":
400
+ hostname_file = "/etc/myname"
401
+
402
+ if current_hostname != hostname:
403
+ yield "hostname {0}".format(hostname)
404
+ else:
405
+ host.noop("hostname is set")
406
+
407
+ if hostname_file:
408
+ # Create a whole new hostname file
409
+ file = StringIO("{0}\n".format(hostname))
410
+
411
+ # And ensure it exists
412
+ yield from files.put._inner(src=file, dest=hostname_file)
413
+
414
+
415
+ @operation()
416
+ def sysctl(
417
+ key: str,
418
+ value: str | int | list[str | int],
419
+ persist=False,
420
+ persist_file="/etc/sysctl.conf",
421
+ ):
422
+ """
423
+ Edit sysctl configuration.
424
+
425
+ + key: name of the sysctl setting to ensure
426
+ + value: the value or list of values the sysctl should be
427
+ + persist: whether to write this sysctl to the config
428
+ + persist_file: file to write the sysctl to persist on reboot
429
+
430
+ **Example:**
431
+
432
+ .. code:: python
433
+
434
+ server.sysctl(
435
+ name="Change the fs.file-max value",
436
+ key="fs.file-max",
437
+ value=100000,
438
+ persist=True,
439
+ )
440
+ """
441
+
442
+ string_value = " ".join(["{0}".format(v) for v in value]) if isinstance(value, list) else value
443
+
444
+ value = [try_int(v) for v in value] if isinstance(value, list) else try_int(value)
445
+
446
+ existing_sysctls = host.get_fact(Sysctl, keys=[key])
447
+ existing_value = existing_sysctls.get(key)
448
+
449
+ if existing_value != value:
450
+ yield "sysctl {0}='{1}'".format(key, string_value)
451
+ else:
452
+ host.noop("sysctl {0} is set to {1}".format(key, string_value))
453
+
454
+ if persist:
455
+ yield from files.line._inner(
456
+ path=persist_file,
457
+ line="{0}[[:space:]]*=[[:space:]]*{1}".format(key, string_value),
458
+ replace="{0} = {1}".format(key, string_value),
459
+ )
460
+
461
+
462
+ @operation()
463
+ def service(
464
+ service: str,
465
+ running=True,
466
+ restarted=False,
467
+ reloaded=False,
468
+ command: str | None = None,
469
+ enabled: bool | None = None,
470
+ ):
471
+ """
472
+ Manage the state of services. This command checks for the presence of all the
473
+ Linux init systems pyinfra can handle and executes the relevant operation.
474
+
475
+ + service: name of the service to manage
476
+ + running: whether the service should be running
477
+ + restarted: whether the service should be restarted
478
+ + reloaded: whether the service should be reloaded
479
+ + command: custom command execute
480
+ + enabled: whether this service should be enabled/disabled on boot
481
+
482
+ **Example:**
483
+
484
+ .. code:: python
485
+
486
+ server.service(
487
+ name="Enable open-vm-tools service",
488
+ service="open-vm-tools",
489
+ enabled=True,
490
+ )
491
+ """
492
+
493
+ service_operation: "PyinfraOperation"
494
+
495
+ if host.get_fact(Which, command="systemctl"):
496
+ service_operation = systemd.service
497
+
498
+ elif host.get_fact(Which, command="rc-service"):
499
+ service_operation = openrc.service
500
+
501
+ elif host.get_fact(Which, command="initctl"):
502
+ service_operation = upstart.service
503
+
504
+ elif host.get_fact(Which, command="sv"):
505
+ service_operation = runit.service
506
+
507
+ elif (
508
+ host.get_fact(Which, command="service")
509
+ or host.get_fact(Link, path="/etc/init.d")
510
+ or host.get_fact(Directory, path="/etc/init.d")
511
+ ):
512
+ service_operation = sysvinit.service
513
+
514
+ # NOTE: important that we are not Linux here because /etc/rc.d will exist but checking it's
515
+ # contents may trigger things (like a reboot: https://github.com/Fizzadar/pyinfra/issues/819)
516
+ elif host.get_fact(Os) != "Linux" and bool(host.get_fact(Directory, path="/etc/rc.d")):
517
+ service_operation = bsdinit.service
518
+
519
+ else:
520
+ raise OperationError(
521
+ ("No init system found (no systemctl, initctl, /etc/init.d or /etc/rc.d found)"),
522
+ )
523
+
524
+ yield from service_operation._inner(
525
+ service=service,
526
+ running=running,
527
+ restarted=restarted,
528
+ reloaded=reloaded,
529
+ command=command,
530
+ enabled=enabled,
531
+ )
532
+
533
+
534
+ @operation()
535
+ def packages(
536
+ packages: str | list[str],
537
+ present=True,
538
+ ):
539
+ """
540
+ Add or remove system packages. This command checks for the presence of all the
541
+ system package managers pyinfra can handle and executes the relevant operation.
542
+
543
+ + packages: list of packages to ensure
544
+ + present: whether the packages should be installed
545
+
546
+ **Example:**
547
+
548
+ .. code:: python
549
+
550
+ server.packages(
551
+ name="Install Vim and vimpager",
552
+ packages=["vimpager", "vim"],
553
+ )
554
+ """
555
+
556
+ package_operation: "PyinfraOperation"
557
+
558
+ # TODO: improve this - use LinuxDistribution fact + mapping with fallback below?
559
+ # Here to be preferred on openSUSE which also provides aptitude
560
+ # See: https://github.com/Fizzadar/pyinfra/issues/799
561
+ if host.get_fact(Which, command="zypper"):
562
+ package_operation = zypper.packages
563
+
564
+ elif host.get_fact(Which, command="apk"):
565
+ package_operation = apk.packages
566
+
567
+ elif host.get_fact(Which, command="apt"):
568
+ package_operation = apt.packages
569
+
570
+ elif host.get_fact(Which, command="brew"):
571
+ package_operation = brew.packages
572
+
573
+ elif host.get_fact(Which, command="dnf"):
574
+ package_operation = dnf.packages
575
+
576
+ elif host.get_fact(Which, command="pacman"):
577
+ package_operation = pacman.packages
578
+
579
+ elif host.get_fact(Which, command="xbps-install") or host.get_fact(Which, command="xbps"):
580
+ package_operation = xbps.packages
581
+
582
+ elif host.get_fact(Which, command="yum"):
583
+ package_operation = yum.packages
584
+
585
+ elif host.get_fact(Which, command="pkg") or host.get_fact(Which, command="pkg_add"):
586
+ package_operation = pkg.packages
587
+
588
+ else:
589
+ raise OperationError(
590
+ (
591
+ "No system package manager found "
592
+ "(no apk, apt, brew, dnf, pacman, pkg, xbps, yum or zypper found)"
593
+ ),
594
+ )
595
+
596
+ yield from package_operation._inner(packages=packages, present=present)
597
+
598
+
599
+ crontab = crontab_.crontab
600
+
601
+
602
+ @operation()
603
+ def group(group: str, present=True, system=False, gid: int | str | None = None):
604
+ """
605
+ Add/remove system groups.
606
+
607
+ + group: name of the group to ensure
608
+ + present: whether the group should be present or not
609
+ + system: whether to create a system group
610
+ + gid: use a specific groupid number
611
+
612
+ System users:
613
+ System users don't exist on BSD, so the argument is ignored for BSD targets.
614
+
615
+ **Examples:**
616
+
617
+ .. code:: python
618
+
619
+ server.group(
620
+ name="Create docker group",
621
+ group="docker",
622
+ )
623
+
624
+ # multiple groups
625
+ for group in ["wheel", "lusers"]:
626
+ server.group(
627
+ name=f"Create the group {group}",
628
+ group=group,
629
+ )
630
+ """
631
+
632
+ groups = host.get_fact(Groups)
633
+ os_type = host.get_fact(Os)
634
+ is_present = group in groups
635
+
636
+ # Group exists but we don't want them?
637
+ if not present and is_present:
638
+ if os_type == "FreeBSD":
639
+ yield "pw groupdel -n {0}".format(group)
640
+ else:
641
+ yield "groupdel {0}".format(group)
642
+
643
+ # Group doesn't exist and we want it?
644
+ elif present and not is_present:
645
+ args = []
646
+
647
+ # BSD doesn't do system users
648
+ if system and "BSD" not in host.get_fact(Os):
649
+ args.append("-r")
650
+
651
+ if os_type == "FreeBSD":
652
+ args.append("-n {0}".format(group))
653
+ else:
654
+ args.append(group)
655
+
656
+ if gid:
657
+ if os_type == "FreeBSD":
658
+ args.append("-g {0}".format(gid))
659
+ else:
660
+ args.append("--gid {0}".format(gid))
661
+
662
+ # Groups are often added by other operations (package installs), so check
663
+ # for the group at runtime before adding.
664
+ group_add_command = "groupadd"
665
+ if os_type == "FreeBSD":
666
+ group_add_command = "pw groupadd"
667
+ yield "{0} {1}".format(group_add_command, " ".join(args))
668
+
669
+
670
+ @operation()
671
+ def user_authorized_keys(
672
+ user: str,
673
+ public_keys: str | list[str],
674
+ group: str | None = None,
675
+ delete_keys=False,
676
+ authorized_key_directory: str | None = None,
677
+ authorized_key_filename: str | None = None,
678
+ ):
679
+ """
680
+ Manage `authorized_keys` of system users.
681
+
682
+ + user: name of the user to ensure
683
+ + public_keys: list of public keys to attach to this user, ``home`` must be specified
684
+ + group: the users primary group
685
+ + delete_keys: whether to remove any keys not specified in ``public_keys``
686
+
687
+ Public keys:
688
+ These can be provided as strings containing the public key or as a path to
689
+ a public key file which pyinfra will read.
690
+
691
+ **Examples:**
692
+
693
+ .. code:: python
694
+
695
+ server.user_authorized_keys(
696
+ name="Ensure user has a public key",
697
+ user="kevin",
698
+ public_keys=["ed25519..."],
699
+ )
700
+ """
701
+
702
+ if not authorized_key_directory:
703
+ home = host.get_fact(Home, user=user)
704
+ authorized_key_directory = f"{home}/.ssh"
705
+
706
+ if not authorized_key_filename:
707
+ authorized_key_filename = "authorized_keys"
708
+
709
+ if isinstance(public_keys, str):
710
+ public_keys = [public_keys]
711
+
712
+ def read_any_pub_key_file(key):
713
+ try_path = key
714
+ if state.cwd:
715
+ try_path = path.join(state.cwd, key)
716
+
717
+ if path.exists(try_path):
718
+ with open(try_path, "r") as f:
719
+ return [key.strip() for key in f.readlines()]
720
+
721
+ return [key.strip()]
722
+
723
+ public_keys = [key for key_or_file in public_keys for key in read_any_pub_key_file(key_or_file)]
724
+
725
+ # Ensure .ssh directory
726
+ # note that this always outputs commands unless the SSH user has access to the
727
+ # authorized_keys file, ie the SSH user is the user defined in this function
728
+ yield from files.directory._inner(
729
+ path=authorized_key_directory,
730
+ user=user,
731
+ group=group or user,
732
+ mode=700,
733
+ )
734
+
735
+ authorized_key_file = f"{authorized_key_directory}/{authorized_key_filename}"
736
+
737
+ if delete_keys:
738
+ # Create a whole new authorized_keys file
739
+ keys_file = StringIO(
740
+ "{0}\n".format(
741
+ "\n".join(public_keys),
742
+ ),
743
+ )
744
+
745
+ # And ensure it exists
746
+ yield from files.put._inner(
747
+ src=keys_file,
748
+ dest=authorized_key_file,
749
+ user=user,
750
+ group=group or user,
751
+ mode=600,
752
+ )
753
+
754
+ else:
755
+ # Ensure authorized_keys exists
756
+ yield from files.file._inner(
757
+ path=authorized_key_file,
758
+ user=user,
759
+ group=group or user,
760
+ mode=600,
761
+ )
762
+
763
+ # And every public key is present
764
+ for key in public_keys:
765
+ yield from files.line._inner(path=authorized_key_file, line=key, ensure_newline=True)
766
+
767
+
768
+ @operation()
769
+ def user(
770
+ user: str,
771
+ present=True,
772
+ home: str | None = None,
773
+ shell: str | None = None,
774
+ group: str | None = None,
775
+ groups: list[str] | None = None,
776
+ append=False,
777
+ public_keys: str | list[str] | None = None,
778
+ delete_keys=False,
779
+ ensure_home=True,
780
+ create_home=False,
781
+ system=False,
782
+ uid: int | None = None,
783
+ comment: str | None = None,
784
+ unique=True,
785
+ password: str | None = None,
786
+ ):
787
+ """
788
+ Add/remove/update system users & their ssh `authorized_keys`.
789
+
790
+ + user: name of the user to ensure
791
+ + present: whether this user should exist
792
+ + home: the users home directory
793
+ + shell: the users shell
794
+ + group: the users primary group
795
+ + groups: the users secondary groups
796
+ + append: whether to add `user` to `groups`, w/o losing membership of other groups
797
+ + public_keys: list of public keys to attach to this user, ``home`` must be specified
798
+ + delete_keys: whether to remove any keys not specified in ``public_keys``
799
+ + ensure_home: whether to ensure the ``home`` directory exists
800
+ + create_home: whether user create new user home directories from the system skeleton
801
+ + system: whether to create a system account
802
+ + uid: use a specific userid number
803
+ + comment: the user GECOS comment
804
+ + unique: prevent creating users with duplicate UID
805
+ + password: set the encrypted password for the user
806
+
807
+ Home directory:
808
+ When ``ensure_home`` or ``public_keys`` are provided, ``home`` defaults to
809
+ ``/home/{name}``. When ``create_home`` is ``True`` any newly created users
810
+ will be created with the ``-m`` flag to build a new home directory from the
811
+ systems skeleton directory.
812
+
813
+ Public keys:
814
+ These can be provided as strings containing the public key or as a path to
815
+ a public key file which pyinfra will read.
816
+
817
+ **Examples:**
818
+
819
+ .. code:: python
820
+
821
+ server.user(
822
+ name="Ensure user is removed",
823
+ user="kevin",
824
+ present=False,
825
+ )
826
+
827
+ server.user(
828
+ name="Ensure myweb user exists",
829
+ user="myweb",
830
+ shell="/bin/bash",
831
+ )
832
+
833
+ # multiple users
834
+ for user in ["kevin", "bob"]:
835
+ server.user(
836
+ name=f"Ensure user {user} is removed",
837
+ user=user,
838
+ present=False,
839
+ )
840
+ """
841
+
842
+ users = host.get_fact(Users)
843
+ existing_groups = host.get_fact(Groups)
844
+ existing_user = users.get(user)
845
+ os_type = host.get_fact(Os)
846
+ if groups is None:
847
+ groups = []
848
+
849
+ if home is None:
850
+ home = "/home/{0}".format(user)
851
+ if existing_user:
852
+ home = existing_user.get("home", home)
853
+
854
+ # User not wanted?
855
+ if not present:
856
+ if existing_user:
857
+ if os_type == "FreeBSD":
858
+ yield "pw userdel -n {0}".format(user)
859
+ else:
860
+ yield "userdel {0}".format(user)
861
+ return
862
+
863
+ # User doesn't exist but we want them?
864
+ if present and existing_user is None:
865
+ # Fix the case where a group of the same name already exists, tell useradd to use this
866
+ # group rather than failing trying to create it.
867
+ if not group and user in existing_groups:
868
+ group = user
869
+
870
+ # Create the user w/home/shell
871
+ args = []
872
+
873
+ if home:
874
+ args.append("-d {0}".format(home))
875
+
876
+ if shell:
877
+ args.append("-s {0}".format(shell))
878
+
879
+ if group:
880
+ args.append("-g {0}".format(group))
881
+
882
+ if groups:
883
+ args.append("-G {0}".format(",".join(groups)))
884
+
885
+ if system and "BSD" not in host.get_fact(Os):
886
+ args.append("-r")
887
+
888
+ if uid:
889
+ if os_type == "FreeBSD":
890
+ args.append("-u {0}".format(uid))
891
+ else:
892
+ args.append("--uid {0}".format(uid))
893
+
894
+ if comment:
895
+ args.append("-c '{0}'".format(comment))
896
+
897
+ if not unique:
898
+ args.append("-o")
899
+
900
+ if create_home:
901
+ args.append("-m")
902
+ elif os_type != "FreeBSD":
903
+ args.append("-M")
904
+
905
+ if password and os_type != "FreeBSD":
906
+ args.append("-p '{0}'".format(password))
907
+
908
+ # Users are often added by other operations (package installs), so check
909
+ # for the user at runtime before adding.
910
+ add_user_command = "useradd"
911
+
912
+ if os_type == "FreeBSD":
913
+ add_user_command = "pw useradd"
914
+
915
+ if password:
916
+ yield "echo '{3}' | {0} -n {2} -H 0 {1}".format(
917
+ add_user_command, " ".join(args), user, password
918
+ )
919
+ else:
920
+ yield "{0} -n {2} {1}".format(
921
+ add_user_command,
922
+ " ".join(args),
923
+ user,
924
+ )
925
+ else:
926
+ yield "{0} {1} {2}".format(
927
+ add_user_command,
928
+ " ".join(args),
929
+ user,
930
+ )
931
+
932
+ # User exists and we want them, check home/shell/keys/password
933
+ else:
934
+ args = []
935
+
936
+ # Check homedir
937
+ if home and existing_user["home"] != home:
938
+ args.append("-d {0}".format(home))
939
+
940
+ # Check shell
941
+ if shell and existing_user["shell"] != shell:
942
+ args.append("-s {0}".format(shell))
943
+
944
+ # Check primary group
945
+ if group and existing_user["group"] != group:
946
+ args.append("-g {0}".format(group))
947
+
948
+ # Check secondary groups, if defined
949
+ if groups:
950
+ if append:
951
+ if not set(groups).issubset(existing_user["groups"]):
952
+ args.append("-a")
953
+ args.append("-G {0}".format(",".join(groups)))
954
+ elif set(existing_user["groups"]) != set(groups):
955
+ args.append("-G {0}".format(",".join(groups)))
956
+
957
+ if comment and existing_user["comment"] != comment:
958
+ args.append("-c '{0}'".format(comment))
959
+
960
+ if password and existing_user["password"] != password:
961
+ if os_type == "FreeBSD":
962
+ yield "echo '{0}' | pw usermod -n {1} -H 0".format(password, user)
963
+ else:
964
+ args.append("-p '{0}'".format(password))
965
+
966
+ # Need to mod the user?
967
+ if args:
968
+ if os_type == "FreeBSD":
969
+ yield "pw usermod -n {1} {0}".format(" ".join(args), user)
970
+ else:
971
+ yield "usermod {0} {1}".format(" ".join(args), user)
972
+
973
+ # Ensure home directory ownership
974
+ if ensure_home and home:
975
+ yield from files.directory._inner(
976
+ path=home,
977
+ user=user,
978
+ group=group or user,
979
+ # Don't fail if the home directory exists as a link
980
+ _no_fail_on_link=True,
981
+ )
982
+
983
+ # Add SSH keys
984
+ if public_keys is not None:
985
+ yield from user_authorized_keys._inner(
986
+ user=user,
987
+ public_keys=public_keys,
988
+ group=group,
989
+ delete_keys=delete_keys,
990
+ authorized_key_directory="{0}/.ssh".format(home),
991
+ authorized_key_filename=None,
992
+ )
993
+
994
+
995
+ @operation()
996
+ def locale(
997
+ locale: str,
998
+ present=True,
999
+ ):
1000
+ """
1001
+ Enable/Disable locale.
1002
+
1003
+ + locale: name of the locale to enable/disable
1004
+ + present: whether this locale should be present or not
1005
+
1006
+ **Examples:**
1007
+
1008
+ .. code:: python
1009
+
1010
+ server.locale(
1011
+ name="Ensure en_GB.UTF-8 locale is not present",
1012
+ locale="en_GB.UTF-8",
1013
+ present=False,
1014
+ )
1015
+
1016
+ server.locale(
1017
+ name="Ensure en_GB.UTF-8 locale is present",
1018
+ locale="en_GB.UTF-8",
1019
+ )
1020
+
1021
+ """
1022
+
1023
+ locales = host.get_fact(Locales)
1024
+
1025
+ logger.debug("Enabled locales: {0}".format(locales))
1026
+
1027
+ locales_definitions_file = "/etc/locale.gen"
1028
+
1029
+ # Find the matching line in /etc/locale.gen
1030
+ matching_lines = host.get_fact(
1031
+ FindInFile, path=locales_definitions_file, pattern=rf"^.*{locale}[[:space:]]\+.*$"
1032
+ )
1033
+
1034
+ if not matching_lines:
1035
+ raise OperationError(f"Locale {locale} not found in {locales_definitions_file}")
1036
+
1037
+ if len(matching_lines) > 1:
1038
+ raise OperationError(f"Multiple locales matches for {locale} in {locales_definitions_file}")
1039
+
1040
+ matching_line = matching_lines[0]
1041
+
1042
+ # Remove locale
1043
+ if not present and locale in locales:
1044
+ logger.debug(f"Removing locale {locale}")
1045
+
1046
+ yield from files.line._inner(
1047
+ path=locales_definitions_file, line=f"^{matching_line}$", replace=f"# {matching_line}"
1048
+ )
1049
+
1050
+ yield "locale-gen"
1051
+
1052
+ # Add locale
1053
+ if present and locale not in locales:
1054
+ logger.debug(f"Adding locale {locale}")
1055
+
1056
+ yield from files.replace._inner(
1057
+ path=locales_definitions_file,
1058
+ text=f"^{matching_line}$",
1059
+ replace=f"{matching_line}".replace("# ", ""),
1060
+ )
1061
+
1062
+ yield "locale-gen"
1063
+
1064
+
1065
+ @operation()
1066
+ def security_limit(
1067
+ domain: str,
1068
+ limit_type: str,
1069
+ item: str,
1070
+ value: int,
1071
+ ):
1072
+ """
1073
+ Edit /etc/security/limits.conf configuration.
1074
+
1075
+ + domain: the domain (user, group, or wildcard) for the limit
1076
+ + limit_type: the type of limit (hard or soft)
1077
+ + item: the item to limit (e.g., nofile, nproc)
1078
+ + value: the value for the limit
1079
+
1080
+ **Example:**
1081
+
1082
+ .. code:: python
1083
+
1084
+ security_limit(
1085
+ name="Set nofile limit for all users",
1086
+ domain='*',
1087
+ limit_type='soft',
1088
+ item='nofile',
1089
+ value=1024,
1090
+ )
1091
+ """
1092
+
1093
+ line_format = f"{domain}\t{limit_type}\t{item}\t{value}"
1094
+
1095
+ yield from files.line._inner(
1096
+ path="/etc/security/limits.conf",
1097
+ line=f"^{domain}[[:space:]]+{limit_type}[[:space:]]+{item}",
1098
+ replace=line_format,
1099
+ )