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