diff --git a/cloudinit/config/cc_ntp.py b/cloudinit/config/cc_ntp.py index 1f636d09a8e..40e32338613 100644 --- a/cloudinit/config/cc_ntp.py +++ b/cloudinit/config/cc_ntp.py @@ -25,6 +25,7 @@ distros = [ "almalinux", "alpine", + "azurelinux", "centos", "cloudlinux", "cos", @@ -109,6 +110,15 @@ "service_name": "ntpd", }, }, + "azurelinux": { + "chrony": { + "service_name": "chronyd", + }, + "systemd-timesyncd": { + "check_exe": "/usr/lib/systemd/systemd-timesyncd", + "confpath": "/etc/systemd/timesyncd.conf", + }, + }, "centos": { "ntp": { "service_name": "ntpd", diff --git a/cloudinit/config/cc_resolv_conf.py b/cloudinit/config/cc_resolv_conf.py index aa88919cc33..c32e8a4e5cd 100644 --- a/cloudinit/config/cc_resolv_conf.py +++ b/cloudinit/config/cc_resolv_conf.py @@ -58,6 +58,7 @@ "description": MODULE_DESCRIPTION, "distros": [ "alpine", + "azurelinux", "fedora", "mariner", "opensuse", diff --git a/cloudinit/config/cc_yum_add_repo.py b/cloudinit/config/cc_yum_add_repo.py index 1ab5008f037..4df2234a479 100644 --- a/cloudinit/config/cc_yum_add_repo.py +++ b/cloudinit/config/cc_yum_add_repo.py @@ -27,6 +27,7 @@ distros = [ "almalinux", + "azurelinux", "centos", "cloudlinux", "eurolinux", diff --git a/cloudinit/distros/__init__.py b/cloudinit/distros/__init__.py index 8b4c7e9345c..4b51c0d8b85 100644 --- a/cloudinit/distros/__init__.py +++ b/cloudinit/distros/__init__.py @@ -68,6 +68,7 @@ "redhat": [ "almalinux", "amazon", + "azurelinux", "centos", "cloudlinux", "eurolinux", diff --git a/cloudinit/distros/azurelinux.py b/cloudinit/distros/azurelinux.py new file mode 100644 index 00000000000..5098a45942d --- /dev/null +++ b/cloudinit/distros/azurelinux.py @@ -0,0 +1,72 @@ +# Copyright (C) 2024 Microsoft Corporation +# +# Author: Dan Streetman +# +# This file is part of cloud-init. See LICENSE file for license information. + +import logging + +from cloudinit import subp, util +from cloudinit.distros import rhel +from cloudinit.net.netplan import CLOUDINIT_NETPLAN_FILE + +LOG = logging.getLogger(__name__) + +NETWORK_FILE_HEADER = """\ +# This file is generated from information provided by the datasource. Changes +# to it will not persist across an instance reboot. To disable cloud-init's +# network configuration capabilities, write a file +# /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg with the following: +# network: {config: disabled} +""" + + +class Distro(rhel.Distro): + def __init__(self, name, cfg, paths): + super().__init__(name, cfg, paths) + self.osfamily = "azurelinux" + + self.network_conf_dir = "/etc/systemd/network/" + self.systemd_locale_conf_fn = "/etc/locale.conf" + self.resolve_conf_fn = "/etc/systemd/resolved.conf" + self.init_cmd = ["systemctl"] + + self.network_conf_fn = {"netplan": CLOUDINIT_NETPLAN_FILE} + self.renderer_configs = { + "networkd": { + "resolv_conf_fn": self.resolve_conf_fn, + "network_conf_dir": self.network_conf_dir, + }, + "netplan": { + "netplan_path": self.network_conf_fn["netplan"], + "netplan_header": NETWORK_FILE_HEADER, + "postcmds": "True", + }, + } + + def package_command(self, command, args=None, pkgs=None): + if pkgs is None: + pkgs = [] + + if subp.which("dnf"): + LOG.debug("Using DNF for package management") + cmd = ["dnf"] + else: + LOG.debug("Using TDNF for package management") + cmd = ["tdnf"] + # Determines whether or not dnf/tdnf prompts for confirmation + # of critical actions. We don't want to prompt... + cmd.append("-y") + + if args and isinstance(args, str): + cmd.append(args) + elif args and isinstance(args, list): + cmd.extend(args) + + cmd.append(command) + + pkglist = util.expand_package_list("%s-%s", pkgs) + cmd.extend(pkglist) + + # Allow the output of this to flow outwards (ie not be captured) + subp.subp(cmd, capture=False) diff --git a/cloudinit/util.py b/cloudinit/util.py index eeed511d8ba..12c49b78514 100644 --- a/cloudinit/util.py +++ b/cloudinit/util.py @@ -649,6 +649,7 @@ def _get_variant(info): "almalinux", "alpine", "arch", + "azurelinux", "centos", "cloudlinux", "debian", diff --git a/config/cloud.cfg.tmpl b/config/cloud.cfg.tmpl index e21770326d0..5219b946205 100644 --- a/config/cloud.cfg.tmpl +++ b/config/cloud.cfg.tmpl @@ -4,14 +4,15 @@ {% set is_bsd = variant in ["dragonfly", "freebsd", "netbsd", "openbsd"] %} {% set is_rhel = variant in ["almalinux", "centos", "cloudlinux", "eurolinux", "miraclelinux", "rhel", "rocky", "virtuozzo"] %} -{% set gecos = ({"amazon": "EC2 Default User", "centos": "Cloud User", - "debian": "Debian", "dragonfly": "DragonFly", - "freebsd": "FreeBSD", "mariner": "MarinerOS", - "rhel": "Cloud User", "netbsd": "NetBSD", - "openbsd": "openBSD", "openmandriva": "OpenMandriva admin", - "photon": "PhotonOS", "ubuntu": "Ubuntu", - "unknown": "Ubuntu"}) %} +{% set gecos = ({"amazon": "EC2 Default User", "azurelinux": "Azure Linux", + "centos": "Cloud User", "debian": "Debian", + "dragonfly": "DragonFly", "freebsd": "FreeBSD", + "mariner": "MarinerOS", "rhel": "Cloud User", + "netbsd": "NetBSD", "openbsd": "openBSD", + "openmandriva": "OpenMandriva admin", "photon": "PhotonOS", + "ubuntu": "Ubuntu", "unknown": "Ubuntu"}) %} {% set groups = ({"alpine": "adm, wheel", "arch": "wheel, users", + "azurelinux": "wheel", "debian": "adm, audio, cdrom, dialout, dip, floppy, netdev, plugdev, sudo, video", "gentoo": "users, wheel", "mariner": "wheel", "photon": "wheel", @@ -165,8 +166,8 @@ cloud_config_modules: {% if variant == "ubuntu" %} - ubuntu_pro {% endif %} -{% elif variant in ["fedora", "mariner", "openeuler", "openmandriva", - "photon"] or is_rhel %} +{% elif variant in ["azurelinux", "fedora", "mariner", "openeuler", + "openmandriva", "photon"] or is_rhel %} {% if is_rhel %} - rh_subscription {% endif %} @@ -219,10 +220,10 @@ cloud_final_modules: # (not accessible to handlers/transforms) system_info: # This will affect which distro class gets used -{% if variant in ["alpine", "amazon", "arch", "debian", "fedora", "freebsd", - "gentoo", "mariner", "netbsd", "openbsd", "OpenCloudOS", - "openeuler", "openmandriva", "photon", "suse", "TencentOS", - "ubuntu"] or is_rhel %} +{% if variant in ["alpine", "amazon", "arch", "azurelinux", "debian", "fedora", + "freebsd", "gentoo", "mariner", "netbsd", "openbsd", + "OpenCloudOS", "openeuler", "openmandriva", "photon", "suse", + "TencentOS", "ubuntu"] or is_rhel %} distro: {{ variant }} {% elif variant == "dragonfly" %} distro: dragonflybsd @@ -237,9 +238,10 @@ system_info: {% else %} name: {{ variant }} {% endif %} -{% if variant in ["alpine", "amazon", "arch", "debian", "fedora", "gentoo", - "mariner", "OpenCloudOS", "openeuler", "openmandriva", - "photon", "suse", "TencentOS", "ubuntu", "unknown"] +{% if variant in ["alpine", "amazon", "arch", "azurelinux", "debian", "fedora", + "gentoo", "mariner", "OpenCloudOS", "openeuler", + "openmandriva", "photon", "suse", "TencentOS", "ubuntu", + "unknown"] or is_bsd or is_rhel %} lock_passwd: True {% endif %} @@ -292,7 +294,7 @@ system_info: {% elif variant in ["freebsd", "netbsd", "openbsd"] %} network: renderers: ['{{ variant }}'] -{% elif variant in ["mariner", "photon"] %} +{% elif variant in ["azurelinux", "mariner", "photon"] %} network: renderers: ['networkd'] {% elif variant == "openmandriva" %} @@ -318,9 +320,10 @@ system_info: # Automatically discover the best ntp_client ntp_client: auto {% endif %} -{% if variant in ["alpine", "amazon", "arch", "debian", "fedora", "gentoo", - "mariner", "OpenCloudOS", "openeuler", "openmandriva", - "photon", "suse", "TencentOS", "ubuntu", "unknown"] +{% if variant in ["alpine", "amazon", "arch", "azurelinux", "debian", "fedora", + "gentoo", "mariner", "OpenCloudOS", "openeuler", + "openmandriva", "photon", "suse", "TencentOS", "ubuntu", + "unknown"] or is_rhel %} # Other config here will be given to the distro class and/or path classes paths: @@ -365,8 +368,9 @@ system_info: {% endif %} {% if variant in ["debian", "ubuntu", "unknown"] %} ssh_svcname: ssh -{% elif variant in ["alpine", "amazon", "arch", "fedora", "gentoo", - "mariner", "OpenCloudOS", "openeuler", "openmandriva", - "photon", "suse", "TencentOS"] or is_rhel %} +{% elif variant in ["alpine", "amazon", "arch", "azurelinux", "fedora", + "gentoo", "mariner", "OpenCloudOS", "openeuler", + "openmandriva", "photon", "suse", "TencentOS"] + or is_rhel %} ssh_svcname: sshd {% endif %} diff --git a/doc/rtd/reference/network-config.rst b/doc/rtd/reference/network-config.rst index d9e67cf7ea5..028f306f806 100644 --- a/doc/rtd/reference/network-config.rst +++ b/doc/rtd/reference/network-config.rst @@ -273,7 +273,7 @@ Example output: .. code-block:: usage: /usr/bin/cloud-init devel net-convert [-h] -p PATH -k {eni,network_data.json,yaml,azure-imds,vmware-imc} -d PATH -D - {alpine,arch,debian,ubuntu,freebsd,dragonfly,gentoo,cos,netbsd,openbsd,almalinux,amazon,centos,cloudlinux,eurolinux,fedora,mariner,miraclelinux,openmandriva,photon,rhel,rocky,virtuozzo,opensuse,sles,openEuler} + {alpine,arch,azurelinux,debian,ubuntu,freebsd,dragonfly,gentoo,cos,netbsd,openbsd,almalinux,amazon,centos,cloudlinux,eurolinux,fedora,mariner,miraclelinux,openmandriva,photon,rhel,rocky,virtuozzo,opensuse,sles,openEuler} [-m name,mac] [--debug] -O {eni,netplan,networkd,sysconfig,network-manager} options: @@ -284,7 +284,7 @@ Example output: The format of the given network config -d PATH, --directory PATH directory to place output in - -D {alpine,arch,debian,ubuntu,freebsd,dragonfly,gentoo,cos,netbsd,openbsd,almalinux,amazon,centos,cloudlinux,eurolinux,fedora,mariner,miraclelinux,openmandriva,photon,rhel,rocky,virtuozzo,opensuse,sles,openeuler}, --distro {alpine,arch,debian,ubuntu,freebsd,dragonfly,gentoo,cos,netbsd,openbsd,almalinux,amazon,centos,cloudlinux,eurolinux,fedora,mariner,miraclelinux,openmandriva,photon,rhel,rocky,virtuozzo,opensuse,sles,openEuler} + -D {alpine,arch,azurelinux,debian,ubuntu,freebsd,dragonfly,gentoo,cos,netbsd,openbsd,almalinux,amazon,centos,cloudlinux,eurolinux,fedora,mariner,miraclelinux,openmandriva,photon,rhel,rocky,virtuozzo,opensuse,sles,openeuler}, --distro {alpine,arch,azurelinux,debian,ubuntu,freebsd,dragonfly,gentoo,cos,netbsd,openbsd,almalinux,amazon,centos,cloudlinux,eurolinux,fedora,mariner,miraclelinux,openmandriva,photon,rhel,rocky,virtuozzo,opensuse,sles,openEuler} -m name,mac, --mac name,mac interface name to mac mapping --debug enable debug logging to stderr. diff --git a/templates/hosts.azurelinux.tmpl b/templates/hosts.azurelinux.tmpl new file mode 100644 index 00000000000..8e3c23f6f12 --- /dev/null +++ b/templates/hosts.azurelinux.tmpl @@ -0,0 +1,22 @@ +## template:jinja +{# +This file /etc/cloud/templates/hosts.azurelinux.tmpl is only utilized +if enabled in cloud-config. Specifically, in order to enable it +you need to add the following to config: + manage_etc_hosts: True +-#} +# Your system has configured 'manage_etc_hosts' as True. +# As a result, if you wish for changes to this file to persist +# then you will need to either +# a.) make changes to the master file in /etc/cloud/templates/hosts.azurelinux.tmpl +# b.) change or remove the value of 'manage_etc_hosts' in +# /etc/cloud/cloud.cfg or cloud-config from user-data +# +# The following lines are desirable for IPv4 capable hosts +127.0.0.1 {{fqdn}} {{hostname}} +127.0.0.1 localhost.localdomain localhost +127.0.0.1 localhost4.localdomain4 localhost4 + +# The following lines are desirable for IPv6 capable hosts +::1 {{fqdn}} {{hostname}} +::1 localhost6.localdomain6 localhost6 diff --git a/tests/unittests/distros/test_azurelinux.py b/tests/unittests/distros/test_azurelinux.py new file mode 100644 index 00000000000..03c895bc2a2 --- /dev/null +++ b/tests/unittests/distros/test_azurelinux.py @@ -0,0 +1,25 @@ +# This file is part of cloud-init. See LICENSE file for license information. + +from tests.unittests.helpers import CiTestCase + +from . import _get_distro + +SYSTEM_INFO = { + "paths": { + "cloud_dir": "/var/lib/cloud/", + "templates_dir": "/etc/cloud/templates/", + }, + "network": {"renderers": "networkd"}, +} + + +class TestAzurelinux(CiTestCase): + with_logs = True + distro = _get_distro("azurelinux", SYSTEM_INFO) + expected_log_line = "Rely on Azure Linux default network config" + + def test_network_renderer(self): + self.assertEqual(self.distro._cfg["network"]["renderers"], "networkd") + + def test_get_distro(self): + self.assertEqual(self.distro.osfamily, "azurelinux") diff --git a/tests/unittests/distros/test_netconfig.py b/tests/unittests/distros/test_netconfig.py index 48690d7128d..e3e2c8fe159 100644 --- a/tests/unittests/distros/test_netconfig.py +++ b/tests/unittests/distros/test_netconfig.py @@ -1293,6 +1293,131 @@ def test_mariner_network_config_v1_with_duplicates(self): ) +class TestNetCfgDistroAzureLinux(TestNetCfgDistroBase): + def setUp(self): + super().setUp() + self.distro = self._get_distro("azurelinux", renderers=["networkd"]) + + def create_conf_dict(self, contents): + content_dict = {} + for line in contents: + if line: + line = line.strip() + if line and re.search(r"^\[(.+)\]$", line): + content_dict[line] = [] + key = line + elif line: + assert key + content_dict[key].append(line) + + return content_dict + + def compare_dicts(self, actual, expected): + for k, v in actual.items(): + self.assertEqual(sorted(expected[k]), sorted(v)) + + def _apply_and_verify( + self, apply_fn, config, expected_cfgs=None, bringup=False + ): + if not expected_cfgs: + raise ValueError("expected_cfg must not be None") + + tmpd = None + with mock.patch("cloudinit.net.networkd.available") as m_avail: + m_avail.return_value = True + with self.reRooted(tmpd) as tmpd: + apply_fn(config, bringup) + + results = dir2dict(tmpd) + for cfgpath, expected in expected_cfgs.items(): + actual = self.create_conf_dict(results[cfgpath].splitlines()) + self.compare_dicts(actual, expected) + self.assertEqual(0o644, get_mode(cfgpath, tmpd)) + + def nwk_file_path(self, ifname): + return "/etc/systemd/network/10-cloud-init-%s.network" % ifname + + def net_cfg_1(self, ifname): + ret = ( + """\ + [Match] + Name=%s + [Network] + DHCP=no + [Address] + Address=192.168.1.5/24 + [Route] + Gateway=192.168.1.254""" + % ifname + ) + return ret + + def net_cfg_2(self, ifname): + ret = ( + """\ + [Match] + Name=%s + [Network] + DHCP=ipv4""" + % ifname + ) + return ret + + def test_azurelinux_network_config_v1(self): + tmp = self.net_cfg_1("eth0").splitlines() + expected_eth0 = self.create_conf_dict(tmp) + + tmp = self.net_cfg_2("eth1").splitlines() + expected_eth1 = self.create_conf_dict(tmp) + + expected_cfgs = { + self.nwk_file_path("eth0"): expected_eth0, + self.nwk_file_path("eth1"): expected_eth1, + } + + self._apply_and_verify( + self.distro.apply_network_config, V1_NET_CFG, expected_cfgs.copy() + ) + + def test_azurelinux_network_config_v2(self): + tmp = self.net_cfg_1("eth7").splitlines() + expected_eth7 = self.create_conf_dict(tmp) + + tmp = self.net_cfg_2("eth9").splitlines() + expected_eth9 = self.create_conf_dict(tmp) + + expected_cfgs = { + self.nwk_file_path("eth7"): expected_eth7, + self.nwk_file_path("eth9"): expected_eth9, + } + + self._apply_and_verify( + self.distro.apply_network_config, V2_NET_CFG, expected_cfgs.copy() + ) + + def test_azurelinux_network_config_v1_with_duplicates(self): + expected = """\ + [Match] + Name=eth0 + [Network] + DHCP=no + DNS=1.2.3.4 + Domains=test.com + [Address] + Address=192.168.0.102/24""" + + net_cfg = safeyaml.load(V1_NET_CFG_WITH_DUPS) + + expected = self.create_conf_dict(expected.splitlines()) + expected_cfgs = { + self.nwk_file_path("eth0"): expected, + } + + self._apply_and_verify( + self.distro.apply_network_config, net_cfg, expected_cfgs.copy() + ) + + def get_mode(path, target=None): # Mask upper st_mode bits like S_IFREG bit preserve sticky and isuid/osgid return os.stat(subp.target_path(target, path)).st_mode & 0o777 diff --git a/tests/unittests/test_cli.py b/tests/unittests/test_cli.py index 24762450295..575b107c758 100644 --- a/tests/unittests/test_cli.py +++ b/tests/unittests/test_cli.py @@ -300,13 +300,12 @@ def test_wb_schema_subcommand_parser(self, m_read_cfg, capsys): ["all"], [ "**Supported distros:** all", - "**Supported distros:** almalinux, alpine, centos, " - "cloudlinux, cos, debian, eurolinux, fedora, freebsd, " - "mariner, miraclelinux, " - "openbsd, openeuler, OpenCloudOS, openmandriva, " - "opensuse, opensuse-microos, opensuse-tumbleweed, " - "opensuse-leap, photon, rhel, rocky, sle_hpc, " - "sle-micro, sles, TencentOS, ubuntu, virtuozzo", + "**Supported distros:** almalinux, alpine, azurelinux, " + "centos, cloudlinux, cos, debian, eurolinux, fedora, " + "freebsd, mariner, miraclelinux, openbsd, openeuler, " + "OpenCloudOS, openmandriva, opensuse, opensuse-microos, " + "opensuse-tumbleweed, opensuse-leap, photon, rhel, rocky, " + "sle_hpc, sle-micro, sles, TencentOS, ubuntu, virtuozzo", " **resize_rootfs:** ", "(``true``/``false``/``noblock``)", "runcmd:\n - [ ls, -l, / ]\n", diff --git a/tests/unittests/test_render_template.py b/tests/unittests/test_render_template.py index 150e61b1d8d..0ed9464821d 100644 --- a/tests/unittests/test_render_template.py +++ b/tests/unittests/test_render_template.py @@ -11,6 +11,7 @@ DISTRO_VARIANTS = [ "amazon", "arch", + "azurelinux", "centos", "debian", "eurolinux", diff --git a/tests/unittests/test_util.py b/tests/unittests/test_util.py index 70edb40bbf6..2c7490870b9 100644 --- a/tests/unittests/test_util.py +++ b/tests/unittests/test_util.py @@ -400,6 +400,20 @@ """ ) +OS_RELEASE_AZURELINUX = dedent( + """\ + NAME="Microsoft Azure Linux" + VERSION="3.0.20240206" + ID=azurelinux + VERSION_ID="3.0" + PRETTY_NAME="Microsoft Azure Linux 3.0" + ANSI_COLOR="1;34" + HOME_URL="https://aka.ms/azurelinux" + BUG_REPORT_URL="https://aka.ms/azurelinux" + SUPPORT_URL="https://aka.ms/azurelinux" +""" +) + @pytest.mark.usefixtures("fake_filesystem") class TestUtil: @@ -1249,6 +1263,14 @@ def test_get_linux_mariner_os_release(self, m_os_release, m_path_exists): dist = util.get_linux_distro() self.assertEqual(("mariner", "2.0", ""), dist) + @mock.patch("cloudinit.util.load_text_file") + def test_get_linux_azurelinux_os_release(self, m_os_release, m_path_exists): + """Verify we get the correct name and machine arch on Azure Linux""" + m_os_release.return_value = OS_RELEASE_AZURELINUX + m_path_exists.side_effect = TestGetLinuxDistro.os_release_exists + dist = util.get_linux_distro() + self.assertEqual(("azurelinux", "3.0", ""), dist) + @mock.patch(M_PATH + "load_text_file") def test_get_linux_openmandriva(self, m_os_release, m_path_exists): """Verify we get the correct name and machine arch on OpenMandriva""" @@ -1310,6 +1332,7 @@ class TestGetVariant: ({"system": "Linux", "dist": ("almalinux",)}, "almalinux"), ({"system": "linux", "dist": ("alpine",)}, "alpine"), ({"system": "linux", "dist": ("arch",)}, "arch"), + ({"system": "linux", "dist": ("azurelinux",)}, "azurelinux"), ({"system": "linux", "dist": ("centos",)}, "centos"), ({"system": "linux", "dist": ("cloudlinux",)}, "cloudlinux"), ({"system": "linux", "dist": ("debian",)}, "debian"), diff --git a/tools/render-template b/tools/render-template index 5ef5a374bc8..c3af642a08f 100755 --- a/tools/render-template +++ b/tools/render-template @@ -15,6 +15,7 @@ def main(): "alpine", "amazon", "arch", + "azurelinux", "benchmark", "centos", "cloudlinux",