From 828a91e10f5b6010a858deceeb9d53306aea4858 Mon Sep 17 00:00:00 2001 From: Mostafa Abdelwahab Date: Sun, 3 May 2026 13:10:07 +0300 Subject: [PATCH 01/11] Add unit test to test precedence behavior between user-data.users and default user config Signed-off-by: Mostafa Abdelwahab --- .../distros/test_user_data_normalize.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/unittests/distros/test_user_data_normalize.py b/tests/unittests/distros/test_user_data_normalize.py index a1a77d1a0e9..925a5a93855 100644 --- a/tests/unittests/distros/test_user_data_normalize.py +++ b/tests/unittests/distros/test_user_data_normalize.py @@ -196,6 +196,30 @@ def test_users_dict_default_additional(self): assert users["bob"]["blah"] is True assert users["bob"]["default"] is True + def test_users_dict_override_default_attribute(self): + distro = self._make_distro("ubuntu", bcfg) + ug_cfg = { + "users": ["default", {"name": "bob", "lock_passwd": False}], + } + users, _ = self._norm(ug_cfg, distro) + + assert "bob" in users + assert "name" not in users["bob"] + + for key, val in bcfg.items(): + if key == "lock_passwd": + # Assert that the default user config is True + assert val is True + # Assert that the resolved value + # matches the passed config: False + assert users["bob"][key] is False + elif key == "groups": + assert users["bob"][key] == ",".join(val) + elif key != "name": + assert users["bob"][key] == val + + assert users["bob"]["default"] is True + def test_users_dict_extract(self): distro = self._make_distro("ubuntu", bcfg) ug_cfg = { From 52e50aae50d7cd2c3735f0a572d465d3719bb53a Mon Sep 17 00:00:00 2001 From: Mostafa Abdelwahab Date: Sun, 3 May 2026 13:43:31 +0300 Subject: [PATCH 02/11] fix(ug_util): prioritize user-data.users over the default user config Signed-off-by: Mostafa Abdelwahab --- cloudinit/distros/ug_util.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/cloudinit/distros/ug_util.py b/cloudinit/distros/ug_util.py index 2d0a887e7c4..53ad0a7d20b 100644 --- a/cloudinit/distros/ug_util.py +++ b/cloudinit/distros/ug_util.py @@ -134,13 +134,14 @@ def _normalize_users(u_cfg, def_user_cfg=None): # Now merge the extracted groups with the default config provided users_groups = util.uniq_merge_sorted(parsed_groups, def_groups) parsed_config["groups"] = ",".join(users_groups) - # The real config for the default user is the combination of the - # default user config provided by the distro, the default user - # config provided by the above merging for the user 'default' and - # then the parsed config from the user's 'real name' which does not - # have to be 'default' (but could be) + # The real config for the default user is the combination of: + # - the parsed config from the user's 'real name' which does + # not have to be 'default' (but could be) + # - then the default user config provided by the above merging + # for the user 'default' + # - then the default user config provided by the distro not users[def_user] = util.mergemanydict( - [def_user_cfg, def_config, parsed_config] + [parsed_config, def_config, def_user_cfg] ) # Ensure that only the default user that we found (if any) is actually From 151d8622fec8faae0e5677719a2d709299ab548e Mon Sep 17 00:00:00 2001 From: Mostafa Abdelwahab Date: Fri, 15 May 2026 17:27:52 +0300 Subject: [PATCH 03/11] add integration tests --- .../modules/test_set_password.py | 40 +++++++------ .../modules/test_users_groups.py | 57 ++++++++++++++++++- tests/integration_tests/util.py | 16 ++++++ 3 files changed, 91 insertions(+), 22 deletions(-) diff --git a/tests/integration_tests/modules/test_set_password.py b/tests/integration_tests/modules/test_set_password.py index ec6df783250..e04783520e3 100644 --- a/tests/integration_tests/modules/test_set_password.py +++ b/tests/integration_tests/modules/test_set_password.py @@ -14,7 +14,10 @@ from tests.integration_tests.decorators import retry from tests.integration_tests.releases import CURRENT_RELEASE, IS_UBUNTU -from tests.integration_tests.util import get_console_log +from tests.integration_tests.util import ( + fetch_and_parse_etc_shadow, + get_console_log, +) COMMON_USER_DATA = """\ #cloud-config @@ -38,6 +41,9 @@ # sha256 gojanego passwd: "$5$iW$XsxmWCdpwIW8Yhv.Jn/R3uk6A4UaicfW5Xp7C9p9pg." lock_passwd: false + - name: sally + # sha256 gosallygo + passwd: "$5$bA$KBMTe8lXf0e8lE4f4hYPU0h6h0HzQX4vHpnq6xHn9Q2" - name: "mikey" lock_passwd: false """ @@ -94,41 +100,33 @@ class Mixin: """Shared test definitions.""" - def _fetch_and_parse_etc_shadow(self, class_client): - """Fetch /etc/shadow and parse it into Python data structures - - Returns: ({user: password}, [duplicate, users]) - """ - shadow_content = class_client.read_from_file("/etc/shadow") - users = {} - dupes = [] - for line in shadow_content.splitlines(): - user, encpw = line.split(":")[0:2] - if user in users: - dupes.append(user) - users[user] = encpw - return users, dupes - def test_no_duplicate_users_in_shadow(self, class_client): """Confirm that set_passwords has not added duplicate shadow entries""" - _, dupes = self._fetch_and_parse_etc_shadow(class_client) + _, dupes = fetch_and_parse_etc_shadow(class_client) assert [] == dupes def test_password_in_users_dict_set_correctly(self, class_client): """Test that the password specified in the users dict is set.""" - shadow_users, _ = self._fetch_and_parse_etc_shadow(class_client) + shadow_users, _ = fetch_and_parse_etc_shadow(class_client) assert USERS_PASSWD_VALUES["jane"] == shadow_users["jane"] + def test_hashed_password_without_lock_passwd_override_is_locked( + self, class_client + ): + """Hashed passwords are locked when lock_passwd is not set.""" + shadow_users, _ = fetch_and_parse_etc_shadow(class_client) + assert f"!{USERS_PASSWD_VALUES['sally']}" == shadow_users["sally"] + def test_password_in_chpasswd_list_set_correctly(self, class_client): """Test that a chpasswd password overrides one in the users dict.""" - shadow_users, _ = self._fetch_and_parse_etc_shadow(class_client) + shadow_users, _ = fetch_and_parse_etc_shadow(class_client) mikey_hash = "$5$xZ$B2YGGEx2AOf4PeW48KC6.QyT1W2B4rZ9Qbltudtha89" assert mikey_hash == shadow_users["mikey"] def test_random_passwords_set_correctly(self, class_client): """Test that RANDOM chpasswd entries replace users dict passwords.""" - shadow_users, _ = self._fetch_and_parse_etc_shadow(class_client) + shadow_users, _ = fetch_and_parse_etc_shadow(class_client) # These should have been changed assert shadow_users["harry"] != USERS_PASSWD_VALUES["harry"] @@ -170,7 +168,7 @@ def test_explicit_password_set_correctly(self, class_client): ) if minor_version > 12: pytest.xfail("Instance under test doesn't have 'crypt' in stdlib") - shadow_users, _ = self._fetch_and_parse_etc_shadow(class_client) + shadow_users, _ = fetch_and_parse_etc_shadow(class_client) fmt_and_salt = shadow_users["tom"].rsplit("$", 1)[0] diff --git a/tests/integration_tests/modules/test_users_groups.py b/tests/integration_tests/modules/test_users_groups.py index 55813220fc2..c56e4744a6c 100644 --- a/tests/integration_tests/modules/test_users_groups.py +++ b/tests/integration_tests/modules/test_users_groups.py @@ -16,7 +16,10 @@ JAMMY, NOBLE, ) -from tests.integration_tests.util import verify_clean_boot +from tests.integration_tests.util import ( + fetch_and_parse_etc_shadow, + verify_clean_boot, +) USER_DATA = """\ #cloud-config @@ -192,3 +195,55 @@ def test_sudoers_includedir(client: IntegrationInstance): "/etc/sudoers.d/90-cloud-init-users" ).splitlines()[1:] assert sudoers_content_before == sudoers_content_after + + +USER_DATA_OVERRIDE = """\ +#cloud-config +users: + - default + - name: ubuntu + shell: /bin/sh + lock_passwd: false + passwd: $5$xZ$B2YGGEx2AOf4PeW48KC6.QyT1W2B4rZ9Qbltudtha89 +""" + + +@pytest.mark.ci +@pytest.mark.skipif(not IS_UBUNTU, reason="Test is Ubuntu specific") +@pytest.mark.user_data(USER_DATA_OVERRIDE) +def test_default_user_settings_override(client: IntegrationInstance): + """ + Test that the default user settings are correctly overridden. + """ + # Check shell + shell_set = ( + client.execute(["getent", "passwd", "ubuntu"]) + .stdout.strip() + .split(":")[-1] + ) + assert "/bin/sh" == shell_set + # Check password is not locked + passwd_status = client.execute(["passwd", "-S", "ubuntu"]).stdout + assert re.search(r"^ubuntu\s+P\b", passwd_status) + exepected_passwd_hash = "$5$xZ$B2YGGEx2AOf4PeW48KC6.QyT1W2B4rZ9Qbltudtha89" + parsed_shadow, _ = fetch_and_parse_etc_shadow(client) + assert parsed_shadow["ubuntu"] == exepected_passwd_hash + + +@pytest.mark.skipif(not IS_UBUNTU, reason="Test is Ubuntu specific") +def test_default_user_settings(client: IntegrationInstance): + """ + This test serves as a "negative control" for + test_default_user_settings_override, confirming the default + user settings are as expected when not overridden by user-data. + """ + # Check shell + shell_set = ( + client.execute(["getent", "passwd", "ubuntu"]) + .stdout.strip() + .split(":")[-1] + ) + assert "/bin/bash" == shell_set + # Check password is not locked + passwd_status = client.execute(["passwd", "-S", "ubuntu"]).stdout + assert re.search(r"^ubuntu\s+L\b", passwd_status) diff --git a/tests/integration_tests/util.py b/tests/integration_tests/util.py index 6e86b677552..7e5b0616531 100644 --- a/tests/integration_tests/util.py +++ b/tests/integration_tests/util.py @@ -692,3 +692,19 @@ def get_datetime_from_string( ) ) return converted_datetime + + +def fetch_and_parse_etc_shadow(client): + """Fetch /etc/shadow and parse it into Python data structures + + Returns: ({user: password}, [duplicate, users]) + """ + shadow_content = client.read_from_file("/etc/shadow") + users = {} + dupes = [] + for line in shadow_content.splitlines(): + user, encpw = line.split(":")[0:2] + if user in users: + dupes.append(user) + users[user] = encpw + return users, dupes From 653f98f80257ed43326ee846663ed0b9b9eb2a89 Mon Sep 17 00:00:00 2001 From: Mostafa Abdelwahab Date: Sat, 16 May 2026 10:30:08 +0300 Subject: [PATCH 04/11] Add cloud-init clean and reboot to the integration test Signed-off-by: Mostafa Abdelwahab --- tests/integration_tests/cmd/test_status.py | 6 ++---- tests/integration_tests/modules/test_users_groups.py | 8 +++++++- tests/integration_tests/util.py | 12 ++++++++++++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/tests/integration_tests/cmd/test_status.py b/tests/integration_tests/cmd/test_status.py index f318dedb1d2..066e56f7e5e 100644 --- a/tests/integration_tests/cmd/test_status.py +++ b/tests/integration_tests/cmd/test_status.py @@ -10,6 +10,7 @@ from tests.integration_tests.integration_settings import PLATFORM from tests.integration_tests.releases import CURRENT_RELEASE, IS_UBUNTU, JAMMY from tests.integration_tests.util import ( + clean_cloud_init_and_restart_instance, push_and_enable_systemd_unit, wait_for_cloud_init, ) @@ -156,10 +157,7 @@ def test_status_block_through_all_boot_status(client): push_and_enable_systemd_unit( client, "before-cloud-init-local.service", BEFORE_CLOUD_INIT_LOCAL ) - client.instance.clean() - client.instance.restart() - wait_for_cloud_init(client).stdout.strip() - client.execute("cloud-init status --wait") + clean_cloud_init_and_restart_instance(client) # Assert that before-cloud-init-local.service started before # cloud-init-local.service could create status.json diff --git a/tests/integration_tests/modules/test_users_groups.py b/tests/integration_tests/modules/test_users_groups.py index c56e4744a6c..f4c32d0ea20 100644 --- a/tests/integration_tests/modules/test_users_groups.py +++ b/tests/integration_tests/modules/test_users_groups.py @@ -17,6 +17,7 @@ NOBLE, ) from tests.integration_tests.util import ( + clean_cloud_init_and_restart_instance, fetch_and_parse_etc_shadow, verify_clean_boot, ) @@ -215,6 +216,10 @@ def test_default_user_settings_override(client: IntegrationInstance): """ Test that the default user settings are correctly overridden. """ + # Github CI does not use cloud_init_source in place but rather installs + # the cloud-init-base package after the instance boot so default user is + # not overriden until cloud-initn is cleaned and the instance rebooted. + clean_cloud_init_and_restart_instance(client) # Check shell shell_set = ( client.execute(["getent", "passwd", "ubuntu"]) @@ -237,7 +242,8 @@ def test_default_user_settings(client: IntegrationInstance): test_default_user_settings_override, confirming the default user settings are as expected when not overridden by user-data. """ - # Check shell + clean_cloud_init_and_restart_instance(client) + # Check shel shell_set = ( client.execute(["getent", "passwd", "ubuntu"]) .stdout.strip() diff --git a/tests/integration_tests/util.py b/tests/integration_tests/util.py index 7e5b0616531..cfc10498239 100644 --- a/tests/integration_tests/util.py +++ b/tests/integration_tests/util.py @@ -708,3 +708,15 @@ def fetch_and_parse_etc_shadow(client): dupes.append(user) users[user] = encpw return users, dupes + + +def clean_cloud_init_and_restart_instance(client): + """Clean cloud-init and restart the instance + + This function cleans the cloud-init state and restarts the instance, + waiting for cloud-init to complete its initialization. + """ + client.instance.clean() + client.instance.restart() + wait_for_cloud_init(client).stdout.strip() + client.execute("cloud-init status --wait") From eded6c174513bcb5e2c20dd19ce2c6e9f099802e Mon Sep 17 00:00:00 2001 From: Mostafa Abdelwahab Date: Sat, 16 May 2026 10:33:46 +0300 Subject: [PATCH 05/11] Typo fix --- cloudinit/distros/ug_util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloudinit/distros/ug_util.py b/cloudinit/distros/ug_util.py index 53ad0a7d20b..cd50d3b45dd 100644 --- a/cloudinit/distros/ug_util.py +++ b/cloudinit/distros/ug_util.py @@ -139,7 +139,7 @@ def _normalize_users(u_cfg, def_user_cfg=None): # not have to be 'default' (but could be) # - then the default user config provided by the above merging # for the user 'default' - # - then the default user config provided by the distro not + # - then the default user config provided by the distro users[def_user] = util.mergemanydict( [parsed_config, def_config, def_user_cfg] ) From ac1bf7ea0330aafe1ac16e00b60ac2b1ade7fd7a Mon Sep 17 00:00:00 2001 From: Mostafa Abdelwahab Date: Sun, 17 May 2026 15:19:12 +0300 Subject: [PATCH 06/11] Remove unecessary change for commit --- tests/integration_tests/cmd/test_status.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/integration_tests/cmd/test_status.py b/tests/integration_tests/cmd/test_status.py index 066e56f7e5e..f318dedb1d2 100644 --- a/tests/integration_tests/cmd/test_status.py +++ b/tests/integration_tests/cmd/test_status.py @@ -10,7 +10,6 @@ from tests.integration_tests.integration_settings import PLATFORM from tests.integration_tests.releases import CURRENT_RELEASE, IS_UBUNTU, JAMMY from tests.integration_tests.util import ( - clean_cloud_init_and_restart_instance, push_and_enable_systemd_unit, wait_for_cloud_init, ) @@ -157,7 +156,10 @@ def test_status_block_through_all_boot_status(client): push_and_enable_systemd_unit( client, "before-cloud-init-local.service", BEFORE_CLOUD_INIT_LOCAL ) - clean_cloud_init_and_restart_instance(client) + client.instance.clean() + client.instance.restart() + wait_for_cloud_init(client).stdout.strip() + client.execute("cloud-init status --wait") # Assert that before-cloud-init-local.service started before # cloud-init-local.service could create status.json From 13900913e2b9100bc4cadfd44abe9dd5e0c83f6e Mon Sep 17 00:00:00 2001 From: Mostafa Abdelwahab Date: Sun, 17 May 2026 17:55:58 +0300 Subject: [PATCH 07/11] Move to OntegrationInstance restart --- tests/integration_tests/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration_tests/util.py b/tests/integration_tests/util.py index cfc10498239..cc9601b3cda 100644 --- a/tests/integration_tests/util.py +++ b/tests/integration_tests/util.py @@ -717,6 +717,6 @@ def clean_cloud_init_and_restart_instance(client): waiting for cloud-init to complete its initialization. """ client.instance.clean() - client.instance.restart() + client.restart() wait_for_cloud_init(client).stdout.strip() client.execute("cloud-init status --wait") From acf6834510ead027d75a1a9ef158a04d2c5ac1db Mon Sep 17 00:00:00 2001 From: Mostafa Abdelwahab Date: Sun, 17 May 2026 17:56:46 +0300 Subject: [PATCH 08/11] Add debug --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 7565be59722..20e49023d8e 100644 --- a/tox.ini +++ b/tox.ini @@ -277,7 +277,7 @@ passenv = [testenv:integration-tests-ci] deps = -r{toxinidir}/integration-requirements.txt -commands = {envpython} -m pytest --log-cli-level=INFO {posargs:tests/integration_tests} +commands = {envpython} -m pytest --log-cli-level=DEBUG {posargs:tests/integration_tests} passenv = CLOUD_INIT_* SSH_AUTH_SOCK From 16b358cbb753df175ade48103cfe231d1ae6e972 Mon Sep 17 00:00:00 2001 From: Mostafa Abdelwahab Date: Mon, 18 May 2026 10:20:43 +0300 Subject: [PATCH 09/11] change passwd to hashed_passwd --- tests/integration_tests/modules/test_users_groups.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration_tests/modules/test_users_groups.py b/tests/integration_tests/modules/test_users_groups.py index f4c32d0ea20..e4b064a622c 100644 --- a/tests/integration_tests/modules/test_users_groups.py +++ b/tests/integration_tests/modules/test_users_groups.py @@ -205,7 +205,7 @@ def test_sudoers_includedir(client: IntegrationInstance): - name: ubuntu shell: /bin/sh lock_passwd: false - passwd: $5$xZ$B2YGGEx2AOf4PeW48KC6.QyT1W2B4rZ9Qbltudtha89 + hashed_passwd: $5$xZ$B2YGGEx2AOf4PeW48KC6.QyT1W2B4rZ9Qbltudtha89 """ @@ -226,7 +226,7 @@ def test_default_user_settings_override(client: IntegrationInstance): .stdout.strip() .split(":")[-1] ) - assert "/bin/sh" == shell_set + assert "/bin/bash" == shell_set # Check password is not locked passwd_status = client.execute(["passwd", "-S", "ubuntu"]).stdout assert re.search(r"^ubuntu\s+P\b", passwd_status) From 975c1482659fb6777f46745dfdb54771d5e0511f Mon Sep 17 00:00:00 2001 From: Mostafa Abdelwahab Date: Mon, 18 May 2026 10:34:56 +0300 Subject: [PATCH 10/11] Remove Debug --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 20e49023d8e..7565be59722 100644 --- a/tox.ini +++ b/tox.ini @@ -277,7 +277,7 @@ passenv = [testenv:integration-tests-ci] deps = -r{toxinidir}/integration-requirements.txt -commands = {envpython} -m pytest --log-cli-level=DEBUG {posargs:tests/integration_tests} +commands = {envpython} -m pytest --log-cli-level=INFO {posargs:tests/integration_tests} passenv = CLOUD_INIT_* SSH_AUTH_SOCK From 6dec7752f49fa191ff2864fe1b03161fa8a9f73d Mon Sep 17 00:00:00 2001 From: Mostafa Abdelwahab Date: Mon, 18 May 2026 11:00:47 +0300 Subject: [PATCH 11/11] Cleanup --- .../modules/test_users_groups.py | 16 +++++++++------- tests/integration_tests/util.py | 12 ------------ 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/tests/integration_tests/modules/test_users_groups.py b/tests/integration_tests/modules/test_users_groups.py index e4b064a622c..e4a23551965 100644 --- a/tests/integration_tests/modules/test_users_groups.py +++ b/tests/integration_tests/modules/test_users_groups.py @@ -10,6 +10,7 @@ import pytest from tests.integration_tests.instances import IntegrationInstance +from tests.integration_tests.integration_settings import CLOUD_INIT_SOURCE from tests.integration_tests.releases import ( CURRENT_RELEASE, IS_UBUNTU, @@ -17,7 +18,6 @@ NOBLE, ) from tests.integration_tests.util import ( - clean_cloud_init_and_restart_instance, fetch_and_parse_etc_shadow, verify_clean_boot, ) @@ -216,17 +216,20 @@ def test_default_user_settings_override(client: IntegrationInstance): """ Test that the default user settings are correctly overridden. """ - # Github CI does not use cloud_init_source in place but rather installs - # the cloud-init-base package after the instance boot so default user is - # not overriden until cloud-initn is cleaned and the instance rebooted. - clean_cloud_init_and_restart_instance(client) # Check shell shell_set = ( client.execute(["getent", "passwd", "ubuntu"]) .stdout.strip() .split(":")[-1] ) - assert "/bin/bash" == shell_set + if CLOUD_INIT_SOURCE in ["NONE", "IN_PLACE"]: + assert ( + "/bin/sh" == shell_set + ), "Shell setting not overriden even though the user is new" + else: + assert ( + "/bin/bash" == shell_set + ), "Shell setting overriden even though user already exists" # Check password is not locked passwd_status = client.execute(["passwd", "-S", "ubuntu"]).stdout assert re.search(r"^ubuntu\s+P\b", passwd_status) @@ -242,7 +245,6 @@ def test_default_user_settings(client: IntegrationInstance): test_default_user_settings_override, confirming the default user settings are as expected when not overridden by user-data. """ - clean_cloud_init_and_restart_instance(client) # Check shel shell_set = ( client.execute(["getent", "passwd", "ubuntu"]) diff --git a/tests/integration_tests/util.py b/tests/integration_tests/util.py index cc9601b3cda..7e5b0616531 100644 --- a/tests/integration_tests/util.py +++ b/tests/integration_tests/util.py @@ -708,15 +708,3 @@ def fetch_and_parse_etc_shadow(client): dupes.append(user) users[user] = encpw return users, dupes - - -def clean_cloud_init_and_restart_instance(client): - """Clean cloud-init and restart the instance - - This function cleans the cloud-init state and restarts the instance, - waiting for cloud-init to complete its initialization. - """ - client.instance.clean() - client.restart() - wait_for_cloud_init(client).stdout.strip() - client.execute("cloud-init status --wait")