Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cloudinit/distros/__init__.py
Original file line number Diff line number Diff line change
@@ -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 <scott.moser@canonical.com>
# Author: Juerg Haefliger <juerg.haefliger@hp.com>
# Author: Joshua Harlow <harlowja@yahoo-inc.com>
# Author: Ben Howard <ben.howard@canonical.com>
# Author: Paul Oberosler <paul.oberosler@raspberrypi.com>
#
# This file is part of cloud-init. See LICENSE file for license information.

Expand Down Expand Up @@ -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 = {
Expand All @@ -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)
Expand Down
105 changes: 87 additions & 18 deletions cloudinit/distros/raspberry_pi_os.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions config/cloud.cfg.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -46,6 +47,7 @@ users:
- default
{% endif %}

{% endif %}
{% if variant == "photon" %}
# VMware guest customization.
disable_vmware_customization: true
Expand Down
43 changes: 26 additions & 17 deletions tests/unittests/distros/test_raspberry_pi_os.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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",
Expand Down