diff --git a/cloudinit/distros/debian.py b/cloudinit/distros/debian.py index cef6e1fb8c0..1936c34dccd 100644 --- a/cloudinit/distros/debian.py +++ b/cloudinit/distros/debian.py @@ -109,7 +109,12 @@ def apply_locale(self, locale, out_fn=None, keyname="LANG"): need_conf = not conf_fn_exists or need_regen or sys_locale_unset if need_regen: - regenerate_locale(locale, out_fn, keyname=keyname) + regenerate_locale( + locale, + out_fn, + keyname=keyname, + install_function=self.install_packages, + ) else: LOG.debug( "System has '%s=%s' requested '%s', skipping regeneration.", @@ -119,7 +124,12 @@ def apply_locale(self, locale, out_fn=None, keyname="LANG"): ) if need_conf: - update_locale_conf(locale, out_fn, keyname=keyname) + update_locale_conf( + locale, + out_fn, + keyname=keyname, + install_function=self.install_packages, + ) # once we've updated the system config, invalidate cache self.system_locale = None @@ -267,11 +277,15 @@ def read_system_locale(sys_path=LOCALE_CONF_FN, keyname="LANG"): return sys_val -def update_locale_conf(locale, sys_path, keyname="LANG"): +def update_locale_conf( + locale, sys_path, keyname="LANG", install_function=None +): """Update system locale config""" LOG.debug( "Updating %s with locale setting %s=%s", sys_path, keyname, locale ) + if not subp.which("update-locale"): + install_function(["locales"]) subp.subp( [ "update-locale", @@ -282,7 +296,7 @@ def update_locale_conf(locale, sys_path, keyname="LANG"): ) -def regenerate_locale(locale, sys_path, keyname="LANG"): +def regenerate_locale(locale, sys_path, keyname="LANG", install_function=None): """ Run locale-gen for the provided locale and set the default system variable `keyname` appropriately in the provided `sys_path`. @@ -298,5 +312,7 @@ def regenerate_locale(locale, sys_path, keyname="LANG"): return # finally, trigger regeneration + if not subp.which("locale-gen"): + install_function(["locales"]) LOG.debug("Generating locales for %s", locale) subp.subp(["locale-gen", locale], capture=False) diff --git a/tests/integration_tests/modules/test_combined.py b/tests/integration_tests/modules/test_combined.py index 46f6d5b9d6b..97f922d15cc 100644 --- a/tests/integration_tests/modules/test_combined.py +++ b/tests/integration_tests/modules/test_combined.py @@ -21,7 +21,10 @@ from cloudinit.util import is_true from tests.integration_tests.decorators import retry from tests.integration_tests.instances import IntegrationInstance -from tests.integration_tests.integration_settings import PLATFORM +from tests.integration_tests.integration_settings import ( + OS_IMAGE_TYPE, + PLATFORM, +) from tests.integration_tests.releases import CURRENT_RELEASE, IS_UBUNTU, JAMMY from tests.integration_tests.util import ( get_feature_flag_value, @@ -183,14 +186,16 @@ def test_configured_locale(self, class_client: IntegrationInstance): assert "LANG=en_GB.UTF-8" in default_locale locale_a = client.execute("locale -a") - verify_ordered_items_in_text(["en_GB.utf8", "en_US.utf8"], locale_a) - - locale_gen = client.execute( - "cat /etc/locale.gen | grep -v '^#' | uniq" - ) - verify_ordered_items_in_text( - ["en_GB.UTF-8", "en_US.UTF-8"], locale_gen - ) + locale_gen = client.execute("grep -v '^#' /etc/locale.gen | uniq") + if OS_IMAGE_TYPE == "minimal": + # Minimal images don't have a en_US.utf8 locale + expected_locales = ["C.utf8", "en_GB.utf8"] + expected_locale_gen = ["en_GB.UTF-8", "UTF-8"] + else: + expected_locales = ["en_GB.utf8", "en_US.utf8"] + expected_locale_gen = ["en_GB.UTF-8", "en_US.UTF-8"] + verify_ordered_items_in_text(expected_locales, locale_a) + verify_ordered_items_in_text(expected_locale_gen, locale_gen) def test_random_seed_data(self, class_client: IntegrationInstance): """Integration test for the random seed module. diff --git a/tests/unittests/config/test_cc_locale.py b/tests/unittests/config/test_cc_locale.py index 3bdf8b34dbf..fb564323adc 100644 --- a/tests/unittests/config/test_cc_locale.py +++ b/tests/unittests/config/test_cc_locale.py @@ -111,15 +111,20 @@ def test_locale_update_config_if_different_than_default(self, tmpdir): with mock.patch( "cloudinit.distros.debian.LOCALE_CONF_FN", locale_conf.strpath ): - cc_locale.handle("cc_locale", cfg, cc, []) - m_subp.assert_called_with( - [ - "update-locale", - "--locale-file=%s" % locale_conf.strpath, - "LANG=C.UTF-8", - ], - capture=False, - ) + with mock.patch( + "cloudinit.distros.debian.subp.which", + return_value="/usr/sbin/update-locale", + ) as m_which: + cc_locale.handle("cc_locale", cfg, cc, []) + m_subp.assert_called_with( + [ + "update-locale", + "--locale-file=%s" % locale_conf.strpath, + "LANG=C.UTF-8", + ], + capture=False, + ) + m_which.assert_called_once_with("update-locale") class TestLocaleSchema: diff --git a/tests/unittests/distros/test_debian.py b/tests/unittests/distros/test_debian.py index 9da9502f029..9634d185ce2 100644 --- a/tests/unittests/distros/test_debian.py +++ b/tests/unittests/distros/test_debian.py @@ -1,4 +1,6 @@ # This file is part of cloud-init. See LICENSE file for license information. +from unittest import mock + import pytest from cloudinit import util @@ -11,6 +13,10 @@ class TestDebianApplyLocale: @pytest.fixture def m_subp(self, mocker): + mocker.patch( + "cloudinit.distros.debian.subp.which", + return_value="/usr/bin/locale-gen", + ) yield mocker.patch( "cloudinit.distros.debian.subp.subp", return_value=(None, None) ) @@ -26,15 +32,34 @@ def test_no_rerun(self, distro, m_subp): distro.apply_locale(locale, out_fn=LOCALE_PATH) m_subp.assert_not_called() - def test_no_regen_on_c_utf8(self, distro, m_subp): - """If locale is set to C.UTF8, do not attempt to call locale-gen""" + @pytest.mark.parametrize( + "which_response,install_pkgs", + (("", ["locales"]), ("/usr/bin/update-locale", [])), + ) + def test_no_regen_on_c_utf8( + self, which_response, install_pkgs, distro, mocker, m_subp + ): + """If locale is set to C.UTF8, do not attempt to call locale-gen. + + Install locales deb package if not present and update-locale is called. + """ + m_which = mocker.patch( + "cloudinit.distros.debian.subp.which", + return_value=which_response, + ) m_subp.return_value = (None, None) locale = "C.UTF-8" util.write_file(LOCALE_PATH, "LANG=%s\n" % "en_US.UTF-8", omode="w") - distro.apply_locale(locale, out_fn=LOCALE_PATH) + with mock.patch.object(distro, "install_packages") as m_install: + distro.apply_locale(locale, out_fn=LOCALE_PATH) assert [ ["update-locale", f"--locale-file={LOCALE_PATH}", f"LANG={locale}"] ] == [p[0][0] for p in m_subp.call_args_list] + m_which.assert_called_with("update-locale") + if install_pkgs: + m_install.assert_called_once_with(install_pkgs) + else: + m_install.assert_not_called() def test_rerun_if_different(self, distro, m_subp, caplog): """If system has different locale, locale-gen should be called.""" @@ -59,10 +84,24 @@ def test_rerun_if_different(self, distro, m_subp, caplog): in caplog.text ) - def test_rerun_if_no_file(self, distro, m_subp): - """If system has no locale file, locale-gen should be called.""" + @pytest.mark.parametrize( + "which_response,install_pkgs", + (("", ["locales"]), ("/usr/bin/locale-gen", [])), + ) + def test_rerun_if_no_file( + self, which_response, install_pkgs, mocker, distro, m_subp + ): + """If system has no locale file, locale-gen should be called. + + Install locales package if absent and locale-gen called. + """ + m_which = mocker.patch( + "cloudinit.distros.debian.subp.which", + return_value=which_response, + ) locale = "en_US.UTF-8" - distro.apply_locale(locale, out_fn=LOCALE_PATH) + with mock.patch.object(distro, "install_packages") as m_install: + distro.apply_locale(locale, out_fn=LOCALE_PATH) assert [ ["locale-gen", locale], [ @@ -71,6 +110,14 @@ def test_rerun_if_no_file(self, distro, m_subp): f"LANG={locale}", ], ] == [p[0][0] for p in m_subp.call_args_list] + assert [ + mock.call("locale-gen"), + mock.call("update-locale"), + ] == m_which.call_args_list + if install_pkgs: + m_install.assert_called_with(install_pkgs) + else: + m_install.assert_not_called() def test_rerun_on_unset_system_locale(self, distro, m_subp, caplog): """If system has unset locale, locale-gen should be called."""