sysrepo: Implement parts of ietf-system module

Implement announcing OS name and OS release through ietf-system YANG
model [1], specifically via its top-level container system-state.
The properties are obtained from /etc/os-release file.

[1] https://tools.ietf.org/html/rfc7317

Change-Id: I82d5fd54659ea365232a3a9455dd73f84b8fd0d1
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 76e6078..f07caac 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -122,6 +122,18 @@
         PkgConfig::LIBYANG
     )
 
+# - ietf-system
+add_library(velia-system STATIC
+        src/system/Sysrepo.cpp
+        src/system/Sysrepo.h
+        )
+target_link_libraries(velia-system
+    PUBLIC
+        velia-utils
+        PkgConfig::SYSREPO
+        PkgConfig::LIBYANG
+    )
+
 # - daemon
 add_executable(veliad
         src/main.cpp
@@ -133,6 +145,7 @@
         velia-health
         velia-ietf-hardware
         velia-ietf-hardware-sysrepo
+        velia-system
         docopt
         )
 add_dependencies(veliad target-VELIA_VERSION)
@@ -222,6 +235,14 @@
     velia_test(hardware_emmc velia-ietf-hardware FsTestUtils)
     velia_test(hardware_hwmon velia-ietf-hardware FsTestUtils)
 
+    sysrepo_fixture_env(sysrepo-ietf-system YANG ${CMAKE_CURRENT_SOURCE_DIR}/yang/ietf-system@2014-08-06.yang)
+    velia_test(sysrepo_system velia-system DbusTesting)
+    set_tests_properties(
+            test-sysrepo_system
+            PROPERTIES FIXTURES_REQUIRED sysrepo:env:sysrepo-ietf-system
+            RESOURCE_LOCK sysrepo
+    )
+
     sysrepo_fixture_env(sysrepo-ietf-hardware YANG ${CMAKE_CURRENT_SOURCE_DIR}/yang/iana-hardware@2018-03-13.yang YANG ${CMAKE_CURRENT_SOURCE_DIR}/yang/ietf-hardware@2018-03-13.yang FEATURE hardware-sensor)
     velia_test(hardware_ietf-hardware velia-ietf-hardware velia-ietf-hardware-sysrepo)
     set_tests_properties(
diff --git a/src/main.cpp b/src/main.cpp
index 23571b2..c0128df 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -11,6 +11,7 @@
 #include "health/manager/StateManager.h"
 #include "health/outputs/callables.h"
 #include "ietf-hardware/sysrepo/Sysrepo.h"
+#include "system/Sysrepo.h"
 #include "utils/exceptions.h"
 #include "utils/journal.h"
 #include "utils/log-init.h"
@@ -102,6 +103,10 @@
         spdlog::get("main")->debug("Initializing Sysrepo ietf-hardware callback");
         auto sysrepoIETFHardware = velia::ietf_hardware::sysrepo::Sysrepo(srSubscription, ietfHardware);
 
+        // initialize ietf-system
+        spdlog::get("main")->debug("Initializing Sysrepo for system models");
+        auto sysrepoSystem = velia::system::Sysrepo(srSess, "/etc/os-release");
+
         // Gracefully leave dbus event loop on SIGTERM
         struct sigaction sigact;
         memset(&sigact, 0, sizeof(sigact));
diff --git a/src/system/Sysrepo.cpp b/src/system/Sysrepo.cpp
new file mode 100644
index 0000000..c9ce0fa
--- /dev/null
+++ b/src/system/Sysrepo.cpp
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2021 CESNET, https://photonics.cesnet.cz/
+ *
+ * Written by Tomáš Pecka <tomas.pecka@fit.cvut.cz>
+ *
+ */
+
+#include <boost/algorithm/string/predicate.hpp>
+#include <fstream>
+#include "Sysrepo.h"
+#include "utils/io.h"
+#include "utils/log.h"
+
+using namespace std::literals;
+
+namespace {
+
+const auto IETF_SYSTEM_MODULE_NAME = "ietf-system"s;
+const auto IETF_SYSTEM_STATE_MODULE_PREFIX = "/"s + IETF_SYSTEM_MODULE_NAME + ":system-state/"s;
+
+/** @brief Returns key=value pairs from (e.g. /etc/os-release) as a std::map */
+std::map<std::string, std::string> parseKeyValueFile(const std::filesystem::path& path)
+{
+    std::map<std::string, std::string> res;
+    std::ifstream ifs(path);
+    if (!ifs.is_open())
+        throw std::invalid_argument("File '" + std::string(path) + "' not found.");
+
+    std::string line;
+    while (std::getline(ifs, line)) {
+        // man os-release: Lines beginning with "#" shall be ignored as comments. Blank lines are permitted and ignored.
+        if (line.empty() || boost::algorithm::starts_with(line, "#")) {
+            continue;
+        }
+
+        size_t equalSignPos = line.find_first_of('=');
+        if (equalSignPos != std::string::npos) {
+            std::string key = line.substr(0, equalSignPos);
+            std::string val = line.substr(equalSignPos + 1);
+
+            // remove quotes from value
+            if (val.length() >= 2 && val.front() == '"' && val.front() == val.back()) {
+                val = val.substr(1, val.length() - 2);
+            }
+
+            res[key] = val;
+        } else { // when there is no = sign, treat the value as empty string
+            res[line] = "";
+        }
+    }
+
+    return res;
+}
+
+}
+
+namespace velia::system {
+
+/** @brief Reads some OS-identification data from osRelease file and publishes them via ietf-system model */
+Sysrepo::Sysrepo(std::shared_ptr<::sysrepo::Session> srSession, const std::filesystem::path& osRelease)
+    : m_srSession(std::move(srSession))
+    , m_log(spdlog::get("system"))
+{
+    std::map<std::string, std::string> osReleaseContents = parseKeyValueFile(osRelease);
+
+    std::map<std::string, std::string> opsSystemStateData {
+        {IETF_SYSTEM_STATE_MODULE_PREFIX + "platform/os-name", osReleaseContents.at("NAME")},
+        {IETF_SYSTEM_STATE_MODULE_PREFIX + "platform/os-release", osReleaseContents.at("VERSION")},
+        {IETF_SYSTEM_STATE_MODULE_PREFIX + "platform/os-version", osReleaseContents.at("VERSION")},
+    };
+
+    sr_datastore_t oldDatastore = m_srSession->session_get_ds();
+    m_srSession->session_switch_ds(SR_DS_OPERATIONAL);
+
+    for (const auto& [k, v] : opsSystemStateData) {
+        m_log->debug("Pushing to sysrepo: {} = {}", k, v);
+        m_srSession->set_item_str(k.c_str(), v.c_str());
+    }
+
+    m_srSession->apply_changes();
+    m_srSession->session_switch_ds(oldDatastore);
+}
+}
diff --git a/src/system/Sysrepo.h b/src/system/Sysrepo.h
new file mode 100644
index 0000000..0aeda9a
--- /dev/null
+++ b/src/system/Sysrepo.h
@@ -0,0 +1,23 @@
+/*
+ * Copyright (C) 2021 CESNET, https://photonics.cesnet.cz/
+ *
+ * Written by Tomáš Pecka <tomas.pecka@fit.cvut.cz>
+ *
+ */
+#pragma once
+
+#include <filesystem>
+#include <sysrepo-cpp/Session.hpp>
+#include "utils/log-fwd.h"
+
+namespace velia::system {
+
+class Sysrepo {
+public:
+    explicit Sysrepo(std::shared_ptr<::sysrepo::Session> srSession, const std::filesystem::path& osRelease);
+
+private:
+    std::shared_ptr<::sysrepo::Session> m_srSession;
+    velia::Log m_log;
+};
+}
diff --git a/src/utils/log-init.cpp b/src/utils/log-init.cpp
index 7b6724f..6a74f26 100644
--- a/src/utils/log-init.cpp
+++ b/src/utils/log-init.cpp
@@ -17,7 +17,7 @@
 */
 void initLogs(std::shared_ptr<spdlog::sinks::sink> sink)
 {
-    for (const auto& name : {"main", "health", "hardware", "sysrepo"}) {
+    for (const auto& name : {"main", "health", "hardware", "sysrepo", "system"}) {
         spdlog::register_logger(std::make_shared<spdlog::logger>(name, sink));
     }
 }
diff --git a/tests/sysrepo_system.cpp b/tests/sysrepo_system.cpp
new file mode 100644
index 0000000..f10a3e9
--- /dev/null
+++ b/tests/sysrepo_system.cpp
@@ -0,0 +1,72 @@
+#include "trompeloeil_doctest.h"
+#include "pretty_printers.h"
+#include "system/Sysrepo.h"
+#include "test_log_setup.h"
+#include "test_sysrepo_helpers.h"
+#include "tests/configure.cmake.h"
+
+using namespace std::literals;
+
+TEST_CASE("System stuff in Sysrepo")
+{
+    trompeloeil::sequence seq1;
+
+    TEST_SYSREPO_INIT_LOGS;
+    TEST_SYSREPO_INIT;
+
+    SECTION("Test system-state")
+    {
+        TEST_SYSREPO_INIT_CLIENT;
+        static const auto modulePrefix = "/ietf-system:system-state"s;
+
+        SECTION("Valid data")
+        {
+            std::filesystem::path file;
+            std::map<std::string, std::string> expected;
+
+            SECTION("Real data")
+            {
+                file = CMAKE_CURRENT_SOURCE_DIR "/tests/system/os-release";
+                expected = {
+                    {"/clock", ""},
+                    {"/platform", ""},
+                    {"/platform/os-name", "CzechLight"},
+                    {"/platform/os-release", "v4-105-g8294175-dirty"},
+                    {"/platform/os-version", "v4-105-g8294175-dirty"},
+                };
+            }
+
+            SECTION("Missing =")
+            {
+                file = CMAKE_CURRENT_SOURCE_DIR "/tests/system/missing-equal";
+                expected = {
+                    {"/clock", ""},
+                    {"/platform", ""},
+                    {"/platform/os-name", ""},
+                    {"/platform/os-release", ""},
+                    {"/platform/os-version", ""},
+                };
+            }
+
+            SECTION("Empty values")
+            {
+                file = CMAKE_CURRENT_SOURCE_DIR "/tests/system/empty-values";
+                expected = {
+                    {"/clock", ""},
+                    {"/platform", ""},
+                    {"/platform/os-name", ""},
+                    {"/platform/os-release", ""},
+                    {"/platform/os-version", ""},
+                };
+            }
+
+            auto sysrepo = std::make_shared<velia::system::Sysrepo>(srSess, file);
+            REQUIRE(dataFromSysrepo(client, modulePrefix, SR_DS_OPERATIONAL) == expected);
+        }
+
+        SECTION("Invalid data (missing VERSION and NAME keys)")
+        {
+            REQUIRE_THROWS_AS(std::make_shared<velia::system::Sysrepo>(srSess, CMAKE_CURRENT_SOURCE_DIR "/tests/system/missing-keys"), std::out_of_range);
+        }
+    }
+}
diff --git a/tests/system/empty-values b/tests/system/empty-values
new file mode 100644
index 0000000..910749b
--- /dev/null
+++ b/tests/system/empty-values
@@ -0,0 +1,2 @@
+VERSION=
+NAME=
diff --git a/tests/system/missing-equal b/tests/system/missing-equal
new file mode 100644
index 0000000..ad16e1c
--- /dev/null
+++ b/tests/system/missing-equal
@@ -0,0 +1,2 @@
+VERSION
+NAME
diff --git a/tests/system/missing-keys b/tests/system/missing-keys
new file mode 100644
index 0000000..ee45a08
--- /dev/null
+++ b/tests/system/missing-keys
@@ -0,0 +1,5 @@
+# asd
+# asd
+PRETTY_NAME="Czech Light v4-105-g8294175-dirty"
+
+# asd
diff --git a/tests/system/os-release b/tests/system/os-release
new file mode 100644
index 0000000..c586c33
--- /dev/null
+++ b/tests/system/os-release
@@ -0,0 +1,15 @@
+BUILDROOT_VERSION=2020.11-rc1-45-g386283e33b
+ID=buildroot
+BUILDROOT_VERSION_ID=2020.11-rc1
+NAME=CzechLight
+# When building under CI, these git revisions might not necessarily refer to
+# something that is available from Gerrit's git repositories. If the job which
+# produced this image is a result of a Zuul job tree with speculatively merged
+# changes, then these refs are private to Zuul mergers.
+PRETTY_NAME="Czech Light v4-105-g8294175-dirty"
+
+VERSION=v4-105-g8294175-dirty
+CLA_SYSREPO_VERSION=v4-170-g082749ea-dirty
+NETCONF_CLI_VERSION=0651966
+CPP_DEPENDENCIES_VERSION=cd88475
+GAMMARUS_VERSION=ee4affa