From e934761ccbf5ccd648c627535d1beaf41f5bcae1 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Fri, 17 Apr 2026 13:27:59 -0400 Subject: [PATCH 01/16] Add CPE Informatio and support for RLC Linux --- execution-environment.yml | 3 + files/distribution.py | 786 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 789 insertions(+) create mode 100644 files/distribution.py diff --git a/execution-environment.yml b/execution-environment.yml index 325c5da3..608eef85 100644 --- a/execution-environment.yml +++ b/execution-environment.yml @@ -93,6 +93,8 @@ additional_build_files: dest: configs - src: files/update-ca-trust dest: files + - src: files/distribution.py + dest: files additional_build_steps: append_base: - RUN $PYCMD -m pip install -U pip @@ -119,4 +121,5 @@ additional_build_steps: && alternatives --install /usr/bin/python python /usr/bin/python3.11 1 && alternatives --install /usr/bin/pip3 pip3 /usr/bin/pip3.11 1 - COPY --chmod=755 _build/files/update-ca-trust /usr/bin/update-ca-trust + - COPY --chmod=755 _build/files/distribution.py /usr/local/lib/python3.11/site-package/ansible/module_utils/facts/system/distribution.py diff --git a/files/distribution.py b/files/distribution.py new file mode 100644 index 00000000..6adaaee1 --- /dev/null +++ b/files/distribution.py @@ -0,0 +1,786 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +import os +import platform +import re +import typing as t + +from ansible.module_utils.common.sys_info import get_distribution, get_distribution_version, \ + get_distribution_codename +from ansible.module_utils.facts.utils import get_file_content, get_file_lines +from ansible.module_utils.facts.collector import BaseFactCollector + + +def get_uname(module, flags=('-v')): + if isinstance(flags, str): + flags = flags.split() + command = ['uname'] + command.extend(flags) + rc, out, err = module.run_command(command) + if rc == 0: + return out + return None + + +def _file_exists(path, allow_empty=False): + # not finding the file, exit early + if not os.path.isfile(path): + return False + + # if just the path needs to exists (ie, it can be empty) we are done + if allow_empty: + return True + + # file exists but is empty and we dont allow_empty + if os.path.getsize(path) == 0: + return False + + # file exists with some content + return True + + +class DistributionFiles: + """has-a various distro file parsers (os-release, etc) and logic for finding the right one.""" + # every distribution name mentioned here, must have one of + # - allowempty == True + # - be listed in SEARCH_STRING + # - have a function get_distribution_DISTNAME implemented + # keep names in sync with Conditionals page of docs + OSDIST_LIST = ( + {'path': '/etc/ciq-release', 'name': 'RLCLinux'}, + {'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': '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': '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'}, + ) + + SEARCH_STRING = { + 'OracleLinux': 'Oracle Linux', + 'RedHat': 'Red Hat', + 'RLCLinux': 'Rocky Linux from CIQ', + 'Altlinux': 'ALT', + 'SMGL': 'Source Mage GNU/Linux', + } + + # We can't include this in SEARCH_STRING because a name match on its keys + # causes a fallback to using the first whitespace separated item from the file content + # as the name. For os-release, that is in form 'NAME=Arch' + OS_RELEASE_ALIAS = { + 'Archlinux': 'Arch Linux' + } + + STRIP_QUOTES = r'\'\"\\' + + def __init__(self, module): + self.module = module + + def _get_file_content(self, path): + return get_file_content(path) + + def _get_dist_file_content(self, path, allow_empty=False): + # can't find that dist file, or it is incorrectly empty + if not _file_exists(path, allow_empty=allow_empty): + return False, None + + data = self._get_file_content(path) + return True, data + + def _get_os_release_data(self, allow_empty=False): + path = '/etc/os-release' + + if not _file_exists(path, allow_empty=allow_empty): + return False, None + + data = self._get_file_content(path) + + os_name = ''; + os_variant = ''; + os_cpe_name = '' + + for line in data.splitlines(): + if re.search('^NAME=', line): + key, value = line.split('=', 1) + + os_name = value.strip('"') + + elif re.search('^VARIANT=', line): + key, value = line.split('=', 1) + + os_variant = value.strip('"') + + elif re.search('^CPE_NAME=', line): + key, value = line.split('=', 1) + + os_cpe_name = value.strip('"') + + return True, { 'os_name': os_name, 'os_variant': os_variant, 'os_cpe_name': os_cpe_name } + + def _parse_dist_file(self, name, dist_file_content, path, collected_facts): + dist_file_dict = {} + dist_file_content = dist_file_content.strip(DistributionFiles.STRIP_QUOTES) + if name in self.SEARCH_STRING: + # look for the distribution string in the data and replace according to RELEASE_NAME_MAP + # only the distribution name is set, the version is assumed to be correct from distro.linux_distribution() + if self.SEARCH_STRING[name] in dist_file_content: + # this sets distribution=RedHat if 'Red Hat' shows up in data + dist_file_dict['distribution'] = name + dist_file_dict['distribution_file_search_string'] = self.SEARCH_STRING[name] + else: + # this sets distribution to what's in the data, e.g. CentOS, Scientific, ... + dist_file_dict['distribution'] = dist_file_content.split()[0] + + return True, dist_file_dict + + if name in self.OS_RELEASE_ALIAS: + if self.OS_RELEASE_ALIAS[name] in dist_file_content: + dist_file_dict['distribution'] = name + return True, dist_file_dict + return False, dist_file_dict + + # call a dedicated function for parsing the file content + # TODO: replace with a map or a class + try: + # FIXME: most of these dont actually look at the dist file contents, but random other stuff + distfunc_name = 'parse_distribution_file_' + name + distfunc = getattr(self, distfunc_name) + parsed, dist_file_dict = distfunc(name, dist_file_content, path, collected_facts) + return parsed, dist_file_dict + except AttributeError as exc: + self.module.debug('exc: %s' % exc) + # this should never happen, but if it does fail quietly and not with a traceback + return False, dist_file_dict + + return True, dist_file_dict + # to debug multiple matching release files, one can use: + # self.facts['distribution_debug'].append({path + ' ' + name: + # (parsed, + # self.facts['distribution'], + # self.facts['distribution_version'], + # self.facts['distribution_release'], + # )}) + + def _guess_distribution(self): + # try to find out which linux distribution this is + dist = (get_distribution(), get_distribution_version(), get_distribution_codename()) + distribution_guess = { + 'distribution': dist[0] or 'NA', + 'distribution_version': dist[1] or 'NA', + # distribution_release can be the empty string + 'distribution_release': 'NA' if dist[2] is None else dist[2] + } + + distribution_guess['distribution_major_version'] = distribution_guess['distribution_version'].split('.')[0] or 'NA' + return distribution_guess + + def process_dist_files(self): + # Try to handle the exceptions now ... + # self.facts['distribution_debug'] = [] + dist_file_facts = {} + + dist_guess = self._guess_distribution() + dist_file_facts.update(dist_guess) + + for ddict in self.OSDIST_LIST: + name = ddict['name'] + path = ddict['path'] + allow_empty = ddict.get('allowempty', False) + + has_dist_file, dist_file_content = self._get_dist_file_content(path, allow_empty=allow_empty) + + # but we allow_empty. For example, ArchLinux with an empty /etc/arch-release and a + # /etc/os-release with a different name + if has_dist_file and allow_empty: + dist_file_facts['distribution'] = name + dist_file_facts['distribution_file_path'] = path + dist_file_facts['distribution_file_variety'] = name + break + + if not has_dist_file: + # keep looking + continue + + parsed_dist_file, parsed_dist_file_facts = self._parse_dist_file(name, dist_file_content, path, dist_file_facts) + + # finally found the right os dist file and were able to parse it + if parsed_dist_file: + dist_file_facts['distribution'] = name + dist_file_facts['distribution_file_path'] = path + + # distribution and file_variety are the same here, but distribution + # will be changed/mapped to a more specific name. + # ie, dist=Fedora, file_variety=RedHat + dist_file_facts['distribution_file_variety'] = name + dist_file_facts['distribution_file_parsed'] = parsed_dist_file + + # Always look for into standard /etc/os-release file if it exists + os_release_exists, os_release_data = self._get_os_release_data() + + if os_release_exists != False: + dist_file_facts['distribution_os_name'] = os_release_data['os_name'] + dist_file_facts['distribution_os_variant'] = os_release_data['os_variant'] + dist_file_facts['distribution_os_cpe_name'] = os_release_data['os_cpe_name'] + + dist_file_facts.update(parsed_dist_file_facts) + + break + + return dist_file_facts + + # FIXME: split distro file parsing into its own module or class + def parse_distribution_file_Slackware(self, name, data, path, collected_facts): + slackware_facts = {} + if 'Slackware' not in data: + return False, slackware_facts # TODO: remove + slackware_facts['distribution'] = name + version = re.findall(r'\w+[.]\w+\+?', data) + if version: + slackware_facts['distribution_version'] = version[0] + return True, slackware_facts + + def parse_distribution_file_Amazon(self, name, data, path, collected_facts): + amazon_facts = {} + if 'Amazon' not in data: + return False, amazon_facts + amazon_facts['distribution'] = 'Amazon' + if path == '/etc/os-release': + version = re.search(r"VERSION_ID=\"(.*)\"", data) + if version: + distribution_version = version.group(1) + amazon_facts['distribution_version'] = distribution_version + version_data = distribution_version.split(".") + if len(version_data) > 1: + major, minor = version_data + else: + major, minor = version_data[0], 'NA' + + amazon_facts['distribution_major_version'] = major + amazon_facts['distribution_minor_version'] = minor + else: + version = [n for n in data.split() if n.isdigit()] + version = version[0] if version else 'NA' + amazon_facts['distribution_version'] = version + + return True, amazon_facts + + def parse_distribution_file_OpenWrt(self, name, data, path, collected_facts): + openwrt_facts = {} + if 'OpenWrt' not in data: + return False, openwrt_facts # TODO: remove + openwrt_facts['distribution'] = name + version = re.search('DISTRIB_RELEASE="(.*)"', data) + if version: + openwrt_facts['distribution_version'] = version.groups()[0] + release = re.search('DISTRIB_CODENAME="(.*)"', data) + if release: + openwrt_facts['distribution_release'] = release.groups()[0] + return True, openwrt_facts + + def parse_distribution_file_Alpine(self, name, data, path, collected_facts): + alpine_facts = {} + alpine_facts['distribution'] = 'Alpine' + alpine_facts['distribution_version'] = data + return True, alpine_facts + + def parse_distribution_file_SUSE(self, name, data, path, collected_facts): + suse_facts = {} + if 'suse' not in data.lower(): + return False, suse_facts # TODO: remove if tested without this + if path == '/etc/os-release': + for line in data.splitlines(): + distribution = re.search("^NAME=(.*)", line) + if distribution: + suse_facts['distribution'] = distribution.group(1).strip('"') + # example pattern are 13.04 13.0 13 + distribution_version = re.search(r'^VERSION_ID="?([0-9]+\.?[0-9]*)"?', line) + if distribution_version: + suse_facts['distribution_version'] = distribution_version.group(1) + suse_facts['distribution_major_version'] = distribution_version.group(1).split('.')[0] + if 'open' in data.lower(): + release = re.search(r'^VERSION_ID="?[0-9]+\.?([0-9]*)"?', line) + if release: + suse_facts['distribution_release'] = release.groups()[0] + elif 'enterprise' in data.lower() and 'VERSION_ID' in line: + # SLES doesn't got funny release names + release = re.search(r'^VERSION_ID="?[0-9]+\.?([0-9]*)"?', line) + if release.group(1): + release = release.group(1) + else: + release = "0" # no minor number, so it is the first release + suse_facts['distribution_release'] = release + elif path == '/etc/SuSE-release': + if 'open' in data.lower(): + data = data.splitlines() + distdata = get_file_content(path).splitlines()[0] + suse_facts['distribution'] = distdata.split()[0] + for line in data: + release = re.search('CODENAME *= *([^\n]+)', line) + if release: + suse_facts['distribution_release'] = release.groups()[0].strip() + elif 'enterprise' in data.lower(): + lines = data.splitlines() + distribution = lines[0].split()[0] + if "Server" in data: + suse_facts['distribution'] = "SLES" + elif "Desktop" in data: + suse_facts['distribution'] = "SLED" + for line in lines: + release = re.search('PATCHLEVEL = ([0-9]+)', line) # SLES doesn't got funny release names + if release: + suse_facts['distribution_release'] = release.group(1) + suse_facts['distribution_version'] = collected_facts['distribution_version'] + '.' + release.group(1) + + # Check VARIANT_ID first for SLES4SAP or SL-Micro + variant_id_match = re.search(r'^VARIANT_ID="?([^"\n]*)"?', data, re.MULTILINE) + if variant_id_match: + variant_id = variant_id_match.group(1) + if variant_id in ('server-sap', 'sles-sap'): + suse_facts['distribution'] = 'SLES_SAP' + elif variant_id == 'transactional': + suse_facts['distribution'] = 'SL-Micro' + else: + # Fallback for older SLES 15 using baseproduct symlink + if os.path.islink('/etc/products.d/baseproduct'): + resolved = os.path.realpath('/etc/products.d/baseproduct') + if resolved.endswith('SLES_SAP.prod'): + suse_facts['distribution'] = 'SLES_SAP' + elif resolved.endswith('SL-Micro.prod'): + suse_facts['distribution'] = 'SL-Micro' + + return True, suse_facts + + def parse_distribution_file_Debian(self, name, data, path, collected_facts): + debian_facts = {} + if any(distro in data for distro in ('Debian', 'Raspbian')): + debian_facts['distribution'] = 'Debian' + release = re.search(r"PRETTY_NAME=[^(]+ \(?([^)]+?)\)", data) + if release: + debian_facts['distribution_release'] = release.groups()[0] + + # Last resort: try to find release from tzdata as either lsb is missing or this is very old debian + if collected_facts['distribution_release'] == 'NA' and 'Debian' in data: + dpkg_cmd = self.module.get_bin_path('dpkg') + if dpkg_cmd: + cmd = "%s --status tzdata|grep Provides|cut -f2 -d'-'" % dpkg_cmd + rc, out, err = self.module.run_command(cmd) + if rc == 0: + debian_facts['distribution_release'] = out.strip() + debian_version_path = '/etc/debian_version' + distdata = get_file_lines(debian_version_path) + for line in distdata: + m = re.search(r'(\d+)\.(\d+)', line.strip()) + if m: + debian_facts['distribution_minor_version'] = m.groups()[1] + elif 'Ubuntu' in data: + debian_facts['distribution'] = 'Ubuntu' + # nothing else to do, Ubuntu gets correct info from python functions + elif 'SteamOS' in data: + debian_facts['distribution'] = 'SteamOS' + # nothing else to do, SteamOS gets correct info from python functions + elif path in ('/etc/lsb-release', '/etc/os-release') and ('Kali' in data or 'Parrot' in data): + if 'Kali' in data: + # Kali does not provide /etc/lsb-release anymore + debian_facts['distribution'] = 'Kali' + elif 'Parrot' in data: + debian_facts['distribution'] = 'Parrot' + release = re.search('DISTRIB_RELEASE=(.*)', data) + if release: + debian_facts['distribution_release'] = release.groups()[0] + elif 'Devuan' in data: + debian_facts['distribution'] = 'Devuan' + release = re.search(r"PRETTY_NAME=\"?[^(\"]+ \(?([^) \"]+)\)?", data) + if release: + debian_facts['distribution_release'] = release.groups()[0] + version = re.search(r"VERSION_ID=\"(.*)\"", data) + if version: + debian_facts['distribution_version'] = version.group(1) + debian_facts['distribution_major_version'] = version.group(1) + elif 'Cumulus' in data: + debian_facts['distribution'] = 'Cumulus Linux' + version = re.search(r"VERSION_ID=(.*)", data) + if version: + major, _minor, _dummy_ver = version.group(1).split(".") + debian_facts['distribution_version'] = version.group(1) + debian_facts['distribution_major_version'] = major + + release = re.search(r'VERSION="(.*)"', data) + if release: + debian_facts['distribution_release'] = release.groups()[0] + elif "Mint" in data: + debian_facts['distribution'] = 'Linux Mint' + version = re.search(r"VERSION_ID=\"(.*)\"", data) + if version: + debian_facts['distribution_version'] = version.group(1) + debian_facts['distribution_major_version'] = version.group(1).split('.')[0] + elif 'UOS' in data or 'Uos' in data or 'uos' in data: + debian_facts['distribution'] = 'Uos' + release = re.search(r"VERSION_CODENAME=\"?([^\"]+)\"?", data) + if release: + debian_facts['distribution_release'] = release.groups()[0] + version = re.search(r"VERSION_ID=\"(.*)\"", data) + if version: + debian_facts['distribution_version'] = version.group(1) + debian_facts['distribution_major_version'] = version.group(1).split('.')[0] + elif 'Deepin' in data or 'deepin' in data: + debian_facts['distribution'] = 'Deepin' + release = re.search(r"VERSION_CODENAME=\"?([^\"]+)\"?", data) + if release: + debian_facts['distribution_release'] = release.groups()[0] + version = re.search(r"VERSION_ID=\"(.*)\"", data) + if version: + debian_facts['distribution_version'] = version.group(1) + debian_facts['distribution_major_version'] = version.group(1).split('.')[0] + elif 'LMDE' in data: + debian_facts['distribution'] = 'Linux Mint Debian Edition' + else: + return False, debian_facts + + return True, debian_facts + + def parse_distribution_file_Mandriva(self, name, data, path, collected_facts): + mandriva_facts = {} + if 'Mandriva' in data: + mandriva_facts['distribution'] = 'Mandriva' + version = re.search('DISTRIB_RELEASE="(.*)"', data) + if version: + mandriva_facts['distribution_version'] = version.groups()[0] + release = re.search('DISTRIB_CODENAME="(.*)"', data) + if release: + mandriva_facts['distribution_release'] = release.groups()[0] + mandriva_facts['distribution'] = name + else: + return False, mandriva_facts + + return True, mandriva_facts + + def parse_distribution_file_NA(self, name, data, path, collected_facts): + na_facts = {} + for line in data.splitlines(): + distribution = re.search("^NAME=(.*)", line) + if distribution and name == 'NA': + na_facts['distribution'] = distribution.group(1).strip(DistributionFiles.STRIP_QUOTES) + version = re.search("^VERSION=(.*)", line) + if version and collected_facts['distribution_version'] == 'NA': + na_facts['distribution_version'] = version.group(1).strip(DistributionFiles.STRIP_QUOTES) + return True, na_facts + + def parse_distribution_file_Coreos(self, name, data, path, collected_facts): + coreos_facts = {} + # FIXME: pass in ro copy of facts for this kind of thing + distro = get_distribution() + + if distro.lower() == 'coreos': + if not data: + # include fix from #15230, #15228 + # TODO: verify this is ok for above bugs + return False, coreos_facts + release = re.search("^GROUP=(.*)", data) + if release: + coreos_facts['distribution_release'] = release.group(1).strip('"') + else: + return False, coreos_facts # TODO: remove if tested without this + + return True, coreos_facts + + def parse_distribution_file_Flatcar(self, name, data, path, collected_facts): + flatcar_facts = {} + distro = get_distribution() + + if distro.lower() != 'flatcar': + return False, flatcar_facts + + if not data: + return False, flatcar_facts + + version = re.search("VERSION=(.*)", data) + if version: + flatcar_facts['distribution_major_version'] = version.group(1).strip('"').split('.')[0] + flatcar_facts['distribution_version'] = version.group(1).strip('"') + + return True, flatcar_facts + + def parse_distribution_file_ClearLinux(self, name, data, path, collected_facts): + clear_facts = {} + if "clearlinux" not in name.lower(): + return False, clear_facts + + pname = re.search('NAME="(.*)"', data) + if pname: + if 'Clear Linux' not in pname.groups()[0]: + return False, clear_facts + clear_facts['distribution'] = pname.groups()[0] + else: + return False, clear_facts + version = re.search('VERSION_ID=(.*)', data) + if version: + clear_facts['distribution_major_version'] = version.groups()[0] + clear_facts['distribution_version'] = version.groups()[0] + release = re.search('ID=(.*)', data) + if release: + clear_facts['distribution_release'] = release.groups()[0] + return True, clear_facts + + def parse_distribution_file_CentOS(self, name, data, path, collected_facts): + centos_facts = {} + + if 'CentOS Stream' in data: + centos_facts['distribution_release'] = 'Stream' + return True, centos_facts + + if "TencentOS Server" in data: + centos_facts['distribution'] = 'TencentOS' + return True, centos_facts + + return False, centos_facts + + +class Distribution(object): + """ + This subclass of Facts fills the distribution, distribution_version and distribution_release variables + + To do so it checks the existence and content of typical files in /etc containing distribution information + + This is unit tested. Please extend the tests to cover all distributions if you have them available. + """ + + # keep keys in sync with Conditionals page of docs + OS_FAMILY_MAP = {'RedHat': ['RedHat', 'RHEL', 'Fedora', 'CentOS', 'Scientific', 'SLC', + 'Ascendos', 'CloudLinux', 'PSBM', 'OracleLinux', 'OVS', + 'OEL', 'Amazon', 'Amzn', 'Virtuozzo', 'XenServer', 'Alibaba', + 'EulerOS', 'openEuler', 'AlmaLinux', 'Rocky', 'RLCLinux', 'TencentOS', + 'EuroLinux', 'Kylin Linux Advanced Server', 'MIRACLE'], + 'Debian': ['Debian', 'Ubuntu', 'Raspbian', 'Neon', 'KDE neon', + 'Linux Mint', 'SteamOS', 'Devuan', 'Kali', 'Cumulus Linux', + 'Pop!_OS', 'Parrot', 'Pardus GNU/Linux', 'Uos', 'Deepin', 'OSMC', + 'Linux Mint Debian Edition', 'Univention Corporate Server'], + 'Suse': ['SuSE', 'SLES', 'SLED', 'openSUSE', 'openSUSE Tumbleweed', + 'SLES_SAP', 'SUSE_LINUX', 'openSUSE Leap', 'ALP-Dolomite', 'SL-Micro', + 'openSUSE MicroOS'], + 'Archlinux': ['Archlinux', 'Antergos', 'Manjaro'], + 'Mandrake': ['Mandrake', 'Mandriva'], + 'Solaris': ['Solaris', 'Nexenta', 'OmniOS', 'OpenIndiana', 'SmartOS'], + 'Slackware': ['Slackware'], + 'Altlinux': ['Altlinux'], + 'SMGL': ['SMGL'], + 'Gentoo': ['Gentoo', 'Funtoo'], + 'Alpine': ['Alpine'], + 'AIX': ['AIX'], + 'HP-UX': ['HPUX'], + 'Darwin': ['MacOSX'], + 'FreeBSD': ['FreeBSD', 'TrueOS'], + 'ClearLinux': ['Clear Linux OS', 'Clear Linux Mix'], + 'DragonFly': ['DragonflyBSD', 'DragonFlyBSD', 'Gentoo/DragonflyBSD', 'Gentoo/DragonFlyBSD'], + 'NetBSD': ['NetBSD'], } + + OS_FAMILY = {} + for family, names in OS_FAMILY_MAP.items(): + for name in names: + OS_FAMILY[name] = family + + def __init__(self, module): + self.module = module + + def get_distribution_facts(self): + distribution_facts = {} + + # The platform module provides information about the running + # system/distribution. Use this as a baseline and fix buggy systems + # afterwards + system = platform.system() + distribution_facts['distribution'] = system + distribution_facts['distribution_release'] = platform.release() + distribution_facts['distribution_version'] = platform.version() + + systems_implemented = ('AIX', 'HP-UX', 'Darwin', 'FreeBSD', 'OpenBSD', 'SunOS', 'DragonFly', 'NetBSD') + + if system in systems_implemented: + cleanedname = system.replace('-', '') + distfunc = getattr(self, 'get_distribution_' + cleanedname) + dist_func_facts = distfunc() + distribution_facts.update(dist_func_facts) + elif system == 'Linux': + + distribution_files = DistributionFiles(module=self.module) + + # linux_distribution_facts = LinuxDistribution(module).get_distribution_facts() + dist_file_facts = distribution_files.process_dist_files() + + distribution_facts.update(dist_file_facts) + + distro = distribution_facts['distribution'] + + # look for an os family alias for the 'distribution', if there isn't one, use 'distribution' + distribution_facts['os_family'] = self.OS_FAMILY.get(distro, None) or distro + + return distribution_facts + + def get_distribution_AIX(self): + aix_facts = {} + rc, out, err = self.module.run_command("/usr/bin/oslevel") + data = out.split('.') + aix_facts['distribution_major_version'] = data[0] + if len(data) > 1: + aix_facts['distribution_version'] = '%s.%s' % (data[0], data[1]) + aix_facts['distribution_release'] = data[1] + else: + aix_facts['distribution_version'] = data[0] + return aix_facts + + def get_distribution_HPUX(self): + hpux_facts = {} + rc, out, err = self.module.run_command(r"/usr/sbin/swlist |egrep 'HPUX.*OE.*[AB].[0-9]+\.[0-9]+'", use_unsafe_shell=True) + data = re.search(r'HPUX.*OE.*([AB].[0-9]+\.[0-9]+)\.([0-9]+).*', out) + if data: + hpux_facts['distribution_version'] = data.groups()[0] + hpux_facts['distribution_release'] = data.groups()[1] + return hpux_facts + + def get_distribution_Darwin(self): + darwin_facts = {} + darwin_facts['distribution'] = 'MacOSX' + rc, out, err = self.module.run_command("/usr/bin/sw_vers -productVersion") + data = out.split()[-1] + if data: + darwin_facts['distribution_major_version'] = data.split('.')[0] + darwin_facts['distribution_version'] = data + return darwin_facts + + def get_distribution_FreeBSD(self): + freebsd_facts = {} + freebsd_facts['distribution_release'] = platform.release() + data = re.search(r'(\d+)\.(\d+)-(RELEASE|STABLE|CURRENT|RC|PRERELEASE).*', freebsd_facts['distribution_release']) + if 'trueos' in platform.version(): + freebsd_facts['distribution'] = 'TrueOS' + if data: + freebsd_facts['distribution_major_version'] = data.group(1) + freebsd_facts['distribution_version'] = '%s.%s' % (data.group(1), data.group(2)) + return freebsd_facts + + def get_distribution_OpenBSD(self): + openbsd_facts = {} + openbsd_facts['distribution_version'] = platform.release() + rc, out, err = self.module.run_command("/sbin/sysctl -n kern.version") + match = re.match(r'OpenBSD\s[0-9]+.[0-9]+-(\S+)\s.*', out) + if match: + openbsd_facts['distribution_release'] = match.groups()[0] + else: + openbsd_facts['distribution_release'] = 'release' + return openbsd_facts + + def get_distribution_DragonFly(self): + dragonfly_facts = { + 'distribution_release': platform.release() + } + rc, out, dummy = self.module.run_command("/sbin/sysctl -n kern.version") + match = re.search(r'v(\d+)\.(\d+)\.(\d+)-(RELEASE|STABLE|CURRENT).*', out) + if match: + dragonfly_facts['distribution_major_version'] = match.group(1) + dragonfly_facts['distribution_version'] = '%s.%s.%s' % match.groups()[:3] + return dragonfly_facts + + def get_distribution_NetBSD(self): + netbsd_facts = {} + platform_release = platform.release() + netbsd_facts['distribution_release'] = platform_release + rc, out, dummy = self.module.run_command("/sbin/sysctl -n kern.version") + match = re.match(r'NetBSD\s(\d+)\.(\d+)\s\((GENERIC)\).*', out) + if match: + netbsd_facts['distribution_major_version'] = match.group(1) + netbsd_facts['distribution_version'] = '%s.%s' % match.groups()[:2] + else: + netbsd_facts['distribution_major_version'] = platform_release.split('.')[0] + netbsd_facts['distribution_version'] = platform_release + return netbsd_facts + + def get_distribution_SMGL(self): + smgl_facts = {} + smgl_facts['distribution'] = 'Source Mage GNU/Linux' + return smgl_facts + + def get_distribution_SunOS(self): + sunos_facts = {} + + data = get_file_content('/etc/release').splitlines()[0] + + if 'Solaris' in data: + # for solaris 10 uname_r will contain 5.10, for solaris 11 it will have 5.11 + uname_r = get_uname(self.module, flags=['-r']) + ora_prefix = '' + if 'Oracle Solaris' in data: + data = data.replace('Oracle ', '') + ora_prefix = 'Oracle ' + sunos_facts['distribution'] = data.split()[0] + sunos_facts['distribution_version'] = data.split()[1] + sunos_facts['distribution_release'] = ora_prefix + data + sunos_facts['distribution_major_version'] = uname_r.split('.')[1].rstrip() + return sunos_facts + + uname_v = get_uname(self.module, flags=['-v']) + distribution_version = None + + if 'SmartOS' in data: + sunos_facts['distribution'] = 'SmartOS' + if _file_exists('/etc/product'): + product_data = dict([l.split(': ', 1) for l in get_file_content('/etc/product').splitlines() if ': ' in l]) + if 'Image' in product_data: + distribution_version = product_data.get('Image').split()[-1] + elif 'OpenIndiana' in data: + sunos_facts['distribution'] = 'OpenIndiana' + elif 'OmniOS' in data: + sunos_facts['distribution'] = 'OmniOS' + distribution_version = data.split()[-1] + elif uname_v is not None and 'NexentaOS_' in uname_v: + sunos_facts['distribution'] = 'Nexenta' + distribution_version = data.split()[-1].lstrip('v') + + if sunos_facts.get('distribution', '') in ('SmartOS', 'OpenIndiana', 'OmniOS', 'Nexenta'): + sunos_facts['distribution_release'] = data.strip() + if distribution_version is not None: + sunos_facts['distribution_version'] = distribution_version + elif uname_v is not None: + sunos_facts['distribution_version'] = uname_v.splitlines()[0].strip() + return sunos_facts + + return sunos_facts + + +class DistributionFactCollector(BaseFactCollector): + name = 'distribution' + _fact_ids = set(['distribution_version', + 'distribution_release', + 'distribution_major_version', + 'os_family']) # type: t.Set[str] + + def collect(self, module=None, collected_facts=None): + collected_facts = collected_facts or {} + facts_dict = {} + if not module: + return facts_dict + + distribution = Distribution(module=module) + distro_facts = distribution.get_distribution_facts() + + return distro_facts From d5eea1c1c9d1ad040224b1d279289990bf9fc049 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Fri, 17 Apr 2026 14:00:10 -0400 Subject: [PATCH 02/16] Correct site packages typo --- execution-environment.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/execution-environment.yml b/execution-environment.yml index 608eef85..22a45187 100644 --- a/execution-environment.yml +++ b/execution-environment.yml @@ -121,5 +121,5 @@ additional_build_steps: && alternatives --install /usr/bin/python python /usr/bin/python3.11 1 && alternatives --install /usr/bin/pip3 pip3 /usr/bin/pip3.11 1 - COPY --chmod=755 _build/files/update-ca-trust /usr/bin/update-ca-trust - - COPY --chmod=755 _build/files/distribution.py /usr/local/lib/python3.11/site-package/ansible/module_utils/facts/system/distribution.py + - COPY --chmod=755 _build/files/distribution.py /usr/local/lib/python3.11/site-packages/ansible/module_utils/facts/system/distribution.py From 496d7706abe1ca1a7c2f62ef5932c9c0f1d10dd0 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Mon, 20 Apr 2026 13:44:27 -0400 Subject: [PATCH 03/16] fix: Switch from RLCLinux to RLC --- files/distribution.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/files/distribution.py b/files/distribution.py index 6adaaee1..211b4ba2 100644 --- a/files/distribution.py +++ b/files/distribution.py @@ -52,7 +52,7 @@ class DistributionFiles: # - have a function get_distribution_DISTNAME implemented # keep names in sync with Conditionals page of docs OSDIST_LIST = ( - {'path': '/etc/ciq-release', 'name': 'RLCLinux'}, + {'path': '/etc/ciq-release', 'name': 'RLC'}, {'path': '/etc/altlinux-release', 'name': 'Altlinux'}, {'path': '/etc/oracle-release', 'name': 'OracleLinux'}, {'path': '/etc/slackware-version', 'name': 'Slackware'}, @@ -81,7 +81,7 @@ class DistributionFiles: SEARCH_STRING = { 'OracleLinux': 'Oracle Linux', 'RedHat': 'Red Hat', - 'RLCLinux': 'Rocky Linux from CIQ', + 'RLC': 'Rocky Linux from CIQ', 'Altlinux': 'ALT', 'SMGL': 'Source Mage GNU/Linux', } @@ -570,7 +570,7 @@ class Distribution(object): OS_FAMILY_MAP = {'RedHat': ['RedHat', 'RHEL', 'Fedora', 'CentOS', 'Scientific', 'SLC', 'Ascendos', 'CloudLinux', 'PSBM', 'OracleLinux', 'OVS', 'OEL', 'Amazon', 'Amzn', 'Virtuozzo', 'XenServer', 'Alibaba', - 'EulerOS', 'openEuler', 'AlmaLinux', 'Rocky', 'RLCLinux', 'TencentOS', + 'EulerOS', 'openEuler', 'AlmaLinux', 'Rocky', 'RLC', 'TencentOS', 'EuroLinux', 'Kylin Linux Advanced Server', 'MIRACLE'], 'Debian': ['Debian', 'Ubuntu', 'Raspbian', 'Neon', 'KDE neon', 'Linux Mint', 'SteamOS', 'Devuan', 'Kali', 'Cumulus Linux', From 37bbac0867fe0cc9984cb1000b6e759206d1b515 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Tue, 21 Apr 2026 15:07:14 -0400 Subject: [PATCH 04/16] fix: Add FreeBSD Coverage --- files/distribution.py | 71 ++++++++++++++++++++++++------------------- 1 file changed, 40 insertions(+), 31 deletions(-) diff --git a/files/distribution.py b/files/distribution.py index 211b4ba2..b7f4c94d 100644 --- a/files/distribution.py +++ b/files/distribution.py @@ -43,6 +43,35 @@ def _file_exists(path, allow_empty=False): # file exists with some content return True +def _get_os_release_data(allow_empty=False): + path = '/etc/os-release' + + if not _file_exists(path, allow_empty=allow_empty): + return False, None + + data = get_file_content(path) + + os_name = ''; + os_variant = ''; + os_cpe_name = '' + + for line in data.splitlines(): + if re.search('^NAME=', line): + key, value = line.split('=', 1) + + os_name = value.strip('"') + + elif re.search('^VARIANT=', line): + key, value = line.split('=', 1) + + os_variant = value.strip('"') + + elif re.search('^CPE_NAME=', line): + key, value = line.split('=', 1) + + os_cpe_name = value.strip('"') + + return True, { 'os_name': os_name, 'os_variant': os_variant, 'os_cpe_name': os_cpe_name } class DistributionFiles: """has-a various distro file parsers (os-release, etc) and logic for finding the right one.""" @@ -109,36 +138,6 @@ def _get_dist_file_content(self, path, allow_empty=False): data = self._get_file_content(path) return True, data - def _get_os_release_data(self, allow_empty=False): - path = '/etc/os-release' - - if not _file_exists(path, allow_empty=allow_empty): - return False, None - - data = self._get_file_content(path) - - os_name = ''; - os_variant = ''; - os_cpe_name = '' - - for line in data.splitlines(): - if re.search('^NAME=', line): - key, value = line.split('=', 1) - - os_name = value.strip('"') - - elif re.search('^VARIANT=', line): - key, value = line.split('=', 1) - - os_variant = value.strip('"') - - elif re.search('^CPE_NAME=', line): - key, value = line.split('=', 1) - - os_cpe_name = value.strip('"') - - return True, { 'os_name': os_name, 'os_variant': os_variant, 'os_cpe_name': os_cpe_name } - def _parse_dist_file(self, name, dist_file_content, path, collected_facts): dist_file_dict = {} dist_file_content = dist_file_content.strip(DistributionFiles.STRIP_QUOTES) @@ -237,7 +236,7 @@ def process_dist_files(self): dist_file_facts['distribution_file_parsed'] = parsed_dist_file # Always look for into standard /etc/os-release file if it exists - os_release_exists, os_release_data = self._get_os_release_data() + os_release_exists, os_release_data = _get_os_release_data() if os_release_exists != False: dist_file_facts['distribution_os_name'] = os_release_data['os_name'] @@ -674,9 +673,19 @@ def get_distribution_FreeBSD(self): data = re.search(r'(\d+)\.(\d+)-(RELEASE|STABLE|CURRENT|RC|PRERELEASE).*', freebsd_facts['distribution_release']) if 'trueos' in platform.version(): freebsd_facts['distribution'] = 'TrueOS' + if data: freebsd_facts['distribution_major_version'] = data.group(1) freebsd_facts['distribution_version'] = '%s.%s' % (data.group(1), data.group(2)) + + # Always look for into standard /etc/os-release file if it exists + os_release_exists, os_release_data = _get_os_release_data() + + if os_release_exists != False: + freebsd_facts['distribution_os_name'] = os_release_data['os_name'] + freebsd_facts['distribution_os_variant'] = os_release_data['os_variant'] + freebsd_facts['distribution_os_cpe_name'] = os_release_data['os_cpe_name'] + return freebsd_facts def get_distribution_OpenBSD(self): From ea9028bbaa1135d74be2ec0d1c7263da8ac92f4b Mon Sep 17 00:00:00 2001 From: TheWitness Date: Wed, 22 Apr 2026 08:03:14 -0400 Subject: [PATCH 05/16] fix: Based on comments from @cigamit --- execution-environment.yml | 2 +- files/distribution.py | 795 ------------------------------------- files/distribution.py.diff | 95 +++++ 3 files changed, 96 insertions(+), 796 deletions(-) delete mode 100644 files/distribution.py create mode 100644 files/distribution.py.diff diff --git a/execution-environment.yml b/execution-environment.yml index 22a45187..117c8e11 100644 --- a/execution-environment.yml +++ b/execution-environment.yml @@ -121,5 +121,5 @@ additional_build_steps: && alternatives --install /usr/bin/python python /usr/bin/python3.11 1 && alternatives --install /usr/bin/pip3 pip3 /usr/bin/pip3.11 1 - COPY --chmod=755 _build/files/update-ca-trust /usr/bin/update-ca-trust - - COPY --chmod=755 _build/files/distribution.py /usr/local/lib/python3.11/site-packages/ansible/module_utils/facts/system/distribution.py + - RUN patch -F 50 -p1 /usr/local/lib/python3.11/site-packages/ansible/module_utils/facts/system/distribution.py < _build/files/distribution.py.diff diff --git a/files/distribution.py b/files/distribution.py deleted file mode 100644 index b7f4c94d..00000000 --- a/files/distribution.py +++ /dev/null @@ -1,795 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) Ansible Project -# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) - -from __future__ import annotations - -import os -import platform -import re -import typing as t - -from ansible.module_utils.common.sys_info import get_distribution, get_distribution_version, \ - get_distribution_codename -from ansible.module_utils.facts.utils import get_file_content, get_file_lines -from ansible.module_utils.facts.collector import BaseFactCollector - - -def get_uname(module, flags=('-v')): - if isinstance(flags, str): - flags = flags.split() - command = ['uname'] - command.extend(flags) - rc, out, err = module.run_command(command) - if rc == 0: - return out - return None - - -def _file_exists(path, allow_empty=False): - # not finding the file, exit early - if not os.path.isfile(path): - return False - - # if just the path needs to exists (ie, it can be empty) we are done - if allow_empty: - return True - - # file exists but is empty and we dont allow_empty - if os.path.getsize(path) == 0: - return False - - # file exists with some content - return True - -def _get_os_release_data(allow_empty=False): - path = '/etc/os-release' - - if not _file_exists(path, allow_empty=allow_empty): - return False, None - - data = get_file_content(path) - - os_name = ''; - os_variant = ''; - os_cpe_name = '' - - for line in data.splitlines(): - if re.search('^NAME=', line): - key, value = line.split('=', 1) - - os_name = value.strip('"') - - elif re.search('^VARIANT=', line): - key, value = line.split('=', 1) - - os_variant = value.strip('"') - - elif re.search('^CPE_NAME=', line): - key, value = line.split('=', 1) - - os_cpe_name = value.strip('"') - - return True, { 'os_name': os_name, 'os_variant': os_variant, 'os_cpe_name': os_cpe_name } - -class DistributionFiles: - """has-a various distro file parsers (os-release, etc) and logic for finding the right one.""" - # every distribution name mentioned here, must have one of - # - allowempty == True - # - be listed in SEARCH_STRING - # - have a function get_distribution_DISTNAME implemented - # keep names in sync with Conditionals page of docs - OSDIST_LIST = ( - {'path': '/etc/ciq-release', 'name': 'RLC'}, - {'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': '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': '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'}, - ) - - SEARCH_STRING = { - 'OracleLinux': 'Oracle Linux', - 'RedHat': 'Red Hat', - 'RLC': 'Rocky Linux from CIQ', - 'Altlinux': 'ALT', - 'SMGL': 'Source Mage GNU/Linux', - } - - # We can't include this in SEARCH_STRING because a name match on its keys - # causes a fallback to using the first whitespace separated item from the file content - # as the name. For os-release, that is in form 'NAME=Arch' - OS_RELEASE_ALIAS = { - 'Archlinux': 'Arch Linux' - } - - STRIP_QUOTES = r'\'\"\\' - - def __init__(self, module): - self.module = module - - def _get_file_content(self, path): - return get_file_content(path) - - def _get_dist_file_content(self, path, allow_empty=False): - # can't find that dist file, or it is incorrectly empty - if not _file_exists(path, allow_empty=allow_empty): - return False, None - - data = self._get_file_content(path) - return True, data - - def _parse_dist_file(self, name, dist_file_content, path, collected_facts): - dist_file_dict = {} - dist_file_content = dist_file_content.strip(DistributionFiles.STRIP_QUOTES) - if name in self.SEARCH_STRING: - # look for the distribution string in the data and replace according to RELEASE_NAME_MAP - # only the distribution name is set, the version is assumed to be correct from distro.linux_distribution() - if self.SEARCH_STRING[name] in dist_file_content: - # this sets distribution=RedHat if 'Red Hat' shows up in data - dist_file_dict['distribution'] = name - dist_file_dict['distribution_file_search_string'] = self.SEARCH_STRING[name] - else: - # this sets distribution to what's in the data, e.g. CentOS, Scientific, ... - dist_file_dict['distribution'] = dist_file_content.split()[0] - - return True, dist_file_dict - - if name in self.OS_RELEASE_ALIAS: - if self.OS_RELEASE_ALIAS[name] in dist_file_content: - dist_file_dict['distribution'] = name - return True, dist_file_dict - return False, dist_file_dict - - # call a dedicated function for parsing the file content - # TODO: replace with a map or a class - try: - # FIXME: most of these dont actually look at the dist file contents, but random other stuff - distfunc_name = 'parse_distribution_file_' + name - distfunc = getattr(self, distfunc_name) - parsed, dist_file_dict = distfunc(name, dist_file_content, path, collected_facts) - return parsed, dist_file_dict - except AttributeError as exc: - self.module.debug('exc: %s' % exc) - # this should never happen, but if it does fail quietly and not with a traceback - return False, dist_file_dict - - return True, dist_file_dict - # to debug multiple matching release files, one can use: - # self.facts['distribution_debug'].append({path + ' ' + name: - # (parsed, - # self.facts['distribution'], - # self.facts['distribution_version'], - # self.facts['distribution_release'], - # )}) - - def _guess_distribution(self): - # try to find out which linux distribution this is - dist = (get_distribution(), get_distribution_version(), get_distribution_codename()) - distribution_guess = { - 'distribution': dist[0] or 'NA', - 'distribution_version': dist[1] or 'NA', - # distribution_release can be the empty string - 'distribution_release': 'NA' if dist[2] is None else dist[2] - } - - distribution_guess['distribution_major_version'] = distribution_guess['distribution_version'].split('.')[0] or 'NA' - return distribution_guess - - def process_dist_files(self): - # Try to handle the exceptions now ... - # self.facts['distribution_debug'] = [] - dist_file_facts = {} - - dist_guess = self._guess_distribution() - dist_file_facts.update(dist_guess) - - for ddict in self.OSDIST_LIST: - name = ddict['name'] - path = ddict['path'] - allow_empty = ddict.get('allowempty', False) - - has_dist_file, dist_file_content = self._get_dist_file_content(path, allow_empty=allow_empty) - - # but we allow_empty. For example, ArchLinux with an empty /etc/arch-release and a - # /etc/os-release with a different name - if has_dist_file and allow_empty: - dist_file_facts['distribution'] = name - dist_file_facts['distribution_file_path'] = path - dist_file_facts['distribution_file_variety'] = name - break - - if not has_dist_file: - # keep looking - continue - - parsed_dist_file, parsed_dist_file_facts = self._parse_dist_file(name, dist_file_content, path, dist_file_facts) - - # finally found the right os dist file and were able to parse it - if parsed_dist_file: - dist_file_facts['distribution'] = name - dist_file_facts['distribution_file_path'] = path - - # distribution and file_variety are the same here, but distribution - # will be changed/mapped to a more specific name. - # ie, dist=Fedora, file_variety=RedHat - dist_file_facts['distribution_file_variety'] = name - dist_file_facts['distribution_file_parsed'] = parsed_dist_file - - # Always look for into standard /etc/os-release file if it exists - os_release_exists, os_release_data = _get_os_release_data() - - if os_release_exists != False: - dist_file_facts['distribution_os_name'] = os_release_data['os_name'] - dist_file_facts['distribution_os_variant'] = os_release_data['os_variant'] - dist_file_facts['distribution_os_cpe_name'] = os_release_data['os_cpe_name'] - - dist_file_facts.update(parsed_dist_file_facts) - - break - - return dist_file_facts - - # FIXME: split distro file parsing into its own module or class - def parse_distribution_file_Slackware(self, name, data, path, collected_facts): - slackware_facts = {} - if 'Slackware' not in data: - return False, slackware_facts # TODO: remove - slackware_facts['distribution'] = name - version = re.findall(r'\w+[.]\w+\+?', data) - if version: - slackware_facts['distribution_version'] = version[0] - return True, slackware_facts - - def parse_distribution_file_Amazon(self, name, data, path, collected_facts): - amazon_facts = {} - if 'Amazon' not in data: - return False, amazon_facts - amazon_facts['distribution'] = 'Amazon' - if path == '/etc/os-release': - version = re.search(r"VERSION_ID=\"(.*)\"", data) - if version: - distribution_version = version.group(1) - amazon_facts['distribution_version'] = distribution_version - version_data = distribution_version.split(".") - if len(version_data) > 1: - major, minor = version_data - else: - major, minor = version_data[0], 'NA' - - amazon_facts['distribution_major_version'] = major - amazon_facts['distribution_minor_version'] = minor - else: - version = [n for n in data.split() if n.isdigit()] - version = version[0] if version else 'NA' - amazon_facts['distribution_version'] = version - - return True, amazon_facts - - def parse_distribution_file_OpenWrt(self, name, data, path, collected_facts): - openwrt_facts = {} - if 'OpenWrt' not in data: - return False, openwrt_facts # TODO: remove - openwrt_facts['distribution'] = name - version = re.search('DISTRIB_RELEASE="(.*)"', data) - if version: - openwrt_facts['distribution_version'] = version.groups()[0] - release = re.search('DISTRIB_CODENAME="(.*)"', data) - if release: - openwrt_facts['distribution_release'] = release.groups()[0] - return True, openwrt_facts - - def parse_distribution_file_Alpine(self, name, data, path, collected_facts): - alpine_facts = {} - alpine_facts['distribution'] = 'Alpine' - alpine_facts['distribution_version'] = data - return True, alpine_facts - - def parse_distribution_file_SUSE(self, name, data, path, collected_facts): - suse_facts = {} - if 'suse' not in data.lower(): - return False, suse_facts # TODO: remove if tested without this - if path == '/etc/os-release': - for line in data.splitlines(): - distribution = re.search("^NAME=(.*)", line) - if distribution: - suse_facts['distribution'] = distribution.group(1).strip('"') - # example pattern are 13.04 13.0 13 - distribution_version = re.search(r'^VERSION_ID="?([0-9]+\.?[0-9]*)"?', line) - if distribution_version: - suse_facts['distribution_version'] = distribution_version.group(1) - suse_facts['distribution_major_version'] = distribution_version.group(1).split('.')[0] - if 'open' in data.lower(): - release = re.search(r'^VERSION_ID="?[0-9]+\.?([0-9]*)"?', line) - if release: - suse_facts['distribution_release'] = release.groups()[0] - elif 'enterprise' in data.lower() and 'VERSION_ID' in line: - # SLES doesn't got funny release names - release = re.search(r'^VERSION_ID="?[0-9]+\.?([0-9]*)"?', line) - if release.group(1): - release = release.group(1) - else: - release = "0" # no minor number, so it is the first release - suse_facts['distribution_release'] = release - elif path == '/etc/SuSE-release': - if 'open' in data.lower(): - data = data.splitlines() - distdata = get_file_content(path).splitlines()[0] - suse_facts['distribution'] = distdata.split()[0] - for line in data: - release = re.search('CODENAME *= *([^\n]+)', line) - if release: - suse_facts['distribution_release'] = release.groups()[0].strip() - elif 'enterprise' in data.lower(): - lines = data.splitlines() - distribution = lines[0].split()[0] - if "Server" in data: - suse_facts['distribution'] = "SLES" - elif "Desktop" in data: - suse_facts['distribution'] = "SLED" - for line in lines: - release = re.search('PATCHLEVEL = ([0-9]+)', line) # SLES doesn't got funny release names - if release: - suse_facts['distribution_release'] = release.group(1) - suse_facts['distribution_version'] = collected_facts['distribution_version'] + '.' + release.group(1) - - # Check VARIANT_ID first for SLES4SAP or SL-Micro - variant_id_match = re.search(r'^VARIANT_ID="?([^"\n]*)"?', data, re.MULTILINE) - if variant_id_match: - variant_id = variant_id_match.group(1) - if variant_id in ('server-sap', 'sles-sap'): - suse_facts['distribution'] = 'SLES_SAP' - elif variant_id == 'transactional': - suse_facts['distribution'] = 'SL-Micro' - else: - # Fallback for older SLES 15 using baseproduct symlink - if os.path.islink('/etc/products.d/baseproduct'): - resolved = os.path.realpath('/etc/products.d/baseproduct') - if resolved.endswith('SLES_SAP.prod'): - suse_facts['distribution'] = 'SLES_SAP' - elif resolved.endswith('SL-Micro.prod'): - suse_facts['distribution'] = 'SL-Micro' - - return True, suse_facts - - def parse_distribution_file_Debian(self, name, data, path, collected_facts): - debian_facts = {} - if any(distro in data for distro in ('Debian', 'Raspbian')): - debian_facts['distribution'] = 'Debian' - release = re.search(r"PRETTY_NAME=[^(]+ \(?([^)]+?)\)", data) - if release: - debian_facts['distribution_release'] = release.groups()[0] - - # Last resort: try to find release from tzdata as either lsb is missing or this is very old debian - if collected_facts['distribution_release'] == 'NA' and 'Debian' in data: - dpkg_cmd = self.module.get_bin_path('dpkg') - if dpkg_cmd: - cmd = "%s --status tzdata|grep Provides|cut -f2 -d'-'" % dpkg_cmd - rc, out, err = self.module.run_command(cmd) - if rc == 0: - debian_facts['distribution_release'] = out.strip() - debian_version_path = '/etc/debian_version' - distdata = get_file_lines(debian_version_path) - for line in distdata: - m = re.search(r'(\d+)\.(\d+)', line.strip()) - if m: - debian_facts['distribution_minor_version'] = m.groups()[1] - elif 'Ubuntu' in data: - debian_facts['distribution'] = 'Ubuntu' - # nothing else to do, Ubuntu gets correct info from python functions - elif 'SteamOS' in data: - debian_facts['distribution'] = 'SteamOS' - # nothing else to do, SteamOS gets correct info from python functions - elif path in ('/etc/lsb-release', '/etc/os-release') and ('Kali' in data or 'Parrot' in data): - if 'Kali' in data: - # Kali does not provide /etc/lsb-release anymore - debian_facts['distribution'] = 'Kali' - elif 'Parrot' in data: - debian_facts['distribution'] = 'Parrot' - release = re.search('DISTRIB_RELEASE=(.*)', data) - if release: - debian_facts['distribution_release'] = release.groups()[0] - elif 'Devuan' in data: - debian_facts['distribution'] = 'Devuan' - release = re.search(r"PRETTY_NAME=\"?[^(\"]+ \(?([^) \"]+)\)?", data) - if release: - debian_facts['distribution_release'] = release.groups()[0] - version = re.search(r"VERSION_ID=\"(.*)\"", data) - if version: - debian_facts['distribution_version'] = version.group(1) - debian_facts['distribution_major_version'] = version.group(1) - elif 'Cumulus' in data: - debian_facts['distribution'] = 'Cumulus Linux' - version = re.search(r"VERSION_ID=(.*)", data) - if version: - major, _minor, _dummy_ver = version.group(1).split(".") - debian_facts['distribution_version'] = version.group(1) - debian_facts['distribution_major_version'] = major - - release = re.search(r'VERSION="(.*)"', data) - if release: - debian_facts['distribution_release'] = release.groups()[0] - elif "Mint" in data: - debian_facts['distribution'] = 'Linux Mint' - version = re.search(r"VERSION_ID=\"(.*)\"", data) - if version: - debian_facts['distribution_version'] = version.group(1) - debian_facts['distribution_major_version'] = version.group(1).split('.')[0] - elif 'UOS' in data or 'Uos' in data or 'uos' in data: - debian_facts['distribution'] = 'Uos' - release = re.search(r"VERSION_CODENAME=\"?([^\"]+)\"?", data) - if release: - debian_facts['distribution_release'] = release.groups()[0] - version = re.search(r"VERSION_ID=\"(.*)\"", data) - if version: - debian_facts['distribution_version'] = version.group(1) - debian_facts['distribution_major_version'] = version.group(1).split('.')[0] - elif 'Deepin' in data or 'deepin' in data: - debian_facts['distribution'] = 'Deepin' - release = re.search(r"VERSION_CODENAME=\"?([^\"]+)\"?", data) - if release: - debian_facts['distribution_release'] = release.groups()[0] - version = re.search(r"VERSION_ID=\"(.*)\"", data) - if version: - debian_facts['distribution_version'] = version.group(1) - debian_facts['distribution_major_version'] = version.group(1).split('.')[0] - elif 'LMDE' in data: - debian_facts['distribution'] = 'Linux Mint Debian Edition' - else: - return False, debian_facts - - return True, debian_facts - - def parse_distribution_file_Mandriva(self, name, data, path, collected_facts): - mandriva_facts = {} - if 'Mandriva' in data: - mandriva_facts['distribution'] = 'Mandriva' - version = re.search('DISTRIB_RELEASE="(.*)"', data) - if version: - mandriva_facts['distribution_version'] = version.groups()[0] - release = re.search('DISTRIB_CODENAME="(.*)"', data) - if release: - mandriva_facts['distribution_release'] = release.groups()[0] - mandriva_facts['distribution'] = name - else: - return False, mandriva_facts - - return True, mandriva_facts - - def parse_distribution_file_NA(self, name, data, path, collected_facts): - na_facts = {} - for line in data.splitlines(): - distribution = re.search("^NAME=(.*)", line) - if distribution and name == 'NA': - na_facts['distribution'] = distribution.group(1).strip(DistributionFiles.STRIP_QUOTES) - version = re.search("^VERSION=(.*)", line) - if version and collected_facts['distribution_version'] == 'NA': - na_facts['distribution_version'] = version.group(1).strip(DistributionFiles.STRIP_QUOTES) - return True, na_facts - - def parse_distribution_file_Coreos(self, name, data, path, collected_facts): - coreos_facts = {} - # FIXME: pass in ro copy of facts for this kind of thing - distro = get_distribution() - - if distro.lower() == 'coreos': - if not data: - # include fix from #15230, #15228 - # TODO: verify this is ok for above bugs - return False, coreos_facts - release = re.search("^GROUP=(.*)", data) - if release: - coreos_facts['distribution_release'] = release.group(1).strip('"') - else: - return False, coreos_facts # TODO: remove if tested without this - - return True, coreos_facts - - def parse_distribution_file_Flatcar(self, name, data, path, collected_facts): - flatcar_facts = {} - distro = get_distribution() - - if distro.lower() != 'flatcar': - return False, flatcar_facts - - if not data: - return False, flatcar_facts - - version = re.search("VERSION=(.*)", data) - if version: - flatcar_facts['distribution_major_version'] = version.group(1).strip('"').split('.')[0] - flatcar_facts['distribution_version'] = version.group(1).strip('"') - - return True, flatcar_facts - - def parse_distribution_file_ClearLinux(self, name, data, path, collected_facts): - clear_facts = {} - if "clearlinux" not in name.lower(): - return False, clear_facts - - pname = re.search('NAME="(.*)"', data) - if pname: - if 'Clear Linux' not in pname.groups()[0]: - return False, clear_facts - clear_facts['distribution'] = pname.groups()[0] - else: - return False, clear_facts - version = re.search('VERSION_ID=(.*)', data) - if version: - clear_facts['distribution_major_version'] = version.groups()[0] - clear_facts['distribution_version'] = version.groups()[0] - release = re.search('ID=(.*)', data) - if release: - clear_facts['distribution_release'] = release.groups()[0] - return True, clear_facts - - def parse_distribution_file_CentOS(self, name, data, path, collected_facts): - centos_facts = {} - - if 'CentOS Stream' in data: - centos_facts['distribution_release'] = 'Stream' - return True, centos_facts - - if "TencentOS Server" in data: - centos_facts['distribution'] = 'TencentOS' - return True, centos_facts - - return False, centos_facts - - -class Distribution(object): - """ - This subclass of Facts fills the distribution, distribution_version and distribution_release variables - - To do so it checks the existence and content of typical files in /etc containing distribution information - - This is unit tested. Please extend the tests to cover all distributions if you have them available. - """ - - # keep keys in sync with Conditionals page of docs - OS_FAMILY_MAP = {'RedHat': ['RedHat', 'RHEL', 'Fedora', 'CentOS', 'Scientific', 'SLC', - 'Ascendos', 'CloudLinux', 'PSBM', 'OracleLinux', 'OVS', - 'OEL', 'Amazon', 'Amzn', 'Virtuozzo', 'XenServer', 'Alibaba', - 'EulerOS', 'openEuler', 'AlmaLinux', 'Rocky', 'RLC', 'TencentOS', - 'EuroLinux', 'Kylin Linux Advanced Server', 'MIRACLE'], - 'Debian': ['Debian', 'Ubuntu', 'Raspbian', 'Neon', 'KDE neon', - 'Linux Mint', 'SteamOS', 'Devuan', 'Kali', 'Cumulus Linux', - 'Pop!_OS', 'Parrot', 'Pardus GNU/Linux', 'Uos', 'Deepin', 'OSMC', - 'Linux Mint Debian Edition', 'Univention Corporate Server'], - 'Suse': ['SuSE', 'SLES', 'SLED', 'openSUSE', 'openSUSE Tumbleweed', - 'SLES_SAP', 'SUSE_LINUX', 'openSUSE Leap', 'ALP-Dolomite', 'SL-Micro', - 'openSUSE MicroOS'], - 'Archlinux': ['Archlinux', 'Antergos', 'Manjaro'], - 'Mandrake': ['Mandrake', 'Mandriva'], - 'Solaris': ['Solaris', 'Nexenta', 'OmniOS', 'OpenIndiana', 'SmartOS'], - 'Slackware': ['Slackware'], - 'Altlinux': ['Altlinux'], - 'SMGL': ['SMGL'], - 'Gentoo': ['Gentoo', 'Funtoo'], - 'Alpine': ['Alpine'], - 'AIX': ['AIX'], - 'HP-UX': ['HPUX'], - 'Darwin': ['MacOSX'], - 'FreeBSD': ['FreeBSD', 'TrueOS'], - 'ClearLinux': ['Clear Linux OS', 'Clear Linux Mix'], - 'DragonFly': ['DragonflyBSD', 'DragonFlyBSD', 'Gentoo/DragonflyBSD', 'Gentoo/DragonFlyBSD'], - 'NetBSD': ['NetBSD'], } - - OS_FAMILY = {} - for family, names in OS_FAMILY_MAP.items(): - for name in names: - OS_FAMILY[name] = family - - def __init__(self, module): - self.module = module - - def get_distribution_facts(self): - distribution_facts = {} - - # The platform module provides information about the running - # system/distribution. Use this as a baseline and fix buggy systems - # afterwards - system = platform.system() - distribution_facts['distribution'] = system - distribution_facts['distribution_release'] = platform.release() - distribution_facts['distribution_version'] = platform.version() - - systems_implemented = ('AIX', 'HP-UX', 'Darwin', 'FreeBSD', 'OpenBSD', 'SunOS', 'DragonFly', 'NetBSD') - - if system in systems_implemented: - cleanedname = system.replace('-', '') - distfunc = getattr(self, 'get_distribution_' + cleanedname) - dist_func_facts = distfunc() - distribution_facts.update(dist_func_facts) - elif system == 'Linux': - - distribution_files = DistributionFiles(module=self.module) - - # linux_distribution_facts = LinuxDistribution(module).get_distribution_facts() - dist_file_facts = distribution_files.process_dist_files() - - distribution_facts.update(dist_file_facts) - - distro = distribution_facts['distribution'] - - # look for an os family alias for the 'distribution', if there isn't one, use 'distribution' - distribution_facts['os_family'] = self.OS_FAMILY.get(distro, None) or distro - - return distribution_facts - - def get_distribution_AIX(self): - aix_facts = {} - rc, out, err = self.module.run_command("/usr/bin/oslevel") - data = out.split('.') - aix_facts['distribution_major_version'] = data[0] - if len(data) > 1: - aix_facts['distribution_version'] = '%s.%s' % (data[0], data[1]) - aix_facts['distribution_release'] = data[1] - else: - aix_facts['distribution_version'] = data[0] - return aix_facts - - def get_distribution_HPUX(self): - hpux_facts = {} - rc, out, err = self.module.run_command(r"/usr/sbin/swlist |egrep 'HPUX.*OE.*[AB].[0-9]+\.[0-9]+'", use_unsafe_shell=True) - data = re.search(r'HPUX.*OE.*([AB].[0-9]+\.[0-9]+)\.([0-9]+).*', out) - if data: - hpux_facts['distribution_version'] = data.groups()[0] - hpux_facts['distribution_release'] = data.groups()[1] - return hpux_facts - - def get_distribution_Darwin(self): - darwin_facts = {} - darwin_facts['distribution'] = 'MacOSX' - rc, out, err = self.module.run_command("/usr/bin/sw_vers -productVersion") - data = out.split()[-1] - if data: - darwin_facts['distribution_major_version'] = data.split('.')[0] - darwin_facts['distribution_version'] = data - return darwin_facts - - def get_distribution_FreeBSD(self): - freebsd_facts = {} - freebsd_facts['distribution_release'] = platform.release() - data = re.search(r'(\d+)\.(\d+)-(RELEASE|STABLE|CURRENT|RC|PRERELEASE).*', freebsd_facts['distribution_release']) - if 'trueos' in platform.version(): - freebsd_facts['distribution'] = 'TrueOS' - - if data: - freebsd_facts['distribution_major_version'] = data.group(1) - freebsd_facts['distribution_version'] = '%s.%s' % (data.group(1), data.group(2)) - - # Always look for into standard /etc/os-release file if it exists - os_release_exists, os_release_data = _get_os_release_data() - - if os_release_exists != False: - freebsd_facts['distribution_os_name'] = os_release_data['os_name'] - freebsd_facts['distribution_os_variant'] = os_release_data['os_variant'] - freebsd_facts['distribution_os_cpe_name'] = os_release_data['os_cpe_name'] - - return freebsd_facts - - def get_distribution_OpenBSD(self): - openbsd_facts = {} - openbsd_facts['distribution_version'] = platform.release() - rc, out, err = self.module.run_command("/sbin/sysctl -n kern.version") - match = re.match(r'OpenBSD\s[0-9]+.[0-9]+-(\S+)\s.*', out) - if match: - openbsd_facts['distribution_release'] = match.groups()[0] - else: - openbsd_facts['distribution_release'] = 'release' - return openbsd_facts - - def get_distribution_DragonFly(self): - dragonfly_facts = { - 'distribution_release': platform.release() - } - rc, out, dummy = self.module.run_command("/sbin/sysctl -n kern.version") - match = re.search(r'v(\d+)\.(\d+)\.(\d+)-(RELEASE|STABLE|CURRENT).*', out) - if match: - dragonfly_facts['distribution_major_version'] = match.group(1) - dragonfly_facts['distribution_version'] = '%s.%s.%s' % match.groups()[:3] - return dragonfly_facts - - def get_distribution_NetBSD(self): - netbsd_facts = {} - platform_release = platform.release() - netbsd_facts['distribution_release'] = platform_release - rc, out, dummy = self.module.run_command("/sbin/sysctl -n kern.version") - match = re.match(r'NetBSD\s(\d+)\.(\d+)\s\((GENERIC)\).*', out) - if match: - netbsd_facts['distribution_major_version'] = match.group(1) - netbsd_facts['distribution_version'] = '%s.%s' % match.groups()[:2] - else: - netbsd_facts['distribution_major_version'] = platform_release.split('.')[0] - netbsd_facts['distribution_version'] = platform_release - return netbsd_facts - - def get_distribution_SMGL(self): - smgl_facts = {} - smgl_facts['distribution'] = 'Source Mage GNU/Linux' - return smgl_facts - - def get_distribution_SunOS(self): - sunos_facts = {} - - data = get_file_content('/etc/release').splitlines()[0] - - if 'Solaris' in data: - # for solaris 10 uname_r will contain 5.10, for solaris 11 it will have 5.11 - uname_r = get_uname(self.module, flags=['-r']) - ora_prefix = '' - if 'Oracle Solaris' in data: - data = data.replace('Oracle ', '') - ora_prefix = 'Oracle ' - sunos_facts['distribution'] = data.split()[0] - sunos_facts['distribution_version'] = data.split()[1] - sunos_facts['distribution_release'] = ora_prefix + data - sunos_facts['distribution_major_version'] = uname_r.split('.')[1].rstrip() - return sunos_facts - - uname_v = get_uname(self.module, flags=['-v']) - distribution_version = None - - if 'SmartOS' in data: - sunos_facts['distribution'] = 'SmartOS' - if _file_exists('/etc/product'): - product_data = dict([l.split(': ', 1) for l in get_file_content('/etc/product').splitlines() if ': ' in l]) - if 'Image' in product_data: - distribution_version = product_data.get('Image').split()[-1] - elif 'OpenIndiana' in data: - sunos_facts['distribution'] = 'OpenIndiana' - elif 'OmniOS' in data: - sunos_facts['distribution'] = 'OmniOS' - distribution_version = data.split()[-1] - elif uname_v is not None and 'NexentaOS_' in uname_v: - sunos_facts['distribution'] = 'Nexenta' - distribution_version = data.split()[-1].lstrip('v') - - if sunos_facts.get('distribution', '') in ('SmartOS', 'OpenIndiana', 'OmniOS', 'Nexenta'): - sunos_facts['distribution_release'] = data.strip() - if distribution_version is not None: - sunos_facts['distribution_version'] = distribution_version - elif uname_v is not None: - sunos_facts['distribution_version'] = uname_v.splitlines()[0].strip() - return sunos_facts - - return sunos_facts - - -class DistributionFactCollector(BaseFactCollector): - name = 'distribution' - _fact_ids = set(['distribution_version', - 'distribution_release', - 'distribution_major_version', - 'os_family']) # type: t.Set[str] - - def collect(self, module=None, collected_facts=None): - collected_facts = collected_facts or {} - facts_dict = {} - if not module: - return facts_dict - - distribution = Distribution(module=module) - distro_facts = distribution.get_distribution_facts() - - return distro_facts diff --git a/files/distribution.py.diff b/files/distribution.py.diff new file mode 100644 index 00000000..e802213a --- /dev/null +++ b/files/distribution.py.diff @@ -0,0 +1,95 @@ +--- distribution.py.backup 2026-04-21 15:39:58.496829194 -0400 ++++ distribution.new.py 2026-04-21 15:44:30.893425940 -0400 +@@ -45,6 +45,36 @@ + # file exists with some content + return True + ++def _get_os_release_data(allow_empty=False): ++ path = '/etc/os-release' ++ ++ if not _file_exists(path, allow_empty=allow_empty): ++ return False, None ++ ++ data = get_file_content(path) ++ ++ os_name = ''; ++ os_variant = ''; ++ os_cpe_name = '' ++ ++ for line in data.splitlines(): ++ if re.search('^NAME=', line): ++ key, value = line.split('=', 1) ++ ++ os_name = value.strip('"') ++ ++ elif re.search('^VARIANT=', line): ++ key, value = line.split('=', 1) ++ ++ os_variant = value.strip('"') ++ ++ elif re.search('^CPE_NAME=', line): ++ key, value = line.split('=', 1) ++ ++ os_cpe_name = value.strip('"') ++ ++ return True, { 'os_name': os_name, 'os_variant': os_variant, 'os_cpe_name': os_cpe_name } ++ + + class DistributionFiles: + '''has-a various distro file parsers (os-release, etc) and logic for finding the right one.''' +@@ -54,6 +84,7 @@ + # - have a function get_distribution_DISTNAME implemented + # keep names in sync with Conditionals page of docs + OSDIST_LIST = ( ++ {'path': '/etc/ciq-release', 'name': 'RLC'}, + {'path': '/etc/altlinux-release', 'name': 'Altlinux'}, + {'path': '/etc/oracle-release', 'name': 'OracleLinux'}, + {'path': '/etc/slackware-version', 'name': 'Slackware'}, +@@ -82,6 +113,7 @@ + SEARCH_STRING = { + 'OracleLinux': 'Oracle Linux', + 'RedHat': 'Red Hat', ++ 'RLC': 'Rocky Linux from CIQ', + 'Altlinux': 'ALT', + 'SMGL': 'Source Mage GNU/Linux', + } +@@ -199,12 +231,23 @@ + if parsed_dist_file: + dist_file_facts['distribution'] = name + dist_file_facts['distribution_file_path'] = path ++ + # distribution and file_variety are the same here, but distribution + # will be changed/mapped to a more specific name. + # ie, dist=Fedora, file_variety=RedHat + dist_file_facts['distribution_file_variety'] = name + dist_file_facts['distribution_file_parsed'] = parsed_dist_file ++ ++ # Always look for into standard /etc/os-release file if it exists ++ os_release_exists, os_release_data = _get_os_release_data() ++ ++ if os_release_exists != False: ++ dist_file_facts['distribution_os_name'] = os_release_data['os_name'] ++ dist_file_facts['distribution_os_variant'] = os_release_data['os_variant'] ++ dist_file_facts['distribution_os_cpe_name'] = os_release_data['os_cpe_name'] ++ + dist_file_facts.update(parsed_dist_file_facts) ++ + break + + return dist_file_facts +@@ -617,6 +660,15 @@ + if data: + freebsd_facts['distribution_major_version'] = data.group(1) + freebsd_facts['distribution_version'] = '%s.%s' % (data.group(1), data.group(2)) ++ ++ # Always look for into standard /etc/os-release file if it exists ++ os_release_exists, os_release_data = _get_os_release_data() ++ ++ if os_release_exists != False: ++ freebsd_facts['distribution_os_name'] = os_release_data['os_name'] ++ freebsd_facts['distribution_os_variant'] = os_release_data['os_variant'] ++ freebsd_facts['distribution_os_cpe_name'] = os_release_data['os_cpe_name'] ++ + return freebsd_facts + + def get_distribution_OpenBSD(self): From 408464b2a698d4aceb448dfe616efac48aaa8915 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Wed, 22 Apr 2026 08:59:35 -0400 Subject: [PATCH 06/16] Update execution-environment.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- execution-environment.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/execution-environment.yml b/execution-environment.yml index 117c8e11..6524aa26 100644 --- a/execution-environment.yml +++ b/execution-environment.yml @@ -93,7 +93,7 @@ additional_build_files: dest: configs - src: files/update-ca-trust dest: files - - src: files/distribution.py + - src: files/distribution.py.diff dest: files additional_build_steps: append_base: From f2f550eebd36b0a14812d6ad08e22ab8059026c6 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Wed, 22 Apr 2026 11:04:41 -0400 Subject: [PATCH 07/16] debug: More debugging --- execution-environment.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/execution-environment.yml b/execution-environment.yml index 6524aa26..75af9217 100644 --- a/execution-environment.yml +++ b/execution-environment.yml @@ -121,5 +121,5 @@ additional_build_steps: && alternatives --install /usr/bin/python python /usr/bin/python3.11 1 && alternatives --install /usr/bin/pip3 pip3 /usr/bin/pip3.11 1 - COPY --chmod=755 _build/files/update-ca-trust /usr/bin/update-ca-trust - - RUN patch -F 50 -p1 /usr/local/lib/python3.11/site-packages/ansible/module_utils/facts/system/distribution.py < _build/files/distribution.py.diff + - RUN patch -vvv -verbose -F 50 -p1 /usr/local/lib/python3.11/site-packages/ansible/module_utils/facts/system/distribution.py < _build/files/distribution.py.diff From b350f59df972cefa7a9213ed6c0d5c798afdf421 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Wed, 22 Apr 2026 11:15:07 -0400 Subject: [PATCH 08/16] debug: Backing out verbose change --- execution-environment.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/execution-environment.yml b/execution-environment.yml index 75af9217..6524aa26 100644 --- a/execution-environment.yml +++ b/execution-environment.yml @@ -121,5 +121,5 @@ additional_build_steps: && alternatives --install /usr/bin/python python /usr/bin/python3.11 1 && alternatives --install /usr/bin/pip3 pip3 /usr/bin/pip3.11 1 - COPY --chmod=755 _build/files/update-ca-trust /usr/bin/update-ca-trust - - RUN patch -vvv -verbose -F 50 -p1 /usr/local/lib/python3.11/site-packages/ansible/module_utils/facts/system/distribution.py < _build/files/distribution.py.diff + - RUN patch -F 50 -p1 /usr/local/lib/python3.11/site-packages/ansible/module_utils/facts/system/distribution.py < _build/files/distribution.py.diff From e6a2c5e510e3fa66de29ee113c2c4143d1c6982d Mon Sep 17 00:00:00 2001 From: TheWitness Date: Wed, 22 Apr 2026 12:59:18 -0400 Subject: [PATCH 09/16] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- files/distribution.py.diff | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/files/distribution.py.diff b/files/distribution.py.diff index e802213a..f58fdbc3 100644 --- a/files/distribution.py.diff +++ b/files/distribution.py.diff @@ -12,9 +12,9 @@ + + data = get_file_content(path) + -+ os_name = ''; -+ os_variant = ''; -+ os_cpe_name = '' ++ os_name = "" ++ os_variant = "" ++ os_cpe_name = "" + + for line in data.splitlines(): + if re.search('^NAME=', line): From 9c8ede849baf51f1db3310bf2d3266cc56a273b0 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Wed, 22 Apr 2026 13:46:31 -0400 Subject: [PATCH 10/16] fix: Use the full file --- execution-environment.yml | 5 +- files/distribution.py | 779 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 782 insertions(+), 2 deletions(-) create mode 100644 files/distribution.py diff --git a/execution-environment.yml b/execution-environment.yml index 6524aa26..3f48b54a 100644 --- a/execution-environment.yml +++ b/execution-environment.yml @@ -93,6 +93,8 @@ additional_build_files: dest: configs - src: files/update-ca-trust dest: files + - src: files/distribution.py + dest: files - src: files/distribution.py.diff dest: files additional_build_steps: @@ -121,5 +123,4 @@ additional_build_steps: && alternatives --install /usr/bin/python python /usr/bin/python3.11 1 && alternatives --install /usr/bin/pip3 pip3 /usr/bin/pip3.11 1 - COPY --chmod=755 _build/files/update-ca-trust /usr/bin/update-ca-trust - - RUN patch -F 50 -p1 /usr/local/lib/python3.11/site-packages/ansible/module_utils/facts/system/distribution.py < _build/files/distribution.py.diff - + - COPY --chmod=755 _build/files/distribution.py /usr/local/lib/python3.11/site-packages/ansible/module_utils/facts/system/distribution.py diff --git a/files/distribution.py b/files/distribution.py new file mode 100644 index 00000000..9b4a9154 --- /dev/null +++ b/files/distribution.py @@ -0,0 +1,779 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import os +import platform +import re + +import ansible.module_utils.compat.typing as t + +from ansible.module_utils.common.sys_info import get_distribution, get_distribution_version, \ + get_distribution_codename +from ansible.module_utils.facts.utils import get_file_content, get_file_lines +from ansible.module_utils.facts.collector import BaseFactCollector + + +def get_uname(module, flags=('-v')): + if isinstance(flags, str): + flags = flags.split() + command = ['uname'] + command.extend(flags) + rc, out, err = module.run_command(command) + if rc == 0: + return out + return None + + +def _file_exists(path, allow_empty=False): + # not finding the file, exit early + if not os.path.exists(path): + return False + + # if just the path needs to exists (ie, it can be empty) we are done + if allow_empty: + return True + + # file exists but is empty and we dont allow_empty + if os.path.getsize(path) == 0: + return False + + # file exists with some content + return True + +def _get_os_release_data(allow_empty=False): + path = '/etc/os-release' + + if not _file_exists(path, allow_empty=allow_empty): + return False, None + + data = get_file_content(path) + + os_name = ''; + os_variant = ''; + os_cpe_name = '' + + for line in data.splitlines(): + if re.search('^NAME=', line): + key, value = line.split('=', 1) + + os_name = value.strip('"') + + elif re.search('^VARIANT=', line): + key, value = line.split('=', 1) + + os_variant = value.strip('"') + + elif re.search('^CPE_NAME=', line): + key, value = line.split('=', 1) + + os_cpe_name = value.strip('"') + + return True, { 'os_name': os_name, 'os_variant': os_variant, 'os_cpe_name': os_cpe_name } + + +class DistributionFiles: + '''has-a various distro file parsers (os-release, etc) and logic for finding the right one.''' + # every distribution name mentioned here, must have one of + # - allowempty == True + # - be listed in SEARCH_STRING + # - have a function get_distribution_DISTNAME implemented + # keep names in sync with Conditionals page of docs + OSDIST_LIST = ( + {'path': '/etc/ciq-release', 'name': 'RLC'}, + {'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': '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': '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'}, + ) + + SEARCH_STRING = { + 'OracleLinux': 'Oracle Linux', + 'RedHat': 'Red Hat', + 'RLC': 'Rocky Linux from CIQ', + 'Altlinux': 'ALT', + 'SMGL': 'Source Mage GNU/Linux', + } + + # We can't include this in SEARCH_STRING because a name match on its keys + # causes a fallback to using the first whitespace separated item from the file content + # as the name. For os-release, that is in form 'NAME=Arch' + OS_RELEASE_ALIAS = { + 'Archlinux': 'Arch Linux' + } + + STRIP_QUOTES = r'\'\"\\' + + def __init__(self, module): + self.module = module + + def _get_file_content(self, path): + return get_file_content(path) + + def _get_dist_file_content(self, path, allow_empty=False): + # cant find that dist file or it is incorrectly empty + if not _file_exists(path, allow_empty=allow_empty): + return False, None + + data = self._get_file_content(path) + return True, data + + def _parse_dist_file(self, name, dist_file_content, path, collected_facts): + dist_file_dict = {} + dist_file_content = dist_file_content.strip(DistributionFiles.STRIP_QUOTES) + if name in self.SEARCH_STRING: + # look for the distribution string in the data and replace according to RELEASE_NAME_MAP + # only the distribution name is set, the version is assumed to be correct from distro.linux_distribution() + if self.SEARCH_STRING[name] in dist_file_content: + # this sets distribution=RedHat if 'Red Hat' shows up in data + dist_file_dict['distribution'] = name + dist_file_dict['distribution_file_search_string'] = self.SEARCH_STRING[name] + else: + # this sets distribution to what's in the data, e.g. CentOS, Scientific, ... + dist_file_dict['distribution'] = dist_file_content.split()[0] + + return True, dist_file_dict + + if name in self.OS_RELEASE_ALIAS: + if self.OS_RELEASE_ALIAS[name] in dist_file_content: + dist_file_dict['distribution'] = name + return True, dist_file_dict + return False, dist_file_dict + + # call a dedicated function for parsing the file content + # TODO: replace with a map or a class + try: + # FIXME: most of these dont actually look at the dist file contents, but random other stuff + distfunc_name = 'parse_distribution_file_' + name + distfunc = getattr(self, distfunc_name) + parsed, dist_file_dict = distfunc(name, dist_file_content, path, collected_facts) + return parsed, dist_file_dict + except AttributeError as exc: + self.module.debug('exc: %s' % exc) + # this should never happen, but if it does fail quietly and not with a traceback + return False, dist_file_dict + + return True, dist_file_dict + # to debug multiple matching release files, one can use: + # self.facts['distribution_debug'].append({path + ' ' + name: + # (parsed, + # self.facts['distribution'], + # self.facts['distribution_version'], + # self.facts['distribution_release'], + # )}) + + def _guess_distribution(self): + # try to find out which linux distribution this is + dist = (get_distribution(), get_distribution_version(), get_distribution_codename()) + distribution_guess = { + 'distribution': dist[0] or 'NA', + 'distribution_version': dist[1] or 'NA', + # distribution_release can be the empty string + 'distribution_release': 'NA' if dist[2] is None else dist[2] + } + + distribution_guess['distribution_major_version'] = distribution_guess['distribution_version'].split('.')[0] or 'NA' + return distribution_guess + + def process_dist_files(self): + # Try to handle the exceptions now ... + # self.facts['distribution_debug'] = [] + dist_file_facts = {} + + dist_guess = self._guess_distribution() + dist_file_facts.update(dist_guess) + + for ddict in self.OSDIST_LIST: + name = ddict['name'] + path = ddict['path'] + allow_empty = ddict.get('allowempty', False) + + has_dist_file, dist_file_content = self._get_dist_file_content(path, allow_empty=allow_empty) + + # but we allow_empty. For example, ArchLinux with an empty /etc/arch-release and a + # /etc/os-release with a different name + if has_dist_file and allow_empty: + dist_file_facts['distribution'] = name + dist_file_facts['distribution_file_path'] = path + dist_file_facts['distribution_file_variety'] = name + break + + if not has_dist_file: + # keep looking + continue + + parsed_dist_file, parsed_dist_file_facts = self._parse_dist_file(name, dist_file_content, path, dist_file_facts) + + # finally found the right os dist file and were able to parse it + if parsed_dist_file: + dist_file_facts['distribution'] = name + dist_file_facts['distribution_file_path'] = path + + # distribution and file_variety are the same here, but distribution + # will be changed/mapped to a more specific name. + # ie, dist=Fedora, file_variety=RedHat + dist_file_facts['distribution_file_variety'] = name + dist_file_facts['distribution_file_parsed'] = parsed_dist_file + + # Always look for into standard /etc/os-release file if it exists + os_release_exists, os_release_data = _get_os_release_data() + + if os_release_exists != False: + dist_file_facts['distribution_os_name'] = os_release_data['os_name'] + dist_file_facts['distribution_os_variant'] = os_release_data['os_variant'] + dist_file_facts['distribution_os_cpe_name'] = os_release_data['os_cpe_name'] + + dist_file_facts.update(parsed_dist_file_facts) + + break + + return dist_file_facts + + # TODO: FIXME: split distro file parsing into its own module or class + def parse_distribution_file_Slackware(self, name, data, path, collected_facts): + slackware_facts = {} + if 'Slackware' not in data: + return False, slackware_facts # TODO: remove + slackware_facts['distribution'] = name + version = re.findall(r'\w+[.]\w+\+?', data) + if version: + slackware_facts['distribution_version'] = version[0] + return True, slackware_facts + + def parse_distribution_file_Amazon(self, name, data, path, collected_facts): + amazon_facts = {} + if 'Amazon' not in data: + return False, amazon_facts + amazon_facts['distribution'] = 'Amazon' + if path == '/etc/os-release': + version = re.search(r"VERSION_ID=\"(.*)\"", data) + if version: + distribution_version = version.group(1) + amazon_facts['distribution_version'] = distribution_version + version_data = distribution_version.split(".") + if len(version_data) > 1: + major, minor = version_data + else: + major, minor = version_data[0], 'NA' + + amazon_facts['distribution_major_version'] = major + amazon_facts['distribution_minor_version'] = minor + else: + version = [n for n in data.split() if n.isdigit()] + version = version[0] if version else 'NA' + amazon_facts['distribution_version'] = version + + return True, amazon_facts + + def parse_distribution_file_OpenWrt(self, name, data, path, collected_facts): + openwrt_facts = {} + if 'OpenWrt' not in data: + return False, openwrt_facts # TODO: remove + openwrt_facts['distribution'] = name + version = re.search('DISTRIB_RELEASE="(.*)"', data) + if version: + openwrt_facts['distribution_version'] = version.groups()[0] + release = re.search('DISTRIB_CODENAME="(.*)"', data) + if release: + openwrt_facts['distribution_release'] = release.groups()[0] + return True, openwrt_facts + + def parse_distribution_file_Alpine(self, name, data, path, collected_facts): + alpine_facts = {} + alpine_facts['distribution'] = 'Alpine' + alpine_facts['distribution_version'] = data + return True, alpine_facts + + def parse_distribution_file_SUSE(self, name, data, path, collected_facts): + suse_facts = {} + if 'suse' not in data.lower(): + return False, suse_facts # TODO: remove if tested without this + if path == '/etc/os-release': + for line in data.splitlines(): + distribution = re.search("^NAME=(.*)", line) + if distribution: + suse_facts['distribution'] = distribution.group(1).strip('"') + # example pattern are 13.04 13.0 13 + distribution_version = re.search(r'^VERSION_ID="?([0-9]+\.?[0-9]*)"?', line) + if distribution_version: + suse_facts['distribution_version'] = distribution_version.group(1) + suse_facts['distribution_major_version'] = distribution_version.group(1).split('.')[0] + if 'open' in data.lower(): + release = re.search(r'^VERSION_ID="?[0-9]+\.?([0-9]*)"?', line) + if release: + suse_facts['distribution_release'] = release.groups()[0] + elif 'enterprise' in data.lower() and 'VERSION_ID' in line: + # SLES doesn't got funny release names + release = re.search(r'^VERSION_ID="?[0-9]+\.?([0-9]*)"?', line) + if release.group(1): + release = release.group(1) + else: + release = "0" # no minor number, so it is the first release + suse_facts['distribution_release'] = release + elif path == '/etc/SuSE-release': + if 'open' in data.lower(): + data = data.splitlines() + distdata = get_file_content(path).splitlines()[0] + suse_facts['distribution'] = distdata.split()[0] + for line in data: + release = re.search('CODENAME *= *([^\n]+)', line) + if release: + suse_facts['distribution_release'] = release.groups()[0].strip() + elif 'enterprise' in data.lower(): + lines = data.splitlines() + distribution = lines[0].split()[0] + if "Server" in data: + suse_facts['distribution'] = "SLES" + elif "Desktop" in data: + suse_facts['distribution'] = "SLED" + for line in lines: + release = re.search('PATCHLEVEL = ([0-9]+)', line) # SLES doesn't got funny release names + if release: + suse_facts['distribution_release'] = release.group(1) + suse_facts['distribution_version'] = collected_facts['distribution_version'] + '.' + release.group(1) + + # See https://www.suse.com/support/kb/doc/?id=000019341 for SLES for SAP + if os.path.islink('/etc/products.d/baseproduct') and os.path.realpath('/etc/products.d/baseproduct').endswith('SLES_SAP.prod'): + suse_facts['distribution'] = 'SLES_SAP' + + return True, suse_facts + + def parse_distribution_file_Debian(self, name, data, path, collected_facts): + debian_facts = {} + if 'Debian' in data or 'Raspbian' in data: + debian_facts['distribution'] = 'Debian' + release = re.search(r"PRETTY_NAME=[^(]+ \(?([^)]+?)\)", data) + if release: + debian_facts['distribution_release'] = release.groups()[0] + + # Last resort: try to find release from tzdata as either lsb is missing or this is very old debian + if collected_facts['distribution_release'] == 'NA' and 'Debian' in data: + dpkg_cmd = self.module.get_bin_path('dpkg') + if dpkg_cmd: + cmd = "%s --status tzdata|grep Provides|cut -f2 -d'-'" % dpkg_cmd + rc, out, err = self.module.run_command(cmd) + if rc == 0: + debian_facts['distribution_release'] = out.strip() + debian_version_path = '/etc/debian_version' + distdata = get_file_lines(debian_version_path) + for line in distdata: + m = re.search(r'(\d+)\.(\d+)', line.strip()) + if m: + debian_facts['distribution_minor_version'] = m.groups()[1] + elif 'Ubuntu' in data: + debian_facts['distribution'] = 'Ubuntu' + # nothing else to do, Ubuntu gets correct info from python functions + elif 'SteamOS' in data: + debian_facts['distribution'] = 'SteamOS' + # nothing else to do, SteamOS gets correct info from python functions + elif path in ('/etc/lsb-release', '/etc/os-release') and ('Kali' in data or 'Parrot' in data): + if 'Kali' in data: + # Kali does not provide /etc/lsb-release anymore + debian_facts['distribution'] = 'Kali' + elif 'Parrot' in data: + debian_facts['distribution'] = 'Parrot' + release = re.search('DISTRIB_RELEASE=(.*)', data) + if release: + debian_facts['distribution_release'] = release.groups()[0] + elif 'Devuan' in data: + debian_facts['distribution'] = 'Devuan' + release = re.search(r"PRETTY_NAME=\"?[^(\"]+ \(?([^) \"]+)\)?", data) + if release: + debian_facts['distribution_release'] = release.groups()[0] + version = re.search(r"VERSION_ID=\"(.*)\"", data) + if version: + debian_facts['distribution_version'] = version.group(1) + debian_facts['distribution_major_version'] = version.group(1) + elif 'Cumulus' in data: + debian_facts['distribution'] = 'Cumulus Linux' + version = re.search(r"VERSION_ID=(.*)", data) + if version: + major, _minor, _dummy_ver = version.group(1).split(".") + debian_facts['distribution_version'] = version.group(1) + debian_facts['distribution_major_version'] = major + + release = re.search(r'VERSION="(.*)"', data) + if release: + debian_facts['distribution_release'] = release.groups()[0] + elif "Mint" in data: + debian_facts['distribution'] = 'Linux Mint' + version = re.search(r"VERSION_ID=\"(.*)\"", data) + if version: + debian_facts['distribution_version'] = version.group(1) + debian_facts['distribution_major_version'] = version.group(1).split('.')[0] + elif 'UOS' in data or 'Uos' in data or 'uos' in data: + debian_facts['distribution'] = 'Uos' + release = re.search(r"VERSION_CODENAME=\"?([^\"]+)\"?", data) + if release: + debian_facts['distribution_release'] = release.groups()[0] + version = re.search(r"VERSION_ID=\"(.*)\"", data) + if version: + debian_facts['distribution_version'] = version.group(1) + debian_facts['distribution_major_version'] = version.group(1).split('.')[0] + elif 'Deepin' in data or 'deepin' in data: + debian_facts['distribution'] = 'Deepin' + release = re.search(r"VERSION_CODENAME=\"?([^\"]+)\"?", data) + if release: + debian_facts['distribution_release'] = release.groups()[0] + version = re.search(r"VERSION_ID=\"(.*)\"", data) + if version: + debian_facts['distribution_version'] = version.group(1) + debian_facts['distribution_major_version'] = version.group(1).split('.')[0] + else: + return False, debian_facts + + return True, debian_facts + + def parse_distribution_file_Mandriva(self, name, data, path, collected_facts): + mandriva_facts = {} + if 'Mandriva' in data: + mandriva_facts['distribution'] = 'Mandriva' + version = re.search('DISTRIB_RELEASE="(.*)"', data) + if version: + mandriva_facts['distribution_version'] = version.groups()[0] + release = re.search('DISTRIB_CODENAME="(.*)"', data) + if release: + mandriva_facts['distribution_release'] = release.groups()[0] + mandriva_facts['distribution'] = name + else: + return False, mandriva_facts + + return True, mandriva_facts + + def parse_distribution_file_NA(self, name, data, path, collected_facts): + na_facts = {} + for line in data.splitlines(): + distribution = re.search("^NAME=(.*)", line) + if distribution and name == 'NA': + na_facts['distribution'] = distribution.group(1).strip('"') + version = re.search("^VERSION=(.*)", line) + if version and collected_facts['distribution_version'] == 'NA': + na_facts['distribution_version'] = version.group(1).strip('"') + return True, na_facts + + def parse_distribution_file_Coreos(self, name, data, path, collected_facts): + coreos_facts = {} + # FIXME: pass in ro copy of facts for this kind of thing + distro = get_distribution() + + if distro.lower() == 'coreos': + if not data: + # include fix from #15230, #15228 + # TODO: verify this is ok for above bugs + return False, coreos_facts + release = re.search("^GROUP=(.*)", data) + if release: + coreos_facts['distribution_release'] = release.group(1).strip('"') + else: + return False, coreos_facts # TODO: remove if tested without this + + return True, coreos_facts + + def parse_distribution_file_Flatcar(self, name, data, path, collected_facts): + flatcar_facts = {} + distro = get_distribution() + + if distro.lower() != 'flatcar': + return False, flatcar_facts + + if not data: + return False, flatcar_facts + + version = re.search("VERSION=(.*)", data) + if version: + flatcar_facts['distribution_major_version'] = version.group(1).strip('"').split('.')[0] + flatcar_facts['distribution_version'] = version.group(1).strip('"') + + return True, flatcar_facts + + def parse_distribution_file_ClearLinux(self, name, data, path, collected_facts): + clear_facts = {} + if "clearlinux" not in name.lower(): + return False, clear_facts + + pname = re.search('NAME="(.*)"', data) + if pname: + if 'Clear Linux' not in pname.groups()[0]: + return False, clear_facts + clear_facts['distribution'] = pname.groups()[0] + version = re.search('VERSION_ID=(.*)', data) + if version: + clear_facts['distribution_major_version'] = version.groups()[0] + clear_facts['distribution_version'] = version.groups()[0] + release = re.search('ID=(.*)', data) + if release: + clear_facts['distribution_release'] = release.groups()[0] + return True, clear_facts + + def parse_distribution_file_CentOS(self, name, data, path, collected_facts): + centos_facts = {} + + if 'CentOS Stream' in data: + centos_facts['distribution_release'] = 'Stream' + return True, centos_facts + + if "TencentOS Server" in data: + centos_facts['distribution'] = 'TencentOS' + return True, centos_facts + + return False, centos_facts + + +class Distribution(object): + """ + This subclass of Facts fills the distribution, distribution_version and distribution_release variables + + To do so it checks the existence and content of typical files in /etc containing distribution information + + This is unit tested. Please extend the tests to cover all distributions if you have them available. + """ + + # keep keys in sync with Conditionals page of docs + OS_FAMILY_MAP = {'RedHat': ['RedHat', 'RHEL', 'Fedora', 'CentOS', 'Scientific', 'SLC', + 'Ascendos', 'CloudLinux', 'PSBM', 'OracleLinux', 'OVS', + 'OEL', 'Amazon', 'Virtuozzo', 'XenServer', 'Alibaba', + 'EulerOS', 'openEuler', 'AlmaLinux', 'Rocky', 'TencentOS', + 'EuroLinux', 'Kylin Linux Advanced Server'], + 'Debian': ['Debian', 'Ubuntu', 'Raspbian', 'Neon', 'KDE neon', + 'Linux Mint', 'SteamOS', 'Devuan', 'Kali', 'Cumulus Linux', + 'Pop!_OS', 'Parrot', 'Pardus GNU/Linux', 'Uos', 'Deepin', 'OSMC'], + 'Suse': ['SuSE', 'SLES', 'SLED', 'openSUSE', 'openSUSE Tumbleweed', + 'SLES_SAP', 'SUSE_LINUX', 'openSUSE Leap'], + 'Archlinux': ['Archlinux', 'Antergos', 'Manjaro'], + 'Mandrake': ['Mandrake', 'Mandriva'], + 'Solaris': ['Solaris', 'Nexenta', 'OmniOS', 'OpenIndiana', 'SmartOS'], + 'Slackware': ['Slackware'], + 'Altlinux': ['Altlinux'], + 'SGML': ['SGML'], + 'Gentoo': ['Gentoo', 'Funtoo'], + 'Alpine': ['Alpine'], + 'AIX': ['AIX'], + 'HP-UX': ['HPUX'], + 'Darwin': ['MacOSX'], + 'FreeBSD': ['FreeBSD', 'TrueOS'], + 'ClearLinux': ['Clear Linux OS', 'Clear Linux Mix'], + 'DragonFly': ['DragonflyBSD', 'DragonFlyBSD', 'Gentoo/DragonflyBSD', 'Gentoo/DragonFlyBSD'], + 'NetBSD': ['NetBSD'], } + + OS_FAMILY = {} + for family, names in OS_FAMILY_MAP.items(): + for name in names: + OS_FAMILY[name] = family + + def __init__(self, module): + self.module = module + + def get_distribution_facts(self): + distribution_facts = {} + + # The platform module provides information about the running + # system/distribution. Use this as a baseline and fix buggy systems + # afterwards + system = platform.system() + distribution_facts['distribution'] = system + distribution_facts['distribution_release'] = platform.release() + distribution_facts['distribution_version'] = platform.version() + + systems_implemented = ('AIX', 'HP-UX', 'Darwin', 'FreeBSD', 'OpenBSD', 'SunOS', 'DragonFly', 'NetBSD') + + if system in systems_implemented: + cleanedname = system.replace('-', '') + distfunc = getattr(self, 'get_distribution_' + cleanedname) + dist_func_facts = distfunc() + distribution_facts.update(dist_func_facts) + elif system == 'Linux': + + distribution_files = DistributionFiles(module=self.module) + + # linux_distribution_facts = LinuxDistribution(module).get_distribution_facts() + dist_file_facts = distribution_files.process_dist_files() + + distribution_facts.update(dist_file_facts) + + distro = distribution_facts['distribution'] + + # look for a os family alias for the 'distribution', if there isnt one, use 'distribution' + distribution_facts['os_family'] = self.OS_FAMILY.get(distro, None) or distro + + return distribution_facts + + def get_distribution_AIX(self): + aix_facts = {} + rc, out, err = self.module.run_command("/usr/bin/oslevel") + data = out.split('.') + aix_facts['distribution_major_version'] = data[0] + if len(data) > 1: + aix_facts['distribution_version'] = '%s.%s' % (data[0], data[1]) + aix_facts['distribution_release'] = data[1] + else: + aix_facts['distribution_version'] = data[0] + return aix_facts + + def get_distribution_HPUX(self): + hpux_facts = {} + rc, out, err = self.module.run_command(r"/usr/sbin/swlist |egrep 'HPUX.*OE.*[AB].[0-9]+\.[0-9]+'", use_unsafe_shell=True) + data = re.search(r'HPUX.*OE.*([AB].[0-9]+\.[0-9]+)\.([0-9]+).*', out) + if data: + hpux_facts['distribution_version'] = data.groups()[0] + hpux_facts['distribution_release'] = data.groups()[1] + return hpux_facts + + def get_distribution_Darwin(self): + darwin_facts = {} + darwin_facts['distribution'] = 'MacOSX' + rc, out, err = self.module.run_command("/usr/bin/sw_vers -productVersion") + data = out.split()[-1] + if data: + darwin_facts['distribution_major_version'] = data.split('.')[0] + darwin_facts['distribution_version'] = data + return darwin_facts + + def get_distribution_FreeBSD(self): + freebsd_facts = {} + freebsd_facts['distribution_release'] = platform.release() + data = re.search(r'(\d+)\.(\d+)-(RELEASE|STABLE|CURRENT|RC|PRERELEASE).*', freebsd_facts['distribution_release']) + if 'trueos' in platform.version(): + freebsd_facts['distribution'] = 'TrueOS' + if data: + freebsd_facts['distribution_major_version'] = data.group(1) + freebsd_facts['distribution_version'] = '%s.%s' % (data.group(1), data.group(2)) + + # Always look for into standard /etc/os-release file if it exists + os_release_exists, os_release_data = _get_os_release_data() + + if os_release_exists != False: + freebsd_facts['distribution_os_name'] = os_release_data['os_name'] + freebsd_facts['distribution_os_variant'] = os_release_data['os_variant'] + freebsd_facts['distribution_os_cpe_name'] = os_release_data['os_cpe_name'] + + return freebsd_facts + + def get_distribution_OpenBSD(self): + openbsd_facts = {} + openbsd_facts['distribution_version'] = platform.release() + rc, out, err = self.module.run_command("/sbin/sysctl -n kern.version") + match = re.match(r'OpenBSD\s[0-9]+.[0-9]+-(\S+)\s.*', out) + if match: + openbsd_facts['distribution_release'] = match.groups()[0] + else: + openbsd_facts['distribution_release'] = 'release' + return openbsd_facts + + def get_distribution_DragonFly(self): + dragonfly_facts = { + 'distribution_release': platform.release() + } + rc, out, dummy = self.module.run_command("/sbin/sysctl -n kern.version") + match = re.search(r'v(\d+)\.(\d+)\.(\d+)-(RELEASE|STABLE|CURRENT).*', out) + if match: + dragonfly_facts['distribution_major_version'] = match.group(1) + dragonfly_facts['distribution_version'] = '%s.%s.%s' % match.groups()[:3] + return dragonfly_facts + + def get_distribution_NetBSD(self): + netbsd_facts = {} + platform_release = platform.release() + netbsd_facts['distribution_release'] = platform_release + rc, out, dummy = self.module.run_command("/sbin/sysctl -n kern.version") + match = re.match(r'NetBSD\s(\d+)\.(\d+)\s\((GENERIC)\).*', out) + if match: + netbsd_facts['distribution_major_version'] = match.group(1) + netbsd_facts['distribution_version'] = '%s.%s' % match.groups()[:2] + else: + netbsd_facts['distribution_major_version'] = platform_release.split('.')[0] + netbsd_facts['distribution_version'] = platform_release + return netbsd_facts + + def get_distribution_SMGL(self): + smgl_facts = {} + smgl_facts['distribution'] = 'Source Mage GNU/Linux' + return smgl_facts + + def get_distribution_SunOS(self): + sunos_facts = {} + + data = get_file_content('/etc/release').splitlines()[0] + + if 'Solaris' in data: + # for solaris 10 uname_r will contain 5.10, for solaris 11 it will have 5.11 + uname_r = get_uname(self.module, flags=['-r']) + ora_prefix = '' + if 'Oracle Solaris' in data: + data = data.replace('Oracle ', '') + ora_prefix = 'Oracle ' + sunos_facts['distribution'] = data.split()[0] + sunos_facts['distribution_version'] = data.split()[1] + sunos_facts['distribution_release'] = ora_prefix + data + sunos_facts['distribution_major_version'] = uname_r.split('.')[1].rstrip() + return sunos_facts + + uname_v = get_uname(self.module, flags=['-v']) + distribution_version = None + + if 'SmartOS' in data: + sunos_facts['distribution'] = 'SmartOS' + if _file_exists('/etc/product'): + product_data = dict([l.split(': ', 1) for l in get_file_content('/etc/product').splitlines() if ': ' in l]) + if 'Image' in product_data: + distribution_version = product_data.get('Image').split()[-1] + elif 'OpenIndiana' in data: + sunos_facts['distribution'] = 'OpenIndiana' + elif 'OmniOS' in data: + sunos_facts['distribution'] = 'OmniOS' + distribution_version = data.split()[-1] + elif uname_v is not None and 'NexentaOS_' in uname_v: + sunos_facts['distribution'] = 'Nexenta' + distribution_version = data.split()[-1].lstrip('v') + + if sunos_facts.get('distribution', '') in ('SmartOS', 'OpenIndiana', 'OmniOS', 'Nexenta'): + sunos_facts['distribution_release'] = data.strip() + if distribution_version is not None: + sunos_facts['distribution_version'] = distribution_version + elif uname_v is not None: + sunos_facts['distribution_version'] = uname_v.splitlines()[0].strip() + return sunos_facts + + return sunos_facts + + +class DistributionFactCollector(BaseFactCollector): + name = 'distribution' + _fact_ids = set(['distribution_version', + 'distribution_release', + 'distribution_major_version', + 'os_family']) # type: t.Set[str] + + def collect(self, module=None, collected_facts=None): + collected_facts = collected_facts or {} + facts_dict = {} + if not module: + return facts_dict + + distribution = Distribution(module=module) + distro_facts = distribution.get_distribution_facts() + + return distro_facts + From 902715d9bca4f8639c5748f76bc0a69d31edfa0f Mon Sep 17 00:00:00 2001 From: TheWitness Date: Wed, 22 Apr 2026 13:48:52 -0400 Subject: [PATCH 11/16] fix: Remove php semicolons --- files/distribution.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/files/distribution.py b/files/distribution.py index 9b4a9154..b20cca16 100644 --- a/files/distribution.py +++ b/files/distribution.py @@ -53,9 +53,9 @@ def _get_os_release_data(allow_empty=False): data = get_file_content(path) - os_name = ''; - os_variant = ''; - os_cpe_name = '' + os_name = "" + os_variant = "" + os_cpe_name = "" for line in data.splitlines(): if re.search('^NAME=', line): From f8acfde3f1d919a67cd9e0651eab8dce0dac1561 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Wed, 22 Apr 2026 16:26:02 -0400 Subject: [PATCH 12/16] fix: Copilot recommendation on grammar --- files/distribution.py.diff | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/files/distribution.py.diff b/files/distribution.py.diff index f58fdbc3..6de89bbe 100644 --- a/files/distribution.py.diff +++ b/files/distribution.py.diff @@ -64,7 +64,7 @@ dist_file_facts['distribution_file_variety'] = name dist_file_facts['distribution_file_parsed'] = parsed_dist_file + -+ # Always look for into standard /etc/os-release file if it exists ++ # Always look into the standard /etc/os-release file if it exists + os_release_exists, os_release_data = _get_os_release_data() + + if os_release_exists != False: @@ -82,7 +82,7 @@ freebsd_facts['distribution_major_version'] = data.group(1) freebsd_facts['distribution_version'] = '%s.%s' % (data.group(1), data.group(2)) + -+ # Always look for into standard /etc/os-release file if it exists ++ # Always look into the standard /etc/os-release file if it exists + os_release_exists, os_release_data = _get_os_release_data() + + if os_release_exists != False: From 46de306dd0f76bf0f7fbfdee179ee196b0b75c0d Mon Sep 17 00:00:00 2001 From: TheWitness Date: Wed, 22 Apr 2026 16:43:11 -0400 Subject: [PATCH 13/16] fix: Recommendation from Copilot --- files/distribution.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/files/distribution.py b/files/distribution.py index b20cca16..466630ce 100644 --- a/files/distribution.py +++ b/files/distribution.py @@ -556,7 +556,7 @@ class Distribution(object): 'Ascendos', 'CloudLinux', 'PSBM', 'OracleLinux', 'OVS', 'OEL', 'Amazon', 'Virtuozzo', 'XenServer', 'Alibaba', 'EulerOS', 'openEuler', 'AlmaLinux', 'Rocky', 'TencentOS', - 'EuroLinux', 'Kylin Linux Advanced Server'], + 'EuroLinux', 'Kylin Linux Advanced Server', 'RLC'], 'Debian': ['Debian', 'Ubuntu', 'Raspbian', 'Neon', 'KDE neon', 'Linux Mint', 'SteamOS', 'Devuan', 'Kali', 'Cumulus Linux', 'Pop!_OS', 'Parrot', 'Pardus GNU/Linux', 'Uos', 'Deepin', 'OSMC'], From 13737dc2a57652c313ba931870446d933a1ddc92 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Wed, 22 Apr 2026 17:03:42 -0400 Subject: [PATCH 14/16] fix:Catch up with distribution.py --- files/distribution.py.diff | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/files/distribution.py.diff b/files/distribution.py.diff index 6de89bbe..38c4bac5 100644 --- a/files/distribution.py.diff +++ b/files/distribution.py.diff @@ -1,9 +1,9 @@ ---- distribution.py.backup 2026-04-21 15:39:58.496829194 -0400 -+++ distribution.new.py 2026-04-21 15:44:30.893425940 -0400 +--- distribution.awx.py.orig 2026-04-22 11:01:29.872681677 -0400 ++++ distribution.py 2026-04-22 16:42:21.587553907 -0400 @@ -45,6 +45,36 @@ # file exists with some content return True - + +def _get_os_release_data(allow_empty=False): + path = '/etc/os-release' + @@ -12,8 +12,8 @@ + + data = get_file_content(path) + -+ os_name = "" -+ os_variant = "" ++ os_name = "" ++ os_variant = "" + os_cpe_name = "" + + for line in data.splitlines(): @@ -34,7 +34,7 @@ + + return True, { 'os_name': os_name, 'os_variant': os_variant, 'os_cpe_name': os_cpe_name } + - + class DistributionFiles: '''has-a various distro file parsers (os-release, etc) and logic for finding the right one.''' @@ -54,6 +84,7 @@ @@ -64,7 +64,7 @@ dist_file_facts['distribution_file_variety'] = name dist_file_facts['distribution_file_parsed'] = parsed_dist_file + -+ # Always look into the standard /etc/os-release file if it exists ++ # Always look for into standard /etc/os-release file if it exists + os_release_exists, os_release_data = _get_os_release_data() + + if os_release_exists != False: @@ -75,14 +75,23 @@ dist_file_facts.update(parsed_dist_file_facts) + break - + return dist_file_facts +@@ -513,7 +556,7 @@ + 'Ascendos', 'CloudLinux', 'PSBM', 'OracleLinux', 'OVS', + 'OEL', 'Amazon', 'Virtuozzo', 'XenServer', 'Alibaba', + 'EulerOS', 'openEuler', 'AlmaLinux', 'Rocky', 'TencentOS', +- 'EuroLinux', 'Kylin Linux Advanced Server'], ++ 'EuroLinux', 'Kylin Linux Advanced Server', 'RLC'], + 'Debian': ['Debian', 'Ubuntu', 'Raspbian', 'Neon', 'KDE neon', + 'Linux Mint', 'SteamOS', 'Devuan', 'Kali', 'Cumulus Linux', + 'Pop!_OS', 'Parrot', 'Pardus GNU/Linux', 'Uos', 'Deepin', 'OSMC'], @@ -617,6 +660,15 @@ if data: freebsd_facts['distribution_major_version'] = data.group(1) freebsd_facts['distribution_version'] = '%s.%s' % (data.group(1), data.group(2)) + -+ # Always look into the standard /etc/os-release file if it exists ++ # Always look for into standard /etc/os-release file if it exists + os_release_exists, os_release_data = _get_os_release_data() + + if os_release_exists != False: @@ -91,5 +100,5 @@ + freebsd_facts['distribution_os_cpe_name'] = os_release_data['os_cpe_name'] + return freebsd_facts - + def get_distribution_OpenBSD(self): From b5ca0f859f9ec422a7576fcdd34ccf4a6defabbe Mon Sep 17 00:00:00 2001 From: TheWitness Date: Wed, 22 Apr 2026 17:06:24 -0400 Subject: [PATCH 15/16] fix: Some more grammar issues --- files/distribution.py | 4 ++-- files/distribution.py.diff | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/files/distribution.py b/files/distribution.py index 466630ce..bac3f7bd 100644 --- a/files/distribution.py +++ b/files/distribution.py @@ -238,7 +238,7 @@ def process_dist_files(self): dist_file_facts['distribution_file_variety'] = name dist_file_facts['distribution_file_parsed'] = parsed_dist_file - # Always look for into standard /etc/os-release file if it exists + # Always look into the standard /etc/os-release file if it exists os_release_exists, os_release_data = _get_os_release_data() if os_release_exists != False: @@ -661,7 +661,7 @@ def get_distribution_FreeBSD(self): freebsd_facts['distribution_major_version'] = data.group(1) freebsd_facts['distribution_version'] = '%s.%s' % (data.group(1), data.group(2)) - # Always look for into standard /etc/os-release file if it exists + # Always look into the standard /etc/os-release file if it exists os_release_exists, os_release_data = _get_os_release_data() if os_release_exists != False: diff --git a/files/distribution.py.diff b/files/distribution.py.diff index 38c4bac5..5efb7574 100644 --- a/files/distribution.py.diff +++ b/files/distribution.py.diff @@ -1,5 +1,5 @@ --- distribution.awx.py.orig 2026-04-22 11:01:29.872681677 -0400 -+++ distribution.py 2026-04-22 16:42:21.587553907 -0400 ++++ distribution.py 2026-04-22 17:05:41.608608116 -0400 @@ -45,6 +45,36 @@ # file exists with some content return True @@ -64,7 +64,7 @@ dist_file_facts['distribution_file_variety'] = name dist_file_facts['distribution_file_parsed'] = parsed_dist_file + -+ # Always look for into standard /etc/os-release file if it exists ++ # Always look into the standard /etc/os-release file if it exists + os_release_exists, os_release_data = _get_os_release_data() + + if os_release_exists != False: @@ -91,7 +91,7 @@ freebsd_facts['distribution_major_version'] = data.group(1) freebsd_facts['distribution_version'] = '%s.%s' % (data.group(1), data.group(2)) + -+ # Always look for into standard /etc/os-release file if it exists ++ # Always look into the standard /etc/os-release file if it exists + os_release_exists, os_release_data = _get_os_release_data() + + if os_release_exists != False: From 6c48e1773c7315ec18a30103cc4b8fd3fa99e001 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Wed, 22 Apr 2026 17:17:08 -0400 Subject: [PATCH 16/16] fix: Recommendation from Copilot. Confirmed upstream --- files/distribution.py | 2 +- files/distribution.py.diff | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/files/distribution.py b/files/distribution.py index bac3f7bd..ce106e3d 100644 --- a/files/distribution.py +++ b/files/distribution.py @@ -567,7 +567,7 @@ class Distribution(object): 'Solaris': ['Solaris', 'Nexenta', 'OmniOS', 'OpenIndiana', 'SmartOS'], 'Slackware': ['Slackware'], 'Altlinux': ['Altlinux'], - 'SGML': ['SGML'], + 'SMGL': ['SMGL'], 'Gentoo': ['Gentoo', 'Funtoo'], 'Alpine': ['Alpine'], 'AIX': ['AIX'], diff --git a/files/distribution.py.diff b/files/distribution.py.diff index 5efb7574..db1f2d12 100644 --- a/files/distribution.py.diff +++ b/files/distribution.py.diff @@ -1,5 +1,5 @@ --- distribution.awx.py.orig 2026-04-22 11:01:29.872681677 -0400 -+++ distribution.py 2026-04-22 17:05:41.608608116 -0400 ++++ distribution.py 2026-04-22 17:16:22.810123443 -0400 @@ -45,6 +45,36 @@ # file exists with some content return True @@ -86,6 +86,15 @@ 'Debian': ['Debian', 'Ubuntu', 'Raspbian', 'Neon', 'KDE neon', 'Linux Mint', 'SteamOS', 'Devuan', 'Kali', 'Cumulus Linux', 'Pop!_OS', 'Parrot', 'Pardus GNU/Linux', 'Uos', 'Deepin', 'OSMC'], +@@ -524,7 +567,7 @@ + 'Solaris': ['Solaris', 'Nexenta', 'OmniOS', 'OpenIndiana', 'SmartOS'], + 'Slackware': ['Slackware'], + 'Altlinux': ['Altlinux'], +- 'SGML': ['SGML'], ++ 'SMGL': ['SMGL'], + 'Gentoo': ['Gentoo', 'Funtoo'], + 'Alpine': ['Alpine'], + 'AIX': ['AIX'], @@ -617,6 +660,15 @@ if data: freebsd_facts['distribution_major_version'] = data.group(1)