diff --git a/cloudinit/config/cc_netplan_nm_patch.py b/cloudinit/config/cc_netplan_nm_patch.py new file mode 100644 index 00000000000..49d6eb698d2 --- /dev/null +++ b/cloudinit/config/cc_netplan_nm_patch.py @@ -0,0 +1,129 @@ +# 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.distros import ALL_DISTROS +from cloudinit.net.netplan import CLOUDINIT_NETPLAN_FILE +from cloudinit.settings import PER_INSTANCE +import logging +import os +import re + +LOG = logging.getLogger(__name__) + +meta: MetaSchema = { + "id": "cc_netplan_nm_patch", + "distros": [ALL_DISTROS], + "frequency": PER_INSTANCE, + "activate_by_schema_keys": [], +} + + +def exec_cmd(command: str) -> str | None: + try: + result = subp.subp(command) + if result.stdout is not None: + return result.stdout + except subp.ProcessExecutionError as e: + LOG.error("Failed to execute command: %s", e) + return None + LOG.debug("Command has no stdout: %s", command) + return None + + +def get_netplan_generated_configs() -> list[str]: + """Get the UUIDs of all connections starting with 'netplan-'.""" + output = exec_cmd(["nmcli", "connection", "show"]) + if output is None: + return [] + + netplan_conns = [] + for line in output.splitlines(): + if line.startswith("netplan-"): + parts = line.split() + if len(parts) > 1: + # name = parts[0] + uuid = parts[1] + netplan_conns.append(uuid) + return netplan_conns + + +def get_connection_object_path(uuid: str) -> str | None: + """Get the D-Bus object path for a connection by UUID.""" + output = exec_cmd( + [ + "busctl", + "call", + "org.freedesktop.NetworkManager", + "/org/freedesktop/NetworkManager/Settings", + "org.freedesktop.NetworkManager.Settings", + "GetConnectionByUuid", + "s", + uuid, + ] + ) + + path_match = ( + re.search( + r'o\s+"(/org/freedesktop/NetworkManager/Settings/\d+)"', output + ) + if output + else None + ) + if path_match: + return path_match.group(1) + else: + LOG.error("Failed to find object path for connection: %s", uuid) + return None + + +def save_connection(obj_path: str) -> None: + """Call the Save method on the D-Bus obj path for a connection.""" + result = exec_cmd( + [ + "busctl", + "call", + "org.freedesktop.NetworkManager", + obj_path, + "org.freedesktop.NetworkManager.Settings.Connection", + "Save", + ] + ) + + if result is None: + LOG.error("Failed to save connection: %s", obj_path) + else: + LOG.debug("Saved connection: %s", obj_path) + + +def handle(name: str, cfg: Config, cloud: Cloud, args: list) -> None: + LOG.debug("Applying netplan patch") + + # remove cloud-init file after NetworkManager has generated + # replacement netplan configurations to avoid conflicts in the + # future + + try: + np_conns = get_netplan_generated_configs() + if not np_conns: + LOG.debug("No netplan connections found") + return + + for conn_uuid in np_conns: + obj_path = get_connection_object_path(conn_uuid) + if obj_path is None: + continue + save_connection(obj_path) + + os.remove(CLOUDINIT_NETPLAN_FILE) + LOG.debug("Netplan cfg has been patched: %s", CLOUDINIT_NETPLAN_FILE) + except subp.ProcessExecutionError as e: + LOG.error("Failed to patch netplan cfg: %s", e) + except Exception as e: + LOG.error("Failed to patch netplan cfg: %s", e) diff --git a/cloudinit/config/schemas/schema-cloud-config-v1.json b/cloudinit/config/schemas/schema-cloud-config-v1.json index 0d782a82a7a..aebe67146ad 100644 --- a/cloudinit/config/schemas/schema-cloud-config-v1.json +++ b/cloudinit/config/schemas/schema-cloud-config-v1.json @@ -35,6 +35,7 @@ "lxd", "mcollective", "mounts", + "netplan_nm_patch", "ntp", "package-update-upgrade-install", "package_update_upgrade_install", diff --git a/config/cloud.cfg.tmpl b/config/cloud.cfg.tmpl index 07873f253ba..0255d5f80ad 100644 --- a/config/cloud.cfg.tmpl +++ b/config/cloud.cfg.tmpl @@ -223,6 +223,9 @@ cloud_final_modules: - mcollective - salt_minion - reset_rmc +{% endif %} +{% if variant == "raspberry-pi-os" %} + - netplan_nm_patch {% endif %} - scripts_vendor - scripts_per_once diff --git a/doc/module-docs/cc_netplan_nm_patch/data.yaml b/doc/module-docs/cc_netplan_nm_patch/data.yaml new file mode 100644 index 00000000000..f2f6df03b19 --- /dev/null +++ b/doc/module-docs/cc_netplan_nm_patch/data.yaml @@ -0,0 +1,12 @@ +cc_netplan_nm_patch: + description: | + This module resolves the issue where if you edit a netplan generated network configuration + in NetworkManager, the changes are saved to netplan as new configuration files. + This will cause netplan overwrite the NetworkManager generated netplan configuration on the next boot. + + This module will give NetworkManager a hint to generated netplan configuration files + and then removes the cloud-init generated netplan configuration file. + + This module expects that NetworkManager has the netplan integration patch applied. + name: Netplan NetworkManager Patch + title: Patch NetworkManager netplan interop issue diff --git a/doc/rtd/reference/modules.rst b/doc/rtd/reference/modules.rst index 59f2ddd8b3c..aec67115598 100644 --- a/doc/rtd/reference/modules.rst +++ b/doc/rtd/reference/modules.rst @@ -64,6 +64,8 @@ Modules :template: modules.tmpl .. datatemplate:yaml:: ../../module-docs/cc_mounts/data.yaml :template: modules.tmpl +.. datatemplate:yaml:: ../../module-docs/cc_netplan_nm_patch/data.yaml + :template: modules.tmpl .. datatemplate:yaml:: ../../module-docs/cc_ntp/data.yaml :template: modules.tmpl .. datatemplate:yaml:: ../../module-docs/cc_package_update_upgrade_install/data.yaml diff --git a/tests/unittests/config/test_cc_netplan_nm_patch.py b/tests/unittests/config/test_cc_netplan_nm_patch.py new file mode 100644 index 00000000000..6f76cba7446 --- /dev/null +++ b/tests/unittests/config/test_cc_netplan_nm_patch.py @@ -0,0 +1,9 @@ +# This file is part of cloud-init. See LICENSE file for license information. + +from tests.unittests.helpers import skipUnlessJsonSchema + + +@skipUnlessJsonSchema() +class TestCCNetplanNmPatch: + def test_schema_validation(self): + pass diff --git a/tests/unittests/config/test_schema.py b/tests/unittests/config/test_schema.py index bbe958f44bd..c8ef5da864c 100644 --- a/tests/unittests/config/test_schema.py +++ b/tests/unittests/config/test_schema.py @@ -252,6 +252,7 @@ def test_get_schema_coalesces_known_schema(self): {"$ref": "#/$defs/cc_lxd"}, {"$ref": "#/$defs/cc_mcollective"}, {"$ref": "#/$defs/cc_mounts"}, + {"$ref": "#/$defs/cc_netplan_nm_patch"}, {"$ref": "#/$defs/cc_ntp"}, {"$ref": "#/$defs/cc_package_update_upgrade_install"}, {"$ref": "#/$defs/cc_phone_home"},