From 683ffc895af23e5903258c6520d65241c95b2a50 Mon Sep 17 00:00:00 2001 From: paulober <44974737+paulober@users.noreply.github.com> Date: Thu, 17 Oct 2024 09:33:51 +0100 Subject: [PATCH] Added Raspberry Pi OS support This commit adds support for the Raspberry Pi OS debian distribution. It includes a distro definition, 3 modules and integration into other systems like config generation. Signed-off-by: paulober <44974737+paulober@users.noreply.github.com> --- .gitignore | 8 + cloudinit/config/cc_apt_configure.py | 2 +- cloudinit/config/cc_apt_pipelining.py | 2 +- cloudinit/config/cc_byobu.py | 2 +- cloudinit/config/cc_ca_certs.py | 11 +- cloudinit/config/cc_ntp.py | 6 + cloudinit/config/cc_rpi_connect.py | 47 ++++ cloudinit/config/cc_rpi_interfaces.py | 180 ++++++++++++++ cloudinit/config/cc_rpi_userdata.py | 228 ++++++++++++++++++ cloudinit/config/cc_ssh_import_id.py | 2 +- .../schemas/schema-cloud-config-v1.json | 111 +++++++++ cloudinit/distros/__init__.py | 2 +- cloudinit/distros/raspberry-pi-os.py | 45 ++++ cloudinit/util.py | 3 + config/cloud.cfg.tmpl | 45 +++- doc/module-docs/cc_rpi_connect/data.yaml | 13 + doc/module-docs/cc_rpi_connect/example1.yaml | 2 + doc/module-docs/cc_rpi_interfaces/data.yaml | 19 ++ .../cc_rpi_interfaces/example1.yaml | 4 + .../cc_rpi_interfaces/example2.yaml | 3 + .../cc_rpi_interfaces/example3.yaml | 6 + .../cc_rpi_interfaces/example4.yaml | 8 + doc/module-docs/cc_rpi_userdata/data.yaml | 26 ++ doc/module-docs/cc_rpi_userdata/example1.yaml | 2 + doc/module-docs/cc_rpi_userdata/example2.yaml | 3 + doc/module-docs/cc_rpi_userdata/example3.yaml | 4 + doc/rtd/reference/availability.rst | 1 + doc/rtd/reference/distros.rst | 1 + doc/rtd/reference/modules.rst | 6 + doc/rtd/reference/network-config.rst | 8 +- pyproject.toml | 3 + systemd/cloud-final.service.tmpl | 2 +- systemd/cloud-init-local.service.tmpl | 4 +- systemd/cloud-init-main.service.tmpl | 4 +- systemd/cloud-init-network.service.tmpl | 14 +- tests/unittests/config/test_cc_rpi_connect.py | 76 ++++++ .../config/test_cc_rpi_interfaces.py | 124 ++++++++++ .../unittests/config/test_cc_rpi_userdata.py | 93 +++++++ tests/unittests/config/test_schema.py | 3 + tests/unittests/test_cli.py | 3 +- tests/unittests/test_render_template.py | 6 + tools/render-template | 1 + 42 files changed, 1108 insertions(+), 25 deletions(-) create mode 100644 cloudinit/config/cc_rpi_connect.py create mode 100644 cloudinit/config/cc_rpi_interfaces.py create mode 100644 cloudinit/config/cc_rpi_userdata.py create mode 100644 cloudinit/distros/raspberry-pi-os.py create mode 100644 doc/module-docs/cc_rpi_connect/data.yaml create mode 100644 doc/module-docs/cc_rpi_connect/example1.yaml create mode 100644 doc/module-docs/cc_rpi_interfaces/data.yaml create mode 100644 doc/module-docs/cc_rpi_interfaces/example1.yaml create mode 100644 doc/module-docs/cc_rpi_interfaces/example2.yaml create mode 100644 doc/module-docs/cc_rpi_interfaces/example3.yaml create mode 100644 doc/module-docs/cc_rpi_interfaces/example4.yaml create mode 100644 doc/module-docs/cc_rpi_userdata/data.yaml create mode 100644 doc/module-docs/cc_rpi_userdata/example1.yaml create mode 100644 doc/module-docs/cc_rpi_userdata/example2.yaml create mode 100644 doc/module-docs/cc_rpi_userdata/example3.yaml create mode 100644 tests/unittests/config/test_cc_rpi_connect.py create mode 100644 tests/unittests/config/test_cc_rpi_interfaces.py create mode 100644 tests/unittests/config/test_cc_rpi_userdata.py diff --git a/.gitignore b/.gitignore index 8a85858a472..a4405880734 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,13 @@ build cloud_init.egg-info dist +*.deb +*.tar.xz +*.tar.gz +*.build +*.dsc +*.changes +*.buildinfo *.pyc __pycache__ .tox @@ -37,3 +44,4 @@ cloud-init_*.upload # user test settings tests/integration_tests/user_settings.py + diff --git a/cloudinit/config/cc_apt_configure.py b/cloudinit/config/cc_apt_configure.py index 35445711e60..e6e0b8ddebb 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 8ecb51f46ba..356639a5a0f 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 2e56e1c918f..8b3f7762d59 100644 --- a/cloudinit/config/cc_ca_certs.py +++ b/cloudinit/config/cc_ca_certs.py @@ -83,6 +83,7 @@ "alpine", "debian", "fedora", + "raspberry-pi-os", "rhel", "opensuse", "opensuse-microos", @@ -157,10 +158,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 647c5d4f087..1e9f6b5e9c0 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_rpi_connect.py b/cloudinit/config/cc_rpi_connect.py new file mode 100644 index 00000000000..09e6aa0c301 --- /dev/null +++ b/cloudinit/config/cc_rpi_connect.py @@ -0,0 +1,47 @@ +# Copyright (C) 2024, Raspberry Pi Ltd. +# +# Author: Paul Oberosler +# +# This file is part of cloud-init. See LICENSE file for license information. + +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 +import logging + + +LOG = logging.getLogger(__name__) +ENABLE_RPI_CONNECT_KEY = "enable_rpi_connect" + +meta: MetaSchema = { + "id": "cc_rpi_connect", + "distros": ["raspberry-pi-os"], + "frequency": PER_INSTANCE, + "activate_by_schema_keys": [ENABLE_RPI_CONNECT_KEY], +} + + +def configure_rpi_connect(enable: bool) -> None: + LOG.debug(f"Configuring rpi-connect: {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 handle(name: str, cfg: Config, cloud: Cloud, args: list) -> None: + if ENABLE_RPI_CONNECT_KEY in cfg: + # expect it to be a dictionary + enable = cfg[ENABLE_RPI_CONNECT_KEY] + + if isinstance(enable, bool): + configure_rpi_connect(enable) + else: + LOG.warning( + "Invalid value for %s: %s", ENABLE_RPI_CONNECT_KEY, enable + ) diff --git a/cloudinit/config/cc_rpi_interfaces.py b/cloudinit/config/cc_rpi_interfaces.py new file mode 100644 index 00000000000..d8fecf014b7 --- /dev/null +++ b/cloudinit/config/cc_rpi_interfaces.py @@ -0,0 +1,180 @@ +# Copyright (C) 2024, Raspberry Pi Ltd. +# +# Author: Paul Oberosler +# +# This file is part of cloud-init. See LICENSE file for license information. + +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 +import logging + + +LOG = logging.getLogger(__name__) +RPI_INTERFACES_KEY = "rpi_interfaces" +SUPPORTED_INTERFACES = { + "spi": "do_spi", + "i2c": "do_i2c", + "serial": "do_serial", + "onewire": "do_onewire", + "remote_gpio": "do_rgpio", + "ssh": "enable_ssh", +} +RASPI_CONFIG_SERIAL_CONS_FN = "do_serial_cons" +RASPI_CONFIG_SERIAL_HW_FN = "do_serial_hw" + +meta: MetaSchema = { + "id": "cc_rpi_interfaces", + "distros": ["raspberry-pi-os"], + "frequency": PER_INSTANCE, + "activate_by_schema_keys": [RPI_INTERFACES_KEY], +} + + +# TODO: test +def require_reboot(cfg: Config) -> None: + cfg["power_state"] = cfg.get("power_state", {}) + cfg["power_state"]["mode"] = cfg["power_state"].get("mode", "reboot") + cfg["power_state"]["condition"] = True + + +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: dict | bool, instCfg: Config) -> None: + enable_console = False + enable_hw = False + + if isinstance(cfg, dict): + enable_console = cfg.get("console", False) + enable_hw = cfg.get("hardware", False) + 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) + + require_reboot(instCfg) + except subp.ProcessExecutionError as e: + LOG.error("Failed to configure serial console: %s", e) + + +def enable_ssh(cfg: Config, enable: bool) -> None: + if not enable: + return + + try: + subp.subp( + [ + "/usr/lib/raspberry-pi-sys-mods/imager_custom", + SUPPORTED_INTERFACES["ssh"], + ] + ) + require_reboot(cfg) + except subp.ProcessExecutionError as e: + LOG.error("Failed to enable ssh: %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_INTERFACES_KEY not in cfg: + return + elif not isinstance(cfg[RPI_INTERFACES_KEY], dict): + LOG.warning( + "Invalid value for %s: %s", + RPI_INTERFACES_KEY, + cfg[RPI_INTERFACES_KEY], + ) + return + elif not cfg[RPI_INTERFACES_KEY]: + LOG.debug("Empty value for %s. Skipping...", RPI_INTERFACES_KEY) + return + + # check for supported ARM interfaces + for key in cfg[RPI_INTERFACES_KEY]: + if key not in SUPPORTED_INTERFACES.keys(): + LOG.warning("Invalid key for %s: %s", RPI_INTERFACES_KEY, key) + continue + + enable = cfg[RPI_INTERFACES_KEY][key] + + if key == "serial": + if not isinstance(enable, dict) and not isinstance(enable, bool): + LOG.warning( + "Invalid value for %s.%s: %s", + RPI_INTERFACES_KEY, + key, + enable, + ) + else: + configure_serial_interface(enable, cfg) + continue + elif key == "ssh": + if not isinstance(enable, bool): + LOG.warning( + "Invalid value for %s.%s: %s", + RPI_INTERFACES_KEY, + key, + enable, + ) + else: + enable_ssh(cfg, enable) + continue + + if isinstance(enable, bool): + configure_interface(key, enable) + else: + LOG.warning( + "Invalid value for %s.%s: %s", RPI_INTERFACES_KEY, key, enable + ) diff --git a/cloudinit/config/cc_rpi_userdata.py b/cloudinit/config/cc_rpi_userdata.py new file mode 100644 index 00000000000..2c19f711187 --- /dev/null +++ b/cloudinit/config/cc_rpi_userdata.py @@ -0,0 +1,228 @@ +# Copyright (C) 2024, Raspberry Pi Ltd. +# +# Author: Paul Oberosler +# +# This file is part of cloud-init. See LICENSE file for license information. + +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_ALWAYS +import logging +import os +import subprocess +import time + +LOG = logging.getLogger(__name__) +DISABLE_PIWIZ_KEY = "disable_piwiz" +RPI_USERCONF_KEY = "rpi_userconf" +USERCONF_SERVICE_TTY = "/dev/tty8" +MODULE_DEACTIVATION_FILE = "/var/lib/userconf-pi/deactivate" + +meta: MetaSchema = { + "id": "cc_rpi_userdata", + "distros": ["raspberry-pi-os"], + # Run every boot to trigger setup wizard even when no settings + "frequency": PER_ALWAYS, + # "activate_by_schema_keys": [DISABLE_PIWIZ_KEY, RPI_USERCONF_KEY], + # When provided it would only start the module + # when the keys are present in the configuration + "activate_by_schema_keys": [], +} + + +def bool_to_str(value: bool | None) -> str: + return "Yes" if value else "No" + + +def get_fwloc_or_default() -> str: + fwloc = None + try: + # Run the command and capture the output + fwloc = subp.subp( + ["/usr/lib/raspberrypi-sys-mods/get_fw_loc"], decode="strict" + ).stdout.strip() + + # If the output is empty, set the default value + if not fwloc: + fwloc = "/boot" + except subp.ProcessExecutionError: + # If the command fails, set the default value + fwloc = "/boot" + return fwloc + + +def run_userconf_service( + base: str | None, passwd_override: str | None = None +) -> bool: + try: + # reset the TTY device + os.system(f"echo 'reset\\r\\n' > {USERCONF_SERVICE_TTY}") + + time.sleep(1) + # Execute the command on different tty + result = subp.subp( + [ + "openvt", + "-s", + "-f", + "-w", + "-c", + USERCONF_SERVICE_TTY[-1], + "--", + "/usr/lib/userconf-pi/userconf-service", + ], + timeout=(None if not passwd_override else 10), + decode="strict", + ) + + if base: + try: + os.remove(f"{base}/userconf.txt") + except FileNotFoundError: + pass + + if result.stderr: + # Handle failure and restart if needed (Restart=on-failure logic) + LOG.debug(f"Userconf stderr service output: {result.stderr}") + return False + else: + lib_dir = os.path.dirname(MODULE_DEACTIVATION_FILE) + # create deactivation file + os.system( + f"mkdir -p {lib_dir} " "&& touch {MODULE_DEACTIVATION_FILE}" + ) + LOG.debug("Userconf service completed successfully.") + return True + except subprocess.TimeoutExpired: + if base and os.path.exists(f"{base}/failed_userconf.txt"): + LOG.error("Invalid credentials provided for userconf-pi.") + os.remove(f"{base}/failed_userconf.txt") + else: + LOG.error("Userconf service timed out.") + return False + except Exception as e: + LOG.warning("Error running service: %s", e) + if base: + try: + os.remove(f"{base}/userconf.txt") + except FileNotFoundError: + pass + return False + + +def run_service( + passwd_override: str | None = None, user_override: str | None = None +) -> bool: + # Ensure the TTY exists before trying to open it + if not os.path.exists(USERCONF_SERVICE_TTY): + if not passwd_override: + LOG.error("TTY device %s does not exist.", USERCONF_SERVICE_TTY) + return False + else: + LOG.debug("TTY device %s does not exist.", USERCONF_SERVICE_TTY) + + # should never happen and not solvable by the user + assert (passwd_override is None and user_override is None) or ( + passwd_override is not None and user_override is not None + ), ( + "Internal error: User override is required when password " + "override is provided." + ) + + base: str | None = None + if passwd_override: + # write /boot/firmware/userconf.txt + # this will make userconf-service + # run silently with the provided credentials + base = get_fwloc_or_default() + assert base, "Internal error: Failed to get firmware location." + with open(f"{base}/userconf.txt", "w") as f: + f.write(f"{user_override}:{passwd_override}") + LOG.debug("Userconf override file written to %s/userconf.txt", base) + + LOG.debug("Start running userconf-pi service loop...") + while True: + if run_userconf_service(base, passwd_override): + break + # Wait for a moment before retrying + time.sleep(1) + LOG.debug("Userconf-pi service loop: retrying") + LOG.debug("Userconf-pi service loop finished.") + return True + + +def configure_pizwiz( + cfg: Config, + disable: bool, + passwd_override: str | None, + user_override: str | None = None, +) -> None: + LOG.debug( + "Configuring piwiz with disable_piwiz=%s, passwd_override=%s, " + "user_override=%s", + bool_to_str(disable), + bool_to_str(passwd_override is not None), + bool_to_str(user_override is not None), + ) + + if disable: + # execute cancel rename script to ensure + # piwiz isn't started (on desktop) + os.system("/usr/bin/cancel-rename pi") + else: + # execute userconf-pi service + # on desktop this doesn't have any effect + # as piwiz is started by the desktop environment + run_service(passwd_override, user_override) + + # populate users for other cloud-init modules to use + cfg["users"] = cfg.get("users", []) + if user_override and user_override not in cfg["users"]: + cfg["users"].append(user_override) + elif "pi" not in cfg["users"]: + cfg["users"].append("pi") + + +def handle(name: str, cfg: Config, cloud: Cloud, args: list) -> None: + disable_piwiz: bool = False + password_override: str | None = None + user_override: str | None = None + + if os.path.exists(MODULE_DEACTIVATION_FILE) or not os.path.exists( + "/usr/lib/userconf-pi" + ): + LOG.debug( + "Userconf-Pi: deactivation file detected or userconf-pi " + "not installed. Skipping..." + ) + return + + if RPI_USERCONF_KEY in cfg: + # expect it to be a dictionary + userconf = cfg[RPI_USERCONF_KEY] + + # look over miss configuration to + if isinstance(userconf, dict) and "password" in userconf: + password_override = userconf["password"] + # user key is optional with default to pi + user_override = userconf.get("user", "pi") + LOG.debug( + "Userconf override: user=%s, password=", + user_override, + ) + else: + LOG.error("Invalid userconf-pi configuration: %s", userconf) + + if not password_override and DISABLE_PIWIZ_KEY in cfg: + if isinstance(cfg[DISABLE_PIWIZ_KEY], bool): + disable_piwiz = cfg[DISABLE_PIWIZ_KEY] + else: + LOG.error( + "Invalid %s configuration: %s", + str(cfg[DISABLE_PIWIZ_KEY]), + DISABLE_PIWIZ_KEY, + ) + + configure_pizwiz(cfg, disable_piwiz, password_override, user_override) diff --git a/cloudinit/config/cc_ssh_import_id.py b/cloudinit/config/cc_ssh_import_id.py index afed7e205bc..4e517165473 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 3d1c1a89697..0d782a82a7a 100644 --- a/cloudinit/config/schemas/schema-cloud-config-v1.json +++ b/cloudinit/config/schemas/schema-cloud-config-v1.json @@ -50,6 +50,12 @@ "resolv_conf", "rh-subscription", "rh_subscription", + "rpi-connect", + "rpi_connect", + "rpi-interfaces", + "rpi_interfaces", + "rpi-userdata", + "rpi_userdata", "rsyslog", "runcmd", "salt-minion", @@ -2601,6 +2607,98 @@ } } }, + "cc_rpi_connect": { + "type": "object", + "properties": { + "enable_rpi_connect": { + "type": "boolean", + "default": false, + "description": "Install and enable Raspberry Pi Connect. Default: ``false``." + } + } + }, + "cc_rpi_interfaces": { + "type": "object", + "properties": { + "rpi_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 + }, + "ssh": { + "type": "boolean", + "description": "Enable SSH. Default: ``false``.", + "default": false + } + } + } + } + }, + "cc_rpi_userdata": { + "type": "object", + "properties": { + "disable_piwiz": { + "type": "boolean", + "default": false, + "description": "Manually disable the Raspberry Pi first boot wizard. Default: ``false``." + }, + "rpi_userconf": { + "type": "object", + "properties": { + "password": { + "type": "string", + "description": "The password for the default user." + }, + "user": { + "type": "string", + "description": "The username for the default user. Default: ``pi``." + } + } + } + } + }, "cc_rsyslog": { "type": "object", "properties": { @@ -3865,6 +3963,15 @@ { "$ref": "#/$defs/cc_rh_subscription" }, + { + "$ref": "#/$defs/cc_rpi_connect" + }, + { + "$ref": "#/$defs/cc_rpi_interfaces" + }, + { + "$ref": "#/$defs/cc_rpi_userdata" + }, { "$ref": "#/$defs/cc_rsyslog" }, @@ -3961,10 +4068,12 @@ "create_hostname_file": {}, "device_aliases": {}, "disable_ec2_metadata": {}, + "disable_piwiz": {}, "disable_root": {}, "disable_root_opts": {}, "disk_setup": {}, "drivers": {}, + "enable_rpi_connect": {}, "fan": {}, "final_message": {}, "fqdn": {}, @@ -4006,6 +4115,8 @@ "resize_rootfs": {}, "resolv_conf": {}, "rh_subscription": {}, + "rpi_interfaces": {}, + "rpi_userconf": {}, "rsyslog": {}, "runcmd": {}, "salt_minion": {}, diff --git a/cloudinit/distros/__init__.py b/cloudinit/distros/__init__.py index e65cbfb5d89..a0fabd247d6 100644 --- a/cloudinit/distros/__init__.py +++ b/cloudinit/distros/__init__.py @@ -63,7 +63,7 @@ "alpine": ["alpine"], "aosc": ["aosc"], "arch": ["arch"], - "debian": ["debian", "ubuntu"], + "debian": ["debian", "ubuntu", "raspberry-pi-os"], "freebsd": ["freebsd", "dragonfly"], "gentoo": ["gentoo", "cos"], "netbsd": ["netbsd"], diff --git a/cloudinit/distros/raspberry-pi-os.py b/cloudinit/distros/raspberry-pi-os.py new file mode 100644 index 00000000000..c0bfa097e1a --- /dev/null +++ b/cloudinit/distros/raspberry-pi-os.py @@ -0,0 +1,45 @@ +# Copyright (C) 2024 Raspberry Pi Ltd. All rights reserved. +# +# Author: Paul Oberosler +# +# This file is part of cloud-init. See LICENSE file for license information. + +from cloudinit import subp +from cloudinit.distros import debian + + +class Distro(debian.Distro): + def __init__(self, name, cfg, paths): + super().__init__(name, cfg, paths) + + 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: + subp.subp( + [ + "/usr/bin/raspi-config", + "nonint", + "do_change_locale", + f"{locale}.UTF-8", + ] + ) diff --git a/cloudinit/util.py b/cloudinit/util.py index 8025f4d51c4..0d241e2cd4e 100644 --- a/cloudinit/util.py +++ b/cloudinit/util.py @@ -549,6 +549,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() @@ -624,6 +626,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 82a7e0fe537..741f6cb0918 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", "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 @@ -134,9 +137,15 @@ cloud_init_modules: - ca_certs {% endif %} - rsyslog +{% if variant == "raspberry-pi-os" %} + - rpi_userdata +{% endif %} - users_groups - ssh - set_passwords +{% if variant == "raspberry-pi-os" %} + - rpi_interfaces +{% endif %} # The modules that run in the 'config' stage cloud_config_modules: @@ -156,6 +165,9 @@ cloud_config_modules: {% endif %} - locale {% endif %} +{% if variant == "raspberry-pi-os" %} + - rpi_interfaces +{% endif %} {% if variant == "alpine" %} - apk_configure {% elif variant in ["debian", "ubuntu", "unknown"] %} @@ -204,6 +216,9 @@ cloud_final_modules: - chef {% endif %} - ansible +{% if variant == "raspberry-pi-os" %} + - rpi_connect +{% endif %} {% if variant not in ["azurelinux"] %} - mcollective - salt_minion @@ -227,8 +242,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 @@ -245,8 +260,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 %} @@ -305,6 +320,11 @@ system_info: {% elif variant == "openmandriva" %} network: renderers: ['network-manager', 'networkd'] +{% elif variant == "raspberry-pi-os" %} + network: + dhcp_client_priority: [dhclient, dhcpcd, udhcpc] + renderers: ['netplan', 'network-manager', 'networkd', 'eni'] + activators: ['netplan', 'network-manager', 'networkd', 'eni'] {% elif variant in ["ubuntu", "unknown"] %} {# SRU_BLOCKER: do not ship network renderers on Xenial, Bionic or Eoan #} network: @@ -344,6 +364,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] @@ -371,7 +404,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_rpi_connect/data.yaml b/doc/module-docs/cc_rpi_connect/data.yaml new file mode 100644 index 00000000000..c738697c4f3 --- /dev/null +++ b/doc/module-docs/cc_rpi_connect/data.yaml @@ -0,0 +1,13 @@ +cc_rpi_connect: + description: | + This module handles Raspberry Pi Connect installation and enablement. + It will install the Raspberry Pi Connect service and enable it to run on boot. + + Supported operating systems: + - Raspberry Pi OS (bookworm and later) + examples: + - comment: > + This example will enable the Raspberry Pi Connect service. + file: cc_rpi_connect/example1.yaml + name: Raspberry Pi Connect + title: Install and enable Raspberry Pi Connect service diff --git a/doc/module-docs/cc_rpi_connect/example1.yaml b/doc/module-docs/cc_rpi_connect/example1.yaml new file mode 100644 index 00000000000..61b46e62b13 --- /dev/null +++ b/doc/module-docs/cc_rpi_connect/example1.yaml @@ -0,0 +1,2 @@ +#cloud-config +enable_rpi_connect: true diff --git a/doc/module-docs/cc_rpi_interfaces/data.yaml b/doc/module-docs/cc_rpi_interfaces/data.yaml new file mode 100644 index 00000000000..168ee408c81 --- /dev/null +++ b/doc/module-docs/cc_rpi_interfaces/data.yaml @@ -0,0 +1,19 @@ +cc_rpi_interfaces: + description: | + This module handles ARM interface configuration for Raspberry Pi. + I also has an option to enable the ssh service which is disabled by default. + examples: + - comment: > + This example will enable the SPI and I2C interfaces on Raspberry Pi. + file: cc_rpi_interfaces/example1.yaml + - comment: > + This example will enable the serial interface on Raspberry Pi. + file: cc_rpi_interfaces/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_rpi_interfaces/example3.yaml + - comment: > + This example will enable ssh and the UART hardware without binding it to the console. + file: cc_rpi_interfaces/example4.yaml + name: Raspberry Pi Interfaces + title: Configure Raspberry Pi ARM interfaces diff --git a/doc/module-docs/cc_rpi_interfaces/example1.yaml b/doc/module-docs/cc_rpi_interfaces/example1.yaml new file mode 100644 index 00000000000..68a0268851a --- /dev/null +++ b/doc/module-docs/cc_rpi_interfaces/example1.yaml @@ -0,0 +1,4 @@ +#cloud-config +rpi_interfaces: + spi: true + i2c: true diff --git a/doc/module-docs/cc_rpi_interfaces/example2.yaml b/doc/module-docs/cc_rpi_interfaces/example2.yaml new file mode 100644 index 00000000000..eeae5fd71c4 --- /dev/null +++ b/doc/module-docs/cc_rpi_interfaces/example2.yaml @@ -0,0 +1,3 @@ +#cloud-config +rpi_interfaces: + serial: true diff --git a/doc/module-docs/cc_rpi_interfaces/example3.yaml b/doc/module-docs/cc_rpi_interfaces/example3.yaml new file mode 100644 index 00000000000..b7eb744c243 --- /dev/null +++ b/doc/module-docs/cc_rpi_interfaces/example3.yaml @@ -0,0 +1,6 @@ +#cloud-config +rpi_interfaces: + serial: + # Pi 5 only | disabling hardware while enabling console + console: true + hardware: false diff --git a/doc/module-docs/cc_rpi_interfaces/example4.yaml b/doc/module-docs/cc_rpi_interfaces/example4.yaml new file mode 100644 index 00000000000..4822fd4ae70 --- /dev/null +++ b/doc/module-docs/cc_rpi_interfaces/example4.yaml @@ -0,0 +1,8 @@ +#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_rpi_userdata/data.yaml b/doc/module-docs/cc_rpi_userdata/data.yaml new file mode 100644 index 00000000000..04a9ebc3ee1 --- /dev/null +++ b/doc/module-docs/cc_rpi_userdata/data.yaml @@ -0,0 +1,26 @@ +cc_rpi_userdata: + description: | + A cloud-init module that is intented to run in the final stage of the cloud-init process. + + It will take care of configuring and triggering the setup wizards on Raspberry Pi OS. + + On desktop images, it will configure the setup wizard (piwiz) or + disable it if requested. + + On lite images, it will configure/disable the userconf-pi package + and trigger the userconf-pi setup wizard. + + Supported operating systems: + - Raspberry Pi OS (bookworm and later) + examples: + - comment: > + This example will manually disable the setup wizard. + file: cc_rpi_userdata/example1.yaml + - comment: > + This example will set a password for the default pi user. (disables the setup wizard) + file: cc_rpi_userdata/example2.yaml + - comment: > + This example will trigger a rename of the default pi user and set a password. (disables the setup wizard) + file: cc_rpi_userdata/example3.yaml + name: Raspberry Pi OS Userdata + title: Configure and trigger Raspberry Pi OS setup wizards diff --git a/doc/module-docs/cc_rpi_userdata/example1.yaml b/doc/module-docs/cc_rpi_userdata/example1.yaml new file mode 100644 index 00000000000..e6b284d7b73 --- /dev/null +++ b/doc/module-docs/cc_rpi_userdata/example1.yaml @@ -0,0 +1,2 @@ +#cloud-config +disable_piwiz: true diff --git a/doc/module-docs/cc_rpi_userdata/example2.yaml b/doc/module-docs/cc_rpi_userdata/example2.yaml new file mode 100644 index 00000000000..93d9edb8348 --- /dev/null +++ b/doc/module-docs/cc_rpi_userdata/example2.yaml @@ -0,0 +1,3 @@ +#cloud-config +rpi_userconf: + password: "my_super_secret_password_hash" diff --git a/doc/module-docs/cc_rpi_userdata/example3.yaml b/doc/module-docs/cc_rpi_userdata/example3.yaml new file mode 100644 index 00000000000..3b98695de3c --- /dev/null +++ b/doc/module-docs/cc_rpi_userdata/example3.yaml @@ -0,0 +1,4 @@ +#cloud-config +rpi_userconf: + user: "my_new_user_name" + password: "my_super_secret_password_hash" diff --git a/doc/rtd/reference/availability.rst b/doc/rtd/reference/availability.rst index f26417d76cf..310cf737a6c 100644 --- a/doc/rtd/reference/availability.rst +++ b/doc/rtd/reference/availability.rst @@ -27,6 +27,7 @@ NetBSD, OpenBSD and DragonFlyBSD: - NetBSD - OpenBSD - Photon OS +- Raspberry Pi OS - RHEL/CentOS/AlmaLinux/Rocky Linux/EuroLinux - SLES/openSUSE - Ubuntu diff --git a/doc/rtd/reference/distros.rst b/doc/rtd/reference/distros.rst index d54cb889153..8b7f55f2cfc 100644 --- a/doc/rtd/reference/distros.rst +++ b/doc/rtd/reference/distros.rst @@ -26,6 +26,7 @@ Unix family of operating systems. See the complete list below. * OpenCloudOS * OpenMandriva * PhotonOS +* Raspberry Pi OS * Red Hat Enterprise Linux * Rocky * SLES/openSUSE diff --git a/doc/rtd/reference/modules.rst b/doc/rtd/reference/modules.rst index c5cdef1620a..59f2ddd8b3c 100644 --- a/doc/rtd/reference/modules.rst +++ b/doc/rtd/reference/modules.rst @@ -80,6 +80,12 @@ Modules :template: modules.tmpl .. datatemplate:yaml:: ../../module-docs/cc_rh_subscription/data.yaml :template: modules.tmpl +.. datatemplate:yaml:: ../../module-docs/cc_rpi_connect/data.yaml + :template: modules.tmpl +.. datatemplate:yaml:: ../../module-docs/cc_rpi_interfaces/data.yaml + :template: modules.tmpl +.. datatemplate:yaml:: ../../module-docs/cc_rpi_userdata/data.yaml + :template: modules.tmpl .. datatemplate:yaml:: ../../module-docs/cc_rsyslog/data.yaml :template: modules.tmpl .. datatemplate:yaml:: ../../module-docs/cc_runcmd/data.yaml diff --git a/doc/rtd/reference/network-config.rst b/doc/rtd/reference/network-config.rst index 61a12167d74..3ea63d65e63 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/pyproject.toml b/pyproject.toml index 55b3c3bb054..fbd3a4722d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -139,6 +139,9 @@ module = [ "tests.unittests.config.test_cc_resizefs", "tests.unittests.config.test_cc_resolv_conf", "tests.unittests.config.test_cc_rh_subscription", + "tests.unittests.config.test_cc_rpi_connect", + "tests.unittests.config.test_cc_rpi_interfaces", + "tests.unittests.config.test_cc_rpi_userdata", "tests.unittests.config.test_cc_rsyslog", "tests.unittests.config.test_cc_runcmd", "tests.unittests.config.test_cc_snap", diff --git a/systemd/cloud-final.service.tmpl b/systemd/cloud-final.service.tmpl index 1ace3eb7a32..2e1399a64b6 100644 --- a/systemd/cloud-final.service.tmpl +++ b/systemd/cloud-final.service.tmpl @@ -3,7 +3,7 @@ # https://docs.cloud-init.io/en/latest/explanation/boot.html Description=Cloud-init: Final Stage After=network-online.target time-sync.target cloud-config.service rc-local.service -{% if variant in ["ubuntu", "unknown", "debian"] %} +{% if variant in ["ubuntu", "unknown", "debian", "raspberry-pi-os"] %} After=multi-user.target Before=apt-daily.service {% endif %} diff --git a/systemd/cloud-init-local.service.tmpl b/systemd/cloud-init-local.service.tmpl index e682f637a26..4164c3e7d6c 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 @@ -21,7 +21,7 @@ Before=shutdown.target Before=firewalld.target Conflicts=shutdown.target {% endif %} -{% if variant in ["ubuntu", "unknown", "debian"] %} +{% if variant in ["ubuntu", "unknown", "debian", "raspberry-pi-os"] %} Before=sysinit.target Conflicts=shutdown.target {% endif %} diff --git a/systemd/cloud-init-main.service.tmpl b/systemd/cloud-init-main.service.tmpl index 7a9114bb58b..af550ba81c1 100644 --- a/systemd/cloud-init-main.service.tmpl +++ b/systemd/cloud-init-main.service.tmpl @@ -9,7 +9,7 @@ [Unit] Description=Cloud-init: Single Process Wants=network-pre.target -{% if variant in ["almalinux", "cloudlinux", "ubuntu", "unknown", "debian", "rhel"] %} +{% if variant in ["almalinux", "cloudlinux", "ubuntu", "unknown", "debian", "raspberry-pi-os", "rhel"] %} DefaultDependencies=no {% endif %} {% if variant in ["almalinux", "cloudlinux", "rhel"] %} @@ -18,7 +18,7 @@ After=dbus.socket Before=network.service Before=firewalld.target {% endif %} -{% if variant in ["ubuntu", "unknown", "debian"] %} +{% if variant in ["ubuntu", "unknown", "debian", "raspberry-pi-os"] %} Before=sysinit.target {% endif %} diff --git a/systemd/cloud-init-network.service.tmpl b/systemd/cloud-init-network.service.tmpl index bed96a855ad..474d264e08b 100644 --- a/systemd/cloud-init-network.service.tmpl +++ b/systemd/cloud-init-network.service.tmpl @@ -10,8 +10,12 @@ Wants=sshd-keygen.service Wants=sshd.service After=cloud-init-local.service After=systemd-networkd-wait-online.service -{% if variant in ["ubuntu", "unknown", "debian"] %} +{% if variant in ["ubuntu", "unknown", "debian", "raspberry-pi-os"] %} After=networking.service +{% if variant == "raspberry-pi-os" %} +Wants=NetworkManager-wait-online.service +After=NetworkManager-wait-online.service +{% endif %} {% endif %} {% if variant in ["almalinux", "centos", "cloudlinux", "eurolinux", "fedora", "miraclelinux", "openeuler", "OpenCloudOS", "openmandriva", "rhel", "rocky", @@ -28,11 +32,17 @@ 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 -{% if variant in ["ubuntu", "unknown", "debian"] %} +{% if variant in ["ubuntu", "unknown", "debian", "raspberry-pi-os"] %} +{% if variant not in ["raspberry-pi-os"] %} +{# stops cloud-init-network from starting on rpios #} Before=sysinit.target +{% endif %} Before=shutdown.target Conflicts=shutdown.target {% endif %} diff --git a/tests/unittests/config/test_cc_rpi_connect.py b/tests/unittests/config/test_cc_rpi_connect.py new file mode 100644 index 00000000000..19284e49864 --- /dev/null +++ b/tests/unittests/config/test_cc_rpi_connect.py @@ -0,0 +1,76 @@ +# This file is part of cloud-init. See LICENSE file for license information. + +from cloudinit.config.cc_rpi_connect import ENABLE_RPI_CONNECT_KEY +from cloudinit.config.schema import ( + SchemaValidationError, + get_schema, + validate_cloudconfig_schema, +) +from tests.unittests.helpers import skipUnlessJsonSchema +import pytest + + +""" +def is_notPi() -> bool: + \"""Most tests are Raspberry Pi OS only.\""" + return not os.path.exists("/etc/rpi-issue") + +@mock.patch('cloudinit.subp.subp') +class TestCCRPiConnect(CiTestCase): + \"""Tests work in progress. Just partially implemented to + show the idea.\""" + + @mock.patch('cloudinit.subp.subp') + def test_configure_rpi_connect_enabled(self, mock_subp): + if is_notPi(): + return + config = {ENABLE_RPI_CONNECT_KEY: True} + handle("cc_rpi_connect", config, mock.Mock(), []) + mock_subp.assert_called_with( + ['/usr/bin/raspi-config', 'do_rpi_connect', '0']) + + @mock.patch('cloudinit.subp.subp') + def test_configure_rpi_connect_disabled(self, mock_subp): + if is_notPi(): + return + config = {ENABLE_RPI_CONNECT_KEY: False} + handle("cc_rpi_connect", config, mock.Mock(), []) + mock_subp.assert_called_with( + ['/usr/bin/raspi-config', 'do_rpi_connect', '1']) + + @mock.patch('os.path.exists') + def test_rpi_connect_installed(self, mock_path_exists): + if is_notPi(): + return + # Simulate rpi-connect is installed + mock_path_exists.return_value = True + assert mock_path_exists('/usr/bin/rpi-connect') + + @mock.patch('os.path.exists') + def test_rpi_connect_not_installed(self, mock_path_exists): + if is_notPi(): + return + # Simulate rpi-connect is not installed + mock_path_exists.return_value = False + assert not mock_path_exists('/usr/bin/rpi-connect') +""" + + +@skipUnlessJsonSchema() +class TestCCRPiConnectSchema: + @pytest.mark.parametrize( + "config, error_msg", + [ + ({ENABLE_RPI_CONNECT_KEY: True}, None), + ( + {ENABLE_RPI_CONNECT_KEY: "true"}, + "'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_cc_rpi_interfaces.py b/tests/unittests/config/test_cc_rpi_interfaces.py new file mode 100644 index 00000000000..c9d55db2de6 --- /dev/null +++ b/tests/unittests/config/test_cc_rpi_interfaces.py @@ -0,0 +1,124 @@ +# This file is part of cloud-init. See LICENSE file for license information. + +from cloudinit.config.cc_rpi_interfaces import RPI_INTERFACES_KEY +from cloudinit.config.schema import ( + SchemaValidationError, + get_schema, + validate_cloudconfig_schema, +) +from tests.unittests.helpers import skipUnlessJsonSchema +import pytest + + +""" +def is_notPi() -> bool: + \"""Most tests are Raspberry Pi OS only.\""" + return not os.path.exists("/etc/rpi-issue") + +@mock.patch('cloudinit.subp.subp') +class TestCCRPiInterfaces(CiTestCase): + \"""Tests work in progress. Just partially implemented to + show the idea.\""" + + @mock.patch('cloudinit.subp.subp') + def test_configure_spi_interface(self, mock_subp): + if is_notPi(): + return + config = { + RPI_INTERFACES_KEY: { + "spi": True + } + } + handle("cc_rpi_interfaces", config, mock.Mock(), []) + mock_subp.assert_called_with([ + '/usr/bin/raspi-config', + 'nonint', + SUPPORTED_INTERFACES["spi"], + '0']) + + @mock.patch('cloudinit.subp.subp') + def test_configure_serial_interface_as_dict(self, mock_subp): + if is_notPi(): + return + config = { + RPI_INTERFACES_KEY: { + "serial": { + "console": True, + "hardware": False + } + } + } + handle("cc_rpi_interfaces", config, mock.Mock(), []) + mock_subp.assert_any_call([ + '/usr/bin/raspi-config', 'nonint', 'do_serial_cons', '0']) + + @mock.patch('cloudinit.subp.subp') + def test_configure_invalid_interface(self, mock_subp): + if is_notPi(): + return + config = { + RPI_INTERFACES_KEY: { + "unknown_interface": True + } + } + handle("cc_rpi_interfaces", config, mock.Mock(), []) + mock_subp.assert_not_called() + + @mock.patch('os.path.exists') + @mock.patch('cloudinit.subp.subp') + def test_get_enabled_interfaces(self, mock_subp, mock_path_exists): + if is_notPi(): + return + # Simulate all interfaces enabled (spi, i2c, etc.) + mock_subp.side_effect = [("0", ""), ("0", ""), ("0", ""), ("0", "")] + config = { + RPI_INTERFACES_KEY: { + "spi": True, + "i2c": True, + "onewire": True, + "remote_gpio": True + } + } + handle("cc_rpi_interfaces", config, mock.Mock(), []) + # Assert all interface enabling commands were called + mock_subp.assert_any_call(['/usr/bin/raspi-config', + 'nonint', 'do_spi', '0']) + mock_subp.assert_any_call(['/usr/bin/raspi-config', + 'nonint', 'do_i2c', '0']) + mock_subp.assert_any_call(['/usr/bin/raspi-config', + 'nonint', 'do_onewire', '0']) + mock_subp.assert_any_call(['/usr/bin/raspi-config', + 'nonint', 'do_rgpio', '0']) +""" + + +@skipUnlessJsonSchema() +class TestCCRPiInterfacesSchema: + @pytest.mark.parametrize( + "config, error_msg", + [ + ({RPI_INTERFACES_KEY: {"spi": True, "i2c": False}}, None), + ( + {RPI_INTERFACES_KEY: {"spi": "true"}}, + "'true' is not of type 'boolean'", + ), + ( + { + RPI_INTERFACES_KEY: { + "serial": {"console": True, "hardware": False} + } + }, + None, + ), + ( + {RPI_INTERFACES_KEY: {"serial": {"console": 123}}}, + "123 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_cc_rpi_userdata.py b/tests/unittests/config/test_cc_rpi_userdata.py new file mode 100644 index 00000000000..75d78b05018 --- /dev/null +++ b/tests/unittests/config/test_cc_rpi_userdata.py @@ -0,0 +1,93 @@ +# This file is part of cloud-init. See LICENSE file for license information. + +from cloudinit.config.cc_rpi_userdata import ( + DISABLE_PIWIZ_KEY, + RPI_USERCONF_KEY, +) +from cloudinit.config.schema import ( + SchemaValidationError, + get_schema, + validate_cloudconfig_schema, +) +import pytest +from tests.unittests.helpers import skipUnlessJsonSchema + +""" +def is_notPi() -> bool: + \"""Most tests are Raspberry Pi OS only.\""" + return not os.path.exists("/etc/rpi-issue") + +@mock.patch('cloudinit.subp.subp') +class TestCCRPiUserdata(CiTestCase): + \"""Tests work in progress. Just partially implemented + to show the idea.\""" + + @mock.patch('subprocess.run') + def test_userconf_service_runs(self, mock_run): + if is_notPi(): + return + mock_run.return_value.returncode = 0 + result = run_service('hashedpassword', 'newuser') + assert result is True + + @mock.patch('subprocess.run') + def test_userconf_service_fails(self, mock_run): + if is_notPi(): + return + mock_run.return_value.returncode = 1 + result = run_service('hashedpassword', 'newuser') + assert result is False + + @mock.patch('os.path.exists') + def test_check_piwiz_disabled(self, mock_path_exists): + if is_notPi(): + return + mock_path_exists.side_effect = [False, False, False] + assert not mock_path_exists('/var/lib/userconf-pi/autologin') + assert not mock_path_exists('/etc/ssh/sshd_config.d/rename_user.conf') + assert not mock_path_exists('/etc/xdg/autostart/piwiz.desktop') + + @mock.patch('os.listdir') + def test_check_default_user_renamed(self, mock_listdir): + if is_notPi(): + return + mock_listdir.return_value = ['newuser'] + assert 'newuser' in os.listdir('/home') + + @mock.patch('os.listdir') + def test_default_user_still_exists(self, mock_listdir): + if is_notPi(): + return + mock_listdir.return_value = ['pi'] + assert 'pi' in os.listdir('/home') +""" + + +@skipUnlessJsonSchema() +class TestCCRPiUserdataSchema: + @pytest.mark.parametrize( + "config, error_msg", + [ + ({DISABLE_PIWIZ_KEY: True}, None), + ( + { + RPI_USERCONF_KEY: { + "password": "hashedpassword", + "user": "newuser", + } + }, + None, + ), + ({DISABLE_PIWIZ_KEY: "true"}, "'true' is not of type 'boolean'"), + ( + {RPI_USERCONF_KEY: {"password": 12345}}, + "12345 is not of type 'string'", + ), + ], + ) + 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 23226bb7aa6..bbe958f44bd 100644 --- a/tests/unittests/config/test_schema.py +++ b/tests/unittests/config/test_schema.py @@ -260,6 +260,9 @@ def test_get_schema_coalesces_known_schema(self): {"$ref": "#/$defs/cc_resizefs"}, {"$ref": "#/$defs/cc_resolv_conf"}, {"$ref": "#/$defs/cc_rh_subscription"}, + {"$ref": "#/$defs/cc_rpi_connect"}, + {"$ref": "#/$defs/cc_rpi_interfaces"}, + {"$ref": "#/$defs/cc_rpi_userdata"}, {"$ref": "#/$defs/cc_rsyslog"}, {"$ref": "#/$defs/cc_runcmd"}, {"$ref": "#/$defs/cc_salt_minion"}, diff --git a/tests/unittests/test_cli.py b/tests/unittests/test_cli.py index b6c83c4bfe6..b2da42d0373 100644 --- a/tests/unittests/test_cli.py +++ b/tests/unittests/test_cli.py @@ -318,7 +318,8 @@ def test_wb_schema_subcommand_parser(self, m_read_cfg, capsys): "centos, cloudlinux, cos, debian, eurolinux, fedora, " "freebsd, mariner, miraclelinux, openbsd, openeuler, " "OpenCloudOS, openmandriva, opensuse, opensuse-microos, " - "opensuse-tumbleweed, opensuse-leap, photon, rhel, rocky, " + "opensuse-tumbleweed, opensuse-leap, photon, " + "raspberry-pi-os, rhel, rocky, " "sle_hpc, sle-micro, sles, TencentOS, ubuntu, virtuozzo", " **resize_rootfs:** ", "(``true``/``false``/``noblock``)", diff --git a/tests/unittests/test_render_template.py b/tests/unittests/test_render_template.py index 0ed9464821d..dde51a69904 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,10 @@ 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", "networkd", "eni"] + ) ), ) 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",