-
-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathdistro.py
More file actions
1756 lines (1519 loc) · 58.7 KB
/
Copy pathdistro.py
File metadata and controls
1756 lines (1519 loc) · 58.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#! /usr/bin/env python3
# -*- coding: utf-8; py-indent-offset: 4 -*-
#
# Author: Linuxfabrik GmbH, Zurich, Switzerland
# Contact: info (at) linuxfabrik (dot) ch
# https://www.linuxfabrik.ch/
# License: The Unlicense, see LICENSE file.
# https://github.com/Linuxfabrik/lib/blob/main/CONTRIBUTING.md
"""Provides information about the Linux distribution it runs on, such as a reliable
machine-readable distro ID and "os_family" (known from Ansible).
Source Code is taken, converted and modified from:
* lib/ansible/module_utils/facts/system/distribution.py
Deliberate differences to Ansible:
* Linux only. Ansible additionally handles AIX, Darwin, DragonFly, FreeBSD, HP-UX,
NetBSD, OpenBSD and SunOS, all of which require shelling out. On anything other
than Linux this module reports Ansible's bare baseline: "distribution" is what
platform.system() says, "distribution_release" is the kernel release,
"distribution_version" is the kernel version, and there is no
"distribution_major_version" at all.
* Purely functional, no classes.
* No external dependencies. Ansible derives its baseline facts from the `distro`
package; they are read from /etc/os-release, /etc/lsb-release and the distro
release files directly instead.
* Never shells out. Ansible asks `dpkg` for the release name of pre-8 Debian, and
reaches /etc/lsb-release through the `lsb_release` command rather than reading the
file. That cuts both ways, and it is not only about the release name: Gentoo and
Arch report "NA" here where the command would say "n/a", and Debian 7 reports the
version of /etc/os-release where the command would report the more precise one.
The other way round, Flatcar reports the release name "Oklo" here, which the
command does not know about because Flatcar does not ship it.
* A release file line naming no "release" keyword only yields a version if that
version is purely numeric. The `distro` package accepts any lowercase token there
and consequently reads the version "64-pc-linux-gnu" out of the Source Mage line
"Source Mage GNU/Linux x86_64-pc-linux-gnu".
* A release file whose first line yields neither a version nor a release name is
skipped and the next candidate in /etc is tried. The `distro` package takes any
non-empty first line as the distribution name and stops searching there.
* /etc/debian_version only counts as a version if it holds one. On testing and
unstable it holds a release name such as "trixie/sid", which the `distro` package
reports as the version.
* "distribution_major_version" is derived from "distribution_version" where a
release file parser was the first to find one, as on an Amazon Linux recognised
through /etc/system-release. Ansible has its major version from the `distro`
package by then and leaves "NA" standing in that case.
* Never raises where Ansible raises. A three part Amazon VERSION_ID, a non numeric
SLES VERSION_ID, a Cumulus VERSION_ID that is not exactly three parts, a release
file holding nothing but whitespace, and an openSUSE that ships /etc/SuSE-release
without an /etc/os-release each take Ansible down with a ValueError,
AttributeError, IndexError or TypeError.
* Adds the "os_info" key, holding NAME plus VERSION from /etc/os-release.
"""
# The module is long because it carries one parser plus its docstring per supported
# distribution family; splitting it would separate the parsers from the table that
# dispatches to them.
# pylint: disable=C0302
# All release file parsers share one signature so that they can be dispatched from a
# single table, which leaves some of them with arguments they do not need.
# pylint: disable=W0613
__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
__version__ = '2026080602'
import os
import platform
import re
# Order matters and is not alphabetical: the first entry that parses wins. Oracle
# Linux has to come before Red Hat because it ships a /etc/redhat-release naming Red
# Hat, and UnionTech before Red Hat because its A-version symlinks redhat-release to
# uos-release. The generic 'NA' entry has to stay last.
OSDIST_LIST = (
{'path': '/etc/altlinux-release', 'name': 'Altlinux'},
{'path': '/etc/oracle-release', 'name': 'OracleLinux'},
{'path': '/etc/slackware-version', 'name': 'Slackware'},
{'path': '/etc/centos-release', 'name': 'CentOS'},
{'path': '/etc/redhat-release', 'name': 'UnionTech'},
{'path': '/etc/redhat-release', 'name': 'RedHat'},
{'path': '/etc/vmware-release', 'name': 'VMwareESX', 'allowempty': True},
{'path': '/etc/openwrt_release', 'name': 'OpenWrt'},
{'path': '/etc/os-release', 'name': 'Amazon'},
{'path': '/etc/system-release', 'name': 'Amazon'},
{'path': '/etc/alpine-release', 'name': 'Alpine'},
{'path': '/etc/arch-release', 'name': 'Archlinux', 'allowempty': True},
{'path': '/etc/os-release', 'name': 'Archlinux'},
{'path': '/etc/os-release', 'name': 'SUSE'},
{'path': '/etc/SuSE-release', 'name': 'SUSE'},
{'path': '/etc/gentoo-release', 'name': 'Gentoo'},
{'path': '/etc/os-release', 'name': 'UnionTech'},
{'path': '/etc/os-release', 'name': 'Debian'},
{'path': '/etc/lsb-release', 'name': 'Debian'},
{'path': '/etc/lsb-release', 'name': 'Mandriva'},
{'path': '/etc/sourcemage-release', 'name': 'SMGL'},
{'path': '/usr/lib/os-release', 'name': 'ClearLinux'},
{'path': '/etc/coreos/update.conf', 'name': 'Coreos'},
{'path': '/etc/os-release', 'name': 'Flatcar'},
{'path': '/etc/os-release', 'name': 'NA'},
)
# Keys and members are kept in sync with the Ansible Conditionals documentation, so
# that "os_family" means the same thing here as in a playbook. Non-Linux families are
# left out because this module never reports them.
OS_FAMILY_MAP = {
'Alpine': ['Alpine'],
'Altlinux': ['Altlinux'],
'Archlinux': ['Antergos', 'Archlinux', 'Manjaro'],
'ClearLinux': ['Clear Linux Mix', 'Clear Linux OS'],
'Debian': [
'Cumulus Linux',
'Debian',
'Deepin',
'Devuan',
'KDE neon',
'Kali',
'Linux Mint',
'Linux Mint Debian Edition',
'Neon',
'OSMC',
'Pardus GNU/Linux',
'Parrot',
'Pop!_OS',
'Raspbian',
'SteamOS',
'Ubuntu',
'Univention Corporate Server',
'Uos',
],
'Gentoo': ['Funtoo', 'Gentoo'],
'Mandrake': ['Mandrake', 'Mandriva'],
'RedHat': [
'Alibaba',
'AlmaLinux',
'Amazon',
'Amzn',
'Ascendos',
'CentOS',
'CloudLinux',
'EuroLinux',
'EulerOS',
'Fedora',
'Kylin Linux Advanced Server',
'MIRACLE',
'OEL',
'OVS',
'OracleLinux',
'PSBM',
'RHEL',
'RedHat',
'Rocky',
'SLC',
'Scientific',
'TencentOS',
'UnionTech',
'Virtuozzo',
'XenServer',
'openEuler',
],
'SMGL': ['SMGL'],
'Slackware': ['Slackware'],
'Suse': [
'ALP-Dolomite',
'SLED',
'SLES',
'SLES_SAP',
'SL-Micro',
'SUSE_LINUX',
'SuSE',
'openSUSE',
'openSUSE Leap',
'openSUSE MicroOS',
'openSUSE Tumbleweed',
],
}
# Flattened for lookup. No distribution appears in more than one family.
_OS_FAMILY = {
member: family for family, members in OS_FAMILY_MAP.items() for member in members
}
# Kept apart from SEARCH_STRING: a match on one of its keys falls back to the first
# word of the file, which for an os-release file is the useless 'NAME=Arch'.
OS_RELEASE_ALIAS = {
'Archlinux': 'Arch Linux',
}
# Distributions whose release file is recognised by a marker string rather than by a
# parser. If the marker is absent, the first word of the file becomes the
# distribution name, which is how Scientific Linux and friends are picked up from
# /etc/redhat-release.
SEARCH_STRING = {
'Altlinux': 'ALT',
'OracleLinux': 'Oracle Linux',
'RedHat': 'Red Hat',
'SMGL': 'Source Mage GNU/Linux',
}
# Characters Ansible strips off release file content before parsing it: a quote or
# backslash carries no meaning in any of the formats handled here.
STRIP_QUOTES = r'\'\"\\'
# Basenames that qualify as a release file, such as "rocky-release", "SuSE-release"
# or "slackware-version". The captured word doubles as a distribution ID where no
# other source names one.
_DISTRO_RELEASE_BASENAME_REGEX = re.compile(r'(\w+)[-_](?:release|version)$')
# Basenames that look like a release file but do not identify a distribution. Taken
# from the `distro` package Ansible uses. /etc/system-release is on it because it is
# a symlink whose content repeats the product name, which would otherwise end up
# being reported as the release name of Amazon Linux.
_DISTRO_RELEASE_IGNORE_BASENAMES = (
'board-release',
'debian_version',
'ec2_version',
'iredmail-release',
'lsb-release',
'oem-release',
'os-release',
'plesk-release',
'system-release',
)
# /usr/lib/os-release is the vendor copy and the only one present on image based
# distributions such as Clear Linux.
_OS_RELEASE_PATHS = ('/etc/os-release', '/usr/lib/os-release')
# Release file content naming no "release" keyword, as in "SUSE Linux Enterprise
# Server 11 (x86_64)" or "Ubuntu 20.04.1 LTS". Only a purely numeric version is
# accepted here. The `distro` package allows any lowercase token, which makes it read
# the version "64-pc-linux-gnu" out of "Source Mage GNU/Linux x86_64-pc-linux-gnu".
_RELEASE_CONTENT_NO_KEYWORD_REGEX = re.compile(
r'^(?P<name>.+?)\s+'
r'(?P<version>\d+(?:\.\d+)*)'
r'(?:\s+LTS)?'
r'(?:\s+\((?P<codename>[^)]*)\))?\s*$'
)
# Release file content naming a "release" or "version" keyword, as in
# "Red Hat Enterprise Linux release 9.7 (Plow)" -> name, version, release name.
_RELEASE_CONTENT_REGEX = re.compile(
r'^(?P<name>.+?)\s+(?:release|version)\s+'
r'(?P<version>[\d.+\-a-z]*\d)'
r'(?:\s+LTS)?'
r'(?:\s+\((?P<codename>[^)]*)\))?'
)
def _file_exists(path, allow_empty=False):
"""
Check if a file exists and optionally allow empty files.
This function verifies the existence of a file at the given path. If `allow_empty` is
`False`, it additionally checks that the file is not empty.
Parameters
----------
path : str
Path to the file to check.
allow_empty : bool, optional
Whether to allow empty files as valid. Defaults to `False`.
Returns
-------
bool
`True` if the file exists (and is non-empty unless `allow_empty=True`), otherwise
`False`.
Examples
--------
>>> _file_exists('/etc/os-release')
True
"""
if not os.path.isfile(path):
return False
if allow_empty:
return True
return os.path.getsize(path) > 0
def _get_best_version(distro_id, candidates):
"""
Pick the most precise version out of the candidates.
Parameters
----------
distro_id : str
The lowercase distribution ID, as found in `ID=` of /etc/os-release.
candidates : list
The result of `_get_version_candidates()`.
Returns
-------
str
The most precise version, or an empty string if there is no candidate.
Notes
-----
- CentOS ships only the major version in /etc/os-release while admins expect
`7.9`, and Debian omits the minor version there entirely (Debian bug #931197).
Ansible asks the `distro` package for its "best" version for exactly these
two, which is the candidate carrying the most dots.
- /etc/debian_version only counts as a candidate if it holds a version. Testing
and unstable hold a release name such as `trixie/sid` there, which the `distro`
package happily reports as the version.
Examples
--------
>>> _get_best_version('debian', ['12'])
'12.14'
"""
if distro_id == 'debian':
candidates = candidates + [
line.strip()
for line in _get_file_lines('/etc/debian_version')
if re.match(r'^\d+\.\d+', line.strip())
]
best = ''
for candidate in candidates:
if best == '' or candidate.count('.') > best.count('.'):
best = candidate
if distro_id == 'centos':
return '.'.join(best.split('.')[:2])
return best
def _get_codename(distro_id, os_release, lsb_release, release_info):
"""
Determine the release name, asking every source in the order Ansible does.
Parameters
----------
distro_id : str
The lowercase distribution ID, as found in `ID=` of /etc/os-release.
os_release : dict
The result of `_get_os_release_info()`.
lsb_release : dict
The result of `_get_lsb_release_info()`.
release_info : dict
The result of `_get_distro_release_info()`.
Returns
-------
str or None
The release name, or `None` if no source carries one.
Notes
-----
- The order is `VERSION_CODENAME`, `UBUNTU_CODENAME`, /etc/lsb-release for
Ubuntu, whatever `VERSION` of /etc/os-release stands for, /etc/lsb-release for
everyone else, and finally the release file.
- An empty release name is an answer in itself and survives the first two steps.
Examples
--------
>>> _get_codename('kali', {}, {'distrib_codename': 'kali-rolling'}, {})
'kali-rolling'
"""
codename = os_release.get('version_codename')
if codename is None:
codename = os_release.get('ubuntu_codename')
if codename is None and distro_id == 'ubuntu':
codename = lsb_release.get('distrib_codename')
if codename is not None:
return codename
codename = _get_os_release_codename(os_release)
if codename is None:
codename = (
lsb_release.get('distrib_codename') or release_info.get('codename') or ''
)
return codename or None
# Translation tables the `distro` package applies to the distribution ID, one per
# source it reads the ID from. The lookup key is the value lowercased with blanks
# turned into underscores; anything not listed passes through unchanged.
_NORMALIZED_DISTRO_ID = {
# RHEL 6 and 7, whose ID is derived from the /etc/redhat-release basename.
'redhat': 'rhel',
}
_NORMALIZED_LSB_ID = {
'enterpriseenterpriseas': 'oracle', # Oracle Enterprise Linux 4
'enterpriseenterpriseserver': 'oracle', # Oracle Linux 5
'redhatenterprisecomputenode': 'rhel', # RHEL 6 ComputeNode
'redhatenterpriseserver': 'rhel', # RHEL 6 and 7 Server
'redhatenterpriseworkstation': 'rhel', # RHEL 6 and 7 Workstation
}
_NORMALIZED_OS_ID = {
'ol': 'oracle', # Oracle Linux
'opensuse-leap': 'opensuse', # Newer openSUSE releases report opensuse-leap
}
def _get_distro_id(os_release, lsb_release, release_info):
"""
Determine the distribution ID, asking every source in the order Ansible does.
Parameters
----------
os_release : dict
The result of `_get_os_release_info()`.
lsb_release : dict
The result of `_get_lsb_release_info()`.
release_info : dict
The result of `_get_distro_release_info()`.
Returns
-------
str
The lowercase distribution ID, or an empty string if no source names one.
Notes
-----
- The order is `ID` of /etc/os-release, `DISTRIB_ID` of /etc/lsb-release and the
basename of the release file. Each source has its own translation table, so
that a distribution ends up under one ID no matter which of them answered.
- The release file is what gives RHEL 6, CentOS 6 and SLES 11 an ID at all. None
of them ships an /etc/os-release.
- `distro.id()` has a fourth source, `uname -rs`. It is left out, which is no
difference in practice: the `distro` package discards that output as soon as
the system name is `Linux`.
Examples
--------
>>> _get_distro_id({}, {}, {'id': 'redhat'})
'rhel'
"""
for value, table in (
(os_release.get('id', ''), _NORMALIZED_OS_ID),
(lsb_release.get('distrib_id', ''), _NORMALIZED_LSB_ID),
(release_info.get('id', ''), _NORMALIZED_DISTRO_ID),
):
if value:
value = value.lower().replace(' ', '_')
return table.get(value, value)
return ''
def _get_distro_release_info():
"""
Extract ID, name, version and release name from the first matching release file.
Returns
-------
dict
Any of the keys `id`, `name`, `version` and `codename` that could be
determined. Empty if no release file in /etc is readable or none of them
parses.
Notes
-----
- Replaces the release file handling of the `distro` package Ansible relies on.
- Candidates are sorted so that the result stays stable where a distribution
ships several of them, for example Oracle Linux with /etc/oracle-release next
to /etc/redhat-release.
Examples
--------
>>> _get_distro_release_info()
{'name': 'Red Hat Enterprise Linux', 'version': '9.7', 'codename': 'Plow',
'id': 'redhat'}
"""
try:
basenames = sorted(
basename
for basename in os.listdir('/etc')
if basename not in _DISTRO_RELEASE_IGNORE_BASENAMES
and _DISTRO_RELEASE_BASENAME_REGEX.match(basename)
)
except OSError:
return {}
for basename in basenames:
data = _get_file_content(os.path.join('/etc', basename))
if not data:
continue
# A file carrying no version, such as the os-release formatted
# /etc/centos-release of TencentOS, states nothing worth reporting.
info = _parse_release_content(data.splitlines()[0])
if not info:
continue
info['id'] = _DISTRO_RELEASE_BASENAME_REGEX.match(basename).group(1)
if 'cloudlinux' in info.get('name', '').lower():
# CloudLinux before 7 names itself in an /etc/redhat-release, which would
# otherwise leave it with the ID of Red Hat.
info['id'] = 'cloudlinux'
return info
return {}
def _get_file_content(path, default=None, strip=True):
"""
Read the content of a text file, returning a default if that is not possible.
Mirrors Ansible's `get_file_content`. Reading never raises: containers and jails
regularly expose release files that look readable but are not.
Parameters
----------
path : str
Path to the file to read.
default : any type, optional
Value to return if the file cannot be read or is empty. Defaults to `None`.
strip : bool, optional
Whether to strip surrounding whitespace. Defaults to `True`.
Returns
-------
str or any type
The file contents, or `default` if the file is missing, unreadable or empty.
Notes
-----
- Stripping matters for single-value files such as /etc/alpine-release, whose
content is used as a version verbatim.
Examples
--------
>>> _get_file_content('/etc/alpine-release')
'3.21.7'
"""
if not os.path.isfile(path) or not os.access(path, os.R_OK):
return default
try:
with open(path, encoding='utf-8', errors='replace') as f:
data = f.read()
except OSError:
return default
if strip:
data = data.strip()
return data if data else default
def _get_file_lines(path):
"""
Read a text file and return its lines.
Parameters
----------
path : str
Path to the file to read.
Returns
-------
list
The lines of the file, or an empty list if it cannot be read.
Examples
--------
>>> _get_file_lines('/etc/debian_version')
['12.14']
"""
data = _get_file_content(path)
return data.splitlines() if data else []
def _get_lsb_release_info():
"""
Read /etc/lsb-release into a dictionary.
Returns
-------
dict
The `KEY=value` pairs of the file, with keys lowercased and quotes stripped
from the values. Empty if the file cannot be read.
Notes
-----
- Ansible runs `lsb_release -a` here and never looks at the file. Reading the
file keeps this module free of subprocess calls. The `DISTRIB_` keys stand in
for the `Distributor ID`, `Release`, `Codename` and `Description` fields of
the command, which is not the same set of sources: a host can ship one without
the other. See the module docstring for what that costs and what it gains.
Examples
--------
>>> _get_lsb_release_info()
{'distrib_id': 'Ubuntu', 'distrib_release': '24.04', 'distrib_codename': 'noble', ...}
"""
values = {}
data = _get_file_content('/etc/lsb-release')
if not data:
return values
for line in data.splitlines():
line = line.strip()
if not line or line.startswith('#') or '=' not in line:
continue
key, value = line.split('=', 1)
values[key.strip().lower()] = value.strip().strip(STRIP_QUOTES)
return values
def _get_os_release_codename(os_release):
"""
Determine the release name /etc/os-release stands for.
Parameters
----------
os_release : dict
The result of `_get_os_release_info()`.
Returns
-------
str or None
The release name, or `None` if the file carries none.
Notes
-----
- `VERSION_CODENAME` and `UBUNTU_CODENAME` win over anything derived from
`VERSION`, even when they are empty: a distribution setting them to nothing
states that it has no release name. That is how Fedora ends up without one.
- Deriving the release name from `VERSION` is what makes openEuler report `LTS`
and AlmaLinux `Purple Manul`, neither of which carries a `VERSION_CODENAME`.
Examples
--------
>>> _get_os_release_codename({'version': '8.3 (Purple Manul)'})
'Purple Manul'
"""
if 'version_codename' in os_release:
return os_release['version_codename']
if 'ubuntu_codename' in os_release:
return os_release['ubuntu_codename']
match = re.search(
r'\((?P<paren>\D+)\)|,\s*(?P<comma>\D+)', os_release.get('version', '')
)
if match:
return match.group('paren') or match.group('comma')
return None
def _get_os_release_info():
"""
Read the first available os-release file into a dictionary.
Returns
-------
dict
The `KEY=value` pairs of the file, with keys lowercased and quotes stripped
from the values. Empty if no os-release file can be read.
Examples
--------
>>> _get_os_release_info()
{'name': 'Fedora Linux', 'version': '41 (Workstation Edition)', 'id': 'fedora', ...}
"""
values = {}
for path in _OS_RELEASE_PATHS:
data = _get_file_content(path)
if not data:
continue
for line in data.splitlines():
line = line.strip()
if not line or line.startswith('#') or '=' not in line:
continue
key, value = line.split('=', 1)
values[key.strip().lower()] = value.strip().strip(STRIP_QUOTES)
break
return values
def _get_version_candidates(os_release, lsb_release, release_info):
"""
Collect every version the release files offer, in the order Ansible prefers them.
Parameters
----------
os_release : dict
The result of `_get_os_release_info()`.
lsb_release : dict
The result of `_get_lsb_release_info()`.
release_info : dict
The result of `_get_distro_release_info()`.
Returns
-------
list
The candidates, most preferred first. Sources that carry no version are left
out.
Notes
-----
- Mirrors the candidate list of `distro.version()`. Its uname candidate is left
out, which is no difference in practice: the `distro` package discards the
output of `uname -rs` as soon as the system name is `Linux`, so on Linux that
candidate is always empty.
Examples
--------
>>> _get_version_candidates({'version_id': '9.7'}, {}, {'version': '9.7'})
['9.7', '9.7']
"""
candidates = [
os_release.get('version_id', ''),
lsb_release.get('distrib_release', ''),
release_info.get('version', ''),
_parse_release_content(os_release.get('pretty_name', '')).get('version', ''),
_parse_release_content(lsb_release.get('distrib_description', '')).get(
'version', ''
),
]
return [candidate for candidate in candidates if candidate]
def _guess_distribution():
"""
Provide baseline distribution facts before any release file is parsed.
Combines /etc/os-release, /etc/lsb-release and the distro release files into the
same four facts Ansible derives from the `distro` package. The release file
parsers refine these afterwards.
Returns
-------
dict
`distribution`, `distribution_version`, `distribution_release` and
`distribution_major_version`. Unknown values are reported as `NA`.
Notes
-----
- `distribution_release` is the release name (`Plow`, `noble`, `bookworm`), not
the kernel release.
Examples
--------
>>> _guess_distribution()
{'distribution': 'Redhat', 'distribution_version': '9.7', 'distribution_release':
'Plow', 'distribution_major_version': '9'}
"""
os_release = _get_os_release_info()
lsb_release = _get_lsb_release_info()
release_info = _get_distro_release_info()
distro_id = _get_distro_id(os_release, lsb_release, release_info)
# Ansible normalises these two so that the OS family map and the release file
# varieties agree on one spelling.
distribution = distro_id.capitalize()
if distribution == 'Amzn':
distribution = 'Amazon'
elif distribution == 'Rhel':
distribution = 'Redhat'
elif not distribution:
distribution = 'OtherLinux'
candidates = _get_version_candidates(os_release, lsb_release, release_info)
if distro_id in ('centos', 'debian'):
# Ansible asks for the most precise version it can get for these two.
version = _get_best_version(distro_id, candidates)
else:
version = candidates[0] if candidates else ''
codename = _get_codename(distro_id, os_release, lsb_release, release_info)
guess = {
'distribution': distribution,
'distribution_version': version or 'NA',
'distribution_release': 'NA' if codename is None else codename,
}
guess['distribution_major_version'] = (
guess['distribution_version'].split('.')[0] or 'NA'
)
return guess
def _map_os_family(distribution):
"""
Map a detected distribution to its OS family.
Parameters
----------
distribution : str
The detected distribution name.
Returns
-------
str
The mapped OS family name, or the distribution itself if it has no family.
Examples
--------
>>> _map_os_family('Fedora')
'RedHat'
"""
return _OS_FAMILY.get(distribution) or distribution
def _parse_dist_file(name, data, path, collected_facts):
"""
Dispatch a release file to the parser responsible for it.
Parameters
----------
name : str
The variety name from `OSDIST_LIST`.
data : str
The contents of the release file.
path : str
The path the content was read from.
collected_facts : dict
The facts gathered so far.
Returns
-------
tuple (bool, dict)
- First element: `True` if the file belongs to this variety, `False` otherwise.
- Second element: The parsed facts.
Notes
-----
- A variety without a parser reports no match, which lets `_process_dist_files`
move on to the next candidate.
Examples
--------
>>> _parse_dist_file(
... 'RedHat',
... 'Red Hat Enterprise Linux release 9.7 (Plow)',
... '/etc/redhat-release',
... {},
... )
(True, {'distribution': 'RedHat', 'distribution_file_search_string': 'Red Hat'})
"""
facts = {}
data = data.strip(STRIP_QUOTES)
if name in SEARCH_STRING:
if SEARCH_STRING[name] in data:
# Sets distribution=RedHat if 'Red Hat' shows up in the data.
facts['distribution'] = name
facts['distribution_file_search_string'] = SEARCH_STRING[name]
elif data.split():
# Sets distribution to what is in the data, for example CentOS. Ansible
# indexes unconditionally here, which trips over a release file holding
# nothing but whitespace.
facts['distribution'] = data.split()[0]
return True, facts
if name in OS_RELEASE_ALIAS:
if OS_RELEASE_ALIAS[name] in data:
facts['distribution'] = name
return True, facts
return False, facts
parser = _DIST_FILE_PARSERS.get(name)
if parser is None:
return False, facts
return parser(name, data, path, collected_facts)
def _parse_distribution_file_alpine(name, data, path, collected_facts):
"""
Parse /etc/alpine-release.
Parameters
----------
name : str
The variety name from `OSDIST_LIST`.
data : str
The contents of the release file.
path : str
The path the content was read from.
collected_facts : dict
The facts gathered so far.
Returns
-------
tuple (bool, dict)
Whether the file was parsed, plus the parsed facts.
Notes
-----
- The file holds nothing but the version, so there is no marker to check for.
Examples
--------
>>> _parse_distribution_file_alpine('Alpine', '3.21.7', '/etc/alpine-release', {})
(True, {'distribution': 'Alpine', 'distribution_version': '3.21.7'})
"""
return True, {'distribution': 'Alpine', 'distribution_version': data}
def _parse_distribution_file_amazon(name, data, path, collected_facts):
"""
Parse the Amazon Linux release files.
Parameters
----------
name : str
The variety name from `OSDIST_LIST`.
data : str
The contents of the release file.
path : str
The path the content was read from.
collected_facts : dict
The facts gathered so far.
Returns
-------
tuple (bool, dict)
Whether the file was parsed, plus the parsed facts.
Examples
--------
>>> _parse_distribution_file_amazon(
... 'Amazon', 'NAME="Amazon Linux"\\nVERSION_ID="2023"', '/etc/os-release', {}
... )
(True, {'distribution': 'Amazon', 'distribution_version': '2023', ...})
"""
if 'Amazon' not in data:
return False, {}
facts = {'distribution': 'Amazon'}
if path == '/etc/os-release':
version = re.search(r'VERSION_ID="(.*)"', data)
if version:
distribution_version = version.group(1)
facts['distribution_version'] = distribution_version
# Ansible unpacks into exactly two parts here and raises on anything
# else. Slicing keeps a three part version from taking the lib down.
version_data = distribution_version.split('.')
facts['distribution_major_version'] = version_data[0]
facts['distribution_minor_version'] = (
version_data[1] if len(version_data) > 1 else 'NA'
)
else:
version = [n for n in data.split() if n.isdigit()]
facts['distribution_version'] = version[0] if version else 'NA'
return True, facts
def _parse_distribution_file_centos(name, data, path, collected_facts):
"""
Parse /etc/centos-release.
Parameters
----------
name : str
The variety name from `OSDIST_LIST`.
data : str
The contents of the release file.
path : str
The path the content was read from.
collected_facts : dict
The facts gathered so far.
Returns
-------
tuple (bool, dict)
Whether the file was parsed, plus the parsed facts.
Notes
-----
- Plain CentOS reports no match on purpose, so that /etc/redhat-release gets a
turn and picks the distribution name out of the file content.
Examples
--------
>>> _parse_distribution_file_centos(
... 'CentOS', 'CentOS Stream release 9', '/etc/centos-release', {}
... )
(True, {'distribution_release': 'Stream'})
"""
if 'CentOS Stream' in data:
return True, {'distribution_release': 'Stream'}
if 'TencentOS Server' in data:
return True, {'distribution': 'TencentOS'}
return False, {}
def _parse_distribution_file_clearlinux(name, data, path, collected_facts):
"""
Parse the Clear Linux os-release file.
Parameters
----------
name : str
The variety name from `OSDIST_LIST`.
data : str
The contents of the release file.
path : str
The path the content was read from.
collected_facts : dict
The facts gathered so far.
Returns
-------
tuple (bool, dict)
Whether the file was parsed, plus the parsed facts.