diff -Nru netplan.io-0.105/debian/changelog netplan.io-0.105/debian/changelog --- netplan.io-0.105/debian/changelog 2022-10-11 13:58:36.000000000 +0100 +++ netplan.io-0.105/debian/changelog 2023-02-10 10:30:42.000000000 +0000 @@ -1,3 +1,13 @@ +netplan.io (0.105-0ubuntu3~22.04.1) jammy; urgency=medium + + [ Lukas Märdian ] + Bug fixes: + - d/p/lp1997467: set only specific origin-hint if given (LP: #1997467) + Cherry-picked from upstream: https://github.com/canonical/netplan/pull/299 + - d/libnetplan0.symbols: Add netplan_parser_load_nullable_overrides() API + + -- Danilo Egea Gondolfo Fri, 10 Feb 2023 10:30:42 +0000 + netplan.io (0.105-0ubuntu2~22.04.1) jammy; urgency=medium * Backport netplan.io 0.105-0ubuntu2 to 22.04 (LP: #1988447) diff -Nru netplan.io-0.105/debian/libnetplan0.symbols netplan.io-0.105/debian/libnetplan0.symbols --- netplan.io-0.105/debian/libnetplan0.symbols 2022-10-11 13:58:36.000000000 +0100 +++ netplan.io-0.105/debian/libnetplan0.symbols 2023-02-10 10:30:42.000000000 +0000 @@ -59,6 +59,7 @@ netplan_parser_clear@Base 0.104 netplan_parser_load_keyfile@Base 0.104 netplan_parser_load_nullable_fields@Base 0.105 + netplan_parser_load_nullable_overrides@Base 0.105-0ubuntu4~ netplan_parser_load_yaml@Base 0.104 netplan_parser_load_yaml_from_fd@Base 0.105 netplan_parser_load_yaml_hierarchy@Base 0.104 diff -Nru netplan.io-0.105/debian/patches/lp1997467/0003-generate-util-fix-double-slash-root-filepath.patch netplan.io-0.105/debian/patches/lp1997467/0003-generate-util-fix-double-slash-root-filepath.patch --- netplan.io-0.105/debian/patches/lp1997467/0003-generate-util-fix-double-slash-root-filepath.patch 1970-01-01 01:00:00.000000000 +0100 +++ netplan.io-0.105/debian/patches/lp1997467/0003-generate-util-fix-double-slash-root-filepath.patch 2023-02-10 10:30:42.000000000 +0000 @@ -0,0 +1,55 @@ +From: =?utf-8?q?Lukas_M=C3=A4rdian?= +Date: Wed, 7 Dec 2022 11:26:19 +0100 +Subject: generate:util: fix double-slash root filepath + +Make sure we're using proper paths values (i.e. NOT "//etc/netplan/...") +as we're doing string comparisons on those values and the leading +double-slash might confuse those checks. + +E.g. in netplan.c:netplan_state_write_yaml_file() we compare netdef->filepath +to the filepath of a new YAML file to be written. If a netdef has been parsed +but its filepath is using double slashes, it won't match the new file and the +new file will be deleted instead of updated. + +Starting with an empty Netplan config in /etc/netplan: +$ netplan set --origin-hint test "bridges.br54.dhcp4=false" +=> will create test.yaml, containing the br54 definition +$ netplan set --origin-hint test "bridges.br54.dhcp4=true" +=> updating the same br54 netdef (re-using 'netplan set') will delete test.yaml +--- + src/generate.c | 6 ++++-- + src/util.c | 4 +++- + 2 files changed, 7 insertions(+), 3 deletions(-) + +diff --git a/src/generate.c b/src/generate.c +index 906799d..52f0ac2 100644 +--- a/src/generate.c ++++ b/src/generate.c +@@ -319,8 +319,10 @@ int main(int argc, char** argv) + start_unit_jit("systemd-networkd-wait-online.service"); + start_unit_jit("systemd-networkd.service"); + } +- g_autofree char* glob_run = g_strjoin(NULL, rootdir ?: "", G_DIR_SEPARATOR_S, +- "run/systemd/system/netplan-*.service", NULL); ++ g_autofree char* glob_run = g_build_path(G_DIR_SEPARATOR_S, ++ rootdir ?: G_DIR_SEPARATOR_S, ++ "run/systemd/system/netplan-*.service", ++ NULL); + if (!glob(glob_run, 0, NULL, &gl)) { + for (size_t i = 0; i < gl.gl_pathc; ++i) { + gchar *unit_name = g_path_get_basename(gl.gl_pathv[i]); +diff --git a/src/util.c b/src/util.c +index 6e75cf5..5955b3f 100644 +--- a/src/util.c ++++ b/src/util.c +@@ -112,7 +112,9 @@ unlink_glob(const char* rootdir, const char* _glob) + int find_yaml_glob(const char* rootdir, glob_t* out_glob) + { + int rc; +- g_autofree char* rglob = g_strjoin(NULL, rootdir ?: "", G_DIR_SEPARATOR_S, "{lib,etc,run}/netplan/*.yaml", NULL); ++ g_autofree char* rglob = g_build_path(G_DIR_SEPARATOR_S, ++ rootdir ?: G_DIR_SEPARATOR_S, ++ "{lib,etc,run}/netplan/*.yaml", NULL); + rc = glob(rglob, GLOB_BRACE, NULL, out_glob); + if (rc != 0 && rc != GLOB_NOMATCH) { + // LCOV_EXCL_START diff -Nru netplan.io-0.105/debian/patches/lp1997467/0004-test-cli-set-add-regression-cases-for-LP-1997467.patch netplan.io-0.105/debian/patches/lp1997467/0004-test-cli-set-add-regression-cases-for-LP-1997467.patch --- netplan.io-0.105/debian/patches/lp1997467/0004-test-cli-set-add-regression-cases-for-LP-1997467.patch 1970-01-01 01:00:00.000000000 +0100 +++ netplan.io-0.105/debian/patches/lp1997467/0004-test-cli-set-add-regression-cases-for-LP-1997467.patch 2023-02-10 10:30:42.000000000 +0000 @@ -0,0 +1,65 @@ +From: =?utf-8?q?Lukas_M=C3=A4rdian?= +Date: Wed, 23 Nov 2022 17:16:47 +0100 +Subject: test:cli:set: add regression cases for LP: #1997467 + +When parsing the full YAML hierarchy (from 'netplan set'), new settings might +not end up in the "to_write" GList (and therefore not in the new origin-hint +file), as the same interface might have been defined in another, previous file +(e.g. 0-snapd-defaults.yaml) and therefore any updates are redirected to that +previous file, or ignored if a origin-hint is passed, which does not match +the previous filename (e.g. origin-hint: 90-snapd-config.yaml). + +Still we need to parse the full YAML hierarchy for validation reasons, as a new +origin-hint file might describe new definitions of an interface that contains +references to a netdef from another YAML file. E.g.: + +$ cat 0-snapd-defaults.yaml +network: + ethernets: + eth0: + dhcp4: true + +$ netplan set --origin-hint=90-snapd-config "bridges.br54.interfaces=[eth0]" + +=> This would fail if we wouldn't parse the full YAML hierarchy, before writing +the 90-snapd-config.yaml file, as it would not be aware of the "eth0" netdef. +--- + tests/test_cli_get_set.py | 25 +++++++++++++++++++++++++ + 1 file changed, 25 insertions(+) + +diff --git a/tests/test_cli_get_set.py b/tests/test_cli_get_set.py +index 110e167..c4ed860 100644 +--- a/tests/test_cli_get_set.py ++++ b/tests/test_cli_get_set.py +@@ -97,6 +97,31 @@ class TestSet(unittest.TestCase): + with open(p, 'r') as f: + self.assertIs(True, yaml.safe_load(f)['network']['ethernets']['eth0']['dhcp4']) + ++ def test_set_origin_hint_override(self): ++ defaults = os.path.join(self.workdir.name, 'etc', 'netplan', '0-snapd-defaults.yaml') ++ with open(defaults, 'w') as f: ++ f.write('''network: ++ bridges: {br54: {dhcp4: true}} ++ ethernets: {eth0: {dhcp4: true}}''') ++ self._set(['bridges.br54.dhcp4=false', '--origin-hint=90-snapd-config']) ++ self._set(['bridges.br54.interfaces=[eth0]', '--origin-hint=90-snapd-config']) ++ p = os.path.join(self.workdir.name, 'etc', 'netplan', '90-snapd-config.yaml') ++ self.assertTrue(os.path.isfile(p)) ++ with open(p, 'r') as f: ++ self.assertIs(False, yaml.safe_load(f)['network']['bridges']['br54']['dhcp4']) ++ ++ def test_set_origin_hint_extend(self): ++ p = os.path.join(self.workdir.name, 'etc', 'netplan', '90-snapd-config.yaml') ++ with open(p, 'w') as f: ++ f.write('''network: {bridges: {br54: {dhcp4: true}}}''') ++ self._set(['bridges.br54.dhcp4=false', '--origin-hint=90-snapd-config']) ++ self._set(['bridges.br55.dhcp4=true', '--origin-hint=90-snapd-config']) ++ self.assertTrue(os.path.isfile(p)) ++ with open(p, 'r') as f: ++ yml = yaml.safe_load(f) ++ self.assertIs(False, yml['network']['bridges']['br54']['dhcp4']) ++ self.assertIs(True, yml['network']['bridges']['br55']['dhcp4']) ++ + def test_set_empty_origin_hint(self): + with self.assertRaises(Exception) as context: + self._set(['ethernets.eth0.dhcp4=true', '--origin-hint=']) diff -Nru netplan.io-0.105/debian/patches/lp1997467/0005-parse-Allow-loading-nullable-origin-hint-overrides-n.patch netplan.io-0.105/debian/patches/lp1997467/0005-parse-Allow-loading-nullable-origin-hint-overrides-n.patch --- netplan.io-0.105/debian/patches/lp1997467/0005-parse-Allow-loading-nullable-origin-hint-overrides-n.patch 1970-01-01 01:00:00.000000000 +0100 +++ netplan.io-0.105/debian/patches/lp1997467/0005-parse-Allow-loading-nullable-origin-hint-overrides-n.patch 2023-02-10 10:30:42.000000000 +0000 @@ -0,0 +1,261 @@ +From: =?utf-8?q?Lukas_M=C3=A4rdian?= +Date: Wed, 11 Jan 2023 15:02:37 +0100 +Subject: parse: Allow loading nullable origin-hint overrides (netdefs to be + ignored) + +Add a new API function to allow loading nullable origin-hint overrides, i.e. +all global values (like "renderer", ...) or Netdef-IDs that are part of a given +YAML patch, and are supposed to be overridden inside the yaml hierarchy by the +resulting origin_hint/output file. Overrides (depending on YAML hierarchy) can +only happen on global values or on the individual netdef level. + +The following example shows how this would be represented in the datastructure: +$ netplan set --origin-hint=hint "ethernets.eth0={dhcp4: false, dhcp6: NULL}" + +Origin-hint filename: hint.yaml +YAML patch: +``` +network: + ethernets: + eth0: + dhcp4: false + dchp6: NULL +``` + +The "null_fields" hashmap would contain one unconstrained entry for "eth0", +i.e. a delete operation: +network.ethernets.eth0.dhcp4 => NULL (via load_nullable_fields) + +The "null_overrides" hashmap would contain one entry for the "eth0" Netdef, +contstrained by the origin-hint (i.e. to be ignored only if parsed from files +other than "hint.yaml"): +network.ethernets.eth0 => hint.yaml (via load_nullable_overrides) +--- + include/parse.h | 9 +++++ + netplan/libnetplan.py | 8 ++++ + src/parse.c | 104 +++++++++++++++++++++++++++++++++++++++++++++----- + src/types.h | 1 + + 4 files changed, 112 insertions(+), 10 deletions(-) + +diff --git a/include/parse.h b/include/parse.h +index f15dff1..7bd4df8 100644 +--- a/include/parse.h ++++ b/include/parse.h +@@ -69,6 +69,15 @@ netplan_parser_load_yaml_from_fd(NetplanParser* npp, int input_fd, GError** erro + NETPLAN_PUBLIC gboolean + netplan_parser_load_nullable_fields(NetplanParser* npp, int input_fd, GError** error); + ++/* Load the overrides, i.e. all global values (like "renderer") or Netdef-IDs ++ * that are part of the given YAML patch (), and are supposed to be ++ * overridden inside the yaml hierarchy by the resulting origin_hint file. ++ * They are supposed to be parsed from the origin-hint file given in ++ * only. */ ++NETPLAN_PUBLIC gboolean ++netplan_parser_load_nullable_overrides( ++ NetplanParser* npp, int input_fd, const char* constraint, GError** error); ++ + /********** Old API below this ***********/ + + NETPLAN_PUBLIC gboolean +diff --git a/netplan/libnetplan.py b/netplan/libnetplan.py +index 797ac86..180f8b5 100644 +--- a/netplan/libnetplan.py ++++ b/netplan/libnetplan.py +@@ -99,6 +99,10 @@ class Parser: + lib.netplan_parser_load_nullable_fields.argtypes = [_NetplanParserP, c_int, _GErrorPP] + lib.netplan_parser_load_nullable_fields.restype = c_int + ++ lib.netplan_parser_load_nullable_overrides.argtypes =\ ++ [_NetplanParserP, c_int, c_char_p, _GErrorPP] ++ lib.netplan_parser_load_nullable_overrides.restype = c_int ++ + cls._abi_loaded = True + + def __init__(self): +@@ -120,6 +124,10 @@ class Parser: + def load_nullable_fields(self, input_file: IO): + _checked_lib_call(lib.netplan_parser_load_nullable_fields, self._ptr, input_file.fileno()) + ++ def load_nullable_overrides(self, input_file: IO, constraint: str): ++ _checked_lib_call(lib.netplan_parser_load_nullable_overrides, ++ self._ptr, input_file.fileno(), constraint.encode('utf-8')) ++ + + class State: + _abi_loaded = False +diff --git a/src/parse.c b/src/parse.c +index 8150f8b..efd7d12 100644 +--- a/src/parse.c ++++ b/src/parse.c +@@ -1,7 +1,7 @@ + /* +- * Copyright (C) 2016 Canonical, Ltd. ++ * Copyright (C) 2016-2023 Canonical, Ltd. + * Author: Martin Pitt +- * Lukas Märdian ++ * Author: Lukas Märdian + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by +@@ -2906,10 +2906,20 @@ handle_network_type(NetplanParser* npp, yaml_node_t* node, const char* key_prefi + + value = yaml_document_get_node(&npp->doc, entry->value); + +- if (key_prefix && npp->null_fields) { ++ if (key_prefix && (npp->null_fields || npp->null_overrides)) { + full_key = g_strdup_printf("%s\t%s", key_prefix, key->data.scalar.value); +- if (g_hash_table_contains(npp->null_fields, full_key) || node_is_nulled_out(&npp->doc, value, full_key, npp->null_fields)) ++ /* Ignore NULL fields (about to be deleted) */ ++ if (npp->null_fields && (g_hash_table_contains(npp->null_fields, full_key) || node_is_nulled_out(&npp->doc, value, full_key, npp->null_fields))) + continue; ++ /* Ignore this netdef if it is supposed to be part of the resulting ++ * origin-hint file, but we're not currently processing said filepath. */ ++ if (npp->null_overrides) { ++ const gchar* origin_hint = g_hash_table_lookup(npp->null_overrides, full_key); ++ g_autofree gchar* basename = npp->current.filepath ? ++ g_path_get_basename(npp->current.filepath) : NULL; ++ if (origin_hint && basename && g_strcmp0(origin_hint, basename) != 0) ++ continue; ++ } + } + + /* special-case "renderer:" key to set the per-type backend */ +@@ -3307,6 +3317,11 @@ netplan_parser_reset(NetplanParser* npp) + npp->null_fields = NULL; + } + ++ if (npp->null_overrides) { ++ g_hash_table_destroy(npp->null_overrides); ++ npp->null_overrides = NULL; ++ } ++ + if (npp->sources) { + /* Properly configured at creation not to leak */ + g_hash_table_destroy(npp->sources); +@@ -3323,14 +3338,44 @@ netplan_parser_clear(NetplanParser** npp_p) + g_free(npp); + } + ++/* Check if this is a Netdef-ID or global keyword which can be nullified. ++ * Overrides (depending on YAML hierarchy) can only happen on global values ++ * (like "renderer") or on the individual netdef level. ++ * @return the Netdef-ID/keyword or NULL */ ++static gboolean ++is_netdef_id_or_global_value(const char* full_key) ++{ ++ g_autofree gchar* key = g_strstrip(g_strdup(full_key)); // strip leading '\t' ++ gboolean ret = FALSE; ++ gchar** split = g_strsplit(key, "\t", 0); ++ if (split[0] && g_strcmp0(split[0], "network") == 0) { ++ if (split[1]) { ++ /* check if is valid network type */ ++ for (unsigned i = 0; i < NETPLAN_DEF_TYPE_MAX_; ++i) { ++ const char* def_type_name = netplan_def_type_name(i); ++ if (def_type_name && g_strcmp0(split[1], def_type_name) == 0) { ++ /* return keyword if split[2] is a Netdef-ID ++ * e.g. "network.ethernets.eth0" */ ++ if (split[2] && !split[3]) { ++ ret = TRUE; // a valid Netdef-ID ++ break; ++ } ++ } ++ } ++ } ++ } ++ g_strfreev(split); ++ return ret; ++} ++ + static void +-extract_null_fields(yaml_document_t* doc, yaml_node_t* node, GHashTable* null_fields, char* key_prefix) ++extract_null_fields(yaml_document_t* doc, yaml_node_t* node, GHashTable* null_fields, char* key_prefix, const char* origin_hint) + { + yaml_node_pair_t* entry; + switch (node->type) { + // LCOV_EXCL_START + case YAML_NO_NODE: +- g_hash_table_add(null_fields, key_prefix); ++ g_hash_table_insert(null_fields, key_prefix, NULL); + key_prefix = NULL; + break; + // LCOV_EXCL_STOP +@@ -3338,7 +3383,7 @@ extract_null_fields(yaml_document_t* doc, yaml_node_t* node, GHashTable* null_fi + if ( g_ascii_strcasecmp("null", scalar(node)) == 0 + || g_strcmp0((char*)node->tag, YAML_NULL_TAG) == 0 + || g_strcmp0(scalar(node), "~") == 0) { +- g_hash_table_add(null_fields, key_prefix); ++ g_hash_table_insert(null_fields, key_prefix, NULL); + key_prefix = NULL; + } + break; +@@ -3352,7 +3397,16 @@ extract_null_fields(yaml_document_t* doc, yaml_node_t* node, GHashTable* null_fi + key = yaml_document_get_node(doc, entry->key); + value = yaml_document_get_node(doc, entry->value); + full_key = g_strdup_printf("%s\t%s", key_prefix, key->data.scalar.value); +- extract_null_fields(doc, value, null_fields, full_key); ++ /* If an origin_hint is given, nullify the overrides, like ++ * Netdef-IDs or global values (e.g. "renderer") and track the ++ * origin_hint filename as hashmap value. To ignore such netdefs ++ * or globals during the YAML parsing stage should they be ++ * defined somewhere else outside the origin-hint file. */ ++ if (origin_hint && is_netdef_id_or_global_value(full_key)) { ++ g_hash_table_insert(null_fields, g_strdup(full_key), g_strdup(origin_hint)); ++ g_debug("ignoring previous definition of: %s (except in %s)", full_key, origin_hint); ++ } ++ extract_null_fields(doc, value, null_fields, full_key, origin_hint); + } + break; + // LCOV_EXCL_START +@@ -3375,8 +3429,38 @@ netplan_parser_load_nullable_fields(NetplanParser* npp, int input_fd, GError** e + return TRUE; // LCOV_EXCL_LINE + + if (!npp->null_fields) +- npp->null_fields = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL); ++ npp->null_fields = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free); ++ ++ extract_null_fields(&doc, yaml_document_get_root_node(&doc), npp->null_fields, g_strdup(""), NULL); ++ return TRUE; ++} ++ ++gboolean ++netplan_parser_load_nullable_overrides( ++ NetplanParser* npp, int input_fd, const char* constraint, GError** error) ++{ ++ yaml_document_t doc; ++ if (!load_yaml_from_fd(input_fd, &doc, error)) ++ return FALSE; // LCOV_EXCL_LINE ++ ++ /* empty file? */ ++ if (yaml_document_get_root_node(&doc) == NULL) ++ return TRUE; // LCOV_EXCL_LINE + +- extract_null_fields(&doc, yaml_document_get_root_node(&doc), npp->null_fields, g_strdup("")); ++ if (!npp->null_overrides) ++ npp->null_overrides = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free); ++ ++ /* Track the given origin_hint filename, as a constraint, for any netdef or ++ * global value of the given (i.e. YAML patch), so that those can ++ * be ignored later (inside YAML the parsing stage), shouldn't they ++ * originate from the origin-hint file, but from some other YAML file inside ++ * the hierarchy. ++ * ++ * Examples for "origin_hint:hint.yaml" being tracked in npp->null_overrides: ++ * yaml patch: "network.ethernets.eth0.dhcp4=false" ++ * => network.ethernets.eth0: hint.yaml ++ * yaml patch: "network.renderer=NetworkManager" ++ * => network.renderer: hint.yaml */ ++ extract_null_fields(&doc, yaml_document_get_root_node(&doc), npp->null_overrides, g_strdup(""), constraint); + return TRUE; + } +diff --git a/src/types.h b/src/types.h +index e77a7f5..bd6c25b 100644 +--- a/src/types.h ++++ b/src/types.h +@@ -240,6 +240,7 @@ struct netplan_parser { + + /* Which fields have been nullified by a subsequent patch? */ + GHashTable* null_fields; ++ GHashTable* null_overrides; + }; + + #define NETPLAN_ADVERTISED_RECEIVE_WINDOW_UNSPEC 0 diff -Nru netplan.io-0.105/debian/patches/lp1997467/0006-cli-set-fix-origin-hint-handling-LP-1997467.patch netplan.io-0.105/debian/patches/lp1997467/0006-cli-set-fix-origin-hint-handling-LP-1997467.patch --- netplan.io-0.105/debian/patches/lp1997467/0006-cli-set-fix-origin-hint-handling-LP-1997467.patch 1970-01-01 01:00:00.000000000 +0100 +++ netplan.io-0.105/debian/patches/lp1997467/0006-cli-set-fix-origin-hint-handling-LP-1997467.patch 2023-02-10 10:30:42.000000000 +0000 @@ -0,0 +1,189 @@ +From: =?utf-8?q?Lukas_M=C3=A4rdian?= +Date: Wed, 11 Jan 2023 15:02:55 +0100 +Subject: cli:set: fix origin-hint handling (LP: #1997467) + +When using the new libnetplan YAML parser, we need to ignore any global values +(like "renderer") or netdefs (that are part of a given YAML patch) when parsing +the existing YAML hierarchy, unless they're about to be parsed from an existing +origin-hint file of the same name. + +That is to avoid assigning updates to those stanzas/netdefs to existing network +definitions inside other files (not the origin-hint), as this would lead to +those updates being ignored when we're writing out just the single origin-hint +file (using state.write_yaml_file()). We still need to parse and validate the +full context of other netdefs (not part of a given YAML patch), as the patch +might reference existing netdefs (e.g. as bridge interfaces), or change current +configuration in a way that makes it invalid (e.g. changing vrf tables), +therefore they need to exist in the (validation-)parser's context. Also we +need to parse such netdefs from an origin-hint file of same name, as we'd want +update the definition in that file (instead of overriding it). + +We do this by adding relevant global values and netdefs, which are part of the +YAML patch, to the "null_overrides" data, before parsing the full YAML +hierarchy. Adding the filename of the origin-hint as a constraint, which can be +checked when parsing to not ignore relevant netdefs from an exsisting +origin-hint file. +--- + netplan/cli/commands/set.py | 60 +++++++++++++++++++++++++++++++++++++++------ + tests/test_cli_get_set.py | 51 ++++++++++++++++++++++++++++++++++++-- + 2 files changed, 101 insertions(+), 10 deletions(-) + +diff --git a/netplan/cli/commands/set.py b/netplan/cli/commands/set.py +index ee82fa0..04d6e55 100644 +--- a/netplan/cli/commands/set.py ++++ b/netplan/cli/commands/set.py +@@ -1,7 +1,7 @@ + #!/usr/bin/python3 + # +-# Copyright (C) 2020 Canonical, Ltd. +-# Author: Lukas Märdian ++# Copyright (C) 2020-2023 Canonical, Ltd. ++# Author: Lukas Märdian + # + # This program is free software; you can redistribute it and/or modify + # it under the terms of the GNU General Public License as published by +@@ -71,15 +71,59 @@ class NetplanSet(NetplanCommand): + with tempfile.TemporaryFile() as tmp: + libnetplan.create_yaml_patch(yaml_path, value, tmp) + tmp.flush() ++ ++ # Load fields that are about to be deleted (e.g. some.setting=NULL) ++ # Ignore those fields when parsing subsequent YAML files + tmp.seek(0, io.SEEK_SET) + parser.load_nullable_fields(tmp) ++ ++ # Parse the full, existing YAML config hierarchy + parser.load_yaml_hierarchy(self.root_dir) ++ ++ # Load YAML patch, containing our update (new or deleted settings) + tmp.seek(0, io.SEEK_SET) + parser.load_yaml(tmp) + +- state = libnetplan.State() +- state.import_parser_results(parser) +- if self.origin_hint: +- state.write_yaml_file(filename, self.root_dir) +- else: +- state.update_yaml_hierarchy(FALLBACK_FILENAME, self.root_dir) ++ # Validate the final parser state ++ state = libnetplan.State() ++ state.import_parser_results(parser) ++ ++ if filename: # only act on the output file (a.k.a. "origin-hint") ++ parser_output_file = libnetplan.Parser() ++ ++ # Load fields that are about to be deleted ("some.setting=NULL") ++ # Ignore those fields when parsing subsequent YAML files ++ tmp.seek(0, io.SEEK_SET) ++ parser_output_file.load_nullable_fields(tmp) ++ ++ # Load globals/netdefs that are to be ignored from the existing ++ # YAML hierarchy, as our patch is supposed to override settings ++ # in those netdefs via the output file. ++ # Those netdefs and globals must end up in the output file ++ # (a.k.a. "origin-hint", ), have they been defined in ++ # pre-existing YAML files or not. ++ tmp.seek(0, io.SEEK_SET) ++ parser_output_file.load_nullable_overrides(tmp, constraint=filename) ++ ++ # Parse the full YAML hierarchy and new patch, ignoring any ++ # nullable overrides (netdefs/globals) from pre-existing files ++ # and ignoring any nullable fields (settings to be deleted). ++ # This way we can avoid updates to certain netdefs/globals to be ++ # redirected into existing YAML files (defining those same ++ # stanzas) or ignored, but have them written out to the single ++ # output file. ++ # XXX: The origin file of each individual YAML setting/stanza ++ # should be tracked individually, to avoid this ++ # double-parsing workaround (LP: #2003727) ++ parser_output_file.load_yaml_hierarchy(self.root_dir) ++ tmp.seek(0, io.SEEK_SET) ++ parser_output_file.load_yaml(tmp) ++ ++ # Import the partial parser state, ignoring duplicated netdefs ++ # from pre-existing YAML files, so we can force write the patch ++ # contents to the output file or update this file if exists. ++ state_output_file = libnetplan.State() ++ state_output_file.import_parser_results(parser_output_file) ++ state_output_file.write_yaml_file(filename, self.root_dir) ++ else: ++ state.update_yaml_hierarchy(FALLBACK_FILENAME, self.root_dir) +diff --git a/tests/test_cli_get_set.py b/tests/test_cli_get_set.py +index c4ed860..3f32e93 100644 +--- a/tests/test_cli_get_set.py ++++ b/tests/test_cli_get_set.py +@@ -25,6 +25,7 @@ import glob + + import yaml + ++from netplan.libnetplan import LibNetplanException + from tests.test_utils import call_cli + + +@@ -97,18 +98,64 @@ class TestSet(unittest.TestCase): + with open(p, 'r') as f: + self.assertIs(True, yaml.safe_load(f)['network']['ethernets']['eth0']['dhcp4']) + ++ def test_set_origin_hint_update(self): ++ hint = os.path.join(self.workdir.name, 'etc', 'netplan', 'hint.yaml') ++ with open(hint, 'w') as f: ++ f.write('''network: ++ version: 2 ++ renderer: networkd ++ ethernets: {eth0: {dhcp6: true}}''') ++ self._set(['ethernets.eth0={dhcp4: true, dhcp6: NULL}', '--origin-hint=hint']) ++ self.assertTrue(os.path.isfile(hint)) ++ with open(hint, 'r') as f: ++ yml = yaml.safe_load(f) ++ self.assertEqual(2, yml['network']['version']) ++ self.assertEqual('networkd', yml['network']['renderer']) ++ self.assertTrue(yml['network']['ethernets']['eth0']['dhcp4']) ++ self.assertNotIn('dhcp6', yml['network']['ethernets']['eth0']) ++ + def test_set_origin_hint_override(self): + defaults = os.path.join(self.workdir.name, 'etc', 'netplan', '0-snapd-defaults.yaml') + with open(defaults, 'w') as f: + f.write('''network: +- bridges: {br54: {dhcp4: true}} ++ renderer: NetworkManager ++ bridges: {br54: {dhcp4: true, dhcp6: true}} + ethernets: {eth0: {dhcp4: true}}''') ++ self._set(['bridges.br55.dhcp4=false', '--origin-hint=90-snapd-config']) + self._set(['bridges.br54.dhcp4=false', '--origin-hint=90-snapd-config']) + self._set(['bridges.br54.interfaces=[eth0]', '--origin-hint=90-snapd-config']) + p = os.path.join(self.workdir.name, 'etc', 'netplan', '90-snapd-config.yaml') + self.assertTrue(os.path.isfile(p)) + with open(p, 'r') as f: +- self.assertIs(False, yaml.safe_load(f)['network']['bridges']['br54']['dhcp4']) ++ yml = yaml.safe_load(f) ++ self.assertIs(False, yml['network']['bridges']['br54']['dhcp4']) ++ self.assertNotIn('dhcp6', yml['network']['bridges']['br54']) ++ self.assertEqual(['eth0'], yml['network']['bridges']['br54']['interfaces']) ++ self.assertIs(False, yml['network']['bridges']['br55']['dhcp4']) ++ ++ def test_set_origin_hint_override_invalid_netdef_setting(self): ++ defaults = os.path.join(self.workdir.name, 'etc', 'netplan', '0-snapd-defaults.yaml') ++ with open(defaults, 'w') as f: ++ f.write('''network: ++ vrfs: ++ vrf0: ++ table: 1005 ++ routes: ++ - to: default ++ via: 10.10.10.4 ++ table: 1005 ++''') ++ with self.assertRaises(LibNetplanException) as e: ++ self._set(['vrfs.vrf0.table=1004', '--origin-hint=90-snapd-config']) ++ self.assertIn('vrf0: VRF routes table mismatch (1004 != 1005)', str(e.exception)) ++ # hint/output file should not exist ++ p = os.path.join(self.workdir.name, 'etc', 'netplan', '90-snapd-config.yaml') ++ self.assertFalse(os.path.isfile(p)) ++ # original (defaults) file should stay untouched ++ self.assertTrue(os.path.isfile(defaults)) ++ with open(defaults, 'r') as f: ++ yml = yaml.safe_load(f) ++ self.assertEqual(1005, yml['network']['vrfs']['vrf0']['table']) + + def test_set_origin_hint_extend(self): + p = os.path.join(self.workdir.name, 'etc', 'netplan', '90-snapd-config.yaml') diff -Nru netplan.io-0.105/debian/patches/lp1997467/0007-src-parse-netplan-write-global-renderer-depending-on.patch netplan.io-0.105/debian/patches/lp1997467/0007-src-parse-netplan-write-global-renderer-depending-on.patch --- netplan.io-0.105/debian/patches/lp1997467/0007-src-parse-netplan-write-global-renderer-depending-on.patch 1970-01-01 01:00:00.000000000 +0100 +++ netplan.io-0.105/debian/patches/lp1997467/0007-src-parse-netplan-write-global-renderer-depending-on.patch 2023-02-10 10:30:42.000000000 +0000 @@ -0,0 +1,318 @@ +From: =?utf-8?q?Lukas_M=C3=A4rdian?= +Date: Thu, 8 Dec 2022 18:14:37 +0100 +Subject: src:parse:netplan: write global renderer depending on output file + +When parsing the global renderer, we also want to store the path+filename of +the parsed value in a new ->global_renderer datastructure (in parser & state). +This way, when generating YAML output, we can put a different/new renderer +stanza (e.g. from "netplan set network.renderer=...") into a higher order YAML +file while conserving the renderer data in the original YAML files. + +The renderer can be parsed from an existing file or a new, unnamed YAML patch. +If the path is known, we want to write back the data to that file only. If the +path is unknown/anonymous (i.e. emtpy string) we want to write that data to the +fallback file (e.g. 70-netplan-set.yaml or .yaml). +--- + src/netplan.c | 41 ++++++++++++++++++++++++++++++----------- + src/parse.c | 26 +++++++++++++++++++++++++- + src/types.c | 5 +++++ + src/types.h | 2 ++ + tests/test_cli_get_set.py | 43 ++++++++++++++++++++++++++++++++++++------- + 5 files changed, 98 insertions(+), 19 deletions(-) + +diff --git a/src/netplan.c b/src/netplan.c +index 2fe56d6..4596e30 100644 +--- a/src/netplan.c ++++ b/src/netplan.c +@@ -1,5 +1,5 @@ + /* +- * Copyright (C) 2021 Canonical, Ltd. ++ * Copyright (C) 2021-2023 Canonical, Ltd. + * Author: Lukas Märdian + * + * This program is free software; you can redistribute it and/or modify +@@ -991,7 +991,7 @@ contains_netdef_type(gconstpointer value, gconstpointer user_data) + } + + static gboolean +-netplan_netdef_list_write_yaml(const NetplanState* np_state, GList* netdefs, int out_fd, GError** error) ++netplan_netdef_list_write_yaml(const NetplanState* np_state, GList* netdefs, int out_fd, const char* out_fname, gboolean is_fallback, GError** error) + { + GHashTable *ovs_ports = NULL; + +@@ -1015,11 +1015,27 @@ netplan_netdef_list_write_yaml(const NetplanState* np_state, GList* netdefs, int + /* We support version 2 only, currently */ + YAML_NONNULL_STRING_PLAIN(event, emitter, "version", "2"); + +- if (netplan_state_get_backend(np_state) == NETPLAN_BACKEND_NM) { +- YAML_NONNULL_STRING_PLAIN(event, emitter, "renderer", "NetworkManager"); +- } else if (netplan_state_get_backend(np_state) == NETPLAN_BACKEND_NETWORKD) { +- YAML_NONNULL_STRING_PLAIN(event, emitter, "renderer", "networkd"); ++ /* fallback to default global handling, if renderer was not set for this file */ ++ NetplanBackend renderer = netplan_state_get_backend(np_state); ++ /* Try to find a file specific (global) renderer. ++ * If this is the fallback file (70-netplan-set.yaml or .yaml), ++ * a renderer parsed from a YAML patch takes precedence. */ ++ if (out_fname && np_state->global_renderer) { ++ gpointer value; ++ renderer = GPOINTER_TO_INT(g_hash_table_lookup(np_state->global_renderer, out_fname)); ++ /* A renderer parsed from an (anonymous) YAML patch takes precendence ++ * (e.g. "netplan set ..."). Such data does not have any filename ++ * associated to it in the global_renderer map (i.e. empty string). */ ++ if (is_fallback && g_hash_table_lookup_extended(np_state->global_renderer, "", NULL, &value)) ++ renderer = GPOINTER_TO_INT(value); + } ++ if (renderer == NETPLAN_BACKEND_NM || renderer == NETPLAN_BACKEND_NETWORKD) ++ YAML_NONNULL_STRING_PLAIN(event, emitter, "renderer", netplan_backend_name(renderer)); ++ ++ /* Do not write any netdefs, if we're just setting/updating some globals, ++ * e.g.: netplan set "network.renderer=NetworkManager" */ ++ if (!netdefs) ++ goto skip_netdefs; + + /* Go through the netdefs type-by-type */ + for (unsigned i = 0; i < NETPLAN_DEF_TYPE_MAX_; ++i) { +@@ -1053,6 +1069,7 @@ netplan_netdef_list_write_yaml(const NetplanState* np_state, GList* netdefs, int + } + } + ++skip_netdefs: + write_openvswitch(event, emitter, &np_state->ovs_settings, NETPLAN_BACKEND_NONE, ovs_ports); + + /* Close remaining mappings */ +@@ -1082,7 +1099,7 @@ file_error: + * relevant. + * + * @np_state: the state for which to generate the config +- * @filename: Relevant file basename ++ * @filename: Relevant file basename (e.g. origin-hint.yaml) + * @rootdir: If not %NULL, generate configuration in this root directory + * (useful for testing). + */ +@@ -1106,7 +1123,8 @@ netplan_state_write_yaml_file(const NetplanState* np_state, const char* filename + } + + /* Remove any existing file if there is no data to write */ +- if (to_write == NULL) { ++ gboolean write_globals = !!np_state->global_renderer; ++ if (to_write == NULL && !write_globals) { + if (unlink(path) && errno != ENOENT) { + g_set_error(error, G_FILE_ERROR, errno, "%s", strerror(errno)); + return FALSE; +@@ -1121,7 +1139,7 @@ netplan_state_write_yaml_file(const NetplanState* np_state, const char* filename + return FALSE; + } + +- gboolean ret = netplan_netdef_list_write_yaml(np_state, to_write, out_fd, error); ++ gboolean ret = netplan_netdef_list_write_yaml(np_state, to_write, out_fd, path, TRUE, error); + g_list_free(to_write); + close(out_fd); + if (ret) { +@@ -1146,7 +1164,7 @@ netplan_state_dump_yaml(const NetplanState* np_state, int out_fd, GError** error + if (!np_state->netdefs_ordered && !netplan_state_has_nondefault_globals(np_state)) + return TRUE; + +- return netplan_netdef_list_write_yaml(np_state, np_state->netdefs_ordered, out_fd, error); ++ return netplan_netdef_list_write_yaml(np_state, np_state->netdefs_ordered, out_fd, NULL, TRUE, error); + } + + /** +@@ -1195,11 +1213,12 @@ netplan_state_update_yaml_hierarchy(const NetplanState* np_state, const char* de + g_hash_table_iter_init(&hash_iter, perfile_netdefs); + while (g_hash_table_iter_next (&hash_iter, &key, &value)) { + const char *filename = key; ++ gboolean is_fallback = (g_strcmp0(filename, default_path) == 0); + GList* netdefs = value; + out_fd = open(filename, O_WRONLY | O_CREAT | O_TRUNC, 0640); + if (out_fd < 0) + goto file_error; +- if (!netplan_netdef_list_write_yaml(np_state, netdefs, out_fd, error)) ++ if (!netplan_netdef_list_write_yaml(np_state, netdefs, out_fd, filename, is_fallback, error)) + goto cleanup; // LCOV_EXCL_LINE + close(out_fd); + } +diff --git a/src/parse.c b/src/parse.c +index efd7d12..a66b018 100644 +--- a/src/parse.c ++++ b/src/parse.c +@@ -2789,7 +2789,15 @@ handle_network_version(NetplanParser* npp, yaml_node_t* node, const void* _, GEr + static gboolean + handle_network_renderer(NetplanParser* npp, yaml_node_t* node, const void* _, GError** error) + { +- return parse_renderer(npp, node, &npp->global_backend, error); ++ gboolean res = parse_renderer(npp, node, &npp->global_backend, error); ++ if (!npp->global_renderer) ++ npp->global_renderer = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL); ++ char* key = npp->current.filepath ? g_strdup(npp->current.filepath) : g_strdup(""); ++ /* Track the global renderer value of the current file. ++ * If current.filepath is empty, this YAML is parsed from an unnamed YAML ++ * patch (e.g. via 'netplan set '). */ ++ g_hash_table_insert(npp->global_renderer, key, GINT_TO_POINTER(npp->global_backend)); ++ return res; + } + + static gboolean +@@ -3241,6 +3249,12 @@ netplan_state_import_parser_results(NetplanState* np_state, NetplanParser* npp, + g_hash_table_foreach_steal(npp->sources, insert_kv_into_hash, np_state->sources); + } + ++ if (npp->global_renderer) { ++ if (!np_state->global_renderer) ++ np_state->global_renderer = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL); ++ g_hash_table_foreach_steal(npp->global_renderer, insert_kv_into_hash, np_state->global_renderer); ++ } ++ + /* We need to reset those fields manually as we transfered ownership of the underlying + data to out. If we don't do this, netplan_clear_parser will deallocate data + that we don't own anymore. */ +@@ -3327,6 +3341,11 @@ netplan_parser_reset(NetplanParser* npp) + g_hash_table_destroy(npp->sources); + npp->sources = NULL; + } ++ ++ if (npp->global_renderer) { ++ g_hash_table_destroy(npp->global_renderer); ++ npp->global_renderer = NULL; ++ } + } + + void +@@ -3350,6 +3369,10 @@ is_netdef_id_or_global_value(const char* full_key) + gchar** split = g_strsplit(key, "\t", 0); + if (split[0] && g_strcmp0(split[0], "network") == 0) { + if (split[1]) { ++ if (g_strcmp0(split[1], "renderer") == 0) { ++ ret = TRUE; // a valid global keyword ++ goto cleanup; ++ } + /* check if is valid network type */ + for (unsigned i = 0; i < NETPLAN_DEF_TYPE_MAX_; ++i) { + const char* def_type_name = netplan_def_type_name(i); +@@ -3364,6 +3387,7 @@ is_netdef_id_or_global_value(const char* full_key) + } + } + } ++cleanup: + g_strfreev(split); + return ret; + } +diff --git a/src/types.c b/src/types.c +index 5982a39..fec3b13 100644 +--- a/src/types.c ++++ b/src/types.c +@@ -430,6 +430,11 @@ netplan_state_reset(NetplanState* np_state) + g_hash_table_destroy(np_state->sources); + np_state->sources = NULL; + } ++ ++ if (np_state->global_renderer) { ++ g_hash_table_destroy(np_state->global_renderer); ++ np_state->global_renderer = NULL; ++ } + } + + NetplanBackend +diff --git a/src/types.h b/src/types.h +index bd6c25b..b00ea00 100644 +--- a/src/types.h ++++ b/src/types.h +@@ -182,6 +182,7 @@ struct netplan_state { + /* Hashset of the source files used to create this state. Owns its data (glib-allocated + * char*) and is initialized with g_hash_table_new_full to avoid leaks. */ + GHashTable* sources; ++ GHashTable* global_renderer; + }; + + struct netplan_parser { +@@ -241,6 +242,7 @@ struct netplan_parser { + /* Which fields have been nullified by a subsequent patch? */ + GHashTable* null_fields; + GHashTable* null_overrides; ++ GHashTable* global_renderer; + }; + + #define NETPLAN_ADVERTISED_RECEIVE_WINDOW_UNSPEC 0 +diff --git a/tests/test_cli_get_set.py b/tests/test_cli_get_set.py +index 3f32e93..4d1af8c 100644 +--- a/tests/test_cli_get_set.py ++++ b/tests/test_cli_get_set.py +@@ -118,12 +118,18 @@ class TestSet(unittest.TestCase): + defaults = os.path.join(self.workdir.name, 'etc', 'netplan', '0-snapd-defaults.yaml') + with open(defaults, 'w') as f: + f.write('''network: +- renderer: NetworkManager ++ renderer: networkd + bridges: {br54: {dhcp4: true, dhcp6: true}} + ethernets: {eth0: {dhcp4: true}}''') ++ self._set(['network.version=2', '--origin-hint=90-snapd-config']) ++ self._set(['renderer=NetworkManager', '--origin-hint=90-snapd-config']) + self._set(['bridges.br55.dhcp4=false', '--origin-hint=90-snapd-config']) + self._set(['bridges.br54.dhcp4=false', '--origin-hint=90-snapd-config']) + self._set(['bridges.br54.interfaces=[eth0]', '--origin-hint=90-snapd-config']) ++ self.assertTrue(os.path.isfile(defaults)) ++ with open(defaults, 'r') as f: ++ yml = yaml.safe_load(f) ++ self.assertEqual("networkd", yml['network']['renderer']) + p = os.path.join(self.workdir.name, 'etc', 'netplan', '90-snapd-config.yaml') + self.assertTrue(os.path.isfile(p)) + with open(p, 'r') as f: +@@ -132,6 +138,26 @@ class TestSet(unittest.TestCase): + self.assertNotIn('dhcp6', yml['network']['bridges']['br54']) + self.assertEqual(['eth0'], yml['network']['bridges']['br54']['interfaces']) + self.assertIs(False, yml['network']['bridges']['br55']['dhcp4']) ++ self.assertIs(2, yml['network']['version']) ++ self.assertEqual("NetworkManager", yml['network']['renderer']) ++ ++ def test_set_origin_hint_override_no_leak_renderer(self): ++ defaults = os.path.join(self.workdir.name, 'etc', 'netplan', '0-snapd-defaults.yaml') ++ with open(defaults, 'w') as f: ++ f.write('''network: ++ renderer: networkd ++ bridges: {br54: {dhcp4: true}}''') ++ self._set(['bridges.br54.dhcp4=false', '--origin-hint=90-snapd-config']) ++ self.assertTrue(os.path.isfile(defaults)) ++ with open(defaults, 'r') as f: ++ yml = yaml.safe_load(f) ++ self.assertEqual("networkd", yml['network']['renderer']) ++ p = os.path.join(self.workdir.name, 'etc', 'netplan', '90-snapd-config.yaml') ++ self.assertTrue(os.path.isfile(p)) ++ with open(p, 'r') as f: ++ yml = yaml.safe_load(f) ++ self.assertIs(False, yml['network']['bridges']['br54']['dhcp4']) ++ self.assertNotIn('renderer', yml['network']) + + def test_set_origin_hint_override_invalid_netdef_setting(self): + defaults = os.path.join(self.workdir.name, 'etc', 'netplan', '0-snapd-defaults.yaml') +@@ -168,6 +194,7 @@ class TestSet(unittest.TestCase): + yml = yaml.safe_load(f) + self.assertIs(False, yml['network']['bridges']['br54']['dhcp4']) + self.assertIs(True, yml['network']['bridges']['br55']['dhcp4']) ++ self.assertNotIn('renderer', yml['network']) + + def test_set_empty_origin_hint(self): + with self.assertRaises(Exception) as context: +@@ -274,16 +301,18 @@ class TestSet(unittest.TestCase): + yaml.safe_load(f)['network']['ethernets']['ens3']['dhcp4']) + + def test_set_overwrite(self): +- with open(self.path, 'w') as f: ++ p = os.path.join(self.workdir.name, 'etc', 'netplan', 'test.yaml') ++ with open(p, 'w') as f: + f.write('''network: ++ renderer: networkd + ethernets: + ens3: {dhcp4: "no"}''') + self._set(['ethernets.ens3.dhcp4=true']) +- self.assertTrue(os.path.isfile(self.path)) +- with open(self.path, 'r') as f: +- self.assertIs( +- True, +- yaml.safe_load(f)['network']['ethernets']['ens3']['dhcp4']) ++ self.assertTrue(os.path.isfile(p)) ++ with open(p, 'r') as f: ++ yml = yaml.safe_load(f) ++ self.assertIs(True, yml['network']['ethernets']['ens3']['dhcp4']) ++ self.assertEqual('networkd', yml['network']['renderer']) + + def test_set_delete(self): + with open(self.path, 'w') as f: diff -Nru netplan.io-0.105/debian/patches/series netplan.io-0.105/debian/patches/series --- netplan.io-0.105/debian/patches/series 2022-10-11 13:58:36.000000000 +0100 +++ netplan.io-0.105/debian/patches/series 2023-02-10 10:30:42.000000000 +0000 @@ -1 +1,7 @@ autopkgtest-fixes.patch + +lp1997467/0003-generate-util-fix-double-slash-root-filepath.patch +lp1997467/0004-test-cli-set-add-regression-cases-for-LP-1997467.patch +lp1997467/0005-parse-Allow-loading-nullable-origin-hint-overrides-n.patch +lp1997467/0006-cli-set-fix-origin-hint-handling-LP-1997467.patch +lp1997467/0007-src-parse-netplan-write-global-renderer-depending-on.patch