diff --git a/.gitignore b/.gitignore index c9bc3823ce5..7d671fb9f21 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ srpm/ stage tags tests/integration_tests/user_settings.py + diff --git a/cloudinit/config/cc_apt_configure.py b/cloudinit/config/cc_apt_configure.py index 853e529635c..d22fde88a37 100644 --- a/cloudinit/config/cc_apt_configure.py +++ b/cloudinit/config/cc_apt_configure.py @@ -41,7 +41,7 @@ meta: MetaSchema = { "id": "cc_apt_configure", - "distros": ["ubuntu", "debian"], + "distros": ["ubuntu", "debian", "raspberry-pi-os"], "frequency": PER_INSTANCE, "activate_by_schema_keys": [], } diff --git a/cloudinit/config/cc_apt_pipelining.py b/cloudinit/config/cc_apt_pipelining.py index eacecd171b8..1fb9933bf64 100644 --- a/cloudinit/config/cc_apt_pipelining.py +++ b/cloudinit/config/cc_apt_pipelining.py @@ -28,7 +28,7 @@ meta: MetaSchema = { "id": "cc_apt_pipelining", - "distros": ["ubuntu", "debian"], + "distros": ["ubuntu", "debian", "raspberry-pi-os"], "frequency": PER_INSTANCE, "activate_by_schema_keys": ["apt_pipelining"], } diff --git a/cloudinit/config/cc_byobu.py b/cloudinit/config/cc_byobu.py index 86cc4bda4db..5e9a9a27275 100644 --- a/cloudinit/config/cc_byobu.py +++ b/cloudinit/config/cc_byobu.py @@ -21,7 +21,7 @@ meta: MetaSchema = { "id": "cc_byobu", - "distros": ["ubuntu", "debian"], + "distros": ["ubuntu", "debian", "raspberry-pi-os"], "frequency": PER_INSTANCE, "activate_by_schema_keys": [], } diff --git a/cloudinit/config/cc_ca_certs.py b/cloudinit/config/cc_ca_certs.py index c508cc930d8..67250a05645 100644 --- a/cloudinit/config/cc_ca_certs.py +++ b/cloudinit/config/cc_ca_certs.py @@ -84,6 +84,7 @@ "alpine", "debian", "fedora", + "raspberry-pi-os", "rhel", "opensuse", "opensuse-microos", @@ -158,10 +159,16 @@ def disable_default_ca_certs(distro_name, distro_cfg): """ if distro_name in ["rhel", "photon"]: remove_default_ca_certs(distro_cfg) - elif distro_name in ["alpine", "aosc", "debian", "ubuntu"]: + elif distro_name in [ + "alpine", + "aosc", + "debian", + "raspberry-pi-os", + "ubuntu", + ]: disable_system_ca_certs(distro_cfg) - if distro_name in ["debian", "ubuntu"]: + if distro_name in ["debian", "raspberry-pi-os", "ubuntu"]: debconf_sel = ( "ca-certificates ca-certificates/trust_new_crts " + "select no" ) diff --git a/cloudinit/config/cc_ntp.py b/cloudinit/config/cc_ntp.py index 0501a89a445..a8f1defbfe3 100644 --- a/cloudinit/config/cc_ntp.py +++ b/cloudinit/config/cc_ntp.py @@ -45,6 +45,7 @@ "opensuse-tumbleweed", "opensuse-leap", "photon", + "raspberry-pi-os", "rhel", "rocky", "sle_hpc", @@ -211,6 +212,11 @@ "confpath": "/etc/systemd/timesyncd.conf", }, }, + "raspberry-pi-os": { + "chrony": { + "confpath": "/etc/chrony/chrony.conf", + }, + }, "rhel": { "ntp": { "service_name": "ntpd", diff --git a/cloudinit/config/cc_raspberry_pi.py b/cloudinit/config/cc_raspberry_pi.py new file mode 100644 index 00000000000..a80a8f08b41 --- /dev/null +++ b/cloudinit/config/cc_raspberry_pi.py @@ -0,0 +1,216 @@ +# Copyright (C) 2024-2025, Raspberry Pi Ltd. +# +# Author: Paul Oberosler +# +# This file is part of cloud-init. See LICENSE file for license information. + +import logging +from typing import Union + +from cloudinit import subp +from cloudinit.cloud import Cloud +from cloudinit.config import Config +from cloudinit.config.schema import MetaSchema +from cloudinit.settings import PER_INSTANCE + +LOG = logging.getLogger(__name__) +RPI_BASE_KEY = "rpi" +RPI_INTERFACES_KEY = "interfaces" +ENABLE_RPI_CONNECT_KEY = "enable_rpi_connect" +SUPPORTED_INTERFACES = { + "spi": "do_spi", + "i2c": "do_i2c", + "serial": "do_serial", + "onewire": "do_onewire", + "remote_gpio": "do_rgpio", +} +RASPI_CONFIG_SERIAL_CONS_FN = "do_serial_cons" +RASPI_CONFIG_SERIAL_HW_FN = "do_serial_hw" + +meta: MetaSchema = { + "id": "cc_raspberry_pi", + "distros": ["raspberry-pi-os"], + "frequency": PER_INSTANCE, + "activate_by_schema_keys": [RPI_BASE_KEY], +} + + +def configure_rpi_connect(enable: bool) -> None: + LOG.debug("Configuring rpi-connect: %s", enable) + + num = 0 if enable else 1 + + try: + subp.subp(["/usr/bin/raspi-config", "do_rpi_connect", str(num)]) + except subp.ProcessExecutionError as e: + LOG.error("Failed to configure rpi-connect: %s", e) + + +def is_pifive() -> bool: + try: + subp.subp(["/usr/bin/raspi-config", "nonint", "is_pifive"]) + return True + except subp.ProcessExecutionError: + return False + + +def configure_serial_interface( + cfg: Union[dict, bool], instCfg: Config, cloud: Cloud +) -> None: + def get_bool_field(cfg_dict: dict, name: str, default=False): + val = cfg_dict.get(name, default) + if not isinstance(val, bool): + LOG.warning( + "Invalid value for %s.serial.%s: %s", + RPI_INTERFACES_KEY, + name, + val, + ) + return default + return val + + enable_console = False + enable_hw = False + + if isinstance(cfg, dict): + enable_console = get_bool_field(cfg, "console") + enable_hw = get_bool_field(cfg, "hardware") + + elif isinstance(cfg, bool): + # default to enabling console as if < pi5 + # this will also enable the hardware + enable_console = cfg + + if not is_pifive() and enable_console: + # only pi5 has 2 usable UARTs + # on other models, enabling the console + # will also block the other UART + enable_hw = True + + try: + subp.subp( + [ + "/usr/bin/raspi-config", + "nonint", + RASPI_CONFIG_SERIAL_CONS_FN, + str(0 if enable_console else 1), + ] + ) + + try: + subp.subp( + [ + "/usr/bin/raspi-config", + "nonint", + RASPI_CONFIG_SERIAL_HW_FN, + str(0 if enable_hw else 1), + ] + ) + except subp.ProcessExecutionError as e: + LOG.error("Failed to configure serial hardware: %s", e) + + # Reboot to apply changes + cmd = cloud.distro.shutdown_command( + mode="reboot", + delay="now", + message="Rebooting to apply serial console changes", + ) + subp.subp(cmd) + except subp.ProcessExecutionError as e: + LOG.error("Failed to configure serial console: %s", e) + + +def configure_interface(iface: str, enable: bool) -> None: + assert ( + iface in SUPPORTED_INTERFACES.keys() and iface != "serial" + ), f"Unsupported interface: {iface}" + + try: + subp.subp( + [ + "/usr/bin/raspi-config", + "nonint", + SUPPORTED_INTERFACES[iface], + str(0 if enable else 1), + ] + ) + except subp.ProcessExecutionError as e: + LOG.error("Failed to configure %s: %s", iface, e) + + +def handle(name: str, cfg: Config, cloud: Cloud, args: list) -> None: + if RPI_BASE_KEY not in cfg: + return + elif not isinstance(cfg[RPI_BASE_KEY], dict): + LOG.warning( + "Invalid value for %s: %s", + RPI_BASE_KEY, + cfg[RPI_BASE_KEY], + ) + return + elif not cfg[RPI_BASE_KEY]: + LOG.debug("Empty value for %s. Skipping...", RPI_BASE_KEY) + return + + for key in cfg[RPI_BASE_KEY]: + if key == ENABLE_RPI_CONNECT_KEY: + enable = cfg[RPI_BASE_KEY][key] + + if isinstance(enable, bool): + configure_rpi_connect(enable) + else: + LOG.warning( + "Invalid value for %s: %s", ENABLE_RPI_CONNECT_KEY, enable + ) + continue + elif key == RPI_INTERFACES_KEY: + if not isinstance(cfg[RPI_BASE_KEY][key], dict): + LOG.warning( + "Invalid value for %s: %s", + RPI_BASE_KEY, + cfg[RPI_BASE_KEY][key], + ) + return + elif not cfg[RPI_BASE_KEY][key]: + LOG.debug("Empty value for %s. Skipping...", key) + return + + subkeys = list(cfg[RPI_BASE_KEY][key].keys()) + # Move " serial" to the end if it exists + if "serial" in subkeys: + subkeys.append(subkeys.pop(subkeys.index("serial"))) + + # check for supported ARM interfaces + for subkey in subkeys: + if subkey not in SUPPORTED_INTERFACES.keys(): + LOG.warning( + "Invalid key for %s: %s", RPI_INTERFACES_KEY, subkey + ) + continue + + enable = cfg[RPI_BASE_KEY][key][subkey] + + if subkey == "serial": + if not isinstance(enable, (dict, bool)): + LOG.warning( + "Invalid value for %s.%s: %s", + RPI_INTERFACES_KEY, + subkey, + enable, + ) + else: + configure_serial_interface(enable, cfg, cloud) + continue + + if isinstance(enable, bool): + configure_interface(subkey, enable) + else: + LOG.warning( + "Invalid value for %s.%s: %s", + RPI_INTERFACES_KEY, + subkey, + enable, + ) + else: + LOG.warning("Unsupported key: %s", key) + continue diff --git a/cloudinit/config/cc_ssh_import_id.py b/cloudinit/config/cc_ssh_import_id.py index 3e3bf056d75..7d98832257e 100644 --- a/cloudinit/config/cc_ssh_import_id.py +++ b/cloudinit/config/cc_ssh_import_id.py @@ -23,7 +23,7 @@ meta: MetaSchema = { "id": "cc_ssh_import_id", - "distros": ["alpine", "cos", "debian", "ubuntu"], + "distros": ["alpine", "cos", "debian", "raspberry-pi-os", "ubuntu"], "frequency": PER_INSTANCE, "activate_by_schema_keys": [], } diff --git a/cloudinit/config/schemas/schema-cloud-config-v1.json b/cloudinit/config/schemas/schema-cloud-config-v1.json index a910c5dbc4b..941eac3e9dc 100644 --- a/cloudinit/config/schemas/schema-cloud-config-v1.json +++ b/cloudinit/config/schemas/schema-cloud-config-v1.json @@ -43,6 +43,7 @@ "power-state-change", "power_state_change", "puppet", + "raspberry_pi", "reset-rmc", "reset_rmc", "resizefs", @@ -2644,6 +2645,70 @@ } } }, + "cc_raspberry_pi": { + "type": "object", + "properties": { + "rpi": { + "type": "object", + "properties": { + "interfaces": { + "type": "object", + "properties": { + "spi": { + "type": "boolean", + "description": "Enable SPI interface. Default: ``false``.", + "default": false + }, + "i2c": { + "type": "boolean", + "description": "Enable I2C interface. Default: ``false``.", + "default": false + }, + "serial": { + "default": false, + "description": "Enable serial console. Default: ``false``.", + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "properties": { + "console": { + "type": "boolean", + "description": "Enable serial console. Default: ``false``.", + "default": false + }, + "hardware": { + "type": "boolean", + "description": "Enable UART hardware. Default: ``false``.", + "default": false + } + } + } + ] + }, + "onewire": { + "type": "boolean", + "description": "Enable 1-Wire interface. Default: ``false``.", + "default": false + }, + "remote_gpio": { + "type": "boolean", + "description": "Enable remote GPIO interface. Default: ``false``.", + "default": false + } + } + }, + "enable_rpi_connect": { + "type": "boolean", + "default": false, + "description": "Install and enable Raspberry Pi Connect. Default: ``false``." + } + } + } + } + }, "cc_rsyslog": { "type": "object", "properties": { @@ -3899,6 +3964,9 @@ { "$ref": "#/$defs/cc_puppet" }, + { + "$ref": "#/$defs/cc_raspberry_pi" + }, { "$ref": "#/$defs/cc_resizefs" }, @@ -4049,6 +4117,7 @@ "resize_rootfs": {}, "resolv_conf": {}, "rh_subscription": {}, + "rpi": {}, "rsyslog": {}, "runcmd": {}, "salt_minion": {}, diff --git a/cloudinit/distros/__init__.py b/cloudinit/distros/__init__.py index e9f6d79739a..eb58095cb71 100644 --- a/cloudinit/distros/__init__.py +++ b/cloudinit/distros/__init__.py @@ -64,7 +64,7 @@ "alpine": ["alpine"], "aosc": ["aosc"], "arch": ["arch"], - "debian": ["debian", "ubuntu"], + "debian": ["debian", "ubuntu", "raspberry-pi-os"], "freebsd": ["freebsd", "dragonfly"], "gentoo": ["gentoo", "cos"], "netbsd": ["netbsd"], @@ -1768,6 +1768,11 @@ def _get_arch_package_mirror_info(package_mirrors, arch): def fetch(name: str) -> Type[Distro]: locs, looked_locs = importer.find_module(name, ["", __name__], ["Distro"]) + if not locs: + # Some distros may have a `-` in the name but an `_` in the module + locs, _ = importer.find_module( + name.replace("-", "_"), ["", __name__], ["Distro"] + ) if not locs: raise ImportError( "No distribution found for distro %s (searched %s)" diff --git a/cloudinit/distros/raspberry_pi_os.py b/cloudinit/distros/raspberry_pi_os.py new file mode 100644 index 00000000000..f1bd111acc4 --- /dev/null +++ b/cloudinit/distros/raspberry_pi_os.py @@ -0,0 +1,80 @@ +# Copyright (C) 2024-2025 Raspberry Pi Ltd. All rights reserved. +# +# Author: Paul Oberosler +# +# This file is part of cloud-init. See LICENSE file for license information. + +import logging + +from cloudinit import subp +from cloudinit.distros import debian + +LOG = logging.getLogger(__name__) + + +class Distro(debian.Distro): + def set_keymap(self, layout: str, model: str, variant: str, options: str): + """Currently Raspberry Pi OS sys-mods only supports + setting the layout""" + + subp.subp( + [ + "/usr/lib/raspberrypi-sys-mods/imager_custom", + "set_keymap", + layout, + ] + ) + + def apply_locale(self, locale, out_fn=None, keyname="LANG"): + try: + subp.subp( + [ + "/usr/bin/raspi-config", + "nonint", + "do_change_locale", + f"{locale}", + ] + ) + except subp.ProcessExecutionError: + if not locale.endswith(".UTF-8"): + LOG.info("Trying to set locale %s.UTF-8", locale) + subp.subp( + [ + "/usr/bin/raspi-config", + "nonint", + "do_change_locale", + f"{locale}.UTF-8", + ] + ) + else: + LOG.error("Failed to set locale %s") + + 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. + + Returns False if user already exists, otherwise True. + """ + result = super().add_user(name, **kwargs) + + if not result: + return result + + try: + subp.subp( + [ + "/usr/bin/rename-user", + "-f", + "-s", + ], + update_env={"SUDO_USER": name}, + ) + + except subp.ProcessExecutionError as e: + LOG.error("Failed to setup user: %s", e) + return False + + return True diff --git a/cloudinit/util.py b/cloudinit/util.py index bfcc9c8edba..348cd863f12 100644 --- a/cloudinit/util.py +++ b/cloudinit/util.py @@ -550,6 +550,8 @@ def get_linux_distro(): os_release_rhel = False if os.path.exists("/etc/os-release"): os_release = load_shell_content(load_text_file("/etc/os-release")) + if os.path.exists("/etc/rpi-issue"): + os_release["ID"] = "raspberry-pi-os" if not os_release: os_release_rhel = True os_release = _parse_redhat_release() @@ -625,6 +627,7 @@ def _get_variant(info): "opencloudos", "openmandriva", "photon", + "raspberry-pi-os", "rhel", "rocky", "suse", diff --git a/config/cloud.cfg.tmpl b/config/cloud.cfg.tmpl index 6c891a35e1f..dea7f0312a1 100644 --- a/config/cloud.cfg.tmpl +++ b/config/cloud.cfg.tmpl @@ -10,6 +10,7 @@ "mariner": "MarinerOS", "rhel": "Cloud User", "netbsd": "NetBSD", "openbsd": "openBSD", "openmandriva": "OpenMandriva admin", "photon": "PhotonOS", + "raspberry-pi-os": "Raspberry Pi OS", "ubuntu": "Ubuntu", "unknown": "Ubuntu"}) %} {% set groups = ({"alpine": "adm, wheel", "aosc": "wheel", "arch": "wheel, users", "azurelinux": "wheel", @@ -17,6 +18,7 @@ "gentoo": "users, wheel", "mariner": "wheel", "photon": "wheel", "openmandriva": "wheel, users, systemd-journal", + "raspberry-pi-os": "adm, dialout, cdrom, audio, users, sudo, video, games, plugdev, input, gpio, spi, i2c, netdev, render, lpadmin", "suse": "cdrom, users", "ubuntu": "adm, cdrom, dip, lxd, sudo", "unknown": "adm, cdrom, dip, lxd, sudo"}) %} @@ -24,7 +26,8 @@ "freebsd": "/bin/tcsh", "netbsd": "/bin/sh", "openbsd": "/bin/ksh"}) %} {% set usernames = ({"amazon": "ec2-user", "centos": "cloud-user", - "openmandriva": "omv", "rhel": "cloud-user", + "openmandriva": "omv", "raspberry-pi-os": "pi", + "rhel": "cloud-user", "unknown": "ubuntu"}) %} {% if is_bsd %} syslog_fix_perms: root:wheel @@ -137,6 +140,9 @@ cloud_init_modules: - users_groups - ssh - set_passwords +{% if variant == "raspberry-pi-os" %} + - raspberry_pi +{% endif %} # The modules that run in the 'config' stage cloud_config_modules: @@ -229,8 +235,8 @@ system_info: # This will affect which distro class gets used {% if variant in ["alpine", "amazon", "aosc", "arch", "azurelinux", "debian", "fedora", "freebsd", "gentoo", "mariner", "netbsd", "openbsd", - "OpenCloudOS", "openeuler", "openmandriva", "photon", "suse", - "TencentOS", "ubuntu"] or is_rhel %} + "OpenCloudOS", "openeuler", "openmandriva", "photon", + "raspberry-pi-os", "suse", "TencentOS", "ubuntu"] or is_rhel %} distro: {{ variant }} {% elif variant == "dragonfly" %} distro: dragonflybsd @@ -247,8 +253,8 @@ system_info: {% endif %} {% if variant in ["alpine", "amazon", "aosc", "arch", "azurelinux", "debian", "fedora", "gentoo", "mariner", "OpenCloudOS", "openeuler", - "openmandriva", "photon", "suse", "TencentOS", "ubuntu", - "unknown"] + "openmandriva", "photon", "raspberry-pi-os", + "suse", "TencentOS", "ubuntu", "unknown"] or is_bsd or is_rhel %} lock_passwd: True {% endif %} @@ -307,6 +313,10 @@ system_info: {% elif variant == "openmandriva" %} network: renderers: ['network-manager', 'networkd'] +{% elif variant == "raspberry-pi-os" %} + network: + renderers: ['netplan', 'network-manager'] + activators: ['netplan', 'network-manager'] {% elif variant in ["ubuntu", "unknown"] %} {# SRU_BLOCKER: do not ship network renderers on Xenial, Bionic or Eoan #} network: @@ -326,6 +336,8 @@ system_info: {% if variant in ["debian", "ubuntu", "unknown"] %} # Automatically discover the best ntp_client ntp_client: auto +{% elif variant == "raspberry-pi-os" %} + ntp_client: 'systemd-timesyncd' {% endif %} {% if variant in ["alpine", "amazon", "aosc", "arch", "azurelinux", "debian", "fedora", "gentoo", "mariner", "OpenCloudOS", "openeuler", @@ -346,6 +358,19 @@ system_info: failsafe: primary: https://deb.debian.org/debian security: https://deb.debian.org/debian-security +{% elif variant == "raspberry-pi-os" %} + package_mirrors: + - arches: [arm64] + failsafe: + primary: + - https://deb.debian.org/debian + - http://archive.raspberrypi.com/debian/ + security: https://deb.debian.org/debian-security + - arches: [armhf] + failsafe: + primary: + - http://raspbian.raspberrypi.com/raspbian/ + - http://archive.raspberrypi.com/debian/ {% elif variant in ["ubuntu", "unknown"] %} package_mirrors: - arches: [i386, amd64] @@ -373,7 +398,7 @@ system_info: primary: http://ports.ubuntu.com/ubuntu-ports security: http://ports.ubuntu.com/ubuntu-ports {% endif %} -{% if variant in ["debian", "ubuntu", "unknown"] %} +{% if variant in ["debian", "raspberry-pi-os", "ubuntu", "unknown"] %} ssh_svcname: ssh {% elif variant in ["alpine", "amazon", "aosc", "arch", "azurelinux", "fedora", "gentoo", "mariner", "OpenCloudOS", "openeuler", diff --git a/doc/module-docs/cc_raspberry_pi/data.yaml b/doc/module-docs/cc_raspberry_pi/data.yaml new file mode 100644 index 00000000000..b7882bc1a95 --- /dev/null +++ b/doc/module-docs/cc_raspberry_pi/data.yaml @@ -0,0 +1,26 @@ +cc_raspberry_pi: + description: | + This module handles ARM interface configuration for Raspberry Pi. + + It also handles Raspberry Pi Connect installation and enablement. + Raspberry Pi Connect service will be installed and enabled to auto start on boot. + + This only works on Raspberry Pi OS (bookworm and later). + examples: + - comment: > + This example will enable the SPI and I2C interfaces on Raspberry Pi. + file: cc_raspberry_pi/example1.yaml + - comment: > + This example will enable the serial interface on Raspberry Pi. + file: cc_raspberry_pi/example2.yaml + - comment: > + This example will enable the serial interface on Raspberry Pi 5 and disable the UART hardware while enabling the console. + file: cc_raspberry_pi/example3.yaml + - comment: > + This example will enable ssh and the UART hardware without binding it to the console. + file: cc_raspberry_pi/example4.yaml + - comment: > + This example will enable the Raspberry Pi Connect service. + file: cc_raspberry_pi/example5.yaml + name: Raspberry Pi Configuration + title: Configure Raspberry Pi ARM interfaces and enable Raspberry Pi Connect diff --git a/doc/module-docs/cc_raspberry_pi/example1.yaml b/doc/module-docs/cc_raspberry_pi/example1.yaml new file mode 100644 index 00000000000..0fe2ac83f91 --- /dev/null +++ b/doc/module-docs/cc_raspberry_pi/example1.yaml @@ -0,0 +1,5 @@ +#cloud-config +rpi: + interfaces: + spi: true + i2c: true diff --git a/doc/module-docs/cc_raspberry_pi/example2.yaml b/doc/module-docs/cc_raspberry_pi/example2.yaml new file mode 100644 index 00000000000..83ef6390b7a --- /dev/null +++ b/doc/module-docs/cc_raspberry_pi/example2.yaml @@ -0,0 +1,4 @@ +#cloud-config +rpi: + interfaces: + serial: true diff --git a/doc/module-docs/cc_raspberry_pi/example3.yaml b/doc/module-docs/cc_raspberry_pi/example3.yaml new file mode 100644 index 00000000000..eb74656aa7f --- /dev/null +++ b/doc/module-docs/cc_raspberry_pi/example3.yaml @@ -0,0 +1,7 @@ +#cloud-config +rpi: + interfaces: + serial: + # Pi 5 only | disabling hardware while enabling console + console: true + hardware: false diff --git a/doc/module-docs/cc_raspberry_pi/example4.yaml b/doc/module-docs/cc_raspberry_pi/example4.yaml new file mode 100644 index 00000000000..c600a276ffc --- /dev/null +++ b/doc/module-docs/cc_raspberry_pi/example4.yaml @@ -0,0 +1,9 @@ +#cloud-config +rpi: + interfaces: + ssh: true + # works on all Pi models + # only enables the UART hardware without binding it to the console + serial: + console: false + hardware: true diff --git a/doc/module-docs/cc_raspberry_pi/example5.yaml b/doc/module-docs/cc_raspberry_pi/example5.yaml new file mode 100644 index 00000000000..3d5354ab3db --- /dev/null +++ b/doc/module-docs/cc_raspberry_pi/example5.yaml @@ -0,0 +1,3 @@ +#cloud-config +rpi: + enable_rpi_connect: true diff --git a/doc/rtd/reference/availability.rst b/doc/rtd/reference/availability.rst index c4762208005..5f6b983c55d 100644 --- a/doc/rtd/reference/availability.rst +++ b/doc/rtd/reference/availability.rst @@ -39,6 +39,7 @@ NetBSD, OpenBSD and DragonFlyBSD: - OpenCloudOS - OpenMandriva - Photon OS +- Raspberry Pi OS - Red Hat Enterprise Linux (RHEL) - Rocky Linux - SLES/openSUSE diff --git a/doc/rtd/reference/modules.rst b/doc/rtd/reference/modules.rst index c5cdef1620a..5ce7134de79 100644 --- a/doc/rtd/reference/modules.rst +++ b/doc/rtd/reference/modules.rst @@ -74,6 +74,8 @@ Modules :template: modules.tmpl .. datatemplate:yaml:: ../../module-docs/cc_puppet/data.yaml :template: modules.tmpl +.. datatemplate:yaml:: ../../module-docs/cc_raspberry_pi/data.yaml + :template: modules.tmpl .. datatemplate:yaml:: ../../module-docs/cc_resizefs/data.yaml :template: modules.tmpl .. datatemplate:yaml:: ../../module-docs/cc_resolv_conf/data.yaml diff --git a/doc/rtd/reference/network-config.rst b/doc/rtd/reference/network-config.rst index 46d4d977e76..5bd99c9a4d7 100644 --- a/doc/rtd/reference/network-config.rst +++ b/doc/rtd/reference/network-config.rst @@ -182,13 +182,13 @@ supports a wide range of networking setups. Configuration is typically stored in :file:`/etc/NetworkManager`. It is the default for a number of Linux distributions; notably Fedora, -CentOS/RHEL, and their derivatives. +CentOS/RHEL, Raspberry Pi OS, and their derivatives. ENI --- :file:`/etc/network/interfaces` or ``ENI`` is supported by the ``ifupdown`` -package found in Alpine Linux, Debian and Ubuntu. +package found in Alpine Linux, Debian, Raspberry Pi OS and Ubuntu. Netplan ------- @@ -270,7 +270,7 @@ Example output: .. code-block:: usage: /usr/bin/cloud-init devel net-convert [-h] -p PATH -k {eni,network_data.json,yaml,azure-imds,vmware-imc} -d PATH -D - {alpine,arch,azurelinux,debian,ubuntu,freebsd,dragonfly,gentoo,cos,netbsd,openbsd,almalinux,amazon,centos,cloudlinux,eurolinux,fedora,mariner,miraclelinux,openmandriva,photon,rhel,rocky,virtuozzo,opensuse,sles,openEuler} + {alpine,arch,azurelinux,debian,ubuntu,freebsd,dragonfly,gentoo,cos,netbsd,openbsd,almalinux,amazon,centos,cloudlinux,eurolinux,fedora,mariner,miraclelinux,openmandriva,photon,rhel,rocky,virtuozzo,opensuse,sles,openEuler,raspberry-pi-os} [-m name,mac] [--debug] -O {eni,netplan,networkd,sysconfig,network-manager} options: @@ -281,7 +281,7 @@ Example output: The format of the given network config -d PATH, --directory PATH directory to place output in - -D {alpine,arch,azurelinux,debian,ubuntu,freebsd,dragonfly,gentoo,cos,netbsd,openbsd,almalinux,amazon,centos,cloudlinux,eurolinux,fedora,mariner,miraclelinux,openmandriva,photon,rhel,rocky,virtuozzo,opensuse,sles,openeuler}, --distro {alpine,arch,azurelinux,debian,ubuntu,freebsd,dragonfly,gentoo,cos,netbsd,openbsd,almalinux,amazon,centos,cloudlinux,eurolinux,fedora,mariner,miraclelinux,openmandriva,photon,rhel,rocky,virtuozzo,opensuse,sles,openEuler} + -D {alpine,arch,azurelinux,debian,ubuntu,freebsd,dragonfly,gentoo,cos,netbsd,openbsd,almalinux,amazon,centos,cloudlinux,eurolinux,fedora,mariner,miraclelinux,openmandriva,photon,rhel,rocky,virtuozzo,opensuse,sles,openeuler}, --distro {alpine,arch,azurelinux,debian,ubuntu,freebsd,dragonfly,gentoo,cos,netbsd,openbsd,almalinux,amazon,centos,cloudlinux,eurolinux,fedora,mariner,miraclelinux,openmandriva,photon,rhel,rocky,virtuozzo,opensuse,sles,openEuler,raspberry-pi-os} -m name,mac, --mac name,mac interface name to mac mapping --debug enable debug logging to stderr. diff --git a/systemd/cloud-init-local.service.tmpl b/systemd/cloud-init-local.service.tmpl index 3bbbc7f0bbe..26a6aee1d05 100644 --- a/systemd/cloud-init-local.service.tmpl +++ b/systemd/cloud-init-local.service.tmpl @@ -2,7 +2,7 @@ [Unit] # https://docs.cloud-init.io/en/latest/explanation/boot.html Description=Cloud-init: Local Stage (pre-network) -{% if variant in ["almalinux", "cloudlinux", "ubuntu", "unknown", "debian", "rhel"] %} +{% if variant in ["almalinux", "cloudlinux", "ubuntu", "unknown", "debian", "raspberry-pi-os", "rhel"] %} DefaultDependencies=no {% endif %} Wants=network-pre.target @@ -13,7 +13,7 @@ Before=shutdown.target {% if variant in ["almalinux", "cloudlinux", "rhel"] %} Before=firewalld.target {% endif %} -{% if variant in ["ubuntu", "unknown", "debian"] %} +{% if variant in ["ubuntu", "unknown", "debian", "raspberry-pi-os"] %} Before=sysinit.target {% endif %} Conflicts=shutdown.target diff --git a/systemd/cloud-init-main.service.tmpl b/systemd/cloud-init-main.service.tmpl index b80f324fe7a..751259df990 100644 --- a/systemd/cloud-init-main.service.tmpl +++ b/systemd/cloud-init-main.service.tmpl @@ -8,7 +8,8 @@ # https://www.freedesktop.org/software/systemd/man/latest/systemd-remount-fs.service.html [Unit] Description=Cloud-init: Single Process -{% if variant in ["almalinux", "cloudlinux", "ubuntu", "unknown", "debian", "rhel"] %} +Wants=network-pre.target +{% if variant in ["almalinux", "cloudlinux", "ubuntu", "unknown", "debian", "raspberry-pi-os", "rhel"] %} DefaultDependencies=no {% endif %} {% if variant in ["almalinux", "cloudlinux", "rhel"] %} diff --git a/systemd/cloud-init-network.service.tmpl b/systemd/cloud-init-network.service.tmpl index bdc7c8f8369..61425b4a9fd 100644 --- a/systemd/cloud-init-network.service.tmpl +++ b/systemd/cloud-init-network.service.tmpl @@ -2,7 +2,7 @@ [Unit] # https://docs.cloud-init.io/en/latest/explanation/boot.html Description=Cloud-init: Network Stage -{% if variant not in ["almalinux", "cloudlinux", "photon", "rhel"] %} +{% if variant not in ["almalinux", "cloudlinux", "photon", "raspberry-pi-os", "rhel"] %} DefaultDependencies=no {% endif %} Wants=cloud-init-local.service @@ -12,12 +12,12 @@ After=cloud-init-local.service {% if variant not in ["ubuntu"] %} After=systemd-networkd-wait-online.service {% endif %} -{% if variant in ["ubuntu", "unknown", "debian"] %} +{% if variant in ["ubuntu", "unknown", "debian", "raspberry-pi-os"] %} After=networking.service {% endif %} {% if variant in ["almalinux", "centos", "cloudlinux", "eurolinux", "fedora", - "miraclelinux", "openeuler", "OpenCloudOS", "openmandriva", "rhel", "rocky", - "suse", "TencentOS", "virtuozzo"] %} + "miraclelinux", "openeuler", "OpenCloudOS", "openmandriva", "raspberry-pi-os", + "rhel", "rocky", "suse", "TencentOS", "virtuozzo"] %} After=NetworkManager.service After=NetworkManager-wait-online.service {% endif %} @@ -28,6 +28,9 @@ After=wicked.service After=dbus.service {% endif %} Before=network-online.target +{% if variant == "raspberry-pi-os" %} +Before=avahi-daemon.service +{% endif %} Before=sshd-keygen.service Before=sshd.service Before=systemd-user-sessions.service diff --git a/tests/unittests/config/test_cc_raspberry_pi.py b/tests/unittests/config/test_cc_raspberry_pi.py new file mode 100644 index 00000000000..c0bfc8591cf --- /dev/null +++ b/tests/unittests/config/test_cc_raspberry_pi.py @@ -0,0 +1,215 @@ +# This file is part of cloud-init. See LICENSE file for license information. + +import pytest + +import cloudinit.config.cc_raspberry_pi as cc_rpi +from cloudinit.config.cc_raspberry_pi import ( + ENABLE_RPI_CONNECT_KEY, + RPI_BASE_KEY, + RPI_INTERFACES_KEY, +) +from cloudinit.config.schema import ( + SchemaValidationError, + get_schema, + validate_cloudconfig_schema, +) +from cloudinit.subp import ProcessExecutionError +from tests.unittests.helpers import mock, skipUnlessJsonSchema +from tests.unittests.util import get_cloud + +M_PATH = "cloudinit.config.cc_raspberry_pi." + + +class TestHandleRaspberryPi: + @mock.patch(M_PATH + "configure_rpi_connect") + def test_handle_rpi_connect_enabled(self, m_connect): + cloud = get_cloud("raspberry_pi_os") + cfg = {RPI_BASE_KEY: {ENABLE_RPI_CONNECT_KEY: True}} + cc_rpi.handle("cc_raspberry_pi", cfg, cloud, []) + m_connect.assert_called_once_with(True) + + @mock.patch(M_PATH + "configure_interface") + def test_handle_configure_interface_i2c(self, m_iface): + cloud = get_cloud("raspberry_pi_os") + cfg = {RPI_BASE_KEY: {RPI_INTERFACES_KEY: {"i2c": True}}} + cc_rpi.handle("cc_raspberry_pi", cfg, cloud, []) + m_iface.assert_called_once_with("i2c", True) + + @mock.patch(M_PATH + "configure_serial_interface") + @mock.patch(M_PATH + "is_pifive", return_value=True) + def test_handle_configure_serial_interface_dict(self, m_ispi5, m_serial): + cloud = get_cloud("raspberry_pi_os") + serial_value = { + "console": True, + "hardware": True, + } + cfg = {RPI_BASE_KEY: {RPI_INTERFACES_KEY: {"serial": serial_value}}} + cc_rpi.handle("cc_raspberry_pi", cfg, cloud, []) + m_serial.assert_called_once_with(serial_value, cfg, cloud) + + @mock.patch(M_PATH + "configure_serial_interface") + @mock.patch(M_PATH + "is_pifive", return_value=True) + def test_handle_configure_serial_interface_bool(self, m_ispi5, m_serial): + cloud = get_cloud("raspberry_pi_os") + cfg = {RPI_BASE_KEY: {RPI_INTERFACES_KEY: {"serial": True}}} + cc_rpi.handle("cc_raspberry_pi", cfg, cloud, []) + m_serial.assert_called_once_with(True, cfg, cloud) + + +class TestRaspberryPiMethods: + @mock.patch("cloudinit.subp.subp") + def test_configure_rpi_connect_enable(self, m_subp): + cc_rpi.configure_rpi_connect(True) + m_subp.assert_called_once_with( + ["/usr/bin/raspi-config", "do_rpi_connect", "0"] + ) + + @mock.patch( + "cloudinit.subp.subp", + side_effect=ProcessExecutionError("1", [], "fail"), + ) + def test_configure_rpi_connect_failure(self, m_subp): + cc_rpi.configure_rpi_connect(False) # Should log error but not raise + + @mock.patch("cloudinit.subp.subp", return_value=("ok", "")) + def test_is_pifive_true(self, m_subp): + assert cc_rpi.is_pifive() is True + + @mock.patch( + "cloudinit.subp.subp", + side_effect=ProcessExecutionError("1", [], "fail"), + ) + def test_is_pifive_false(self, m_subp): + assert cc_rpi.is_pifive() is False + + @mock.patch("cloudinit.subp.subp") + def test_configure_interface_valid(self, m_subp): + cc_rpi.configure_interface("i2c", True) + m_subp.assert_called_once_with( + ["/usr/bin/raspi-config", "nonint", "do_i2c", "0"] + ) + + def test_configure_interface_invalid(self): + with pytest.raises(AssertionError): + cc_rpi.configure_interface("invalid_iface", True) + + @mock.patch("cloudinit.subp.subp") + @mock.patch(M_PATH + "is_pifive", return_value=True) + def test_configure_serial_interface_dict_config(self, m_ispi5, m_subp): + cloud = get_cloud("raspberry_pi_os") + cfg = {"console": True, "hardware": False} + + # Simulate is_pifive returning True to prevent enable_hw override + with mock.patch.object( + cloud.distro, "shutdown_command", return_value=["reboot"] + ): + cc_rpi.configure_serial_interface(cfg, {}, cloud) + + expected_calls = [ + mock.call( + [ + "/usr/bin/raspi-config", + "nonint", + cc_rpi.RASPI_CONFIG_SERIAL_CONS_FN, + "0", + ] + ), + mock.call( + [ + "/usr/bin/raspi-config", + "nonint", + cc_rpi.RASPI_CONFIG_SERIAL_HW_FN, + "1", + ] + ), + mock.call(["reboot"]), + ] + m_subp.assert_has_calls(expected_calls, any_order=False) + + @mock.patch("cloudinit.subp.subp") + @mock.patch(M_PATH + "is_pifive", return_value=False) + def test_configure_serial_interface_boolean_config_non_pi5( + self, m_ispi5, m_subp + ): + cloud = get_cloud("raspberry_pi_os") + + with mock.patch.object( + cloud.distro, + "shutdown_command", + return_value=["shutdown", "-r", "now"], + ): + cc_rpi.configure_serial_interface(True, {}, cloud) + + expected_calls = [ + mock.call( + [ + "/usr/bin/raspi-config", + "nonint", + cc_rpi.RASPI_CONFIG_SERIAL_CONS_FN, + "0", + ] + ), + mock.call( + [ + "/usr/bin/raspi-config", + "nonint", + cc_rpi.RASPI_CONFIG_SERIAL_HW_FN, + "0", + ] + ), + mock.call(["shutdown", "-r", "now"]), + ] + m_subp.assert_has_calls(expected_calls, any_order=False) + + +@skipUnlessJsonSchema() +class TestRaspberryPiSchema: + @pytest.mark.parametrize( + "config, error_msg", + [ + ( + { + RPI_BASE_KEY: { + RPI_INTERFACES_KEY: {"spi": True, "i2c": False} + } + }, + None, + ), + ( + {RPI_BASE_KEY: {RPI_INTERFACES_KEY: {"spi": "true"}}}, + f"{RPI_BASE_KEY}.{RPI_INTERFACES_KEY}.spi: 'true'" + " is not of type 'boolean'", + ), + ( + { + RPI_BASE_KEY: { + RPI_INTERFACES_KEY: { + "serial": {"console": True, "hardware": False} + } + } + }, + None, + ), + ( + { + RPI_BASE_KEY: { + RPI_INTERFACES_KEY: {"serial": {"console": 123}} + } + }, + f"{RPI_BASE_KEY}.{RPI_INTERFACES_KEY}.serial.console: " + "123 is not of type 'boolean'", + ), + ({RPI_BASE_KEY: {ENABLE_RPI_CONNECT_KEY: True}}, None), + ( + {RPI_BASE_KEY: {ENABLE_RPI_CONNECT_KEY: "true"}}, + f"{RPI_BASE_KEY}.{ENABLE_RPI_CONNECT_KEY}: 'true'" + " is not of type 'boolean'", + ), + ], + ) + def test_schema_validation(self, config, error_msg): + if error_msg is None: + validate_cloudconfig_schema(config, get_schema(), strict=True) + else: + with pytest.raises(SchemaValidationError, match=error_msg): + validate_cloudconfig_schema(config, get_schema(), strict=True) diff --git a/tests/unittests/config/test_schema.py b/tests/unittests/config/test_schema.py index 3da6d925c02..fb16a491939 100644 --- a/tests/unittests/config/test_schema.py +++ b/tests/unittests/config/test_schema.py @@ -254,6 +254,7 @@ def test_get_schema_coalesces_known_schema(self): {"$ref": "#/$defs/cc_phone_home"}, {"$ref": "#/$defs/cc_power_state_change"}, {"$ref": "#/$defs/cc_puppet"}, + {"$ref": "#/$defs/cc_raspberry_pi"}, {"$ref": "#/$defs/cc_resizefs"}, {"$ref": "#/$defs/cc_resolv_conf"}, {"$ref": "#/$defs/cc_rh_subscription"}, diff --git a/tests/unittests/distros/test_raspberry_pi_os.py b/tests/unittests/distros/test_raspberry_pi_os.py new file mode 100644 index 00000000000..508b84bba11 --- /dev/null +++ b/tests/unittests/distros/test_raspberry_pi_os.py @@ -0,0 +1,95 @@ +# This file is part of cloud-init. See LICENSE file for license information. + +import logging + +from cloudinit.distros import fetch +from cloudinit.subp import ProcessExecutionError +from tests.unittests.helpers import mock + +M_PATH = "cloudinit.distros.raspberry_pi_os." + + +class TestRaspberryPiOS: + @mock.patch(M_PATH + "subp.subp") + def test_set_keymap_calls_imager_custom(self, m_subp): + cls = fetch("raspberry_pi_os") + distro = cls("raspberry-pi-os", {}, None) + distro.set_keymap("us", "pc105", "basic", "") + m_subp.assert_called_once_with( + ["/usr/lib/raspberrypi-sys-mods/imager_custom", "set_keymap", "us"] + ) + + @mock.patch(M_PATH + "subp.subp") + def test_apply_locale_happy_path(self, m_subp): + cls = fetch("raspberry_pi_os") + distro = cls("raspberry-pi-os", {}, None) + distro.apply_locale("en_GB.UTF-8") + m_subp.assert_called_once_with( + [ + "/usr/bin/raspi-config", + "nonint", + "do_change_locale", + "en_GB.UTF-8", + ] + ) + + @mock.patch(M_PATH + "subp.subp") + def test_apply_locale_fallback_to_utf8(self, m_subp): + m_subp.side_effect = [ + ProcessExecutionError("Invalid locale"), # Simulate failure + None, # Fallback succeeds + ] + cls = fetch("raspberry_pi_os") + distro = cls("raspberry-pi-os", {}, None) + distro.apply_locale("en_GB") + assert m_subp.call_count == 2 + m_subp.assert_any_call( + ["/usr/bin/raspi-config", "nonint", "do_change_locale", "en_GB"] + ) + m_subp.assert_any_call( + [ + "/usr/bin/raspi-config", + "nonint", + "do_change_locale", + "en_GB.UTF-8", + ] + ) + + @mock.patch(M_PATH + "subp.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"}, + ) + + @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) + with mock.patch( + "cloudinit.distros.debian.Distro.add_user", return_value=False + ): + assert distro.add_user("pi") is False + m_subp.assert_not_called() + + @mock.patch( + M_PATH + "subp.subp", + side_effect=ProcessExecutionError("rename-user 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 + ): + 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 diff --git a/tests/unittests/test_render_template.py b/tests/unittests/test_render_template.py index 0ed9464821d..a152a8437a7 100644 --- a/tests/unittests/test_render_template.py +++ b/tests/unittests/test_render_template.py @@ -22,6 +22,7 @@ "netbsd", "openbsd", "photon", + "raspberry-pi-os", "rhel", "suse", "ubuntu", @@ -93,6 +94,7 @@ def test_variant_sets_default_user_in_cloud_cfg(self, variant, tmpdir): "amazon": "ec2-user", "rhel": "cloud-user", "centos": "cloud-user", + "raspberry-pi-os": "pi", "unknown": "ubuntu", } default_user = system_cfg["system_info"]["default_user"]["name"] @@ -105,6 +107,7 @@ def test_variant_sets_default_user_in_cloud_cfg(self, variant, tmpdir): ("netbsd", ["netbsd"]), ("openbsd", ["openbsd"]), ("ubuntu", ["netplan", "eni", "sysconfig"]), + ("raspberry-pi-os", ["netplan", "network-manager"]), ), ) def test_variant_sets_network_renderer_priority_in_cloud_cfg( diff --git a/tools/render-template b/tools/render-template index 78beeecb2cf..4b5efcf347b 100755 --- a/tools/render-template +++ b/tools/render-template @@ -34,6 +34,7 @@ def main(): "OpenCloudOS", "openmandriva", "photon", + "raspberry-pi-os", "rhel", "suse", "rocky",