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
pyinfra/facts/server.py CHANGED
@@ -1,173 +1,339 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
1
5
  import re
6
+ import shutil
2
7
  from datetime import datetime
8
+ from tempfile import mkdtemp
9
+ from typing import Dict, Iterable, List, Optional, Tuple, Union
3
10
 
4
11
  from dateutil.parser import parse as parse_date
12
+ from distro import distro
13
+ from typing_extensions import TypedDict, override
5
14
 
15
+ from pyinfra import host
6
16
  from pyinfra.api import FactBase, ShortFactBase
7
17
  from pyinfra.api.util import try_int
18
+ from pyinfra.facts import crontab
19
+
20
+ ISO_DATE_FORMAT = "%Y-%m-%dT%H:%M:%S%z"
21
+
22
+
23
+ class User(FactBase):
24
+ """
25
+ Returns the name of the current user.
26
+ """
27
+
28
+ @override
29
+ def command(self):
30
+ return "echo $USER"
31
+
32
+
33
+ class Home(FactBase[Optional[str]]):
34
+ """
35
+ Returns the home directory of the given user, or the current user if no user is given.
36
+ """
37
+
38
+ @override
39
+ def command(self, user=""):
40
+ return f"echo ~{user}"
41
+
42
+
43
+ class Path(FactBase):
44
+ """
45
+ Returns the path environment variable of the current user.
46
+ """
47
+
48
+ @override
49
+ def command(self):
50
+ return "echo $PATH"
8
51
 
9
52
 
10
- class Home(FactBase):
11
- '''
12
- Returns the home directory of the current user.
13
- '''
53
+ class TmpDir(FactBase):
54
+ """
55
+ Returns the temporary directory of the current server, if configured.
56
+ """
14
57
 
15
- command = 'echo $HOME'
58
+ @override
59
+ def command(self):
60
+ return "echo $TMPDIR"
16
61
 
17
62
 
18
63
  class Hostname(FactBase):
19
- '''
64
+ """
20
65
  Returns the current hostname of the server.
21
- '''
66
+ """
22
67
 
23
- command = 'hostname'
68
+ @override
69
+ def command(self):
70
+ return "uname -n"
24
71
 
25
72
 
26
- class Os(FactBase):
27
- '''
73
+ class Kernel(FactBase):
74
+ """
75
+ Returns the kernel name according to ``uname``.
76
+ """
77
+
78
+ @override
79
+ def command(self):
80
+ return "uname -s"
81
+
82
+
83
+ class KernelVersion(FactBase):
84
+ """
85
+ Returns the kernel version according to ``uname``.
86
+ """
87
+
88
+ @override
89
+ def command(self):
90
+ return "uname -r"
91
+
92
+
93
+ # Deprecated/renamed -> Kernel
94
+ class Os(FactBase[str]):
95
+ """
28
96
  Returns the OS name according to ``uname``.
29
- '''
30
97
 
31
- command = 'uname -s'
98
+ .. warning::
99
+ This fact is deprecated/renamed, please use the ``server.Kernel`` fact.
100
+ """
32
101
 
102
+ @override
103
+ def command(self):
104
+ return "uname -s"
33
105
 
34
- class OsVersion(FactBase):
35
- '''
106
+
107
+ # Deprecated/renamed -> KernelVersion
108
+ class OsVersion(FactBase[str]):
109
+ """
36
110
  Returns the OS version according to ``uname``.
37
- '''
38
111
 
39
- command = 'uname -r'
112
+ .. warning::
113
+ This fact is deprecated/renamed, please use the ``server.KernelVersion`` fact.
114
+ """
115
+
116
+ @override
117
+ def command(self):
118
+ return "uname -r"
40
119
 
41
120
 
42
- class Arch(FactBase):
43
- '''
121
+ class Arch(FactBase[str]):
122
+ """
44
123
  Returns the system architecture according to ``uname``.
45
- '''
124
+ """
46
125
 
47
- command = 'uname -p'
126
+ # ``uname -p`` is not portable and returns ``unknown`` on Debian.
127
+ # ``uname -m`` works on most Linux and BSD systems.
128
+ @override
129
+ def command(self):
130
+ return "uname -m"
48
131
 
49
132
 
50
- class Command(FactBase):
51
- '''
133
+ class Command(FactBase[str]):
134
+ """
52
135
  Returns the raw output lines of a given command.
53
- '''
136
+ """
54
137
 
55
- @staticmethod
56
- def command(command):
138
+ @override
139
+ def command(self, command):
57
140
  return command
58
141
 
59
142
 
60
- class Which(FactBase):
61
- '''
62
- Returns the path of a given command, if available.
63
- '''
143
+ class Which(FactBase[Optional[str]]):
144
+ """
145
+ Returns the path of a given command according to `command -v`, if available.
146
+ """
64
147
 
65
- @staticmethod
66
- def command(name):
67
- return 'which {0}'.format(name)
148
+ @override
149
+ def command(self, command):
150
+ return "command -v {0} || true".format(command)
68
151
 
69
152
 
70
- class Date(FactBase):
71
- '''
153
+ class Date(FactBase[datetime]):
154
+ """
72
155
  Returns the current datetime on the server.
73
- '''
156
+ """
74
157
 
75
- command = 'LANG=C date'
76
158
  default = datetime.now
77
159
 
78
- @staticmethod
79
- def process(output):
80
- return parse_date(output[0])
160
+ @override
161
+ def command(self):
162
+ return f"date +'{ISO_DATE_FORMAT}'"
81
163
 
164
+ @override
165
+ def process(self, output) -> datetime:
166
+ return datetime.strptime(list(output)[0], ISO_DATE_FORMAT)
82
167
 
83
- class Mounts(FactBase):
84
- '''
168
+
169
+ class MacosVersion(FactBase[str]):
170
+ """
171
+ Returns the installed MacOS version.
172
+ """
173
+
174
+ @override
175
+ def requires_command(self) -> str:
176
+ return "sw_vers"
177
+
178
+ @override
179
+ def command(self):
180
+ return "sw_vers -productVersion"
181
+
182
+
183
+ class MountsDict(TypedDict):
184
+ device: str
185
+ type: str
186
+ options: list[str]
187
+
188
+
189
+ class Mounts(FactBase[Dict[str, MountsDict]]):
190
+ """
85
191
  Returns a dictionary of mounted filesystems and information.
86
192
 
87
193
  .. code:: python
88
194
 
89
- "/": {
90
- "device": "/dev/mv2",
91
- "type": "ext4",
92
- "options": [
93
- "rw",
94
- "relatime"
95
- ]
96
- },
97
- ...
98
- '''
195
+ {
196
+ "/": {
197
+ "device": "/dev/mv2",
198
+ "type": "ext4",
199
+ "options": [
200
+ "rw",
201
+ "relatime"
202
+ ]
203
+ },
204
+ }
205
+ """
99
206
 
100
- command = 'mount'
101
207
  default = dict
102
208
 
103
- @staticmethod
104
- def process(output):
105
- devices = {}
209
+ @override
210
+ def command(self) -> str:
211
+ self._kernel = host.get_fact(Kernel)
106
212
 
107
- for line in output:
108
- is_map = False
109
- if line.startswith('map '):
110
- line = line[4:]
111
- is_map = True
213
+ if self._kernel.strip() == "FreeBSD":
214
+ return "mount -p --libxo json"
215
+ else:
216
+ return "cat /proc/self/mountinfo"
112
217
 
113
- device, _, path, other_bits = line.split(' ', 3)
218
+ @override
219
+ def process(self, output) -> dict[str, MountsDict]:
220
+ devices: dict[str, MountsDict] = {}
114
221
 
115
- if is_map:
116
- device = 'map {0}'.format(device)
222
+ def unescape_octal(match: re.Match) -> str:
223
+ s = match.group(0)[1:] # skip the backslash
224
+ return chr(int(s, base=8))
117
225
 
118
- if other_bits.startswith('type'):
119
- _, type_, options = other_bits.split(' ', 2)
120
- options = options.strip('()').split(',')
121
- else:
122
- options = other_bits.strip('()').split(',')
123
- type_ = options.pop(0)
226
+ def replace_octal(s: str) -> str:
227
+ """
228
+ Unescape strings encoded by linux's string_escape_mem with ESCAPE_OCTAL flag.
229
+ """
230
+ return re.sub(r"\\[0-7]{3}", unescape_octal, s)
231
+
232
+ if self._kernel == "FreeBSD":
233
+ full_output = "\n".join(output)
234
+ json_output = json.loads(full_output)
235
+ mount_fstab = json_output["mount"]["fstab"]
236
+
237
+ for entry in mount_fstab:
238
+ path = entry["mntpoint"]
239
+ type_ = entry["fstype"]
240
+ device = entry["device"]
241
+ options = [option.strip() for option in entry["opts"].split(",")]
242
+
243
+ devices[path] = {"device": device, "type": type_, "options": options}
244
+
245
+ return devices
246
+
247
+ for line in output:
248
+ # ignore mount ID, parent ID, major:minor, root
249
+ _, _, _, _, mount_point, mount_options, line = line.split(sep=" ", maxsplit=6)
250
+
251
+ # ignore optional tags "shared", "master", "propagate_from" and "unbindable"
252
+ while True:
253
+ optional, line = line.split(sep=" ", maxsplit=1)
254
+ if optional == "-":
255
+ break
256
+
257
+ fs_type, mount_source, super_options = line.split(sep=" ")
258
+
259
+ mount_options = mount_options.split(sep=",")
260
+
261
+ # escaped: mount_point, mount_source, super_options
262
+ # these strings can contain characters encoded in octal, e.g. '\054' for ','
263
+ mount_point = replace_octal(mount_point)
264
+ mount_source = replace_octal(mount_source)
265
+
266
+ # mount_options will override ro/rw and can be different than the super block options
267
+ # filter them, so they don't appear twice
268
+ super_options = [
269
+ replace_octal(opt)
270
+ for opt in super_options.split(sep=",")
271
+ if opt not in ["ro", "rw"]
272
+ ]
124
273
 
125
- devices[path] = {
126
- 'device': device,
127
- 'type': type_,
128
- 'options': [option.strip() for option in options],
274
+ devices[mount_point] = {
275
+ "device": mount_source,
276
+ "type": fs_type,
277
+ "options": mount_options + super_options,
129
278
  }
130
279
 
131
280
  return devices
132
281
 
133
282
 
283
+ class Port(FactBase[Union[Tuple[str, int], Tuple[None, None]]]):
284
+ """
285
+ Returns the process occuping a port and its PID
286
+ """
287
+
288
+ @override
289
+ def command(self, port: int) -> str:
290
+ return f"ss -lptnH 'src :{port}'"
291
+
292
+ @override
293
+ def process(self, output: Iterable[str]) -> Union[Tuple[str, int], Tuple[None, None]]:
294
+ for line in output:
295
+ proc, pid = line.split('"')[1], int(line.split("pid=")[1].split(",")[0])
296
+ return (proc, pid)
297
+ return None, None
298
+
299
+
134
300
  class KernelModules(FactBase):
135
- '''
301
+ """
136
302
  Returns a dictionary of kernel module name -> info.
137
303
 
138
304
  .. code:: python
139
305
 
140
- 'module_name': {
141
- 'size': 0,
142
- 'instances': 0,
143
- 'state': 'Live',
144
- },
145
- ...
146
- '''
306
+ {
307
+ "module_name": {
308
+ "size": 0,
309
+ "instances": 0,
310
+ "state": "Live",
311
+ },
312
+ }
313
+ """
314
+
315
+ @override
316
+ def command(self):
317
+ return "! test -f /proc/modules || cat /proc/modules"
147
318
 
148
- command = 'cat /proc/modules'
149
319
  default = dict
150
320
 
151
- @staticmethod
152
- def process(output):
321
+ @override
322
+ def process(self, output):
153
323
  modules = {}
154
324
 
155
325
  for line in output:
156
- name, size, instances, depends, state, _ = line.split(' ', 5)
326
+ name, size, instances, depends, state, _ = line.split(" ", 5)
157
327
  instances = int(instances)
158
328
 
159
329
  module = {
160
- 'size': size,
161
- 'instances': instances,
162
- 'state': state,
330
+ "size": size,
331
+ "instances": instances,
332
+ "state": state,
163
333
  }
164
334
 
165
- if depends != '-':
166
- module['depends'] = [
167
- value
168
- for value in depends.split(',')
169
- if value
170
- ]
335
+ if depends != "-":
336
+ module["depends"] = [value for value in depends.split(",") if value]
171
337
 
172
338
  modules[name] = module
173
339
 
@@ -175,7 +341,7 @@ class KernelModules(FactBase):
175
341
 
176
342
 
177
343
  class LsbRelease(FactBase):
178
- '''
344
+ """
179
345
  Returns a dictionary of release information using ``lsb_release``.
180
346
 
181
347
  .. code:: python
@@ -187,25 +353,31 @@ class LsbRelease(FactBase):
187
353
  "codename": "bionic",
188
354
  ...
189
355
  }
190
- '''
356
+ """
191
357
 
192
- command = 'lsb_release -ca'
358
+ @override
359
+ def command(self):
360
+ return "lsb_release -ca"
193
361
 
194
- @staticmethod
195
- def process(output):
362
+ @override
363
+ def requires_command(self):
364
+ return "lsb_release"
365
+
366
+ @override
367
+ def process(self, output):
196
368
  items = {}
197
369
 
198
370
  for line in output:
199
- if ':' not in line:
371
+ if ":" not in line:
200
372
  continue
201
373
 
202
- key, value = line.split(':', 1)
374
+ key, value = line.split(":", 1)
203
375
 
204
376
  key = key.strip().lower()
205
377
 
206
378
  # Turn "distributor id" into "id"
207
- if ' ' in key:
208
- key = key.split(' ')[-1]
379
+ if " " in key:
380
+ key = key.split(" ")[-1]
209
381
 
210
382
  value = value.strip()
211
383
 
@@ -214,8 +386,40 @@ class LsbRelease(FactBase):
214
386
  return items
215
387
 
216
388
 
389
+ class OsRelease(FactBase):
390
+ """
391
+ Returns a dictionary of release information stored in ``/etc/os-release``.
392
+
393
+ .. code:: python
394
+
395
+ {
396
+ "name": "EndeavourOS",
397
+ "pretty_name": "EndeavourOS",
398
+ "id": "endeavouros",
399
+ "id_like": "arch",
400
+ "build_id": "2024.06.25",
401
+ ...
402
+ }
403
+ """
404
+
405
+ @override
406
+ def command(self):
407
+ return "cat /etc/os-release"
408
+
409
+ @override
410
+ def process(self, output):
411
+ items = {}
412
+
413
+ for line in output:
414
+ if "=" in line:
415
+ key, value = line.split("=", 1)
416
+ items[key.strip().lower()] = value.strip().strip('"')
417
+
418
+ return items
419
+
420
+
217
421
  class Sysctl(FactBase):
218
- '''
422
+ """
219
423
  Returns a dictionary of sysctl settings and values.
220
424
 
221
425
  .. code:: python
@@ -226,24 +430,28 @@ class Sysctl(FactBase):
226
430
  44565,
227
431
  360,
228
432
  ],
229
- ...
230
433
  }
231
- '''
434
+ """
232
435
 
233
- command = 'sysctl -a'
234
436
  default = dict
235
437
 
236
- @staticmethod
237
- def process(output):
438
+ @override
439
+ def command(self, keys=None):
440
+ if keys is None:
441
+ return "sysctl -a"
442
+ return f"sysctl {' '.join(keys)}"
443
+
444
+ @override
445
+ def process(self, output):
238
446
  sysctls = {}
239
447
 
240
448
  for line in output:
241
449
  key = values = None
242
450
 
243
- if '=' in line:
244
- key, values = line.split('=', 1)
245
- elif ':' in line:
246
- key, values = line.split(':', 1)
451
+ if "=" in line:
452
+ key, values = line.split("=", 1)
453
+ elif ":" in line:
454
+ key, values = line.split(":", 1)
247
455
  else:
248
456
  continue # pragma: no cover
249
457
 
@@ -251,11 +459,8 @@ class Sysctl(FactBase):
251
459
  key = key.strip()
252
460
  values = values.strip()
253
461
 
254
- if re.match(r'^[a-zA-Z0-9_\.\s]+$', values):
255
- values = [
256
- try_int(item.strip())
257
- for item in values.split()
258
- ]
462
+ if re.match(r"^[a-zA-Z0-9_\-\.\s]+$", values):
463
+ values = [try_int(item.strip()) for item in values.split()]
259
464
 
260
465
  if len(values) == 1:
261
466
  values = values[0]
@@ -265,156 +470,125 @@ class Sysctl(FactBase):
265
470
  return sysctls
266
471
 
267
472
 
268
- class Groups(FactBase):
269
- '''
473
+ class Groups(FactBase[List[str]]):
474
+ """
270
475
  Returns a list of groups on the system.
271
- '''
476
+ """
477
+
478
+ @override
479
+ def command(self):
480
+ return "cat /etc/group"
272
481
 
273
- command = 'cat /etc/group'
274
482
  default = list
275
483
 
276
- @staticmethod
277
- def process(output):
278
- groups = []
484
+ @override
485
+ def process(self, output) -> list[str]:
486
+ groups: list[str] = []
279
487
 
280
488
  for line in output:
281
- if ':' in line:
282
- groups.append(line.split(':')[0])
489
+ if ":" in line:
490
+ groups.append(line.split(":")[0])
283
491
 
284
492
  return groups
285
493
 
286
494
 
287
- class Crontab(FactBase):
288
- '''
289
- Returns a dictionary of cron command -> execution time.
290
-
291
- .. code:: python
292
-
293
- '/path/to/command': {
294
- 'minute': '*',
295
- 'hour': '*',
296
- 'month': '*',
297
- 'day_of_month': '*',
298
- 'day_of_week': '*',
299
- },
300
- ...
301
- '''
302
-
303
- default = dict
304
-
305
- @staticmethod
306
- def command(user=None):
307
- if user:
308
- return 'crontab -l -u {0}'.format(user)
309
-
310
- return 'crontab -l'
311
-
312
- @staticmethod
313
- def process(output):
314
- crons = {}
315
- current_comments = []
316
-
317
- for line in output:
318
- line = line.strip()
319
- if not line or line.startswith('#'):
320
- current_comments.append(line)
321
- continue
322
-
323
- minute, hour, day_of_month, month, day_of_week, command = line.split(' ', 5)
324
- crons[command] = {
325
- 'minute': try_int(minute),
326
- 'hour': try_int(hour),
327
- 'month': try_int(month),
328
- 'day_of_month': try_int(day_of_month),
329
- 'day_of_week': try_int(day_of_week),
330
- 'comments': current_comments,
331
- }
332
- current_comments = []
333
-
334
- return crons
495
+ # for compatibility
496
+ CrontabDict = crontab.CrontabDict
497
+ Crontab = crontab.Crontab
335
498
 
336
499
 
337
500
  class Users(FactBase):
338
- '''
501
+ """
339
502
  Returns a dictionary of users -> details.
340
503
 
341
504
  .. code:: python
342
505
 
343
- 'user_name': {
344
- 'home': '/home/user_name',
345
- 'shell': '/bin/bash,
346
- 'group': 'main_user_group',
347
- 'groups': [
348
- 'other',
349
- 'groups'
350
- ]
351
- },
352
- ...
353
- '''
506
+ {
507
+ "user_name": {
508
+ "comment": "Full Name",
509
+ "home": "/home/user_name",
510
+ "shell": "/bin/bash,
511
+ "group": "main_user_group",
512
+ "groups": [
513
+ "other",
514
+ "groups"
515
+ ],
516
+ "uid": user_id,
517
+ "gid": main_user_group_id,
518
+ "lastlog": last_login_time,
519
+ "password": encrypted_password,
520
+ },
521
+ }
522
+ """
523
+
524
+ @override
525
+ def command(self):
526
+ return """
354
527
 
355
- command = '''
356
528
  for i in `cat /etc/passwd | cut -d: -f1`; do
357
- ID=`id $i`
358
- META=`cat /etc/passwd | grep ^$i: | cut -d: -f6-7`
359
- echo "$ID $META"
529
+ ENTRY=`grep ^$i: /etc/passwd`;
530
+ LASTLOG=`(((lastlog -u $i || lastlogin $i) 2> /dev/null) | grep ^$i | tr -s ' ')`;
531
+ PASSWORD=`(grep ^$i: /etc/shadow || grep ^$i: /etc/master.passwd) 2> /dev/null | cut -d: -f2`;
532
+ echo "$ENTRY|`id -gn $i`|`id -Gn $i`|$LASTLOG|$PASSWORD";
360
533
  done
361
- '''
534
+ """.strip() # noqa
362
535
 
363
536
  default = dict
364
537
 
365
- regex = r'^uid=[0-9]+\(([a-zA-Z0-9_\.\-]+)\) gid=[0-9]+\(([a-zA-Z0-9_\.\-]+)\) groups=([a-zA-Z0-9_\.\-,\(\)\s]+) (.*)$' # noqa
366
- group_regex = r'^[0-9]+\(([a-zA-Z0-9_\.\-]+)\)$'
367
-
538
+ @override
368
539
  def process(self, output):
369
540
  users = {}
370
- for line in output:
371
- matches = re.match(self.regex, line)
372
-
373
- if matches:
374
- # Parse out the home/shell
375
- home_shell = matches.group(4)
376
- home = shell = None
377
-
378
- # /blah: is just a home
379
- if home_shell.endswith(':'):
380
- home = home_shell[:-1]
381
-
382
- # :/blah is just a shell
383
- elif home_shell.startswith(':'):
384
- shell = home_shell[1:]
541
+ rex = r"[A-Z][a-z]{2} [A-Z][a-z]{2} {1,2}\d+ .+$"
385
542
 
386
- # Both home & shell
387
- elif ':' in home_shell:
388
- home, shell = home_shell.split(':')
543
+ for line in output:
544
+ entry, group, user_groups, lastlog, password = line.rsplit("|", 4)
389
545
 
390
- # Main user group
391
- group = matches.group(2)
546
+ if entry:
547
+ # Parse out the comment/home/shell
548
+ entries = entry.split(":")
392
549
 
393
- # Parse the groups
550
+ # Parse groups
394
551
  groups = []
395
- for group_matches in matches.group(3).split(','):
396
- name = re.match(self.group_regex, group_matches.strip())
397
- if name:
398
- name = name.group(1)
399
- else:
400
- continue # pragma: no cover
401
-
552
+ for group_name in user_groups.split(" "):
402
553
  # We only want secondary groups here
403
- if name != group:
404
- groups.append(name)
405
-
406
- users[matches.group(1)] = {
407
- 'group': group,
408
- 'groups': groups,
409
- 'home': home,
410
- 'shell': shell,
554
+ if group_name and group_name != group:
555
+ groups.append(group_name)
556
+
557
+ raw_login_time = None
558
+ login_time = None
559
+
560
+ # Parse lastlog info
561
+ # lastlog output varies, which is why I use regex to match login time
562
+ login = re.search(rex, lastlog)
563
+ if login:
564
+ raw_login_time = login.group()
565
+ login_time = parse_date(raw_login_time)
566
+
567
+ users[entries[0]] = {
568
+ "home": entries[5] or None,
569
+ "comment": entries[4] or None,
570
+ "shell": entries[6] or None,
571
+ "group": group,
572
+ "groups": groups,
573
+ "uid": int(entries[2]),
574
+ "gid": int(entries[3]),
575
+ "lastlog": raw_login_time,
576
+ "login_time": login_time,
577
+ "password": password,
411
578
  }
412
579
 
413
580
  return users
414
581
 
415
582
 
416
- class LinuxDistribution(FactBase):
417
- '''
583
+ class LinuxDistributionDict(TypedDict):
584
+ name: Optional[str]
585
+ major: Optional[int]
586
+ minor: Optional[int]
587
+ release_meta: Dict
588
+
589
+
590
+ class LinuxDistribution(FactBase[LinuxDistributionDict]):
591
+ """
418
592
  Returns a dict of the Linux distribution version. Ubuntu, Debian, CentOS,
419
593
  Fedora & Gentoo currently. Also contains any key/value items located in
420
594
  release files.
@@ -422,72 +596,359 @@ class LinuxDistribution(FactBase):
422
596
  .. code:: python
423
597
 
424
598
  {
425
- 'name': 'CentOS',
426
- 'major': 6,
427
- 'minor': 5,
428
- 'release_meta': {
429
- 'DISTRIB_CODENAME': 'trusty',
599
+ "name": "Ubuntu",
600
+ "major": 20,
601
+ "minor": 04,
602
+ "release_meta": {
603
+ "CODENAME": "focal",
604
+ "ID_LIKE": "debian",
430
605
  ...
431
606
  }
432
607
  }
433
- '''
434
-
435
- command = 'cat /etc/*-release'
436
-
437
- # Currently supported distros
438
- regexes = [
439
- r'(Ubuntu) ([0-9]{2})\.([0-9]{2})',
440
- r'(CentOS) release ([0-9]).([0-9])',
441
- r'(Red Hat Enterprise Linux) Server release ([0-9]).([0-9])',
442
- r'(CentOS) Linux release ([0-9])\.([0-9])',
443
- r'(Debian) GNU/Linux ([0-9])()',
444
- r'(Gentoo) Base System release ([0-9])\.([0-9])',
445
- r'(Fedora) release ([0-9]+)()',
446
- r'(Alpine) Linux v([0-9]+).([0-9]+)',
447
- ]
448
-
608
+ """
609
+
610
+ @override
611
+ def command(self) -> str:
612
+ return (
613
+ "cd /etc/ && for file in $(ls -pdL *-release | grep -v /); "
614
+ 'do echo "/etc/${file}"; cat "/etc/${file}"; echo ---; '
615
+ "done"
616
+ )
617
+
618
+ name_to_pretty_name = {
619
+ "alpine": "Alpine",
620
+ "centos": "CentOS",
621
+ "fedora": "Fedora",
622
+ "gentoo": "Gentoo",
623
+ "opensuse": "openSUSE",
624
+ "rhel": "RedHat",
625
+ "ubuntu": "Ubuntu",
626
+ "debian": "Debian",
627
+ }
628
+
629
+ @override
449
630
  @staticmethod
450
- def default():
631
+ def default() -> LinuxDistributionDict:
451
632
  return {
452
- 'name': None,
453
- 'major': None,
454
- 'minor': None,
633
+ "name": None,
634
+ "major": None,
635
+ "minor": None,
636
+ "release_meta": {},
455
637
  }
456
638
 
457
- def process(self, output):
458
- release_info = {
459
- 'release_meta': {},
460
- }
461
-
462
- # Start with a copy of the default (None) data
463
- release_info.update(self.default())
639
+ @override
640
+ def process(self, output) -> LinuxDistributionDict:
641
+ parts = {}
642
+ for part in "\n".join(output).strip().split("---"):
643
+ if not part.strip():
644
+ continue
645
+ try:
646
+ filename, content = part.strip().split("\n", 1)
647
+ parts[filename] = content
648
+ except ValueError:
649
+ # skip empty files
650
+ # for instance arch linux as an empty file at /etc/arch-release
651
+ continue
464
652
 
465
- for line in output:
466
- # Check if we match a known version/major/minor string
467
- for regex in self.regexes:
468
- matches = re.search(regex, line)
469
- if matches:
470
- release_info.update({
471
- 'name': matches.group(1),
472
- 'major': matches.group(2) and int(matches.group(2)) or None,
473
- 'minor': matches.group(3) and int(matches.group(3)) or None,
474
- })
475
-
476
- if '=' in line:
477
- key, value = line.split('=')
478
- release_info['release_meta'][key] = value.strip('"')
653
+ release_info = self.default()
654
+ if not parts:
655
+ return release_info
656
+
657
+ temp_root = mkdtemp()
658
+ try:
659
+ temp_etc_dir = os.path.join(temp_root, "etc")
660
+ os.mkdir(temp_etc_dir)
661
+
662
+ for filename, content in parts.items():
663
+ with open(
664
+ os.path.join(temp_etc_dir, os.path.basename(filename)),
665
+ "w",
666
+ encoding="utf-8",
667
+ ) as fp:
668
+ fp.write(content)
669
+
670
+ parsed = distro.LinuxDistribution(
671
+ root_dir=temp_root,
672
+ include_lsb=False,
673
+ include_uname=False,
674
+ )
675
+
676
+ release_meta = {key.upper(): value for key, value in parsed.os_release_info().items()}
677
+ # Distro 1.7+ adds this, breaking tests
678
+ # TODO: fix this!
679
+ release_meta.pop("RELEASE_CODENAME", None)
680
+
681
+ release_info.update(
682
+ {
683
+ "name": self.name_to_pretty_name.get(parsed.id(), parsed.name()),
684
+ "major": try_int(parsed.major_version()) or None,
685
+ "minor": try_int(parsed.minor_version()) or None,
686
+ "release_meta": release_meta,
687
+ },
688
+ )
689
+
690
+ finally:
691
+ shutil.rmtree(temp_root)
479
692
 
480
693
  return release_info
481
694
 
482
695
 
483
- class LinuxName(ShortFactBase):
484
- '''
696
+ class LinuxName(ShortFactBase[str]):
697
+ """
485
698
  Returns the name of the Linux distribution. Shortcut for
486
- ``host.fact.linux_distribution['name']``.
487
- '''
699
+ ``host.get_fact(LinuxDistribution)['name']``.
700
+ """
488
701
 
489
702
  fact = LinuxDistribution
490
703
 
704
+ @override
705
+ def process_data(self, data) -> str:
706
+ return data["name"]
707
+
708
+
709
+ class SelinuxDict(TypedDict):
710
+ mode: Optional[str]
711
+
712
+
713
+ class Selinux(FactBase[SelinuxDict]):
714
+ """
715
+ Discovers the SELinux related facts on the target host.
716
+
717
+ .. code:: python
718
+
719
+ {
720
+ "mode": "enabled",
721
+ }
722
+ """
723
+
724
+ @override
725
+ def command(self):
726
+ return "sestatus"
727
+
728
+ @override
729
+ def requires_command(self) -> str:
730
+ return "sestatus"
731
+
732
+ @override
491
733
  @staticmethod
492
- def process_data(data):
493
- return data['name']
734
+ def default() -> SelinuxDict:
735
+ return {
736
+ "mode": None,
737
+ }
738
+
739
+ @override
740
+ def process(self, output) -> SelinuxDict:
741
+ selinux_info = self.default()
742
+
743
+ match = re.match(r"^SELinux status:\s+(\S+)", "\n".join(output))
744
+
745
+ if not match:
746
+ return selinux_info
747
+
748
+ selinux_info["mode"] = match.group(1)
749
+
750
+ return selinux_info
751
+
752
+
753
+ class LinuxGui(FactBase[List[str]]):
754
+ """
755
+ Returns a list of available Linux GUIs.
756
+ """
757
+
758
+ @override
759
+ def command(self):
760
+ return "ls /usr/bin/*session || true"
761
+
762
+ default = list
763
+
764
+ known_gui_binaries = {
765
+ "/usr/bin/gnome-session": "GNOME",
766
+ "/usr/bin/mate-session": "MATE",
767
+ "/usr/bin/lxsession": "LXDE",
768
+ "/usr/bin/plasma_session": "KDE Plasma",
769
+ "/usr/bin/xfce4-session": "XFCE 4",
770
+ }
771
+
772
+ @override
773
+ def process(self, output) -> list[str]:
774
+ gui_names = []
775
+
776
+ for line in output:
777
+ gui_name = self.known_gui_binaries.get(line)
778
+ if gui_name:
779
+ gui_names.append(gui_name)
780
+
781
+ return gui_names
782
+
783
+
784
+ class HasGui(ShortFactBase[bool]):
785
+ """
786
+ Returns a boolean indicating the remote side has GUI capabilities. Linux only.
787
+ """
788
+
789
+ fact = LinuxGui
790
+
791
+ @override
792
+ def process_data(self, data) -> bool:
793
+ return len(data) > 0
794
+
795
+
796
+ class Locales(FactBase[List[str]]):
797
+ """
798
+ Returns installed locales on the target host.
799
+
800
+ .. code:: python
801
+
802
+ ["C.UTF-8", "en_US.UTF-8"]
803
+ """
804
+
805
+ @override
806
+ def command(self) -> str:
807
+ return "locale -a"
808
+
809
+ @override
810
+ def requires_command(self) -> str:
811
+ return "locale"
812
+
813
+ default = list
814
+
815
+ @override
816
+ def process(self, output) -> list[str]:
817
+ # replace utf8 with UTF-8 to match names in /etc/locale.gen
818
+ # return a list of enabled locales
819
+ return [line.replace("utf8", "UTF-8") for line in output]
820
+
821
+
822
+ class SecurityLimits(FactBase):
823
+ """
824
+ Returns a list of security limits on the target host.
825
+
826
+ .. code:: python
827
+
828
+ [
829
+ {
830
+ "domain": "*",
831
+ "limit_type": "soft",
832
+ "item": "nofile",
833
+ "value": "1048576"
834
+ },
835
+ {
836
+ "domain": "*",
837
+ "limit_type": "hard",
838
+ "item": "nofile",
839
+ "value": "1048576"
840
+ },
841
+ {
842
+ "domain": "root",
843
+ "limit_type": "soft",
844
+ "item": "nofile",
845
+ "value": "1048576"
846
+ },
847
+ {
848
+ "domain": "root",
849
+ "limit_type": "hard",
850
+ "item": "nofile",
851
+ "value": "1048576"
852
+ },
853
+ {
854
+ "domain": "*",
855
+ "limit_type": "soft",
856
+ "item": "memlock",
857
+ "value": "unlimited"
858
+ },
859
+ {
860
+ "domain": "*",
861
+ "limit_type": "hard",
862
+ "item": "memlock",
863
+ "value": "unlimited"
864
+ },
865
+ {
866
+ "domain": "root",
867
+ "limit_type": "soft",
868
+ "item": "memlock",
869
+ "value": "unlimited"
870
+ },
871
+ {
872
+ "domain": "root",
873
+ "limit_type": "hard",
874
+ "item": "memlock",
875
+ "value": "unlimited"
876
+ }
877
+ ]
878
+ """
879
+
880
+ @override
881
+ def command(self):
882
+ return "cat /etc/security/limits.conf"
883
+
884
+ default = list
885
+
886
+ @override
887
+ def process(self, output):
888
+ limits = []
889
+
890
+ for line in output:
891
+ if line.startswith("#") or not len(line.strip()):
892
+ continue
893
+
894
+ domain, limit_type, item, value = line.split()
895
+
896
+ limits.append(
897
+ {
898
+ "domain": domain,
899
+ "limit_type": limit_type,
900
+ "item": item,
901
+ "value": value,
902
+ },
903
+ )
904
+
905
+ return limits
906
+
907
+
908
+ class RebootRequired(FactBase[bool]):
909
+ """
910
+ Returns a boolean indicating whether the system requires a reboot.
911
+
912
+ On Linux systems:
913
+ - Checks /var/run/reboot-required and /var/run/reboot-required.pkgs
914
+ - On Alpine Linux, compares installed kernel with running kernel
915
+
916
+ On FreeBSD systems:
917
+ - Compares running kernel version with installed kernel version
918
+ """
919
+
920
+ @override
921
+ def command(self) -> str:
922
+ return """
923
+ # Get OS type
924
+ OS_TYPE=$(uname -s)
925
+ if [ "$OS_TYPE" = "Linux" ]; then
926
+ # Check if it's Alpine Linux
927
+ if [ -f /etc/alpine-release ]; then
928
+ RUNNING_KERNEL=$(uname -r)
929
+ INSTALLED_KERNEL=$(ls -1 /lib/modules | sort -V | tail -n1)
930
+ if [ "$RUNNING_KERNEL" != "$INSTALLED_KERNEL" ]; then
931
+ echo "reboot_required"
932
+ exit 0
933
+ fi
934
+ else
935
+ # Check standard Linux reboot required files
936
+ if [ -f /var/run/reboot-required ] || [ -f /var/run/reboot-required.pkgs ]; then
937
+ echo "reboot_required"
938
+ exit 0
939
+ fi
940
+ fi
941
+ elif [ "$OS_TYPE" = "FreeBSD" ]; then
942
+ RUNNING_VERSION=$(freebsd-version -r)
943
+ INSTALLED_VERSION=$(freebsd-version -k)
944
+ if [ "$RUNNING_VERSION" != "$INSTALLED_VERSION" ]; then
945
+ echo "reboot_required"
946
+ exit 0
947
+ fi
948
+ fi
949
+ echo "no_reboot_required"
950
+ """
951
+
952
+ @override
953
+ def process(self, output) -> bool:
954
+ return list(output)[0].strip() == "reboot_required"