From 7f40786d6fe92a9741df111f63e3d4c5365f6c10 Mon Sep 17 00:00:00 2001 From: paulober Date: Tue, 2 Sep 2025 11:02:25 +0000 Subject: [PATCH 1/9] fix(raspberry-pi-os): Fix user creation to allow for a preseeded user Signed-off-by: paulober --- cloudinit/distros/raspberry_pi_os.py | 22 ++++++------- .../unittests/distros/test_raspberry_pi_os.py | 32 +++++++++++-------- 2 files changed, 29 insertions(+), 25 deletions(-) diff --git a/cloudinit/distros/raspberry_pi_os.py b/cloudinit/distros/raspberry_pi_os.py index a6cf0f98585..8d3d8399cf2 100644 --- a/cloudinit/distros/raspberry_pi_os.py +++ b/cloudinit/distros/raspberry_pi_os.py @@ -13,6 +13,10 @@ class Distro(debian.Distro): + def __init__(self, name, cfg, paths): + super().__init__(name, cfg, paths) + self.default_user_renamed = False + def set_keymap(self, layout: str, model: str, variant: str, options: str): super().set_keymap(layout, model, variant, options) @@ -69,26 +73,20 @@ def apply_locale(self, locale, out_fn=None, keyname="LANG"): def add_user(self, name, **kwargs) -> bool: """ - Add a user to the system using standard GNU tools - - This should be overridden on distros where useradd is not desirable or - not available. + Add a user to the system using standard Raspberry Pi tools Returns False if user already exists, otherwise True. """ - result = super().add_user(name, **kwargs) - - if not result: - return result + if self.default_user_renamed: + return super().add_user(name, **kwargs) + self.default_user_renamed = True try: subp.subp( [ - "/usr/bin/rename-user", - "-f", - "-s", + "/usr/lib/userconf-pi/userconf", + name, ], - update_env={"SUDO_USER": name}, ) except subp.ProcessExecutionError as e: diff --git a/tests/unittests/distros/test_raspberry_pi_os.py b/tests/unittests/distros/test_raspberry_pi_os.py index 2ef36acf117..cce16dc2e59 100644 --- a/tests/unittests/distros/test_raspberry_pi_os.py +++ b/tests/unittests/distros/test_raspberry_pi_os.py @@ -121,33 +121,39 @@ def test_apply_locale_fallback_to_utf8(self, m_subp): def test_add_user_happy_path(self, m_subp): cls = fetch("raspberry_pi_os") distro = cls("raspberry-pi-os", {}, None) - # Mock the superclass add_user to return True - with mock.patch( - "cloudinit.distros.debian.Distro.add_user", return_value=True - ): - assert distro.add_user("pi") is True - m_subp.assert_called_once_with( - ["/usr/bin/rename-user", "-f", "-s"], - update_env={"SUDO_USER": "pi"}, - ) + + assert distro.add_user("pi") is True + m_subp.assert_called_once() + + # Accept both common locations for userconf + args, kwargs = m_subp.call_args + assert args[0][0] in ( + "/usr/lib/userconf-pi/userconf", + "/lib/userconf-pi/userconf" + ) + assert args[0][1] == "pi" + # No env expected + assert kwargs == {} @mock.patch(M_PATH + "subp.subp") def test_add_user_existing_user(self, m_subp): cls = fetch("raspberry_pi_os") distro = cls("raspberry-pi-os", {}, None) + + distro.default_user_renamed = True with mock.patch( "cloudinit.distros.debian.Distro.add_user", return_value=False - ): + ) as m_super: assert distro.add_user("pi") is False m_subp.assert_not_called() + m_super.assert_called_once() # called with ("pi", **{}) @mock.patch( M_PATH + "subp.subp", - side_effect=ProcessExecutionError("rename-user failed"), + side_effect=ProcessExecutionError("userconf failed"), ) - @mock.patch("cloudinit.distros.debian.Distro.add_user", return_value=True) def test_add_user_rename_fails_logs_error( - self, m_super_add_user, m_subp, caplog + self, m_subp, caplog ): cls = fetch("raspberry_pi_os") distro = cls("raspberry-pi-os", {}, None) From d55161de0a0df65f7733eaa10ac219f64f0a5088 Mon Sep 17 00:00:00 2001 From: paulober Date: Tue, 2 Sep 2025 11:14:45 +0000 Subject: [PATCH 2/9] Format Signed-off-by: paulober --- tests/unittests/distros/test_raspberry_pi_os.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/unittests/distros/test_raspberry_pi_os.py b/tests/unittests/distros/test_raspberry_pi_os.py index cce16dc2e59..0a64ff18de5 100644 --- a/tests/unittests/distros/test_raspberry_pi_os.py +++ b/tests/unittests/distros/test_raspberry_pi_os.py @@ -129,7 +129,7 @@ def test_add_user_happy_path(self, m_subp): args, kwargs = m_subp.call_args assert args[0][0] in ( "/usr/lib/userconf-pi/userconf", - "/lib/userconf-pi/userconf" + "/lib/userconf-pi/userconf", ) assert args[0][1] == "pi" # No env expected @@ -152,9 +152,7 @@ def test_add_user_existing_user(self, m_subp): M_PATH + "subp.subp", side_effect=ProcessExecutionError("userconf failed"), ) - def test_add_user_rename_fails_logs_error( - self, m_subp, caplog - ): + def test_add_user_rename_fails_logs_error(self, m_subp, caplog): cls = fetch("raspberry_pi_os") distro = cls("raspberry-pi-os", {}, None) From d1feb1292e7813f9a1a3b4595955c71b70177c24 Mon Sep 17 00:00:00 2001 From: paulober Date: Wed, 3 Sep 2025 08:35:18 +0000 Subject: [PATCH 3/9] Fix cast Signed-off-by: paulober --- tests/unittests/distros/test_raspberry_pi_os.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unittests/distros/test_raspberry_pi_os.py b/tests/unittests/distros/test_raspberry_pi_os.py index 0a64ff18de5..47f6de169ba 100644 --- a/tests/unittests/distros/test_raspberry_pi_os.py +++ b/tests/unittests/distros/test_raspberry_pi_os.py @@ -1,8 +1,10 @@ # This file is part of cloud-init. See LICENSE file for license information. import logging +from typing import cast from cloudinit.distros import fetch +from cloudinit.distros.raspberry_pi_os import Distro as RpiDistro from cloudinit.subp import ProcessExecutionError from tests.unittests.helpers import mock @@ -138,7 +140,7 @@ def test_add_user_happy_path(self, m_subp): @mock.patch(M_PATH + "subp.subp") def test_add_user_existing_user(self, m_subp): cls = fetch("raspberry_pi_os") - distro = cls("raspberry-pi-os", {}, None) + distro = cast(RpiDistro, cls("raspberry-pi-os", {}, None)) distro.default_user_renamed = True with mock.patch( From c6898eb93a11b9b74c9825177b3f6e12bf3de16d Mon Sep 17 00:00:00 2001 From: paulober Date: Thu, 4 Sep 2025 12:08:35 +0000 Subject: [PATCH 4/9] Stop default user creation which would disable the setup wizard Signed-off-by: paulober --- config/cloud.cfg.tmpl | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/cloud.cfg.tmpl b/config/cloud.cfg.tmpl index 558eab2433b..9bd7526daac 100644 --- a/config/cloud.cfg.tmpl +++ b/config/cloud.cfg.tmpl @@ -35,6 +35,7 @@ syslog_fix_perms: root:wheel syslog_fix_perms: root:root {% endif %} +{% if variant != "raspberry-pi-os" %} # A set of users which may be applied and/or used by various modules # when a 'default' entry is found it will reference the 'default_user' # from the distro configuration specified below @@ -46,6 +47,7 @@ users: - default {% endif %} +{% endif %} {% if variant == "photon" %} # VMware guest customization. disable_vmware_customization: true From 1177cd1cd8aa04d2db13e47469e60d7099429b3b Mon Sep 17 00:00:00 2001 From: paulober Date: Mon, 15 Sep 2025 12:21:47 +0000 Subject: [PATCH 5/9] Add support for user options for the preseeded user Signed-off-by: paulober --- cloudinit/distros/raspberry_pi_os.py | 73 +++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 7 deletions(-) diff --git a/cloudinit/distros/raspberry_pi_os.py b/cloudinit/distros/raspberry_pi_os.py index 8d3d8399cf2..dd8a49b965f 100644 --- a/cloudinit/distros/raspberry_pi_os.py +++ b/cloudinit/distros/raspberry_pi_os.py @@ -5,8 +5,9 @@ # This file is part of cloud-init. See LICENSE file for license information. import logging +import os -from cloudinit import net, subp +from cloudinit import net, subp, util from cloudinit.distros import debian LOG = logging.getLogger(__name__) @@ -79,20 +80,78 @@ def add_user(self, name, **kwargs) -> bool: """ if self.default_user_renamed: return super().add_user(name, **kwargs) + self.default_user_renamed = True - try: + # Password precedence: hashed > passwd (legacy hash) > plaintext + pw_hash = kwargs.get("hashed_passwd") or kwargs.get("passwd") + plain = kwargs.get("plain_text_passwd") + + if pw_hash: subp.subp( [ "/usr/lib/userconf-pi/userconf", name, + pw_hash ], ) - - except subp.ProcessExecutionError as e: - LOG.error("Failed to setup user: %s", e) - return False - + else: + subp.subp( + [ + "/usr/lib/userconf-pi/userconf", + name, + ], + ) + if plain: + self.set_passwd(name, plain, hashed=False) + + # honor all other options that would otherwise add_user have taken care of + + # Ensure groups exist if requested + create_groups = kwargs.get("create_groups", True) + groups = kwargs.get("groups") + if isinstance(groups, str): + groups = [g.strip() for g in groups.split(",")] + if create_groups and groups: + for g in groups: + if not util.is_group(g): + self.create_group(g) + + # apply creation-time attributes post-rename + if kwargs.get("gecos"): + subp.subp(["usermod", "-c", kwargs["gecos"], name]) + if kwargs.get("shell"): + subp.subp(["usermod", "-s", kwargs["shell"], name]) + if kwargs.get("primary_group"): + pg = kwargs["primary_group"] + if create_groups and not util.is_group(pg): + self.create_group(pg) + subp.subp(["usermod", "-g", pg, name]) + if groups: + subp.subp(["usermod", "-G", ",".join(groups), name]) + if kwargs.get("expiredate"): + subp.subp(["usermod", "--expiredate", kwargs["expiredate"], name]) + if kwargs.get("inactive"): + subp.subp(["usermod", "--inactive", str(kwargs["inactive"]), name]) + if kwargs.get("uid") is not None: + new_uid = int(kwargs["uid"]) + subp.subp(["usermod", "-u", str(new_uid), name]) + + # Also adjust ownership of the homedir if it exists + homedir = kwargs.get("homedir") or f"/home/{name}" + if os.path.exists(homedir): + for root, dirs, files in os.walk(homedir): + for d in dirs: + util.chownbyid(os.path.join(root, d), uid=new_uid, gid=-1) + for f in files: + util.chownbyid(os.path.join(root, f), uid=new_uid, gid=-1) + util.chownbyid(homedir, uid=new_uid, gid=-1) + if kwargs.get("homedir"): + subp.subp(["usermod", "-d", kwargs["homedir"], "-m", name]) + + # `create_user` will still run post-creation bits: + # hashed/plain_text passwd (already set above, ok if redundant), + # lock_passwd, sudo, doas, ssh_authorized_keys, ssh_redirect_user return True def generate_fallback_config(self): From f0015c56cf9c0af8a53c99547283a723fa7709a7 Mon Sep 17 00:00:00 2001 From: paulober Date: Mon, 15 Sep 2025 12:32:10 +0000 Subject: [PATCH 6/9] Adjust tests Signed-off-by: paulober --- cloudinit/distros/raspberry_pi_os.py | 17 +++++++++-------- tests/unittests/distros/test_raspberry_pi_os.py | 10 +++++----- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/cloudinit/distros/raspberry_pi_os.py b/cloudinit/distros/raspberry_pi_os.py index dd8a49b965f..a969377713f 100644 --- a/cloudinit/distros/raspberry_pi_os.py +++ b/cloudinit/distros/raspberry_pi_os.py @@ -89,11 +89,7 @@ def add_user(self, name, **kwargs) -> bool: if pw_hash: subp.subp( - [ - "/usr/lib/userconf-pi/userconf", - name, - pw_hash - ], + ["/usr/lib/userconf-pi/userconf", name, pw_hash], ) else: subp.subp( @@ -105,7 +101,8 @@ def add_user(self, name, **kwargs) -> bool: if plain: self.set_passwd(name, plain, hashed=False) - # honor all other options that would otherwise add_user have taken care of + # honor all other options that would otherwise + # add_user have taken care of # Ensure groups exist if requested create_groups = kwargs.get("create_groups", True) @@ -142,9 +139,13 @@ def add_user(self, name, **kwargs) -> bool: if os.path.exists(homedir): for root, dirs, files in os.walk(homedir): for d in dirs: - util.chownbyid(os.path.join(root, d), uid=new_uid, gid=-1) + util.chownbyid( + os.path.join(root, d), uid=new_uid, gid=-1 + ) for f in files: - util.chownbyid(os.path.join(root, f), uid=new_uid, gid=-1) + util.chownbyid( + os.path.join(root, f), uid=new_uid, gid=-1 + ) util.chownbyid(homedir, uid=new_uid, gid=-1) if kwargs.get("homedir"): subp.subp(["usermod", "-d", kwargs["homedir"], "-m", name]) diff --git a/tests/unittests/distros/test_raspberry_pi_os.py b/tests/unittests/distros/test_raspberry_pi_os.py index 47f6de169ba..fb5e2d2ab25 100644 --- a/tests/unittests/distros/test_raspberry_pi_os.py +++ b/tests/unittests/distros/test_raspberry_pi_os.py @@ -1,8 +1,9 @@ # This file is part of cloud-init. See LICENSE file for license information. -import logging from typing import cast +import pytest + from cloudinit.distros import fetch from cloudinit.distros.raspberry_pi_os import Distro as RpiDistro from cloudinit.subp import ProcessExecutionError @@ -154,13 +155,12 @@ def test_add_user_existing_user(self, m_subp): M_PATH + "subp.subp", side_effect=ProcessExecutionError("userconf failed"), ) - def test_add_user_rename_fails_logs_error(self, m_subp, caplog): + def test_add_user_rename_fails_logs_error(self, m_subp): cls = fetch("raspberry_pi_os") distro = cls("raspberry-pi-os", {}, None) - with caplog.at_level(logging.ERROR): - assert distro.add_user("pi") is False - assert "Failed to setup user" in caplog.text + with pytest.raises(ProcessExecutionError, match="userconf failed"): + distro.add_user("pi") @mock.patch( "cloudinit.net.generate_fallback_config", From 77f64cb1dba4aa6308f137bd6d895dcf0be0fcf6 Mon Sep 17 00:00:00 2001 From: paulober Date: Fri, 3 Oct 2025 13:32:42 +0000 Subject: [PATCH 7/9] Fix wizard disabling on headless images Signed-off-by: paulober --- cloudinit/distros/__init__.py | 6 ++++++ cloudinit/distros/raspberry_pi_os.py | 3 +++ tests/unittests/distros/test_raspberry_pi_os.py | 5 ++++- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/cloudinit/distros/__init__.py b/cloudinit/distros/__init__.py index c7b71e28199..2aa01d8f353 100644 --- a/cloudinit/distros/__init__.py +++ b/cloudinit/distros/__init__.py @@ -1,11 +1,13 @@ # Copyright (C) 2012 Canonical Ltd. # Copyright (C) 2012, 2013 Hewlett-Packard Development Company, L.P. # Copyright (C) 2012 Yahoo! Inc. +# Copyright (C) 2025 Raspberry Pi Ltd. # # Author: Scott Moser # Author: Juerg Haefliger # Author: Joshua Harlow # Author: Ben Howard +# Author: Paul Oberosler # # This file is part of cloud-init. See LICENSE file for license information. @@ -1368,6 +1370,8 @@ def manage_service( "reload": ["reload-or-restart", service], "try-reload": ["try-reload-or-restart", service], "status": ["status", service], + "mask": ["mask", service], + "unmask": ["unmask", service], } else: cmds = { @@ -1379,6 +1383,8 @@ def manage_service( "reload": [service, "restart"], "try-reload": [service, "restart"], "status": [service, "status"], + "mask": [service, "stop"], + "unmask": [service, "stop"], } cmd = init_cmd + cmds[action] + list(extra_args) return subp.subp(cmd, capture=True, rcs=rcs) diff --git a/cloudinit/distros/raspberry_pi_os.py b/cloudinit/distros/raspberry_pi_os.py index a969377713f..179fe81a736 100644 --- a/cloudinit/distros/raspberry_pi_os.py +++ b/cloudinit/distros/raspberry_pi_os.py @@ -101,6 +101,9 @@ def add_user(self, name, **kwargs) -> bool: if plain: self.set_passwd(name, plain, hashed=False) + # Mask userconfig.service + self.manage_service("mask", "userconfig.service", "--now") + # honor all other options that would otherwise # add_user have taken care of diff --git a/tests/unittests/distros/test_raspberry_pi_os.py b/tests/unittests/distros/test_raspberry_pi_os.py index fb5e2d2ab25..b4b3e333652 100644 --- a/tests/unittests/distros/test_raspberry_pi_os.py +++ b/tests/unittests/distros/test_raspberry_pi_os.py @@ -125,7 +125,10 @@ def test_add_user_happy_path(self, m_subp): cls = fetch("raspberry_pi_os") distro = cls("raspberry-pi-os", {}, None) - assert distro.add_user("pi") is True + with mock.patch( + "cloudinit.distros.Distro.manage_service", return_value=True + ): + assert distro.add_user("pi") is True m_subp.assert_called_once() # Accept both common locations for userconf From 10ecbe6b004619b01aea945270dbb8849d3e8888 Mon Sep 17 00:00:00 2001 From: paulober Date: Mon, 6 Oct 2025 10:13:48 +0000 Subject: [PATCH 8/9] Add comment to masking userconfig service Signed-off-by: paulober --- cloudinit/distros/raspberry_pi_os.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/cloudinit/distros/raspberry_pi_os.py b/cloudinit/distros/raspberry_pi_os.py index 179fe81a736..dd8556baae7 100644 --- a/cloudinit/distros/raspberry_pi_os.py +++ b/cloudinit/distros/raspberry_pi_os.py @@ -101,7 +101,15 @@ def add_user(self, name, **kwargs) -> bool: if plain: self.set_passwd(name, plain, hashed=False) - # Mask userconfig.service + # Mask userconfig.service to ensure it does not start the + # first-run setup wizard on Raspberry Pi OS Lite images. + # The 'systemctl disable' call performed by the userconf tool + # only takes effect after a reboot, so masking it ensures the + # service stays inactive immediately. + # + # On desktop images, userconf alone is sufficient to prevent + # the graphical first-run wizard, but masking the service here + # adds consistency and causes no harm. self.manage_service("mask", "userconfig.service", "--now") # honor all other options that would otherwise From ad8e6f90bfbfca04b6521e7268dbc67ea3575388 Mon Sep 17 00:00:00 2001 From: paulober Date: Mon, 6 Oct 2025 10:18:09 +0000 Subject: [PATCH 9/9] Update comment Signed-off-by: paulober --- cloudinit/distros/raspberry_pi_os.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cloudinit/distros/raspberry_pi_os.py b/cloudinit/distros/raspberry_pi_os.py index dd8556baae7..9e1cce70da0 100644 --- a/cloudinit/distros/raspberry_pi_os.py +++ b/cloudinit/distros/raspberry_pi_os.py @@ -112,8 +112,8 @@ def add_user(self, name, **kwargs) -> bool: # adds consistency and causes no harm. self.manage_service("mask", "userconfig.service", "--now") - # honor all other options that would otherwise - # add_user have taken care of + # Continue handling any remaining options + # that the base add_user() implementation would normally process. # Ensure groups exist if requested create_groups = kwargs.get("create_groups", True)