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 a6cf0f98585..9e1cce70da0 100644 --- a/cloudinit/distros/raspberry_pi_os.py +++ b/cloudinit/distros/raspberry_pi_os.py @@ -5,14 +5,19 @@ # 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__) 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,32 +74,96 @@ 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 self.default_user_renamed: + return super().add_user(name, **kwargs) - if not result: - return result + 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], + ) + else: subp.subp( [ - "/usr/bin/rename-user", - "-f", - "-s", + "/usr/lib/userconf-pi/userconf", + name, ], - update_env={"SUDO_USER": name}, ) - - except subp.ProcessExecutionError as e: - LOG.error("Failed to setup user: %s", e) - return False - + if plain: + self.set_passwd(name, plain, hashed=False) + + # 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") + + # 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) + 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): 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 diff --git a/tests/unittests/distros/test_raspberry_pi_os.py b/tests/unittests/distros/test_raspberry_pi_os.py index 2ef36acf117..b4b3e333652 100644 --- a/tests/unittests/distros/test_raspberry_pi_os.py +++ b/tests/unittests/distros/test_raspberry_pi_os.py @@ -1,8 +1,11 @@ # 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 from tests.unittests.helpers import mock @@ -121,40 +124,46 @@ 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 + "cloudinit.distros.Distro.manage_service", 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"}, - ) + 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 = cast(RpiDistro, 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 - ): + 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",