system: Bridge support for ietf-interfaces config

Introduce a possibility to configure an interface to be enslaved by
another interface.
Our interfaces are not changeable, so the only possible value for a
master interface is a br0 interface, the only bridge in the system.

Change-Id: Ie19192bfed3404781ef4f727695928e50537dd7a
diff --git a/src/system/IETFInterfacesConfig.cpp b/src/system/IETFInterfacesConfig.cpp
index f776d78..421fd85 100644
--- a/src/system/IETFInterfacesConfig.cpp
+++ b/src/system/IETFInterfacesConfig.cpp
@@ -116,8 +116,15 @@
             }
 
             // systemd-networkd auto-generates IPv6 link-layer addresses https://www.freedesktop.org/software/systemd/man/systemd.network.html#LinkLocalAddressing=
-            // disable this behaviour if ipv6 is disabled
-            if (!protocolEnabled(linkEntry, "ipv6")) {
+            // disable this behaviour when IPv6 is disabled or when link enslaved
+            bool isSlave = false;
+
+            if (auto set = linkEntry->find_path("czechlight-network:bridge"); set->number() > 0) {
+                configValues["Network"].push_back("Bridge="s + getValueAsString(set->data().front()));
+                isSlave = true;
+            }
+
+            if (!protocolEnabled(linkEntry, "ipv6") && !isSlave) {
                 configValues["Network"].push_back("LinkLocalAddressing=no");
             }
 
diff --git a/tests/sysrepo_system-ietfinterfaces.cpp b/tests/sysrepo_system-ietfinterfaces.cpp
index 27c301a..9e7accb 100644
--- a/tests/sysrepo_system-ietfinterfaces.cpp
+++ b/tests/sysrepo_system-ietfinterfaces.cpp
@@ -177,7 +177,7 @@
     std::filesystem::remove_all(fakeConfigDir);
     std::filesystem::create_directories(fakeConfigDir);
 
-    auto network = std::make_shared<velia::system::IETFInterfacesConfig>(srSess, fakeConfigDir, std::vector<std::string>{"eth0", "eth1"}, [&fake](const std::vector<std::string>& updatedInterfaces) { fake.cb(updatedInterfaces); });
+    auto network = std::make_shared<velia::system::IETFInterfacesConfig>(srSess, fakeConfigDir, std::vector<std::string>{"br0", "eth0", "eth1"}, [&fake](const std::vector<std::string>& updatedInterfaces) { fake.cb(updatedInterfaces); });
 
 
     std::string expectedContents;
@@ -267,11 +267,12 @@
         REQUIRE(!std::filesystem::exists(expectedFilePath));
     }
 
-    SECTION("Setting IPs to eth0 and eth1")
+    SECTION("Two links")
     {
         const auto expectedFilePathEth0 = fakeConfigDir / "eth0.network";
         const auto expectedFilePathEth1 = fakeConfigDir / "eth1.network";
-        const std::string expectedContentsEth0 = R"([Match]
+
+        std::string expectedContentsEth0 = R"([Match]
 Name=eth0
 
 [Network]
@@ -281,7 +282,7 @@
 LLDP=true
 EmitLLDP=nearest-bridge
 )";
-        const std::string expectedContentsEth1 = R"([Match]
+        std::string expectedContentsEth1 = R"([Match]
 Name=eth1
 
 [Network]
@@ -311,4 +312,189 @@
         REQUIRE(!std::filesystem::exists(expectedFilePathEth0));
         REQUIRE(!std::filesystem::exists(expectedFilePathEth1));
     }
+
+    SECTION("Setup a bridge br0 over eth0 and eth1")
+    {
+        const auto expectedFilePathBr0 = fakeConfigDir / "br0.network";
+        const auto expectedFilePathEth0 = fakeConfigDir / "eth0.network";
+        const auto expectedFilePathEth1 = fakeConfigDir / "eth1.network";
+
+        std::string expectedContentsBr0 = R"([Match]
+Name=br0
+
+[Network]
+LinkLocalAddressing=no
+DHCP=no
+LLDP=true
+EmitLLDP=nearest-bridge
+)";
+
+        std::string expectedContentsEth0 = R"([Match]
+Name=eth0
+
+[Network]
+Bridge=br0
+DHCP=no
+LLDP=true
+EmitLLDP=nearest-bridge
+)";
+
+        std::string expectedContentsEth1 = R"([Match]
+Name=eth1
+
+[Network]
+Bridge=br0
+DHCP=no
+LLDP=true
+EmitLLDP=nearest-bridge
+)";
+
+        // create br0 bridge over eth0 and eth1 with no IP
+        client->set_item_str("/ietf-interfaces:interfaces/interface[name='br0']/enabled", "false");
+        client->set_item_str("/ietf-interfaces:interfaces/interface[name='br0']/type", "iana-if-type:bridge");
+
+        client->set_item_str("/ietf-interfaces:interfaces/interface[name='eth0']/type", "iana-if-type:ethernetCsmacd");
+        client->set_item_str("/ietf-interfaces:interfaces/interface[name='eth0']/czechlight-network:bridge", "br0");
+        client->set_item_str("/ietf-interfaces:interfaces/interface[name='eth0']/ietf-ip:ipv6/ietf-ip:enabled", "false");
+        client->delete_item("/ietf-interfaces:interfaces/interface[name='eth0']/ietf-ip:ipv4");
+
+        client->set_item_str("/ietf-interfaces:interfaces/interface[name='eth1']/type", "iana-if-type:ethernetCsmacd");
+        client->set_item_str("/ietf-interfaces:interfaces/interface[name='eth1']/czechlight-network:bridge", "br0");
+        client->set_item_str("/ietf-interfaces:interfaces/interface[name='eth1']/ietf-ip:ipv4/ietf-ip:enabled", "false");
+        client->delete_item("/ietf-interfaces:interfaces/interface[name='eth1']/ietf-ip:ipv6");
+
+        REQUIRE_CALL(fake, cb(std::vector<std::string>{"br0", "eth0", "eth1"})).IN_SEQUENCE(seq1);
+        client->apply_changes();
+        REQUIRE(std::filesystem::exists(expectedFilePathBr0));
+        REQUIRE(std::filesystem::exists(expectedFilePathEth0));
+        REQUIRE(std::filesystem::exists(expectedFilePathEth1));
+        REQUIRE(velia::utils::readFileToString(expectedFilePathBr0) == expectedContentsBr0);
+        REQUIRE(velia::utils::readFileToString(expectedFilePathEth0) == expectedContentsEth0);
+        REQUIRE(velia::utils::readFileToString(expectedFilePathEth1) == expectedContentsEth1);
+
+        // assign an IPv4 address to br0
+        client->set_item_str("/ietf-interfaces:interfaces/interface[name='br0']/enabled", "true");
+        client->set_item_str("/ietf-interfaces:interfaces/interface[name='br0']/ietf-ip:ipv4/ietf-ip:address[ip='192.0.2.1']/ietf-ip:prefix-length", "24");
+        expectedContentsBr0 = R"([Match]
+Name=br0
+
+[Network]
+Address=192.0.2.1/24
+LinkLocalAddressing=no
+DHCP=no
+LLDP=true
+EmitLLDP=nearest-bridge
+)";
+
+        REQUIRE_CALL(fake, cb(std::vector<std::string>{"br0"})).IN_SEQUENCE(seq1);
+        client->apply_changes();
+        REQUIRE(std::filesystem::exists(expectedFilePathBr0));
+        REQUIRE(std::filesystem::exists(expectedFilePathEth0));
+        REQUIRE(std::filesystem::exists(expectedFilePathEth1));
+        REQUIRE(velia::utils::readFileToString(expectedFilePathBr0) == expectedContentsBr0);
+        REQUIRE(velia::utils::readFileToString(expectedFilePathEth0) == expectedContentsEth0);
+        REQUIRE(velia::utils::readFileToString(expectedFilePathEth1) == expectedContentsEth1);
+
+        // assign also an IPv6 address to br0
+        client->set_item_str("/ietf-interfaces:interfaces/interface[name='br0']/ietf-ip:ipv6/ietf-ip:address[ip='2001:db8::1']/ietf-ip:prefix-length", "32");
+        expectedContentsBr0 = R"([Match]
+Name=br0
+
+[Network]
+Address=192.0.2.1/24
+Address=2001:db8::1/32
+DHCP=no
+LLDP=true
+EmitLLDP=nearest-bridge
+)";
+
+        REQUIRE_CALL(fake, cb(std::vector<std::string>{"br0"})).IN_SEQUENCE(seq1);
+        client->apply_changes();
+        REQUIRE(std::filesystem::exists(expectedFilePathBr0));
+        REQUIRE(std::filesystem::exists(expectedFilePathEth0));
+        REQUIRE(std::filesystem::exists(expectedFilePathEth1));
+        REQUIRE(velia::utils::readFileToString(expectedFilePathBr0) == expectedContentsBr0);
+        REQUIRE(velia::utils::readFileToString(expectedFilePathEth0) == expectedContentsEth0);
+        REQUIRE(velia::utils::readFileToString(expectedFilePathEth1) == expectedContentsEth1);
+
+        // remove eth1 from bridge
+        client->delete_item("/ietf-interfaces:interfaces/interface[name='eth1']/czechlight-network:bridge");
+        client->set_item_str("/ietf-interfaces:interfaces/interface[name='eth1']/ietf-ip:ipv6/ietf-ip:address[ip='2001:db8::2']/ietf-ip:prefix-length", "32");
+
+        expectedContentsEth1 = R"([Match]
+Name=eth1
+
+[Network]
+Address=2001:db8::2/32
+DHCP=no
+LLDP=true
+EmitLLDP=nearest-bridge
+)";
+
+        REQUIRE_CALL(fake, cb(std::vector<std::string>{"eth1"})).IN_SEQUENCE(seq1);
+        client->apply_changes();
+        REQUIRE(std::filesystem::exists(expectedFilePathBr0));
+        REQUIRE(std::filesystem::exists(expectedFilePathEth0));
+        REQUIRE(std::filesystem::exists(expectedFilePathEth1));
+        REQUIRE(velia::utils::readFileToString(expectedFilePathBr0) == expectedContentsBr0);
+        REQUIRE(velia::utils::readFileToString(expectedFilePathEth0) == expectedContentsEth0);
+        REQUIRE(velia::utils::readFileToString(expectedFilePathEth1) == expectedContentsEth1);
+
+        // reset the contents
+        client->delete_item("/ietf-interfaces:interfaces/interface[name='br0']");
+        client->delete_item("/ietf-interfaces:interfaces/interface[name='eth0']");
+        client->delete_item("/ietf-interfaces:interfaces/interface[name='eth1']");
+        REQUIRE_CALL(fake, cb(std::vector<std::string>{"br0", "eth0", "eth1"})).IN_SEQUENCE(seq1);
+        client->apply_changes();
+        REQUIRE(!std::filesystem::exists(expectedFilePathBr0));
+        REQUIRE(!std::filesystem::exists(expectedFilePathEth0));
+        REQUIRE(!std::filesystem::exists(expectedFilePathEth1));
+    }
+
+    SECTION("Slave interface and enabled/disabled IP protocols")
+    {
+        const auto expectedFilePathBr0 = fakeConfigDir / "br0.network";
+        const auto expectedFilePathEth0 = fakeConfigDir / "eth0.network";
+
+        client->set_item_str("/ietf-interfaces:interfaces/interface[name='br0']/type", "iana-if-type:bridge");
+        client->set_item_str("/ietf-interfaces:interfaces/interface[name='br0']/ietf-ip:ipv4/ietf-ip:address[ip='192.0.2.1']/ietf-ip:prefix-length", "24");
+        client->set_item_str("/ietf-interfaces:interfaces/interface[name='eth0']/type", "iana-if-type:ethernetCsmacd");
+        client->set_item_str("/ietf-interfaces:interfaces/interface[name='eth0']/czechlight-network:bridge", "br0");
+
+        SECTION("Can't be a slave when IPv4 enabled")
+        {
+            client->set_item_str("/ietf-interfaces:interfaces/interface[name='eth0']/ietf-ip:ipv4/ietf-ip:address[ip='192.0.2.1']/ietf-ip:prefix-length", "24");
+            REQUIRE_THROWS_AS(client->apply_changes(), sysrepo::sysrepo_exception);
+        }
+
+        SECTION("Can't be a slave when IPv6 enabled")
+        {
+            client->set_item_str("/ietf-interfaces:interfaces/interface[name='eth1']/ietf-ip:ipv6/ietf-ip:address[ip='2001:db8::1']/ietf-ip:prefix-length", "32");
+            REQUIRE_THROWS_AS(client->apply_changes(), sysrepo::sysrepo_exception);
+        }
+
+        SECTION("Can't be a slave when both IPv4 and IPv6 enabled")
+        {
+            client->set_item_str("/ietf-interfaces:interfaces/interface[name='eth0']/ietf-ip:ipv4/ietf-ip:address[ip='192.0.2.1']/ietf-ip:prefix-length", "24");
+            client->set_item_str("/ietf-interfaces:interfaces/interface[name='eth0']/ietf-ip:ipv6/ietf-ip:address[ip='2001:db8::1']/ietf-ip:prefix-length", "32");
+            REQUIRE_THROWS_AS(client->apply_changes(), sysrepo::sysrepo_exception);
+        }
+
+        SECTION("Can be a slave when addresses present but protocol is disabled")
+        {
+            client->set_item_str("/ietf-interfaces:interfaces/interface[name='eth0']/ietf-ip:ipv4/ietf-ip:address[ip='192.0.2.1']/ietf-ip:prefix-length", "24");
+            client->set_item_str("/ietf-interfaces:interfaces/interface[name='eth0']/ietf-ip:ipv4/enabled", "false");
+
+            REQUIRE_CALL(fake, cb(std::vector<std::string>{"br0", "eth0"})).IN_SEQUENCE(seq1);
+            client->apply_changes();
+
+            // reset the contents
+            client->delete_item("/ietf-interfaces:interfaces/interface[name='br0']");
+            client->delete_item("/ietf-interfaces:interfaces/interface[name='eth0']");
+            REQUIRE_CALL(fake, cb(std::vector<std::string>{"br0", "eth0"})).IN_SEQUENCE(seq1);
+            client->apply_changes();
+            REQUIRE(!std::filesystem::exists(expectedFilePathBr0));
+            REQUIRE(!std::filesystem::exists(expectedFilePathEth0));
+        }
+    }
 }
diff --git a/yang/czechlight-network@2021-02-22.yang b/yang/czechlight-network@2021-02-22.yang
index fbdd6fb..2d67e8b 100644
--- a/yang/czechlight-network@2021-02-22.yang
+++ b/yang/czechlight-network@2021-02-22.yang
@@ -73,16 +73,36 @@
         }
     }
 
+    augment /if:interfaces/if:interface {
+        description "Add the option to add this link to a bridge.";
+
+        leaf bridge {
+            must '. = "br0"' {
+                error-message "br0 is the only available bridge interface.";
+            }
+
+            when '(not(../ip:ipv4) or ../ip:ipv4/ip:enabled[.="false"]) and
+                  (not(../ip:ipv6) or ../ip:ipv6/ip:enabled[.="false"])' {
+                description "IP protocols must be disabled for enslaved link.";
+            }
+
+            type if:interface-ref;
+            mandatory false;
+            description "The name of the bridge to add the link to.";
+        }
+    }
+
     // Make it hard to accidentally lose connection by removing ipv4/ipv6 presence containers or all IP addresses
     deviation /if:interfaces/if:interface {
         deviate add {
             must 'if:enabled = "false" or
                   ip:ipv4/ip:enabled = "true" or
-                  ip:ipv6/ip:enabled = "true"' {
+                  ip:ipv6/ip:enabled = "true" or
+                  cla-network:bridge' {
                 error-message "There must always be at least one protocol active unless the interface is disabled.";
             }
-        }
-    }
+		}
+	}
 
     deviation /if:interfaces/if:interface/ip:ipv4 {
         deviate add {