Merge pull request #1160 from kernelkit/upgrade-sysrepo

Bump sysrepo, netopeer,libyang and libnetconf2, libyang-cpp, rousette
This commit is contained in:
Mattias Walström
2025-09-17 10:41:04 +02:00
committed by GitHub
118 changed files with 2498 additions and 24290 deletions
+4
View File
@@ -9,6 +9,10 @@ All notable changes to the project are documented in this file.
### Changes
- Upgrade Buildroot to 2025.02.6 (LTS)
- Upgrade Linux kernel to 6.12.46 (LTS)
- Upgrade libyang to 3.13.5
- Upgrade sysrepo to 3.7.11
- Upgrade netopeer2 (NETCONF) to 2.4.5
- Upgrade rousette (RESTCONF) to v2
- Add support for [Banana Pi R3][BPI-R3], a 7 port switch with 2 WiFi chip
- Add neofetch system information tool for system introspection, issue #1143
- Add mtr and iperf3 network diagnostic tools, issue #1144
@@ -0,0 +1,135 @@
From 22b41e7ed8268b94ccc139138e2037c390a3a616 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bed=C5=99ich=20Schindler?= <bedrich.schindler@gmail.com>
Date: Thu, 10 Jul 2025 15:00:45 +0200
Subject: [PATCH] Implement `SchemaNode::actionRpcs()`
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
This function allows to access list of actions
within schema node.
It is implemented same as `Module::actionRpcs`
to follow same approach, so there is no need to use
collections.
Change-Id: Ie99908cfdd334433fd9ae6f1a909f196e61139fd
Signed-off-by: Jan Kundrát <jan.kundrat@cesnet.cz>
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
include/libyang-cpp/SchemaNode.hpp | 1 +
src/SchemaNode.cpp | 12 ++++++++++++
tests/example_schema.hpp | 28 ++++++++++++++++++++++++++++
tests/schema_node.cpp | 21 +++++++++++++++++++++
4 files changed, 62 insertions(+)
diff --git a/include/libyang-cpp/SchemaNode.hpp b/include/libyang-cpp/SchemaNode.hpp
index 0f1a4c4..9f49fd7 100644
--- a/include/libyang-cpp/SchemaNode.hpp
+++ b/include/libyang-cpp/SchemaNode.hpp
@@ -79,6 +79,7 @@ public:
Collection<SchemaNode, IterationType::Sibling> siblings() const;
Collection<SchemaNode, IterationType::Sibling> immediateChildren() const;
std::vector<ExtensionInstance> extensionInstances() const;
+ std::vector<SchemaNode> actionRpcs() const;
std::vector<When> when() const;
diff --git a/src/SchemaNode.cpp b/src/SchemaNode.cpp
index 26b5099..ce24203 100644
--- a/src/SchemaNode.cpp
+++ b/src/SchemaNode.cpp
@@ -117,6 +117,18 @@ Collection<SchemaNode, IterationType::Sibling> SchemaNode::immediateChildren() c
return c ? c->siblings() : Collection<SchemaNode, IterationType::Sibling>{nullptr, nullptr};
}
+/**
+ * @brief Returns a collection of action nodes (not RPC nodes) as SchemaNode
+ */
+std::vector<SchemaNode> SchemaNode::actionRpcs() const
+{
+ std::vector<SchemaNode> res;
+ for (auto action = reinterpret_cast<const struct lysc_node*>(lysc_node_actions(m_node)); action; action = action->next) {
+ res.emplace_back(SchemaNode{action, m_ctx});
+ }
+ return res;
+}
+
/**
* Returns the YANG description of the node.
*
diff --git a/tests/example_schema.hpp b/tests/example_schema.hpp
index 0d8acb9..02d3d74 100644
--- a/tests/example_schema.hpp
+++ b/tests/example_schema.hpp
@@ -214,6 +214,34 @@ module example-schema {
}
container bigTree {
+ action firstAction {
+ input {
+ leaf inputLeaf1 {
+ type string;
+ }
+ }
+
+ output {
+ leaf outputLeaf1 {
+ type string;
+ }
+ }
+ }
+
+ action secondAction {
+ input {
+ leaf inputLeaf2 {
+ type string;
+ }
+ }
+
+ output {
+ leaf outputLeaf2 {
+ type string;
+ }
+ }
+ }
+
container one {
leaf myLeaf {
type string;
diff --git a/tests/schema_node.cpp b/tests/schema_node.cpp
index 0001377..65ef462 100644
--- a/tests/schema_node.cpp
+++ b/tests/schema_node.cpp
@@ -544,6 +544,27 @@ TEST_CASE("SchemaNode")
REQUIRE(elem.extensionInstances()[2].argument() == "last-modified");
}
+ DOCTEST_SUBCASE("SchemaNode::actionRpcs")
+ {
+ DOCTEST_SUBCASE("no actions")
+ {
+ auto actions = ctx->findPath("/example-schema:presenceContainer").actionRpcs();
+ REQUIRE(actions.size() == 0);
+ }
+
+ DOCTEST_SUBCASE("two actions")
+ {
+ auto actions = ctx->findPath("/example-schema:bigTree").actionRpcs();
+ REQUIRE(actions.size() == 2);
+ REQUIRE(actions[0].asActionRpc().name() == "firstAction");
+ REQUIRE(actions[0].asActionRpc().input().child()->name() == "inputLeaf1");
+ REQUIRE(actions[0].asActionRpc().output().child()->name() == "outputLeaf1");
+ REQUIRE(actions[1].asActionRpc().name() == "secondAction");
+ REQUIRE(actions[1].asActionRpc().input().child()->name() == "inputLeaf2");
+ REQUIRE(actions[1].asActionRpc().output().child()->name() == "outputLeaf2");
+ }
+ }
+
DOCTEST_SUBCASE("SchemaNode::operator==")
{
auto a = ctx->findPath("/type_module:leafString");
--
2.43.0
@@ -1,82 +0,0 @@
From d0f6422fee7a46fcb7445c88f499f61b3eb0ead0 Mon Sep 17 00:00:00 2001
From: Adam Piecek <Adam.Piecek@cesnet.cz>
Date: Wed, 23 Oct 2024 14:37:09 +0200
Subject: [PATCH 01/18] added support for RpcYang in Context::parseOp
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Change-Id: I25182ea2d042be1e6e4246e18aee260cc032e547
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/Context.cpp | 6 ++++--
tests/context.cpp | 21 +++++++++++++++++++++
2 files changed, 25 insertions(+), 2 deletions(-)
diff --git a/src/Context.cpp b/src/Context.cpp
index b844bd9..287f8c8 100644
--- a/src/Context.cpp
+++ b/src/Context.cpp
@@ -221,7 +221,8 @@ std::optional<DataNode> Context::parseExtData(
* - a NETCONF RPC,
* - a NETCONF notification,
* - a RESTCONF notification,
- * - a YANG notification.
+ * - a YANG notification,
+ * - a YANG RPC.
*
* Parsing any of these requires just the schema (which is available through the Context), and the textual payload.
* All the other information are encoded in the textual payload as per the standard.
@@ -243,6 +244,7 @@ ParsedOp Context::parseOp(const std::string& input, const DataFormat format, con
auto in = wrap_ly_in_new_memory(input);
switch (opType) {
+ case OperationType::RpcYang:
case OperationType::RpcNetconf:
case OperationType::NotificationNetconf:
case OperationType::NotificationRestconf:
@@ -254,7 +256,7 @@ ParsedOp Context::parseOp(const std::string& input, const DataFormat format, con
ParsedOp res;
res.tree = tree ? std::optional{libyang::wrapRawNode(tree)} : std::nullopt;
- if (opType == OperationType::NotificationYang) {
+ if ((opType == OperationType::NotificationYang) || (opType == OperationType::RpcYang)) {
res.op = op && tree ? std::optional{DataNode(op, res.tree->m_refs)} : std::nullopt;
} else {
res.op = op ? std::optional{libyang::wrapRawNode(op)} : std::nullopt;
diff --git a/tests/context.cpp b/tests/context.cpp
index 71ae873..11019eb 100644
--- a/tests/context.cpp
+++ b/tests/context.cpp
@@ -464,6 +464,27 @@ TEST_CASE("context")
REQUIRE(data->findPath("/example-schema:leafInt8")->asTerm().valueStr() == "-43");
}
+ DOCTEST_SUBCASE("Context::parseOp")
+ {
+ DOCTEST_SUBCASE("RPC")
+ {
+ ctx->parseModule(example_schema, libyang::SchemaFormat::YANG);
+ std::string dataJson = R"({"example-schema:myRpc":{"inputLeaf":"str"}})";
+ auto pop = ctx->parseOp(dataJson, libyang::DataFormat::JSON, libyang::OperationType::RpcYang);
+ REQUIRE(pop.op->schema().name() == "myRpc");
+ REQUIRE(pop.tree->findPath("/example-schema:myRpc/inputLeaf")->asTerm().valueStr() == "str");
+ }
+ DOCTEST_SUBCASE("action")
+ {
+ ctx->parseModule(example_schema, libyang::SchemaFormat::YANG);
+ std::string datajson = R"({"example-schema:person":[{"name":"john", "poke":{}}]})";
+ auto pop = ctx->parseOp(datajson, libyang::DataFormat::JSON, libyang::OperationType::RpcYang);
+ REQUIRE(pop.op->schema().name() == "poke");
+ REQUIRE(pop.tree->findPath("/example-schema:person[name='john']/poke")->schema().nodeType() == libyang::NodeType::Action);
+ }
+ }
+
+
DOCTEST_SUBCASE("Context::parseExt")
{
ctx->setSearchDir(TESTS_DIR / "yang");
--
2.43.0
@@ -1,53 +0,0 @@
From 7e015f3486bdbb54f1dcc2e2ce51102b1d623081 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Wed, 23 Oct 2024 12:52:24 +0200
Subject: [PATCH 02/18] throw when lyd_validate_all returns error
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Bug: https://github.com/CESNET/libyang-cpp/issues/20
Change-Id: I005a2f1b057978573a4046e7b4cc31d77e36fde3
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/DataNode.cpp | 4 +++-
tests/data_node.cpp | 7 +++++++
2 files changed, 10 insertions(+), 1 deletion(-)
diff --git a/src/DataNode.cpp b/src/DataNode.cpp
index 51c86c8..2ef17f2 100644
--- a/src/DataNode.cpp
+++ b/src/DataNode.cpp
@@ -1170,7 +1170,9 @@ void validateAll(std::optional<libyang::DataNode>& node, const std::optional<Val
}
// TODO: support the `diff` argument
- lyd_validate_all(node ? &node->m_node : nullptr, nullptr, opts ? utils::toValidationOptions(*opts) : 0, nullptr);
+ auto ret = lyd_validate_all(node ? &node->m_node : nullptr, nullptr, opts ? utils::toValidationOptions(*opts) : 0, nullptr);
+ throwIfError(ret, "libyang:validateAll: lyd_validate_all failed");
+
if (!node->m_node) {
node = std::nullopt;
}
diff --git a/tests/data_node.cpp b/tests/data_node.cpp
index a23e4c2..8a2610e 100644
--- a/tests/data_node.cpp
+++ b/tests/data_node.cpp
@@ -489,6 +489,13 @@ TEST_CASE("Data Node manipulation")
REQUIRE_THROWS_WITH_AS(libyang::validateAll(node, libyang::ValidationOptions::NoState), "validateAll: Node is not a unique reference", libyang::Error);
}
+ DOCTEST_SUBCASE("validateAll throws on validation failure")
+ {
+ ctx.parseModule(type_module, libyang::SchemaFormat::YANG);
+ auto node = std::optional{ctx.newPath("/type_module:leafWithConfigFalse", "hi")};
+ REQUIRE_THROWS_WITH_AS(libyang::validateAll(node, libyang::ValidationOptions::NoState), "libyang:validateAll: lyd_validate_all failed: LY_EVALID", libyang::ErrorWithCode);
+ }
+
DOCTEST_SUBCASE("unlink")
{
auto root = ctx.parseData(data2, libyang::DataFormat::JSON);
--
2.43.0
@@ -1,38 +0,0 @@
From 490d8bb242d33213b948485f5b94c55e22cf86a6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Thu, 21 Nov 2024 11:32:44 +0100
Subject: [PATCH 03/18] remove a misleading comment
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
The whole intention within action's input/output handling here was to
put some emphasis on the fact that we aren't tracking the input/output
nodes directly. However, looking at all the other classes this is a bit
redundant, we're using a pattern like this all the time. Just drop the
comment.
Change-Id: Ibd9bf9f1e83c650dda3bc43ef48e61dd6d95da5a
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/SchemaNode.cpp | 3 ---
1 file changed, 3 deletions(-)
diff --git a/src/SchemaNode.cpp b/src/SchemaNode.cpp
index 81e938f..9934cea 100644
--- a/src/SchemaNode.cpp
+++ b/src/SchemaNode.cpp
@@ -640,9 +640,6 @@ bool List::isUserOrdered() const
*/
ActionRpcInput ActionRpc::input() const
{
- // I need a lysc_node* for ActionRpcInput, but m_node->input is a lysp_node_action_inout. lysp_node_action_inout is
- // still just a lysc_node, so I'll just convert to lysc_node.
- // This is not very pretty, but I don't want to introduce another member for ActionRpcInput and ActionRpcOutput.
return ActionRpcInput{reinterpret_cast<const lysc_node*>(&reinterpret_cast<const lysc_node_action*>(m_node)->input), m_ctx};
}
--
2.43.0
@@ -1,35 +0,0 @@
From e1b17386cf61048d2fe27fffb3b763981a225f52 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bed=C5=99ich=20Schindler?= <bedrich.schindler@gmail.com>
Date: Wed, 27 Nov 2024 09:47:47 +0100
Subject: [PATCH 04/18] schema: improve `List::keys()` not to use `std::move`
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
`List::keys()` used `std::move` while iterating over array of leafs.
This was solved without using `std::move`.
Change-Id: I8cbf8780ecd8848e46c1de5d4123a08624536bba
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/SchemaNode.cpp | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/src/SchemaNode.cpp b/src/SchemaNode.cpp
index 9934cea..20e2aff 100644
--- a/src/SchemaNode.cpp
+++ b/src/SchemaNode.cpp
@@ -593,8 +593,7 @@ std::vector<Leaf> List::keys() const
LY_LIST_FOR(list->child, elem)
{
if (lysc_is_key(elem)) {
- Leaf leaf(elem, m_ctx);
- res.emplace_back(std::move(leaf));
+ res.emplace_back(Leaf(elem, m_ctx));
}
}
--
2.43.0
@@ -1,124 +0,0 @@
From 1102ecdcafbc9206f59b383769687e418557838e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bed=C5=99ich=20Schindler?= <bedrich.schindler@gmail.com>
Date: Mon, 25 Nov 2024 15:54:02 +0100
Subject: [PATCH 05/18] schema: make leaf-list's `default` statement available
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Make leaf-list's `default` statement available so that it can be
accessed if end-user requires reading schema nodes.
`LeafList::defaultValuesStr()` returns array of canonized string default
values.
Change-Id: Idc42cd877f1fd3d717d491d09c46b59492527bff
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
include/libyang-cpp/SchemaNode.hpp | 1 +
src/SchemaNode.cpp | 17 +++++++++++++++++
tests/context.cpp | 1 +
tests/example_schema.hpp | 8 ++++++++
tests/schema_node.cpp | 7 +++++++
5 files changed, 34 insertions(+)
diff --git a/include/libyang-cpp/SchemaNode.hpp b/include/libyang-cpp/SchemaNode.hpp
index 83afc06..8ddf9be 100644
--- a/include/libyang-cpp/SchemaNode.hpp
+++ b/include/libyang-cpp/SchemaNode.hpp
@@ -169,6 +169,7 @@ class LIBYANG_CPP_EXPORT LeafList : public SchemaNode {
public:
bool isMandatory() const;
types::Type valueType() const;
+ std::vector<std::string> defaultValuesStr() const;
libyang::types::constraints::ListSize maxElements() const;
libyang::types::constraints::ListSize minElements() const;
std::optional<std::string> units() const;
diff --git a/src/SchemaNode.cpp b/src/SchemaNode.cpp
index 9934cea..95bc09b 100644
--- a/src/SchemaNode.cpp
+++ b/src/SchemaNode.cpp
@@ -472,6 +472,23 @@ types::Type LeafList::valueType() const
return types::Type{reinterpret_cast<const lysc_node_leaflist*>(m_node)->type, typeParsed, m_ctx};
}
+/**
+ * @brief Retrieves the default string values for this leaf-list.
+ *
+ * @return The default values, or an empty vector if the leaf-list does not have default values.
+ *
+ * Wraps `lysc_node_leaflist::dflts`.
+ */
+std::vector<std::string> LeafList::defaultValuesStr() const
+{
+ auto dflts = reinterpret_cast<const lysc_node_leaflist*>(m_node)->dflts;
+ std::vector<std::string> res;
+ for (const auto& it : std::span(dflts, LY_ARRAY_COUNT(dflts))) {
+ res.emplace_back(lyd_value_get_canonical(m_ctx.get(), it));
+ }
+ return res;
+}
+
/**
* @brief Retrieves the units for this leaf.
* @return The units, or std::nullopt if no units are available.
diff --git a/tests/context.cpp b/tests/context.cpp
index 11019eb..902faf6 100644
--- a/tests/context.cpp
+++ b/tests/context.cpp
@@ -733,6 +733,7 @@ TEST_CASE("context")
+--rw iid-valid? instance-identifier
+--rw iid-relaxed? instance-identifier
+--rw leafListBasic* string
+ +--rw leafListWithDefault* int32
+--rw leafListWithMinMaxElements* int32
+--rw leafListWithUnits* int32
+--rw listBasic* [primary-key]
diff --git a/tests/example_schema.hpp b/tests/example_schema.hpp
index c093f50..2861b1a 100644
--- a/tests/example_schema.hpp
+++ b/tests/example_schema.hpp
@@ -525,6 +525,14 @@ module type_module {
ordered-by user;
}
+ leaf-list leafListWithDefault {
+ type int32;
+ default -1;
+ default +512;
+ default 0x400;
+ default 04000;
+ }
+
leaf-list leafListWithMinMaxElements {
type int32;
min-elements 1;
diff --git a/tests/schema_node.cpp b/tests/schema_node.cpp
index a86900d..80c7407 100644
--- a/tests/schema_node.cpp
+++ b/tests/schema_node.cpp
@@ -200,6 +200,7 @@ TEST_CASE("SchemaNode")
"/type_module:iid-valid",
"/type_module:iid-relaxed",
"/type_module:leafListBasic",
+ "/type_module:leafListWithDefault",
"/type_module:leafListWithMinMaxElements",
"/type_module:leafListWithUnits",
"/type_module:listBasic",
@@ -606,6 +607,12 @@ TEST_CASE("SchemaNode")
REQUIRE(!ctx->findPath("/type_module:leafListBasic").asLeafList().isMandatory());
}
+ DOCTEST_SUBCASE("LeafList::defaultValuesStr")
+ {
+ REQUIRE(ctx->findPath("/type_module:leafListWithDefault").asLeafList().defaultValuesStr() == std::vector<std::string>{"-1", "512", "1024", "2048"});
+ REQUIRE(ctx->findPath("/type_module:leafListBasic").asLeafList().defaultValuesStr().size() == 0);
+ }
+
DOCTEST_SUBCASE("LeafList::maxElements")
{
REQUIRE(ctx->findPath("/type_module:leafListWithMinMaxElements").asLeafList().maxElements() == 5);
--
2.43.0
@@ -1,434 +0,0 @@
From 01f2633cef60495d5cafc4b4b1f25273b03ab3cd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bed=C5=99ich=20Schindler?= <bedrich.schindler@gmail.com>
Date: Tue, 22 Oct 2024 15:11:30 +0200
Subject: [PATCH 06/18] schema: Make choice and case statements available
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Make choice and case statements available so that they can be accessed
if end-user requires reading schema nodes.
By design, choice and case statements do not exist in data tree directly.
Only children of one case can be present in the data tree at one time.
That means that choice and case children are not instantiable, thus
`SchemaNode::immediateChildren` must be used (instead of
`SchemaNode::childInstantibles`) if end-user wants to access choice
and case substatements.
Change-Id: Ib089672ad21dda8a0344895835d92d3432fcccb8
Co-authored-by: Jan Kundrát <jan.kundrat@cesnet.cz>
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
include/libyang-cpp/SchemaNode.hpp | 34 +++++++++++++
src/SchemaNode.cpp | 68 ++++++++++++++++++++++++++
tests/context.cpp | 77 +++++++++++++++++++-----------
tests/example_schema.hpp | 60 +++++++++++++++++++++++
tests/schema_node.cpp | 72 ++++++++++++++++++++++++++++
5 files changed, 284 insertions(+), 27 deletions(-)
diff --git a/include/libyang-cpp/SchemaNode.hpp b/include/libyang-cpp/SchemaNode.hpp
index 8ddf9be..0f1a4c4 100644
--- a/include/libyang-cpp/SchemaNode.hpp
+++ b/include/libyang-cpp/SchemaNode.hpp
@@ -22,6 +22,8 @@ class AnyDataAnyXML;
class ActionRpc;
class ActionRpcInput;
class ActionRpcOutput;
+class Case;
+class Choice;
class Container;
class Leaf;
class LeafList;
@@ -62,6 +64,8 @@ public:
// drectly by the user.
// TODO: turn these into a templated `as<>` method.
AnyDataAnyXML asAnyDataAnyXML() const;
+ Case asCase() const;
+ Choice asChoice() const;
Container asContainer() const;
Leaf asLeaf() const;
LeafList asLeafList() const;
@@ -129,6 +133,36 @@ private:
using SchemaNode::SchemaNode;
};
+/**
+ * @brief Class representing a schema definition of a `case` node.
+ *
+ * Wraps `lysc_node_case`.
+ */
+class LIBYANG_CPP_EXPORT Case : public SchemaNode {
+public:
+ friend SchemaNode;
+ friend Choice;
+
+private:
+ using SchemaNode::SchemaNode;
+};
+
+/**
+ * @brief Class representing a schema definition of a `choice` node.
+ *
+ * Wraps `lysc_node_choice`.
+ */
+class LIBYANG_CPP_EXPORT Choice : public SchemaNode {
+public:
+ bool isMandatory() const;
+ std::vector<Case> cases() const;
+ std::optional<Case> defaultCase() const;
+ friend SchemaNode;
+
+private:
+ using SchemaNode::SchemaNode;
+};
+
/**
* @brief Class representing a schema definition of a `container` node.
*/
diff --git a/src/SchemaNode.cpp b/src/SchemaNode.cpp
index bd20402..26b5099 100644
--- a/src/SchemaNode.cpp
+++ b/src/SchemaNode.cpp
@@ -191,6 +191,32 @@ NodeType SchemaNode::nodeType() const
return utils::toNodeType(m_node->nodetype);
}
+/**
+ * @brief Try to cast this SchemaNode to a Case node.
+ * @throws Error If this node is not a case.
+ */
+Case SchemaNode::asCase() const
+{
+ if (nodeType() != NodeType::Case) {
+ throw Error("Schema node is not a case: " + path());
+ }
+
+ return Case{m_node, m_ctx};
+}
+
+/**
+ * @brief Try to cast this SchemaNode to a Choice node.
+ * @throws Error If this node is not a choice.
+ */
+Choice SchemaNode::asChoice() const
+{
+ if (nodeType() != NodeType::Choice) {
+ throw Error("Schema node is not a choice: " + path());
+ }
+
+ return Choice{m_node, m_ctx};
+}
+
/**
* @brief Try to cast this SchemaNode to a Container node.
* @throws Error If this node is not a container.
@@ -401,6 +427,48 @@ bool AnyDataAnyXML::isMandatory() const
return m_node->flags & LYS_MAND_TRUE;
}
+/**
+ * @brief Checks whether this choice is mandatory.
+ *
+ * Wraps flag `LYS_MAND_TRUE`.
+ */
+bool Choice::isMandatory() const
+{
+ return m_node->flags & LYS_MAND_TRUE;
+}
+
+/**
+ * @brief Retrieves the list of cases for this choice.
+ *
+ * Wraps `lysc_node_choice::cases`.
+ */
+std::vector<Case> Choice::cases() const
+{
+ auto choice = reinterpret_cast<const lysc_node_choice*>(m_node);
+ auto cases = reinterpret_cast<lysc_node*>(choice->cases);
+ std::vector<Case> res;
+ lysc_node* elem;
+ LY_LIST_FOR(cases, elem)
+ {
+ res.emplace_back(Case(elem, m_ctx));
+ }
+ return res;
+}
+
+/**
+ * @brief Retrieves the default case for this choice.
+ *
+ * Wraps `lysc_node_choice::dflt`.
+ */
+std::optional<Case> Choice::defaultCase() const
+{
+ auto choice = reinterpret_cast<const lysc_node_choice*>(m_node);
+ if (!choice->dflt) {
+ return std::nullopt;
+ }
+ return Case{reinterpret_cast<lysc_node*>(choice->dflt), m_ctx};
+}
+
/**
* @brief Checks whether this container is mandatory.
*
diff --git a/tests/context.cpp b/tests/context.cpp
index 902faf6..5929b75 100644
--- a/tests/context.cpp
+++ b/tests/context.cpp
@@ -709,33 +709,56 @@ TEST_CASE("context")
auto mod = ctx_pp->parseModule(type_module, libyang::SchemaFormat::YANG);
REQUIRE(mod.printStr(libyang::SchemaOutputFormat::Tree) == R"(module: type_module
- +--rw anydataBasic? anydata
- +--rw anydataWithMandatoryChild anydata
- +--rw anyxmlBasic? anyxml
- +--rw anyxmlWithMandatoryChild anyxml
- +--rw leafBinary? binary
- +--rw leafBits? bits
- +--rw leafEnum? enumeration
- +--rw leafEnum2? enumeration
- +--rw leafNumber? int32
- +--rw leafRef? -> /custom-prefix:listAdvancedWithOneKey/lol
- +--rw leafRefRelaxed? -> /custom-prefix:listAdvancedWithOneKey/lol
- +--rw leafString? string
- +--rw leafUnion? union
- +--rw meal? identityref
- +--ro leafWithConfigFalse? string
- +--rw leafWithDefaultValue? string
- +--rw leafWithDescription? string
- +--rw leafWithMandatoryTrue string
- x--rw leafWithStatusDeprecated? string
- o--rw leafWithStatusObsolete? string
- +--rw leafWithUnits? int32
- +--rw iid-valid? instance-identifier
- +--rw iid-relaxed? instance-identifier
- +--rw leafListBasic* string
- +--rw leafListWithDefault* int32
- +--rw leafListWithMinMaxElements* int32
- +--rw leafListWithUnits* int32
+ +--rw anydataBasic? anydata
+ +--rw anydataWithMandatoryChild anydata
+ +--rw anyxmlBasic? anyxml
+ +--rw anyxmlWithMandatoryChild anyxml
+ +--rw choiceBasicContainer
+ | +--rw (choiceBasic)?
+ | +--:(case1)
+ | | +--rw l? string
+ | | +--rw ll* string
+ | +--:(case2)
+ | +--rw l2? string
+ +--rw choiceWithMandatoryContainer
+ | +--rw (choiceWithMandatory)
+ | +--:(case3)
+ | | +--rw l3? string
+ | +--:(case4)
+ | +--rw l4? string
+ +--rw choiceWithDefaultContainer
+ | +--rw (choiceWithDefault)?
+ | +--:(case5)
+ | | +--rw l5? string
+ | +--:(case6)
+ | +--rw l6? string
+ +--rw implicitCaseContainer
+ | +--rw (implicitCase)?
+ | +--:(implicitLeaf)
+ | +--rw implicitLeaf? string
+ +--rw leafBinary? binary
+ +--rw leafBits? bits
+ +--rw leafEnum? enumeration
+ +--rw leafEnum2? enumeration
+ +--rw leafNumber? int32
+ +--rw leafRef? -> /custom-prefix:listAdvancedWithOneKey/lol
+ +--rw leafRefRelaxed? -> /custom-prefix:listAdvancedWithOneKey/lol
+ +--rw leafString? string
+ +--rw leafUnion? union
+ +--rw meal? identityref
+ +--ro leafWithConfigFalse? string
+ +--rw leafWithDefaultValue? string
+ +--rw leafWithDescription? string
+ +--rw leafWithMandatoryTrue string
+ x--rw leafWithStatusDeprecated? string
+ o--rw leafWithStatusObsolete? string
+ +--rw leafWithUnits? int32
+ +--rw iid-valid? instance-identifier
+ +--rw iid-relaxed? instance-identifier
+ +--rw leafListBasic* string
+ +--rw leafListWithDefault* int32
+ +--rw leafListWithMinMaxElements* int32
+ +--rw leafListWithUnits* int32
+--rw listBasic* [primary-key]
| +--rw primary-key string
+--rw listAdvancedWithOneKey* [lol]
diff --git a/tests/example_schema.hpp b/tests/example_schema.hpp
index 2861b1a..ae3b4de 100644
--- a/tests/example_schema.hpp
+++ b/tests/example_schema.hpp
@@ -390,6 +390,66 @@ module type_module {
mandatory true;
}
+ container choiceBasicContainer {
+ choice choiceBasic {
+ case case1 {
+ leaf l {
+ type string;
+ }
+ leaf-list ll {
+ type string;
+ ordered-by user;
+ }
+ }
+ case case2 {
+ leaf l2 {
+ type string;
+ }
+ }
+ }
+ }
+
+ container choiceWithMandatoryContainer {
+ choice choiceWithMandatory {
+ mandatory true;
+ case case3 {
+ leaf l3 {
+ type string;
+ }
+ }
+ case case4 {
+ leaf l4 {
+ type string;
+ }
+ }
+ }
+ }
+
+ container choiceWithDefaultContainer {
+ choice choiceWithDefault {
+ default case5;
+ case case5 {
+ leaf l5 {
+ type string;
+ }
+ }
+ case case6 {
+ leaf l6 {
+ type string;
+ }
+ }
+ }
+ }
+
+ container implicitCaseContainer {
+ choice implicitCase {
+ leaf implicitLeaf {
+ type string;
+ }
+ }
+ }
+
+
leaf leafBinary {
type binary;
}
diff --git a/tests/schema_node.cpp b/tests/schema_node.cpp
index 80c7407..8d74bd2 100644
--- a/tests/schema_node.cpp
+++ b/tests/schema_node.cpp
@@ -58,6 +58,9 @@ TEST_CASE("SchemaNode")
],
"type_module:anydataWithMandatoryChild": {"content": "test-string"},
"type_module:anyxmlWithMandatoryChild": {"content": "test-string"},
+ "type_module:choiceWithMandatoryContainer": {
+ "l4": "test-string"
+ },
"type_module:containerWithMandatoryChild": {
"leafWithMandatoryTrue": "test-string"
},
@@ -180,6 +183,10 @@ TEST_CASE("SchemaNode")
"/type_module:anydataWithMandatoryChild",
"/type_module:anyxmlBasic",
"/type_module:anyxmlWithMandatoryChild",
+ "/type_module:choiceBasicContainer",
+ "/type_module:choiceWithMandatoryContainer",
+ "/type_module:choiceWithDefaultContainer",
+ "/type_module:implicitCaseContainer",
"/type_module:leafBinary",
"/type_module:leafBits",
"/type_module:leafEnum",
@@ -417,6 +424,71 @@ TEST_CASE("SchemaNode")
REQUIRE(!ctx->findPath("/type_module:anyxmlBasic").asAnyDataAnyXML().isMandatory());
}
+ DOCTEST_SUBCASE("Choice and Case")
+ {
+ std::string xpath;
+ bool isMandatory = false;
+ std::optional<std::string> defaultCase;
+ std::vector<std::string> caseNames;
+ std::optional<libyang::SchemaNode> root;
+
+ DOCTEST_SUBCASE("two cases with nothing fancy")
+ {
+ root = ctx->findPath("/type_module:choiceBasicContainer");
+ caseNames = {"case1", "case2"};
+ }
+
+ DOCTEST_SUBCASE("mandatory choice") {
+ root = ctx->findPath("/type_module:choiceWithMandatoryContainer");
+ isMandatory = true;
+ caseNames = {"case3", "case4"};
+ }
+
+ DOCTEST_SUBCASE("default choice") {
+ root = ctx->findPath("/type_module:choiceWithDefaultContainer");
+ defaultCase = "case5";
+ caseNames = {"case5", "case6"};
+ }
+
+ DOCTEST_SUBCASE("implicit case") {
+ root = ctx->findPath("/type_module:implicitCaseContainer");
+ caseNames = {"implicitLeaf"};
+ }
+
+ // For testing purposes, we have each choice in its own container. As choice and case are not directly instantiable,
+ // we wrap them in a container to simplify the testing process. It allows us to simply address the choice by its
+ // container and then get the choice from it. It also prevents polluting the test schema with unnecessary nodes
+ // and isolates the choice from other nodes.
+ auto container = root->asContainer();
+ auto choice = container.immediateChildren().begin()->asChoice();
+ REQUIRE(choice.isMandatory() == isMandatory);
+ REQUIRE(!!choice.defaultCase() == !!defaultCase);
+ if (defaultCase) {
+ REQUIRE(choice.defaultCase()->name() == *defaultCase);
+ }
+ std::vector<std::string> actualCaseNames;
+ for (const auto& case_ : choice.cases()) {
+ actualCaseNames.push_back(case_.name());
+ }
+ REQUIRE(actualCaseNames == caseNames);
+
+ // Also test child node access for one arbitrary choice/case combination
+ if (root->path() == "/type_module:choiceBasicContainer") {
+ REQUIRE(choice.cases().size() == 2);
+ auto case1 = choice.cases()[0];
+ auto children = case1.immediateChildren();
+ auto it = children.begin();
+ REQUIRE(it->asLeaf().name() == "l");
+ ++it;
+ REQUIRE(it->asLeafList().name() == "ll");
+
+ auto case2 = choice.cases()[1];
+ children = case2.immediateChildren();
+ it = children.begin();
+ REQUIRE(it->asLeaf().name() == "l2");
+ }
+ }
+
DOCTEST_SUBCASE("Container::isMandatory")
{
REQUIRE(ctx->findPath("/type_module:containerWithMandatoryChild").asContainer().isMandatory());
--
2.43.0
@@ -1,129 +0,0 @@
From a1acdc794facf8cbf113f73274ecebd5898c81a1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Tue, 17 Dec 2024 15:08:43 +0100
Subject: [PATCH 07/18] Wrap lyd_change_term for changing the value for a
terminal node
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Previously, the code would require a newPath(...,
libyang::CreationOptions::Update), which is quite a mouthful.
Change-Id: I8a908c0fdd3e48dda830819758522a511adedd3b
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
include/libyang-cpp/DataNode.hpp | 8 ++++++
src/DataNode.cpp | 21 ++++++++++++++++
tests/data_node.cpp | 42 ++++++++++++++++++++++++++------
3 files changed, 63 insertions(+), 8 deletions(-)
diff --git a/include/libyang-cpp/DataNode.hpp b/include/libyang-cpp/DataNode.hpp
index 2211415..851681b 100644
--- a/include/libyang-cpp/DataNode.hpp
+++ b/include/libyang-cpp/DataNode.hpp
@@ -212,6 +212,14 @@ public:
Value value() const;
types::Type valueType() const;
+ /** @brief Was the value changed? */
+ enum class ValueChange {
+ Changed, /**< Yes, this is an actual change of the stored value */
+ ExplicitNonDefault, /**< It still holds the default value, but it's been set explicitly now */
+ EqualValueNotChanged, /**< No change, the previous value is the same as the new one, and it isn't an implicit default */
+ };
+ ValueChange changeValue(const std::string value);
+
private:
using DataNode::DataNode;
};
diff --git a/src/DataNode.cpp b/src/DataNode.cpp
index 2ef17f2..84591e5 100644
--- a/src/DataNode.cpp
+++ b/src/DataNode.cpp
@@ -903,6 +903,27 @@ types::Type DataNodeTerm::valueType() const
return impl(reinterpret_cast<const lyd_node_term*>(m_node)->value);
}
+/** @short Change the term's value
+ *
+ * Wraps `lyd_change_term`.
+ * */
+DataNodeTerm::ValueChange DataNodeTerm::changeValue(const std::string value)
+{
+ auto ret = lyd_change_term(m_node, value.c_str());
+
+ switch (ret) {
+ case LY_SUCCESS:
+ return ValueChange::Changed;
+ case LY_EEXIST:
+ return ValueChange::ExplicitNonDefault;
+ case LY_ENOT:
+ return ValueChange::EqualValueNotChanged;
+ default:
+ throwIfError(ret, "DataNodeTerm::changeValue failed");
+ __builtin_unreachable();
+ }
+}
+
/**
* @brief Returns a collection for iterating depth-first over the subtree this instance points to.
*
diff --git a/tests/data_node.cpp b/tests/data_node.cpp
index 8a2610e..45fd6c1 100644
--- a/tests/data_node.cpp
+++ b/tests/data_node.cpp
@@ -456,15 +456,41 @@ TEST_CASE("Data Node manipulation")
REQUIRE(node.hasDefaultValue());
REQUIRE(node.isImplicitDefault());
- data->newPath("/example-schema3:leafWithDefault", "not-default-value", libyang::CreationOptions::Update);
- node = data->findPath("/example-schema3:leafWithDefault")->asTerm();
- REQUIRE(!node.hasDefaultValue());
- REQUIRE(!node.isImplicitDefault());
+ DOCTEST_SUBCASE("newPath")
+ {
+ data->newPath("/example-schema3:leafWithDefault", "not-default-value", libyang::CreationOptions::Update);
+ node = data->findPath("/example-schema3:leafWithDefault")->asTerm();
+ REQUIRE(!node.hasDefaultValue());
+ REQUIRE(!node.isImplicitDefault());
- data->newPath("/example-schema3:leafWithDefault", "AHOJ", libyang::CreationOptions::Update);
- node = data->findPath("/example-schema3:leafWithDefault")->asTerm();
- REQUIRE(node.hasDefaultValue());
- REQUIRE(!node.isImplicitDefault());
+ data->newPath("/example-schema3:leafWithDefault", "AHOJ", libyang::CreationOptions::Update);
+ node = data->findPath("/example-schema3:leafWithDefault")->asTerm();
+ REQUIRE(node.hasDefaultValue());
+ REQUIRE(!node.isImplicitDefault());
+ }
+
+ DOCTEST_SUBCASE("changing values")
+ {
+ auto node = data->findPath("/example-schema3:leafWithDefault");
+ REQUIRE(!!node);
+ auto term = node->asTerm();
+
+ DOCTEST_SUBCASE("to an arbitrary value") {
+ REQUIRE(term.changeValue("cau") == libyang::DataNodeTerm::ValueChange::Changed);
+ }
+
+ DOCTEST_SUBCASE("from an implicit default to an explicit default") {
+ REQUIRE(term.changeValue("AHOJ") == libyang::DataNodeTerm::ValueChange::ExplicitNonDefault);
+ REQUIRE(term.changeValue("AHOJ") == libyang::DataNodeTerm::ValueChange::EqualValueNotChanged);
+ REQUIRE(term.changeValue("cau") == libyang::DataNodeTerm::ValueChange::Changed);
+ REQUIRE(term.changeValue("cau") == libyang::DataNodeTerm::ValueChange::EqualValueNotChanged);
+ }
+
+ DOCTEST_SUBCASE("from an implicit default to something else") {
+ REQUIRE(term.changeValue("cau") == libyang::DataNodeTerm::ValueChange::Changed);
+ REQUIRE(term.changeValue("cau") == libyang::DataNodeTerm::ValueChange::EqualValueNotChanged);
+ }
+ }
}
DOCTEST_SUBCASE("isTerm")
--
2.43.0
@@ -1,652 +0,0 @@
From 32b200ed06e9adb44a8d4ce6771f18812a54d06e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bed=C5=99ich=20Schindler?= <bedrich.schindler@gmail.com>
Date: Wed, 20 Nov 2024 10:20:19 +0100
Subject: [PATCH 08/18] Add `Module::child()`, `Module::childrenDfs()` and
`Module::immediateChildren()`
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Those functions are implemented in the same manner as in `SchemaNode`
and allows to walk through modules children. This is counterpart to
already implemented `Module::childInstantiables()` that returns
instantiables schema nodes. These return all nodes, including
the schema-only nodes such as choice and case if end-user needs
to read its schema.
While the implementation is inspired by functions in `SchemaNode`,
imlementation of `Module::parent()` and `Module::siblings()` was omitted
as those do no make sense on `Module`.
Change-Id: I38c8374304f859d65343d04d08302e07deb05f27
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
include/libyang-cpp/Collection.hpp | 1 +
include/libyang-cpp/Module.hpp | 5 +
src/Module.cpp | 40 +++
tests/context.cpp | 5 +
tests/example_schema.hpp | 21 ++
tests/schema_node.cpp | 409 +++++++++++++++++++----------
6 files changed, 346 insertions(+), 135 deletions(-)
diff --git a/include/libyang-cpp/Collection.hpp b/include/libyang-cpp/Collection.hpp
index 557a0a2..4324791 100644
--- a/include/libyang-cpp/Collection.hpp
+++ b/include/libyang-cpp/Collection.hpp
@@ -98,6 +98,7 @@ class LIBYANG_CPP_EXPORT Collection {
public:
friend DataNode;
friend Iterator<NodeType, ITER_TYPE>;
+ friend Module;
friend SchemaNode;
~Collection();
Collection(const Collection<NodeType, ITER_TYPE>&);
diff --git a/include/libyang-cpp/Module.hpp b/include/libyang-cpp/Module.hpp
index f10c36f..ab20d36 100644
--- a/include/libyang-cpp/Module.hpp
+++ b/include/libyang-cpp/Module.hpp
@@ -34,6 +34,8 @@ class ChildInstanstiables;
class Identity;
class SchemaNode;
class SubmoduleParsed;
+template <typename NodeType, IterationType ITER_TYPE>
+class Collection;
namespace types {
class IdentityRef;
@@ -86,7 +88,10 @@ public:
std::vector<Identity> identities() const;
+ std::optional<SchemaNode> child() const;
ChildInstanstiables childInstantiables() const;
+ libyang::Collection<SchemaNode, IterationType::Dfs> childrenDfs() const;
+ Collection<SchemaNode, IterationType::Sibling> immediateChildren() const;
std::vector<SchemaNode> actionRpcs() const;
std::string printStr(const SchemaOutputFormat format, const std::optional<SchemaPrintFlags> flags = std::nullopt, std::optional<size_t> lineLength = std::nullopt) const;
diff --git a/src/Module.cpp b/src/Module.cpp
index 4dc9e3b..d6d4023 100644
--- a/src/Module.cpp
+++ b/src/Module.cpp
@@ -8,6 +8,7 @@
#include <algorithm>
#include <libyang-cpp/ChildInstantiables.hpp>
+#include <libyang-cpp/Collection.hpp>
#include <libyang-cpp/Module.hpp>
#include <libyang-cpp/Utils.hpp>
#include <libyang/libyang.h>
@@ -178,6 +179,23 @@ std::vector<Identity> Module::identities() const
return res;
}
+/**
+ * @brief Returns the first child node of this module.
+ * @return The child, or std::nullopt if there are no children.
+ */
+std::optional<SchemaNode> Module::child() const
+{
+ if (!m_module->implemented) {
+ throw Error{"Module::child: module is not implemented"};
+ }
+
+ if (!m_module->compiled->data) {
+ return std::nullopt;
+ }
+
+ return SchemaNode{m_module->compiled->data, m_ctx};
+}
+
/**
* @brief Returns a collection of data instantiable top-level nodes of this module.
*
@@ -191,6 +209,28 @@ ChildInstanstiables Module::childInstantiables() const
return ChildInstanstiables{nullptr, m_module->compiled, m_ctx};
}
+/**
+ * @brief Returns a collection for iterating depth-first over the subtree this module points to.
+ */
+Collection<SchemaNode, IterationType::Dfs> Module::childrenDfs() const
+{
+ if (!m_module->implemented) {
+ throw Error{"Module::childrenDfs: module is not implemented"};
+ }
+ return Collection<SchemaNode, IterationType::Dfs>{m_module->compiled->data, m_ctx};
+}
+
+/**
+ * @brief Returns a collection for iterating over the immediate children of where this module points to.
+ *
+ * This is a convenience function for iterating over this->child().siblings() which does not throw even when module has no children.
+ */
+Collection<SchemaNode, IterationType::Sibling> Module::immediateChildren() const
+{
+ auto c = child();
+ return c ? c->siblings() : Collection<SchemaNode, IterationType::Sibling>{nullptr, nullptr};
+}
+
/**
* @brief Returns a collection of RPC nodes (not action nodes) as SchemaNode
*
diff --git a/tests/context.cpp b/tests/context.cpp
index 5929b75..25343db 100644
--- a/tests/context.cpp
+++ b/tests/context.cpp
@@ -713,6 +713,11 @@ TEST_CASE("context")
+--rw anydataWithMandatoryChild anydata
+--rw anyxmlBasic? anyxml
+--rw anyxmlWithMandatoryChild anyxml
+ +--rw (choiceOnModule)?
+ | +--:(case1)
+ | | +--rw choiceOnModuleLeaf1? string
+ | +--:(case2)
+ | +--rw choiceOnModuleLeaf2? string
+--rw choiceBasicContainer
| +--rw (choiceBasic)?
| +--:(case1)
diff --git a/tests/example_schema.hpp b/tests/example_schema.hpp
index ae3b4de..0d8acb9 100644
--- a/tests/example_schema.hpp
+++ b/tests/example_schema.hpp
@@ -390,6 +390,19 @@ module type_module {
mandatory true;
}
+ choice choiceOnModule {
+ case case1 {
+ leaf choiceOnModuleLeaf1 {
+ type string;
+ }
+ }
+ case case2 {
+ leaf choiceOnModuleLeaf2 {
+ type string;
+ }
+ }
+ }
+
container choiceBasicContainer {
choice choiceBasic {
case case1 {
@@ -787,6 +800,14 @@ module type_module {
}
)"s;
+const auto empty_module = R"(
+module empty_module {
+ yang-version 1.1;
+ namespace "e";
+ prefix "e";
+}
+)"s;
+
const auto with_inet_types_module = R"(
module with-inet-types {
yang-version 1.1;
diff --git a/tests/schema_node.cpp b/tests/schema_node.cpp
index 8d74bd2..0001377 100644
--- a/tests/schema_node.cpp
+++ b/tests/schema_node.cpp
@@ -24,8 +24,10 @@ TEST_CASE("SchemaNode")
libyang::ContextOptions::SetPrivParsed | libyang::ContextOptions::NoYangLibrary | libyang::ContextOptions::DisableSearchCwd};
ctx->parseModule(example_schema, libyang::SchemaFormat::YANG);
ctx->parseModule(type_module, libyang::SchemaFormat::YANG);
+ ctx->parseModule(empty_module, libyang::SchemaFormat::YANG);
ctxWithParsed->parseModule(example_schema, libyang::SchemaFormat::YANG);
ctxWithParsed->parseModule(type_module, libyang::SchemaFormat::YANG);
+ ctxWithParsed->parseModule(empty_module, libyang::SchemaFormat::YANG);
DOCTEST_SUBCASE("context lifetime")
{
@@ -74,10 +76,34 @@ TEST_CASE("SchemaNode")
REQUIRE(node->schema().path() == "/example-schema:person");
}
- DOCTEST_SUBCASE("SchemaNode::child")
+ DOCTEST_SUBCASE("child")
{
- REQUIRE(ctx->findPath("/type_module:listAdvancedWithTwoKey").child()->name() == "first");
- REQUIRE(!ctx->findPath("/type_module:leafString").child().has_value());
+ DOCTEST_SUBCASE("implemented module")
+ {
+ DOCTEST_SUBCASE("SchemaNode::child")
+ {
+ REQUIRE(ctx->findPath("/type_module:listAdvancedWithTwoKey").child()->name() == "first");
+ REQUIRE(!ctx->findPath("/type_module:leafString").child().has_value());
+ }
+
+ DOCTEST_SUBCASE("Module::child")
+ {
+ REQUIRE(ctx->getModule("type_module", std::nullopt)->child()->name() == "anydataBasic");
+ REQUIRE(!ctx->getModule("empty_module", std::nullopt)->child());
+ }
+ }
+
+ DOCTEST_SUBCASE("unimplemented module")
+ {
+ DOCTEST_SUBCASE("Module::child")
+ {
+ ctx->setSearchDir(TESTS_DIR / "yang");
+ auto modYangPatch = ctx->loadModule("ietf-yang-patch", std::nullopt);
+ auto modRestconf = ctx->getModule("ietf-restconf", "2017-01-26");
+ REQUIRE(!modRestconf->implemented());
+ REQUIRE_THROWS_WITH_AS(modRestconf->child(), "Module::child: module is not implemented", libyang::Error);
+ }
+ }
}
DOCTEST_SUBCASE("SchemaNode::config")
@@ -160,162 +186,275 @@ TEST_CASE("SchemaNode")
DOCTEST_SUBCASE("childInstantiables")
{
- std::vector<std::string> expectedPaths;
- std::optional<libyang::ChildInstanstiables> children;
-
- DOCTEST_SUBCASE("SchemaNode::childInstantiables")
+ DOCTEST_SUBCASE("implemented module")
{
- expectedPaths = {
- "/type_module:listAdvancedWithOneKey/lol",
- "/type_module:listAdvancedWithOneKey/notKey1",
- "/type_module:listAdvancedWithOneKey/notKey2",
- "/type_module:listAdvancedWithOneKey/notKey3",
- "/type_module:listAdvancedWithOneKey/notKey4",
- };
+ std::vector<std::string> expectedPaths;
+ std::optional<libyang::ChildInstanstiables> children;
+
+ DOCTEST_SUBCASE("SchemaNode::childInstantiables")
+ {
+ expectedPaths = {
+ "/type_module:listAdvancedWithOneKey/lol",
+ "/type_module:listAdvancedWithOneKey/notKey1",
+ "/type_module:listAdvancedWithOneKey/notKey2",
+ "/type_module:listAdvancedWithOneKey/notKey3",
+ "/type_module:listAdvancedWithOneKey/notKey4",
+ };
+
+ children = ctx->findPath("/type_module:listAdvancedWithOneKey").childInstantiables();
+ }
- children = ctx->findPath("/type_module:listAdvancedWithOneKey").childInstantiables();
- }
+ DOCTEST_SUBCASE("Module::childInstantiables")
+ {
+ expectedPaths = {
+ "/type_module:anydataBasic",
+ "/type_module:anydataWithMandatoryChild",
+ "/type_module:anyxmlBasic",
+ "/type_module:anyxmlWithMandatoryChild",
+ "/type_module:choiceOnModuleLeaf1",
+ "/type_module:choiceOnModuleLeaf2",
+ "/type_module:choiceBasicContainer",
+ "/type_module:choiceWithMandatoryContainer",
+ "/type_module:choiceWithDefaultContainer",
+ "/type_module:implicitCaseContainer",
+ "/type_module:leafBinary",
+ "/type_module:leafBits",
+ "/type_module:leafEnum",
+ "/type_module:leafEnum2",
+ "/type_module:leafNumber",
+ "/type_module:leafRef",
+ "/type_module:leafRefRelaxed",
+ "/type_module:leafString",
+ "/type_module:leafUnion",
+ "/type_module:meal",
+ "/type_module:leafWithConfigFalse",
+ "/type_module:leafWithDefaultValue",
+ "/type_module:leafWithDescription",
+ "/type_module:leafWithMandatoryTrue",
+ "/type_module:leafWithStatusDeprecated",
+ "/type_module:leafWithStatusObsolete",
+ "/type_module:leafWithUnits",
+ "/type_module:iid-valid",
+ "/type_module:iid-relaxed",
+ "/type_module:leafListBasic",
+ "/type_module:leafListWithDefault",
+ "/type_module:leafListWithMinMaxElements",
+ "/type_module:leafListWithUnits",
+ "/type_module:listBasic",
+ "/type_module:listAdvancedWithOneKey",
+ "/type_module:listAdvancedWithTwoKey",
+ "/type_module:listWithMinMaxElements",
+ "/type_module:numeric",
+ "/type_module:container",
+ "/type_module:containerWithMandatoryChild",
+ };
+ children = ctx->getModule("type_module", std::nullopt)->childInstantiables();
+ }
- DOCTEST_SUBCASE("Module::childInstantiables")
- {
- expectedPaths = {
- "/type_module:anydataBasic",
- "/type_module:anydataWithMandatoryChild",
- "/type_module:anyxmlBasic",
- "/type_module:anyxmlWithMandatoryChild",
- "/type_module:choiceBasicContainer",
- "/type_module:choiceWithMandatoryContainer",
- "/type_module:choiceWithDefaultContainer",
- "/type_module:implicitCaseContainer",
- "/type_module:leafBinary",
- "/type_module:leafBits",
- "/type_module:leafEnum",
- "/type_module:leafEnum2",
- "/type_module:leafNumber",
- "/type_module:leafRef",
- "/type_module:leafRefRelaxed",
- "/type_module:leafString",
- "/type_module:leafUnion",
- "/type_module:meal",
- "/type_module:leafWithConfigFalse",
- "/type_module:leafWithDefaultValue",
- "/type_module:leafWithDescription",
- "/type_module:leafWithMandatoryTrue",
- "/type_module:leafWithStatusDeprecated",
- "/type_module:leafWithStatusObsolete",
- "/type_module:leafWithUnits",
- "/type_module:iid-valid",
- "/type_module:iid-relaxed",
- "/type_module:leafListBasic",
- "/type_module:leafListWithDefault",
- "/type_module:leafListWithMinMaxElements",
- "/type_module:leafListWithUnits",
- "/type_module:listBasic",
- "/type_module:listAdvancedWithOneKey",
- "/type_module:listAdvancedWithTwoKey",
- "/type_module:listWithMinMaxElements",
- "/type_module:numeric",
- "/type_module:container",
- "/type_module:containerWithMandatoryChild",
- };
- children = ctx->getModule("type_module", std::nullopt)->childInstantiables();
- }
+ std::vector<std::string> actualPaths;
+ for (const auto& child : *children) {
+ actualPaths.emplace_back(child.path());
+ }
- std::vector<std::string> actualPaths;
- for (const auto& child : *children) {
- actualPaths.emplace_back(child.path());
+ REQUIRE(expectedPaths == actualPaths);
}
- REQUIRE(expectedPaths == actualPaths);
+ DOCTEST_SUBCASE("unimplemented module")
+ {
+ DOCTEST_SUBCASE("Module::childInstantiables")
+ {
+ ctx->setSearchDir(TESTS_DIR / "yang");
+ auto modYangPatch = ctx->loadModule("ietf-yang-patch", std::nullopt);
+ auto modRestconf = ctx->getModule("ietf-restconf", "2017-01-26");
+ REQUIRE(!modRestconf->implemented());
+ REQUIRE_THROWS_WITH_AS(modRestconf->childInstantiables(), "Module::childInstantiables: module is not implemented", libyang::Error);
+ }
+ }
}
- DOCTEST_SUBCASE("SchemaNode::childrenDfs")
+ DOCTEST_SUBCASE("childrenDfs")
{
- std::vector<std::string> expectedPaths;
+ DOCTEST_SUBCASE("implemented module")
+ {
+ std::vector<std::string> expectedPaths;
+ std::optional<libyang::Collection<libyang::SchemaNode, libyang::IterationType::Dfs>> children;
- const char* path;
+ DOCTEST_SUBCASE("SchemaNode::childrenDfs")
+ {
+ DOCTEST_SUBCASE("listAdvancedWithTwoKey")
+ {
+ expectedPaths = {
+ "/type_module:listAdvancedWithTwoKey",
+ "/type_module:listAdvancedWithTwoKey/first",
+ "/type_module:listAdvancedWithTwoKey/second",
+ };
+ children = ctx->findPath("/type_module:listAdvancedWithTwoKey").childrenDfs();
+ }
- DOCTEST_SUBCASE("listAdvancedWithTwoKey")
- {
- expectedPaths = {
- "/type_module:listAdvancedWithTwoKey",
- "/type_module:listAdvancedWithTwoKey/first",
- "/type_module:listAdvancedWithTwoKey/second",
- };
+ DOCTEST_SUBCASE("DFS on a leaf")
+ {
+ expectedPaths = {
+ "/type_module:leafString",
+ };
+ children = ctx->findPath("/type_module:leafString").childrenDfs();
+ }
+ }
- path = "/type_module:listAdvancedWithTwoKey";
- }
+ DOCTEST_SUBCASE("Module::childrenDfs")
+ {
+ expectedPaths = {
+ "/type_module:anydataBasic",
+ };
+ children = ctx->getModule("type_module", std::nullopt)->childrenDfs();
+ }
- DOCTEST_SUBCASE("DFS on a leaf")
- {
- expectedPaths = {
- "/type_module:leafString",
- };
+ std::vector<std::string> actualPaths;
+ for (const auto& it : *children) {
+ actualPaths.emplace_back(it.path());
+ }
- path = "/type_module:leafString";
+ REQUIRE(actualPaths == expectedPaths);
}
- std::vector<std::string> actualPaths;
- for (const auto& it : ctx->findPath(path).childrenDfs()) {
- actualPaths.emplace_back(it.path());
+ DOCTEST_SUBCASE("unimplemented module")
+ {
+ DOCTEST_SUBCASE("Module::childrenDfs")
+ {
+ ctx->setSearchDir(TESTS_DIR / "yang");
+ auto modYangPatch = ctx->loadModule("ietf-yang-patch", std::nullopt);
+ auto modRestconf = ctx->getModule("ietf-restconf", "2017-01-26");
+ REQUIRE(!modRestconf->implemented());
+ REQUIRE_THROWS_WITH_AS(modRestconf->childrenDfs(), "Module::childrenDfs: module is not implemented", libyang::Error);
+ }
}
-
- REQUIRE(actualPaths == expectedPaths);
}
- DOCTEST_SUBCASE("SchemaNode::immediateChildren")
+ DOCTEST_SUBCASE("immediateChildren")
{
- std::vector<std::string> expectedPaths;
- const char* path;
- DOCTEST_SUBCASE("listAdvancedWithTwoKey")
- {
- expectedPaths = {
- "/type_module:listAdvancedWithTwoKey/first",
- "/type_module:listAdvancedWithTwoKey/second",
- };
- path = "/type_module:listAdvancedWithTwoKey";
- }
- DOCTEST_SUBCASE("leaf")
+ DOCTEST_SUBCASE("implemented module")
{
- expectedPaths = {
- };
- path = "/type_module:leafString";
- }
- DOCTEST_SUBCASE("no recursion")
- {
- expectedPaths = {
- "/type_module:container/x",
- "/type_module:container/y",
- "/type_module:container/z",
- };
- path = "/type_module:container";
- }
- DOCTEST_SUBCASE("empty container")
- {
- expectedPaths = {
- };
- path = "/type_module:container/y";
- }
- DOCTEST_SUBCASE("one item")
- {
- expectedPaths = {
- "/type_module:container/z/z1",
- };
- path = "/type_module:container/z";
+ std::vector<std::string> expectedPaths;
+ std::optional<libyang::Collection<libyang::SchemaNode, libyang::IterationType::Sibling>> children;
+
+ DOCTEST_SUBCASE("SchemaNode::immediateChildren")
+ {
+ DOCTEST_SUBCASE("listAdvancedWithTwoKey")
+ {
+ expectedPaths = {
+ "/type_module:listAdvancedWithTwoKey/first",
+ "/type_module:listAdvancedWithTwoKey/second",
+ };
+ children = ctx->findPath("/type_module:listAdvancedWithTwoKey").immediateChildren();
+ }
+ DOCTEST_SUBCASE("leaf")
+ {
+ expectedPaths = {};
+ children = ctx->findPath("/type_module:leafString").immediateChildren();
+ }
+ DOCTEST_SUBCASE("no recursion")
+ {
+ expectedPaths = {
+ "/type_module:container/x",
+ "/type_module:container/y",
+ "/type_module:container/z",
+ };
+ children = ctx->findPath("/type_module:container").immediateChildren();
+ }
+ DOCTEST_SUBCASE("empty container")
+ {
+ expectedPaths = {};
+ children = ctx->findPath("/type_module:container/y").immediateChildren();
+ }
+ DOCTEST_SUBCASE("one item")
+ {
+ expectedPaths = {
+ "/type_module:container/z/z1",
+ };
+ children = ctx->findPath("/type_module:container/z").immediateChildren();
+ }
+ DOCTEST_SUBCASE("two items")
+ {
+ expectedPaths = {
+ "/type_module:container/x/x1",
+ "/type_module:container/x/x2",
+ };
+ children = ctx->findPath("/type_module:container/x").immediateChildren();
+ }
+ }
+
+ DOCTEST_SUBCASE("Module::immediateChildren")
+ {
+ expectedPaths = {
+ "/type_module:anydataBasic",
+ "/type_module:anydataWithMandatoryChild",
+ "/type_module:anyxmlBasic",
+ "/type_module:anyxmlWithMandatoryChild",
+ // choiceOnModule is a choice, so it doesn't have path "/type_module:choiceOnModule".
+ // This node is tested at the end of the test subcase.
+ "/",
+ "/type_module:choiceBasicContainer",
+ "/type_module:choiceWithMandatoryContainer",
+ "/type_module:choiceWithDefaultContainer",
+ "/type_module:implicitCaseContainer",
+ "/type_module:leafBinary",
+ "/type_module:leafBits",
+ "/type_module:leafEnum",
+ "/type_module:leafEnum2",
+ "/type_module:leafNumber",
+ "/type_module:leafRef",
+ "/type_module:leafRefRelaxed",
+ "/type_module:leafString",
+ "/type_module:leafUnion",
+ "/type_module:meal",
+ "/type_module:leafWithConfigFalse",
+ "/type_module:leafWithDefaultValue",
+ "/type_module:leafWithDescription",
+ "/type_module:leafWithMandatoryTrue",
+ "/type_module:leafWithStatusDeprecated",
+ "/type_module:leafWithStatusObsolete",
+ "/type_module:leafWithUnits",
+ "/type_module:iid-valid",
+ "/type_module:iid-relaxed",
+ "/type_module:leafListBasic",
+ "/type_module:leafListWithDefault",
+ "/type_module:leafListWithMinMaxElements",
+ "/type_module:leafListWithUnits",
+ "/type_module:listBasic",
+ "/type_module:listAdvancedWithOneKey",
+ "/type_module:listAdvancedWithTwoKey",
+ "/type_module:listWithMinMaxElements",
+ "/type_module:numeric",
+ "/type_module:container",
+ "/type_module:containerWithMandatoryChild",
+ };
+ children = ctx->getModule("type_module", std::nullopt)->immediateChildren();
+
+ std::vector<std::string> actualNames;
+ for (auto it : children.value()) {
+ actualNames.emplace_back(it.name());
+ }
+ // choiceOnModule is a choice, so it doesn't have path, just name.
+ REQUIRE(actualNames[4] == "choiceOnModule");
+ }
+
+ std::vector<std::string> actualPaths;
+ for (const auto& it : *children) {
+ actualPaths.emplace_back(it.path());
+ }
+ REQUIRE(actualPaths == expectedPaths);
}
- DOCTEST_SUBCASE("two items")
+
+ DOCTEST_SUBCASE("unimplemented module")
{
- expectedPaths = {
- "/type_module:container/x/x1",
- "/type_module:container/x/x2",
- };
- path = "/type_module:container/x";
- }
- std::vector<std::string> actualPaths;
- for (const auto& it : ctx->findPath(path).immediateChildren()) {
- actualPaths.emplace_back(it.path());
+ DOCTEST_SUBCASE("Module::immediateChildren")
+ {
+ ctx->setSearchDir(TESTS_DIR / "yang");
+ auto modYangPatch = ctx->loadModule("ietf-yang-patch", std::nullopt);
+ auto modRestconf = ctx->getModule("ietf-restconf", "2017-01-26");
+ REQUIRE(!modRestconf->implemented());
+ REQUIRE_THROWS_WITH_AS(modRestconf->immediateChildren(), "Module::child: module is not implemented", libyang::Error);
+ }
}
- REQUIRE(actualPaths == expectedPaths);
}
DOCTEST_SUBCASE("SchemaNode::siblings")
--
2.43.0
@@ -1,46 +0,0 @@
From 39c7530caa510144c17521278b721ba1e6d8ff40 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Thu, 9 Jan 2025 15:31:37 +0100
Subject: [PATCH 09/18] upstream stopped reporting schema-mounts node
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Change-Id: I940769d38d56fcfda3e1408c92331fdb00c161e9
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
CMakeLists.txt | 2 +-
tests/context.cpp | 3 +--
2 files changed, 2 insertions(+), 3 deletions(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 3d86809..732f52b 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -28,7 +28,7 @@ option(WITH_DOCS "Create and install internal documentation (needs Doxygen)" ${D
option(BUILD_SHARED_LIBS "By default, shared libs are enabled. Turn off for a static build." ON)
find_package(PkgConfig REQUIRED)
-pkg_check_modules(LIBYANG REQUIRED libyang>=3.4.2 IMPORTED_TARGET)
+pkg_check_modules(LIBYANG REQUIRED libyang>=3.7.8 IMPORTED_TARGET)
set(LIBYANG_CPP_PKG_VERSION "3")
# FIXME from gcc 14.1 on we should be able to use the calendar/time from libstdc++ and thus remove the date dependency
diff --git a/tests/context.cpp b/tests/context.cpp
index 5929b75..9d38fea 100644
--- a/tests/context.cpp
+++ b/tests/context.cpp
@@ -509,8 +509,7 @@ TEST_CASE("context")
"error-message": "hi"
}
]
- },
- "ietf-yang-schema-mount:schema-mounts": {}
+ }
}
)");
}
--
2.43.0
@@ -1,39 +0,0 @@
From 82c963766ad4e4a802db7be656acbedb640745e9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Thu, 30 Jan 2025 16:34:12 +0100
Subject: [PATCH 10/18] CI: temporarily pin version due to anyxml behavior
changes upstream
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Change-Id: Id977f4d045098c1b93656c0efb871c9a1b650e2d
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
.zuul.yaml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.zuul.yaml b/.zuul.yaml
index b41c490..19f0def 100644
--- a/.zuul.yaml
+++ b/.zuul.yaml
@@ -4,13 +4,13 @@
- f38-gcc-cover:
required-projects:
- name: github/CESNET/libyang
- override-checkout: devel
+ override-checkout: cesnet/2025-01-29
- name: github/onqtam/doctest
override-checkout: v2.3.6
- f38-clang-asan-ubsan:
required-projects: &projects
- name: github/CESNET/libyang
- override-checkout: devel
+ override-checkout: cesnet/2025-01-29
- name: github/onqtam/doctest
override-checkout: v2.4.11
- f38-clang-tsan:
--
2.43.0
@@ -1,40 +0,0 @@
From 50a216e35a555961f94a32a71bb2d45ac611d0aa Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Wed, 29 Jan 2025 22:49:08 +0100
Subject: [PATCH 11/18] build: a single place to define package version
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Change-Id: I2cd7397895ed4852f852e99b97543dde76eaff8f
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
CMakeLists.txt | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 732f52b..c518ca8 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -21,7 +21,8 @@ add_custom_target(libyang-cpp-version-cmake
cmake/ProjectGitVersionRunner.cmake
)
include(cmake/ProjectGitVersion.cmake)
-prepare_git_version(LIBYANG_CPP_VERSION "3")
+set(LIBYANG_CPP_PKG_VERSION "3")
+prepare_git_version(LIBYANG_CPP_VERSION ${LIBYANG_CPP_PKG_VERSION})
find_package(Doxygen)
option(WITH_DOCS "Create and install internal documentation (needs Doxygen)" ${DOXYGEN_FOUND})
@@ -29,7 +30,6 @@ option(BUILD_SHARED_LIBS "By default, shared libs are enabled. Turn off for a st
find_package(PkgConfig REQUIRED)
pkg_check_modules(LIBYANG REQUIRED libyang>=3.7.8 IMPORTED_TARGET)
-set(LIBYANG_CPP_PKG_VERSION "3")
# FIXME from gcc 14.1 on we should be able to use the calendar/time from libstdc++ and thus remove the date dependency
find_package(date)
--
2.43.0
@@ -1,173 +0,0 @@
From 1533458346b4f395b1187c646b61bbcb1fddc615 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Mon, 4 Nov 2024 14:52:12 +0100
Subject: [PATCH 12/18] YANG-flavored regular expressions
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Change-Id: I93b2756d0f470585280c076308df3f384bd7765d
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
CMakeLists.txt | 3 +++
include/libyang-cpp/Regex.hpp | 28 ++++++++++++++++++++++
src/Regex.cpp | 45 +++++++++++++++++++++++++++++++++++
tests/regex.cpp | 30 +++++++++++++++++++++++
4 files changed, 106 insertions(+)
create mode 100644 include/libyang-cpp/Regex.hpp
create mode 100644 src/Regex.cpp
create mode 100644 tests/regex.cpp
diff --git a/CMakeLists.txt b/CMakeLists.txt
index c518ca8..512af8c 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -54,6 +54,7 @@ add_library(yang-cpp
src/Enum.cpp
src/Collection.cpp
src/Module.cpp
+ src/Regex.cpp
src/SchemaNode.cpp
src/Set.cpp
src/Type.cpp
@@ -83,6 +84,7 @@ if(${CMAKE_VERSION} VERSION_GREATER_EQUAL "3.24")
libyang-cpp/Enum.hpp
libyang-cpp/ChildInstantiables.hpp
libyang-cpp/Module.hpp
+ libyang-cpp/Regex.hpp
libyang-cpp/Set.hpp
libyang-cpp/SchemaNode.hpp
libyang-cpp/Time.hpp
@@ -119,6 +121,7 @@ if(BUILD_TESTING)
libyang_cpp_test(schema_node)
libyang_cpp_test(unsafe)
target_link_libraries(test_unsafe PkgConfig::LIBYANG)
+ libyang_cpp_test(regex)
if(date_FOUND)
add_executable(test_time-stl-hhdate tests/time.cpp)
diff --git a/include/libyang-cpp/Regex.hpp b/include/libyang-cpp/Regex.hpp
new file mode 100644
index 0000000..31935f2
--- /dev/null
+++ b/include/libyang-cpp/Regex.hpp
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2025 CESNET, https://photonics.cesnet.cz/
+ *
+ * Written by Jan Kundrát <jan.kundrat@cesnet.cz>
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ */
+#pragma once
+#include <libyang-cpp/export.h>
+#include <string>
+
+namespace libyang {
+class Context;
+
+/**
+ * @brief A regular expression pattern which uses the YANG-flavored regex engine
+ */
+class LIBYANG_CPP_EXPORT Regex {
+public:
+ Regex(const std::string& pattern);
+ ~Regex();
+ bool matches(const std::string& input);
+
+private:
+ void* code;
+};
+
+}
diff --git a/src/Regex.cpp b/src/Regex.cpp
new file mode 100644
index 0000000..a34fcd5
--- /dev/null
+++ b/src/Regex.cpp
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2025 CESNET, https://photonics.cesnet.cz/
+ *
+ * Written by Jan Kundrát <jan.kundrat@cesnet.cz>
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ */
+
+// The following header MUST be included before anything else which "might" use PCRE2
+// because that library uses the preprocessor to prepare the "correct" versions of symbols.
+#include <libyang/tree_data.h>
+
+#include <libyang-cpp/Regex.hpp>
+#include <pcre2.h>
+#include "utils/exception.hpp"
+
+#define THE_PCRE2_CODE_P reinterpret_cast<pcre2_code*>(this->code)
+#define THE_PCRE2_CODE_P_P reinterpret_cast<pcre2_code**>(&this->code)
+
+namespace libyang {
+
+Regex::Regex(const std::string& pattern)
+ : code(nullptr)
+{
+ auto res = ly_pattern_compile(nullptr, pattern.c_str(), THE_PCRE2_CODE_P_P);
+ throwIfError(res, ly_last_logmsg());
+}
+
+Regex::~Regex()
+{
+ pcre2_code_free(THE_PCRE2_CODE_P);
+}
+
+bool Regex::matches(const std::string& input)
+{
+ auto res = ly_pattern_match(nullptr, nullptr /* we have a precompiled pattern */, input.c_str(), input.size(), THE_PCRE2_CODE_P_P);
+ if (res == LY_SUCCESS) {
+ return true;
+ } else if (res == LY_ENOT) {
+ return false;
+ } else {
+ throwError(res, ly_last_logmsg());
+ }
+}
+}
diff --git a/tests/regex.cpp b/tests/regex.cpp
new file mode 100644
index 0000000..7594f43
--- /dev/null
+++ b/tests/regex.cpp
@@ -0,0 +1,30 @@
+/*
+ * Copyright (C) 2025 CESNET, https://photonics.cesnet.cz/
+ *
+ * Written by Jan Kundrát <jan.kundrat@cesnet.cz>
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ */
+
+#include <doctest/doctest.h>
+#include <libyang-cpp/Regex.hpp>
+#include <libyang-cpp/Utils.hpp>
+
+TEST_CASE("regex")
+{
+ using libyang::Regex;
+ using namespace std::string_literals;
+
+ REQUIRE_THROWS_WITH_AS(Regex{"\\"}, R"(Regular expression "\" is not valid ("": \ at end of pattern).: LY_EVALID)", libyang::ErrorWithCode);
+
+ Regex re{"ahoj"};
+ REQUIRE(re.matches("ahoj"));
+ REQUIRE(!re.matches("cau"));
+ REQUIRE(re.matches("ahoj")); // test repeated calls as well
+ REQUIRE(!re.matches("oj"));
+ REQUIRE(!re.matches("aho"));
+
+ // Testing runtime errors during pattern *matching* is tricky. There's a limit on backtracking,
+ // so testing a pattern like x+x+y on an obscenely long string of "x" characters *will* do the trick, eventually,
+ // but the PCRE2 library has a default limit of 10M attempts. That's a VERY big number to hit during a test :(.
+}
--
2.43.0
@@ -1,67 +0,0 @@
From 8d406728a53c2e77a4fe7393b7e30d42b8f9b9bb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Thu, 30 Jan 2025 15:40:22 +0100
Subject: [PATCH 13/18] Adapt to upstream changes in anyxml JSON printing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Change-Id: I5f6de28cebc95a446549017c2768b450f4fd6526
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
.zuul.yaml | 4 ++--
CMakeLists.txt | 3 ++-
tests/data_node.cpp | 2 +-
3 files changed, 5 insertions(+), 4 deletions(-)
diff --git a/.zuul.yaml b/.zuul.yaml
index 19f0def..b41c490 100644
--- a/.zuul.yaml
+++ b/.zuul.yaml
@@ -4,13 +4,13 @@
- f38-gcc-cover:
required-projects:
- name: github/CESNET/libyang
- override-checkout: cesnet/2025-01-29
+ override-checkout: devel
- name: github/onqtam/doctest
override-checkout: v2.3.6
- f38-clang-asan-ubsan:
required-projects: &projects
- name: github/CESNET/libyang
- override-checkout: cesnet/2025-01-29
+ override-checkout: devel
- name: github/onqtam/doctest
override-checkout: v2.4.11
- f38-clang-tsan:
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 512af8c..a40fd52 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -29,7 +29,8 @@ option(WITH_DOCS "Create and install internal documentation (needs Doxygen)" ${D
option(BUILD_SHARED_LIBS "By default, shared libs are enabled. Turn off for a static build." ON)
find_package(PkgConfig REQUIRED)
-pkg_check_modules(LIBYANG REQUIRED libyang>=3.7.8 IMPORTED_TARGET)
+# FIXME: it's actually 3.7.12, but that hasn't been released yet
+pkg_check_modules(LIBYANG REQUIRED libyang>=3.7.11 IMPORTED_TARGET)
# FIXME from gcc 14.1 on we should be able to use the calendar/time from libstdc++ and thus remove the date dependency
find_package(date)
diff --git a/tests/data_node.cpp b/tests/data_node.cpp
index 45fd6c1..14470dd 100644
--- a/tests/data_node.cpp
+++ b/tests/data_node.cpp
@@ -1568,7 +1568,7 @@ TEST_CASE("Data Node manipulation")
REQUIRE(*jsonAnyXmlNode.createdNode->printStr(libyang::DataFormat::JSON, libyang::PrintFlags::Shrink | libyang::PrintFlags::WithSiblings)
== R"|({"example-schema:ax":[1,2,3]})|"s);
REQUIRE(*jsonAnyXmlNode.createdNode->printStr(libyang::DataFormat::XML, libyang::PrintFlags::Shrink | libyang::PrintFlags::WithSiblings)
- == R"|(<ax xmlns="http://example.com/coze"/>)|"s);
+ == R"|(<ax xmlns="http://example.com/coze">)|"s + origJSON + "</ax>");
}
REQUIRE(!!val);
--
2.43.0
@@ -1,57 +0,0 @@
From f050e7e4a17ef2e221ca000a544042c33c9541fc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Thu, 13 Mar 2025 19:21:26 +0100
Subject: [PATCH 14/18] fix DataNode::insertSibling() return value
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
When such an insert happens, the C library returns the node which is now
the first sibling among all of the siblings of the node which was used
as a reference during the insert. Our API was also documented this way,
but we were not doing that.
Reported-by: Irfan <irfan.haslanded@gmail.com>
Bug: https://github.com/CESNET/libyang-cpp/issues/29
Change-Id: Id7f84a31e50212d6e2cbce9ab03a351a3721f767
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/DataNode.cpp | 2 +-
tests/data_node.cpp | 7 +++++++
2 files changed, 8 insertions(+), 1 deletion(-)
diff --git a/src/DataNode.cpp b/src/DataNode.cpp
index 84591e5..b899b18 100644
--- a/src/DataNode.cpp
+++ b/src/DataNode.cpp
@@ -711,7 +711,7 @@ DataNode DataNode::insertSibling(DataNode toInsert)
lyd_insert_sibling(this->m_node, toInsert.m_node, &firstSibling);
}, toInsert.parent() ? OperationScope::JustThisNode : OperationScope::AffectsFollowingSiblings, m_refs);
- return DataNode{m_node, m_refs};
+ return DataNode{firstSibling, m_refs};
}
/**
diff --git a/tests/data_node.cpp b/tests/data_node.cpp
index 14470dd..b6ee455 100644
--- a/tests/data_node.cpp
+++ b/tests/data_node.cpp
@@ -973,6 +973,13 @@ TEST_CASE("Data Node manipulation")
REQUIRE(getNumberOrder() == expected);
}
+ DOCTEST_SUBCASE("DataNode::insertSibling")
+ {
+ auto node = ctx.newPath("/example-schema:leafUInt8", "10");
+ REQUIRE(node.insertSibling(ctx.newPath("/example-schema:leafUInt16", "10")).path() == "/example-schema:leafUInt8");
+ REQUIRE(node.insertSibling(ctx.newPath("/example-schema:dummy", "10")).path() == "/example-schema:dummy");
+ }
+
DOCTEST_SUBCASE("DataNode::duplicate")
{
auto root = ctx.parseData(data2, libyang::DataFormat::JSON);
--
2.43.0
@@ -1,260 +0,0 @@
From f958af42bf5d9fbd901ed59ebc1359ac0ddcc00f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Fri, 14 Mar 2025 11:36:50 +0100
Subject: [PATCH 15/18] API/ABI change: opaque node naming
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Our C++ API would ignore the "module name or XML prefix", which turns
out to be *the* relevant part when it comes to opaque node naming. The
prefix is, instead, just that string that might have been inherited from
the parent node when parsing the serialized data; it's an optional
thingy which, if not set explicitly, is implicitly inherited.
Adapt the API for this, and since this *will* break the build, let's
bump the package version.
Change-Id: I199afe5fa7a571034b744531c63b93b9c656563a
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
CMakeLists.txt | 5 ++---
include/libyang-cpp/Context.hpp | 4 ++--
include/libyang-cpp/DataNode.hpp | 11 ++++++++--
src/Context.cpp | 32 ++++++++++++++++++++--------
src/DataNode.cpp | 19 ++++++++++++++---
tests/data_node.cpp | 36 ++++++++++++++++++++++++++------
6 files changed, 82 insertions(+), 25 deletions(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index a40fd52..c5fec45 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -21,7 +21,7 @@ add_custom_target(libyang-cpp-version-cmake
cmake/ProjectGitVersionRunner.cmake
)
include(cmake/ProjectGitVersion.cmake)
-set(LIBYANG_CPP_PKG_VERSION "3")
+set(LIBYANG_CPP_PKG_VERSION "4")
prepare_git_version(LIBYANG_CPP_VERSION ${LIBYANG_CPP_PKG_VERSION})
find_package(Doxygen)
@@ -29,8 +29,7 @@ option(WITH_DOCS "Create and install internal documentation (needs Doxygen)" ${D
option(BUILD_SHARED_LIBS "By default, shared libs are enabled. Turn off for a static build." ON)
find_package(PkgConfig REQUIRED)
-# FIXME: it's actually 3.7.12, but that hasn't been released yet
-pkg_check_modules(LIBYANG REQUIRED libyang>=3.7.11 IMPORTED_TARGET)
+pkg_check_modules(LIBYANG REQUIRED libyang>=3.10.1 IMPORTED_TARGET)
# FIXME from gcc 14.1 on we should be able to use the calendar/time from libstdc++ and thus remove the date dependency
find_package(date)
diff --git a/include/libyang-cpp/Context.hpp b/include/libyang-cpp/Context.hpp
index baa47b3..ca89063 100644
--- a/include/libyang-cpp/Context.hpp
+++ b/include/libyang-cpp/Context.hpp
@@ -115,8 +115,8 @@ public:
CreatedNodes newPath2(const std::string& path, libyang::JSON json, const std::optional<CreationOptions> options = std::nullopt) const;
CreatedNodes newPath2(const std::string& path, libyang::XML xml, const std::optional<CreationOptions> options = std::nullopt) const;
std::optional<DataNode> newExtPath(const ExtensionInstance& ext, const std::string& path, const std::optional<std::string>& value, const std::optional<CreationOptions> options = std::nullopt) const;
- std::optional<DataNode> newOpaqueJSON(const std::string& moduleName, const std::string& name, const std::optional<libyang::JSON>& value) const;
- std::optional<DataNode> newOpaqueXML(const std::string& moduleName, const std::string& name, const std::optional<libyang::XML>& value) const;
+ std::optional<DataNode> newOpaqueJSON(const OpaqueName& name, const std::optional<libyang::JSON>& value) const;
+ std::optional<DataNode> newOpaqueXML(const OpaqueName& name, const std::optional<libyang::XML>& value) const;
SchemaNode findPath(const std::string& dataPath, const InputOutputNodes inputOutputNodes = InputOutputNodes::Input) const;
Set<SchemaNode> findXPath(const std::string& path) const;
diff --git a/include/libyang-cpp/DataNode.hpp b/include/libyang-cpp/DataNode.hpp
index 851681b..310b5e3 100644
--- a/include/libyang-cpp/DataNode.hpp
+++ b/include/libyang-cpp/DataNode.hpp
@@ -225,15 +225,22 @@ private:
};
/**
- * @brief Contains a (possibly module-qualified) name of an opaque node.
+ * @brief Contains a name of an opaque node.
*
- * This is generic container of a prefix/module string and a name string.
+ * An opaque node always has a name, and a module (or XML namespace) to which this node belongs.
+ * Sometimes, it also has a prefix.
+ *
+ * If the prefix is set *and* the underlying node is an opaque JSON one, then the prefix must be the same as the "module or namespace" name.
+ * If the underlying node is an opaque XML one, then the XML prefix might be something completely different, and in that case the real fun begins.
+ * Review the libayng C manual, this is something that the C++ wrapper doesn't really have under control.
*
* Wraps `ly_opaq_name`.
*/
struct LIBYANG_CPP_EXPORT OpaqueName {
+ std::string moduleOrNamespace;
std::optional<std::string> prefix;
std::string name;
+ std::string pretty() const;
};
/**
diff --git a/src/Context.cpp b/src/Context.cpp
index 287f8c8..fec2f27 100644
--- a/src/Context.cpp
+++ b/src/Context.cpp
@@ -378,17 +378,25 @@ std::optional<DataNode> Context::newExtPath(const ExtensionInstance& ext, const
*
* Wraps `lyd_new_opaq`.
*
- * @param moduleName Node module name, used as a prefix as well
* @param name Name of the created node
* @param value JSON data blob, if any
* @return Returns the newly created node (if created)
*/
-std::optional<DataNode> Context::newOpaqueJSON(const std::string& moduleName, const std::string& name, const std::optional<libyang::JSON>& value) const
+std::optional<DataNode> Context::newOpaqueJSON(const OpaqueName& name, const std::optional<libyang::JSON>& value) const
{
+ if (name.prefix && *name.prefix != name.moduleOrNamespace) {
+ throw Error{"invalid opaque JSON node: prefix \"" + *name.prefix + "\" doesn't match module name \"" + name.moduleOrNamespace + "\""};
+ }
lyd_node* out;
- auto err = lyd_new_opaq(nullptr, m_ctx.get(), name.c_str(), value ? value->content.c_str() : nullptr, nullptr, moduleName.c_str(), &out);
+ auto err = lyd_new_opaq(nullptr,
+ m_ctx.get(),
+ name.name.c_str(),
+ value ? value->content.c_str() : nullptr,
+ name.prefix ? name.prefix->c_str() : nullptr,
+ name.moduleOrNamespace.c_str(),
+ &out);
- throwIfError(err, "Couldn't create an opaque JSON node '"s + moduleName + ':' + name + "'");
+ throwIfError(err, "Couldn't create an opaque JSON node " + name.pretty());
if (out) {
return DataNode{out, std::make_shared<internal_refcount>(m_ctx)};
@@ -403,17 +411,23 @@ std::optional<DataNode> Context::newOpaqueJSON(const std::string& moduleName, co
*
* Wraps `lyd_new_opaq2`.
*
- * @param xmlNamespace Node module namespace
* @param name Name of the created node
* @param value XML data blob, if any
* @return Returns the newly created node (if created)
*/
-std::optional<DataNode> Context::newOpaqueXML(const std::string& xmlNamespace, const std::string& name, const std::optional<libyang::XML>& value) const
+std::optional<DataNode> Context::newOpaqueXML(const OpaqueName& name, const std::optional<libyang::XML>& value) const
{
+ // the XML node naming is "complex", we cannot really check the XML namespace for sanity here
lyd_node* out;
- auto err = lyd_new_opaq2(nullptr, m_ctx.get(), name.c_str(), value ? value->content.c_str() : nullptr, nullptr, xmlNamespace.c_str(), &out);
-
- throwIfError(err, "Couldn't create an opaque XML node '"s + name +"' from namespace '" + xmlNamespace + "'");
+ auto err = lyd_new_opaq2(nullptr,
+ m_ctx.get(),
+ name.name.c_str(),
+ value ? value->content.c_str() : nullptr,
+ name.prefix ? name.prefix->c_str() : nullptr,
+ name.moduleOrNamespace.c_str(),
+ &out);
+
+ throwIfError(err, "Couldn't create an opaque XML node " + name.pretty());
if (out) {
return DataNode{out, std::make_shared<internal_refcount>(m_ctx)};
diff --git a/src/DataNode.cpp b/src/DataNode.cpp
index b899b18..344f1b6 100644
--- a/src/DataNode.cpp
+++ b/src/DataNode.cpp
@@ -1112,9 +1112,9 @@ OpaqueName DataNodeOpaque::name() const
{
auto opaq = reinterpret_cast<lyd_node_opaq*>(m_node);
return OpaqueName{
- .prefix = opaq->name.prefix ? std::optional(opaq->name.prefix) : std::nullopt,
- .name = opaq->name.name
- };
+ .moduleOrNamespace = opaq->name.module_name,
+ .prefix = opaq->name.prefix ? std::optional{opaq->name.prefix} : std::nullopt,
+ .name = opaq->name.name};
}
std::string DataNodeOpaque::value() const
@@ -1122,6 +1122,19 @@ std::string DataNodeOpaque::value() const
return reinterpret_cast<lyd_node_opaq*>(m_node)->value;
}
+std::string OpaqueName::pretty() const
+{
+ if (prefix) {
+ if (*prefix == moduleOrNamespace) {
+ return *prefix + ':' + name;
+ } else {
+ return "{" + moduleOrNamespace + "}, " + *prefix + ':' + name;
+ }
+ } else {
+ return "{" + moduleOrNamespace + "}, " + name;
+ }
+}
+
/**
* Wraps a raw non-null lyd_node pointer.
* @param node The pointer to be wrapped. Must not be null.
diff --git a/tests/data_node.cpp b/tests/data_node.cpp
index b6ee455..a1096a6 100644
--- a/tests/data_node.cpp
+++ b/tests/data_node.cpp
@@ -1969,9 +1969,11 @@ TEST_CASE("Data Node manipulation")
DOCTEST_SUBCASE("opaque nodes for sysrepo ops data discard")
{
- auto discard1 = ctx.newOpaqueJSON("sysrepo", "discard-items", libyang::JSON{"/example-schema:a"});
+ // the "short" form with no prefix
+ auto discard1 = ctx.newOpaqueJSON(libyang::OpaqueName{"sysrepo", std::nullopt, "discard-items"}, libyang::JSON{"/example-schema:a"});
REQUIRE(!!discard1);
- auto discard2 = ctx.newOpaqueJSON("sysrepo", "discard-items", libyang::JSON{"/example-schema:b"});
+ // let's use a prefix form here
+ auto discard2 = ctx.newOpaqueJSON(libyang::OpaqueName{"sysrepo", "sysrepo", "discard-items"}, libyang::JSON{"/example-schema:b"});
REQUIRE(!!discard2);
discard1->insertSibling(*discard2);
REQUIRE(*discard1->printStr(libyang::DataFormat::JSON, libyang::PrintFlags::WithSiblings)
@@ -2001,16 +2003,38 @@ TEST_CASE("Data Node manipulation")
auto data = ctx.newPath2("/example-schema:myRpc/outputLeaf", "AHOJ", libyang::CreationOptions::Output).createdNode;
REQUIRE(data);
data->newPath("/example-schema:myRpc/another", "yay", libyang::CreationOptions::Output);
+ std::string prettyName;
- DOCTEST_SUBCASE("JSON") {
- out = ctx.newOpaqueJSON(data->schema().module().name(), "output", std::nullopt);
+ DOCTEST_SUBCASE("JSON no prefix") {
+ out = ctx.newOpaqueJSON({data->schema().module().name(), std::nullopt, "output"}, std::nullopt);
+ prettyName = "{example-schema}, output";
}
- DOCTEST_SUBCASE("XML") {
- out = ctx.newOpaqueXML(data->schema().module().ns(), "output", std::nullopt);
+ DOCTEST_SUBCASE("JSON with prefix") {
+ out = ctx.newOpaqueJSON({data->schema().module().name(), data->schema().module().name(), "output"}, std::nullopt);
+ prettyName = "example-schema:output";
+
+ // wrong prefix is detected
+ REQUIRE_THROWS_WITH_AS(ctx.newOpaqueJSON({data->schema().module().name(), "xxx", "output"}, std::nullopt),
+ R"(invalid opaque JSON node: prefix "xxx" doesn't match module name "example-schema")",
+ libyang::Error);
+ }
+
+ DOCTEST_SUBCASE("XML no prefix") {
+ out = ctx.newOpaqueXML({data->schema().module().ns(), std::nullopt, "output"}, std::nullopt);
+ prettyName = "{http://example.com/coze}, output";
+ }
+
+ DOCTEST_SUBCASE("XML with prefix") {
+ out = ctx.newOpaqueXML({data->schema().module().ns(),
+ data->schema().module().name() /* prefix is a module name, not the XML NS*/,
+ "output"},
+ std::nullopt);
+ prettyName = "{http://example.com/coze}, example-schema:output";
}
REQUIRE(out);
+ REQUIRE(prettyName == out->asOpaque().name().pretty());
data->unlinkWithSiblings();
out->insertChild(*data);
--
2.43.0
@@ -1,462 +0,0 @@
From 8c59ecc5c687f8ee2ce62825835a378b422185f2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Fri, 14 Mar 2025 13:41:01 +0100
Subject: [PATCH 16/18] Add a helper for finding opaque nodes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
This is needed for sysrepo where we want to search through the
sysrepo:discard-items top-level opaque nodes.
At first I wanted to simply wrap lyd_find_sibling_opaq_next(), but its
semantics is quite different to what is needed in the code which uses
sysrepo, so here's my attempt at a nice-ish API.
Change-Id: I2571961e42f6d7a121e27c881cacdcfec0e87762
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
include/libyang-cpp/DataNode.hpp | 2 +
src/DataNode.cpp | 49 ++++++
tests/data_node.cpp | 74 +++++++++
tests/yang/sysrepo@2024-10-25.yang | 257 +++++++++++++++++++++++++++++
4 files changed, 382 insertions(+)
create mode 100644 tests/yang/sysrepo@2024-10-25.yang
diff --git a/include/libyang-cpp/DataNode.hpp b/include/libyang-cpp/DataNode.hpp
index 310b5e3..50d6c0e 100644
--- a/include/libyang-cpp/DataNode.hpp
+++ b/include/libyang-cpp/DataNode.hpp
@@ -102,6 +102,7 @@ public:
bool isOpaque() const;
DataNodeOpaque asOpaque() const;
+ std::optional<DataNodeOpaque> firstOpaqueSibling() const;
// TODO: allow setting the `parent` argument
DataNode duplicate(const std::optional<DuplicationOptions> opts = std::nullopt) const;
@@ -241,6 +242,7 @@ struct LIBYANG_CPP_EXPORT OpaqueName {
std::optional<std::string> prefix;
std::string name;
std::string pretty() const;
+ bool matches(const std::string& prefixIsh, const std::string& name) const;
};
/**
diff --git a/src/DataNode.cpp b/src/DataNode.cpp
index 344f1b6..7e87917 100644
--- a/src/DataNode.cpp
+++ b/src/DataNode.cpp
@@ -1135,6 +1135,20 @@ std::string OpaqueName::pretty() const
}
}
+/**
+ * @short Fuzzy-match a real-world name against a combination of "something like a prefix" and "unqualified name"
+ *
+ * Because libyang doesn't propagate inherited prefixes, and because opaque nodes are magic, we seem to require
+ * this "fuzzy matching". It won't properly report a match on opaque nodes with a prefix that's inherited when
+ * using XML namespaces, though.
+ * */
+bool OpaqueName::matches(const std::string& prefixIsh, const std::string& name) const
+{
+ return name == this->name
+ && (prefixIsh == moduleOrNamespace
+ || (!!prefix && prefixIsh == *prefix));
+}
+
/**
* Wraps a raw non-null lyd_node pointer.
* @param node The pointer to be wrapped. Must not be null.
@@ -1305,4 +1319,39 @@ bool DataNode::siblingsEqual(const libyang::DataNode& other, const DataCompare f
}
}
+/**
+ * @short Find the first opaque node among the siblings
+ *
+ * This function was inspired by `lyd_find_sibling_opaq_next()`.
+ * */
+std::optional<DataNodeOpaque> DataNode::firstOpaqueSibling() const
+{
+ struct lyd_node *candidate = m_node;
+
+ // Skip all non-opaque nodes; libyang guarantees to have them first, followed by a (possibly empty) set
+ // of opaque nodes. This is not documented anywhere, but it was explicitly confirmed by the maintainer:
+ //
+ // JK: can I rely on all non-opaque nodes being listed first among the siblings, and then all opaque nodes
+ // in one continuous sequence (but with an unspecified order among the opaque nodes themselves)?
+ //
+ // MV: yep
+ while (candidate && candidate->schema) {
+ candidate = candidate->next;
+ }
+
+ // walk back through the opaque nodes; however, libyang lists are not your regular linked lists
+ while (candidate
+ && !candidate->prev->schema // don't go from the first opaque node through the non-opaque ones
+ && candidate->prev->next // don't wrap from the first node to the last one in case all of them are opaque
+ ) {
+ candidate = candidate->prev;
+ }
+
+ if (candidate) {
+ return DataNode{candidate, m_refs}.asOpaque();
+ }
+
+ return std::nullopt;
+}
+
}
diff --git a/tests/data_node.cpp b/tests/data_node.cpp
index a1096a6..db5a28e 100644
--- a/tests/data_node.cpp
+++ b/tests/data_node.cpp
@@ -1982,6 +1982,80 @@ TEST_CASE("Data Node manipulation")
"sysrepo:discard-items": "/example-schema:b"
}
)");
+
+ // check that a list which only consists of opaque nodes doesn't break our iteration
+ REQUIRE(!!discard1->firstOpaqueSibling());
+ REQUIRE(*discard1->firstOpaqueSibling() == *discard1);
+ REQUIRE(!!discard2->firstOpaqueSibling());
+ REQUIRE(*discard2->firstOpaqueSibling() == *discard1);
+
+ auto leafInt16 = ctx.newPath("/example-schema:leafInt16", "666");
+ leafInt16.insertSibling(*discard1);
+ REQUIRE(*discard1->firstSibling().printStr(libyang::DataFormat::JSON, libyang::PrintFlags::WithSiblings)
+ == R"({
+ "example-schema:leafInt16": 666,
+ "sysrepo:discard-items": "/example-schema:a",
+ "sysrepo:discard-items": "/example-schema:b"
+}
+)");
+ REQUIRE(leafInt16.firstSibling().path() == "/example-schema:leafInt16");
+ REQUIRE(discard1->firstSibling().path() == "/example-schema:leafInt16");
+ REQUIRE(discard2->firstSibling().path() == "/example-schema:leafInt16");
+
+ auto dummy = ctx.newPath("/example-schema:dummy", "blah");
+ auto opaqueLeaf = ctx.newPath("/example-schema:leafInt32", std::nullopt, libyang::CreationOptions::Opaque);
+ opaqueLeaf.newAttrOpaqueJSON("ietf-netconf", "operation", "delete");
+ dummy.insertSibling(opaqueLeaf);
+
+ // FIXME reword this: this one might not be handled by sysrepo, but we want it for our fuzzy matcher testing anyway
+ auto discard3 = ctx.newOpaqueXML(libyang::OpaqueName{"http://www.sysrepo.org/yang/sysrepo", "sysrepo", "discard-items"}, libyang::XML{"/example-schema:c"});
+ REQUIRE(!!discard3);
+ // notice that it's printed without a proper prefix at first...
+ REQUIRE(*discard3->printStr(libyang::DataFormat::JSON, libyang::PrintFlags::Shrink)
+ == R"({"discard-items":"/example-schema:c"})");
+ // ...but after loading the module, the proper module is added back
+ ctx.parseModule(TESTS_DIR / "yang" / "sysrepo@2024-10-25.yang", libyang::SchemaFormat::YANG);
+ REQUIRE(*discard3->printStr(libyang::DataFormat::JSON, libyang::PrintFlags::Shrink)
+ == R"({"sysrepo:discard-items":"/example-schema:c"})");
+
+ dummy.insertSibling(*discard3);
+ leafInt16.insertSibling(dummy);
+ REQUIRE(*discard1->firstSibling().printStr(libyang::DataFormat::JSON, libyang::PrintFlags::WithSiblings)
+ == R"({
+ "example-schema:dummy": "blah",
+ "example-schema:leafInt16": 666,
+ "sysrepo:discard-items": "/example-schema:a",
+ "sysrepo:discard-items": "/example-schema:b",
+ "example-schema:leafInt32": "",
+ "@example-schema:leafInt32": {
+ "ietf-netconf:operation": "delete"
+ },
+ "sysrepo:discard-items": "/example-schema:c"
+}
+)");
+
+ REQUIRE(dummy.firstOpaqueSibling() == discard1);
+ REQUIRE(dummy.firstOpaqueSibling() != discard2);
+ REQUIRE(leafInt16.firstOpaqueSibling() == discard1);
+ REQUIRE(opaqueLeaf.firstOpaqueSibling() == discard1);
+ REQUIRE(discard1->firstOpaqueSibling() == discard1);
+ REQUIRE(discard2->firstOpaqueSibling() == discard1);
+ REQUIRE(discard3->firstOpaqueSibling() == discard1);
+ REQUIRE(discard1->asOpaque().name().matches("sysrepo", "discard-items"));
+ REQUIRE(!discard1->asOpaque().name().matches("http://www.sysrepo.org/yang/sysrepo", "discard-items"));
+ REQUIRE(discard2->asOpaque().name().matches("sysrepo", "discard-items"));
+ REQUIRE(!discard2->asOpaque().name().matches("http://www.sysrepo.org/yang/sysrepo", "discard-items"));
+ REQUIRE(discard3->asOpaque().name().matches("sysrepo", "discard-items"));
+ REQUIRE(discard3->asOpaque().name().matches("http://www.sysrepo.org/yang/sysrepo", "discard-items"));
+ REQUIRE(!opaqueLeaf.asOpaque().name().matches("sysrepo", "discard-items"));
+
+ REQUIRE(!!dummy.firstOpaqueSibling()->nextSibling());
+ REQUIRE(dummy.firstOpaqueSibling()->nextSibling() == discard2);
+ REQUIRE(!!dummy.firstOpaqueSibling()->nextSibling()->nextSibling());
+ REQUIRE(dummy.firstOpaqueSibling()->nextSibling()->nextSibling() == opaqueLeaf);
+ REQUIRE(!!dummy.firstOpaqueSibling()->nextSibling()->nextSibling()->nextSibling());
+ REQUIRE(dummy.firstOpaqueSibling()->nextSibling()->nextSibling()->nextSibling() == discard3);
+ REQUIRE(!dummy.firstOpaqueSibling()->nextSibling()->nextSibling()->nextSibling()->nextSibling());
}
DOCTEST_SUBCASE("RESTCONF RPC output")
diff --git a/tests/yang/sysrepo@2024-10-25.yang b/tests/yang/sysrepo@2024-10-25.yang
new file mode 100644
index 0000000..f5bc32c
--- /dev/null
+++ b/tests/yang/sysrepo@2024-10-25.yang
@@ -0,0 +1,257 @@
+module sysrepo {
+ namespace "http://www.sysrepo.org/yang/sysrepo";
+ prefix sr;
+
+ yang-version 1.1;
+
+ import ietf-yang-types {
+ prefix yang;
+ }
+
+ import ietf-datastores {
+ prefix ds;
+ }
+
+ import ietf-yang-metadata {
+ prefix md;
+ revision-date 2016-08-05;
+ }
+
+ organization
+ "CESNET";
+
+ contact
+ "Author: Michal Vasko
+ <mvasko@cesnet.cz>";
+
+ description
+ "Sysrepo YANG datastore internal attributes and information.";
+
+ revision "2024-10-25" {
+ description
+ "Removed redundant metadata used for push operational data.";
+ }
+
+ revision "2019-07-10" {
+ description
+ "Initial revision.";
+ }
+
+ typedef module-ref {
+ description
+ "Reference to a module.";
+ type leafref {
+ path "/sysrepo-modules/module/name";
+ }
+ }
+
+ md:annotation operation {
+ type enumeration {
+ enum none {
+ description
+ "Node with this operation must exist but does not affect the datastore in any way.";
+ reference
+ "RFC 6241 section 7.2.: default-operation";
+ }
+ enum ether {
+ description
+ "Node with this operation does not have to exist and does not affect the datastore in any way.";
+ }
+ enum purge {
+ description
+ "Node with this operation represents an arbitrary generic node instance and all
+ the instances will be deleted.";
+ }
+ }
+ description
+ "Additional proprietary <edit-config> operations used internally.";
+ reference
+ "RFC 6241 section 7.2.";
+ }
+
+ identity notification {
+ base ds:datastore;
+ description
+ "Special datastore for storing notifications for replay.";
+ }
+
+ grouping module-info-grp {
+ leaf name {
+ type string;
+ description
+ "Module name.";
+ }
+
+ leaf revision {
+ type string;
+ description
+ "Module revision.";
+ }
+
+ leaf-list enabled-feature {
+ type string;
+ description
+ "List of all the enabled features.";
+ }
+
+ list plugin {
+ key "datastore";
+ description
+ "Module datastore plugin handling specific datastore data.";
+
+ leaf datastore {
+ type identityref {
+ base ds:datastore;
+ }
+ description
+ "Datastore of this plugin.";
+ }
+
+ leaf name {
+ type string;
+ mandatory true;
+ description
+ "Specific plugin name as present in the plugin structures.";
+ }
+ }
+ }
+
+ grouping deps-grp {
+ list lref {
+ description
+ "Dependency of a leafref node.";
+
+ leaf target-path {
+ type yang:xpath1.0;
+ mandatory true;
+ description
+ "Path identifying the leafref target node.";
+ }
+
+ leaf target-module {
+ type module-ref;
+ mandatory true;
+ description
+ "Foreign target module of the leafref.";
+ }
+ }
+
+ list inst-id {
+ description
+ "Dependency of an instance-identifier node.";
+
+ leaf source-path {
+ type yang:xpath1.0;
+ mandatory true;
+ description
+ "Path identifying the instance-identifier node.";
+ }
+
+ leaf default-target-path {
+ type yang:xpath1.0;
+ description
+ "Default instance-identifier value.";
+ }
+ }
+
+ list xpath {
+ description
+ "Dependency of an XPath expression - must or when statement.";
+
+ leaf expression {
+ type yang:xpath1.0;
+ mandatory true;
+ description
+ "XPath expression of the dependency - must or when statement argument.";
+ }
+
+ leaf-list target-module {
+ type module-ref;
+ description
+ "Foreign modules with the data needed for evaluation of the XPath.";
+ }
+ }
+ }
+
+ container sysrepo-modules {
+ config false;
+ description
+ "All installed Sysrepo modules.";
+
+ leaf content-id {
+ type uint32;
+ mandatory true;
+ description
+ "Sysrepo module-set content-id to be used for its generated yang-library data.";
+ }
+
+ list module {
+ key "name";
+ description
+ "Sysrepo module.";
+
+ uses module-info-grp;
+
+ leaf replay-support {
+ type yang:date-and-time;
+ description
+ "Present only if the module supports replay. Means the earliest stored notification if any present.
+ Otherwise the time the replay support was switched on.";
+ }
+
+ container deps {
+ description
+ "Module data dependencies on other modules.";
+ uses deps-grp;
+ }
+
+ leaf-list inverse-deps {
+ type module-ref;
+ description
+ "List of modules that depend on this module.";
+ }
+
+ list rpc {
+ key "path";
+ description
+ "Module RPC/actions.";
+
+ leaf path {
+ type yang:xpath1.0;
+ description
+ "Path identifying the operation.";
+ }
+
+ container in {
+ description
+ "Operation input dependencies.";
+ uses deps-grp;
+ }
+
+ container out {
+ description
+ "Operation output dependencies.";
+ uses deps-grp;
+ }
+ }
+
+ list notification {
+ key "path";
+ description
+ "Module notifications.";
+
+ leaf path {
+ type yang:xpath1.0;
+ description
+ "Path identifying the notification.";
+ }
+
+ container deps {
+ description
+ "Notification dependencies.";
+ uses deps-grp;
+ }
+ }
+ }
+ }
+}
--
2.43.0
@@ -1,56 +0,0 @@
From dab8c9aac96063ccb8541d5d1795ee89b75faeee Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Wed, 2 Apr 2025 11:50:24 -0700
Subject: [PATCH 17/18] build: link to libpcre2 explicitly
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
This is apparently a problem on Mac OS X builds where the transitive
dependency via libyang doesn't take effect:
Undefined symbols for architecture arm64:
"_pcre2_code_free_8", referenced from:
libyang::Regex::~Regex() in Regex.cpp.o
libyang::Regex::~Regex() in Regex.cpp.o
ld: symbol(s) not found for architecture arm64
Let's fix this by linking with libpcre2-8 directly.
We're using a different approach than libyang; they have their own CMake
wrapper. I find it easier to work with pkg-config modules, so let's try
it that way.
Change-Id: I1a64407d852161595b7dca654433190002cc3600
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
CMakeLists.txt | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index c5fec45..4e009aa 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -34,6 +34,9 @@ pkg_check_modules(LIBYANG REQUIRED libyang>=3.10.1 IMPORTED_TARGET)
# FIXME from gcc 14.1 on we should be able to use the calendar/time from libstdc++ and thus remove the date dependency
find_package(date)
+# libyang::Regex::~Regex() bypasses libyang and calls out to libpcre directly
+pkg_check_modules(LIBPCRE2 REQUIRED libpcre2-8 IMPORTED_TARGET)
+
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/include)
include(CheckIncludeFileCXX)
@@ -64,7 +67,7 @@ add_library(yang-cpp
src/utils/newPath.cpp
)
-target_link_libraries(yang-cpp PRIVATE PkgConfig::LIBYANG)
+target_link_libraries(yang-cpp PRIVATE PkgConfig::LIBYANG PkgConfig::LIBPCRE2)
# We do not offer any long-term API/ABI guarantees. To make stuff easier for downstream consumers,
# we will be bumping both API and ABI versions very deliberately.
# There will be no attempts at semver tracking, for example.
--
2.43.0
@@ -1,66 +0,0 @@
From 7519fcd35216072a6b1eebe2a79e19789be345a9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Wed, 9 Apr 2025 15:35:37 +0200
Subject: [PATCH 18/18] CI: renamed project upstream
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Change-Id: I5447f243297fbfde7c364eb3919b00db239bd069
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
.zuul.yaml | 4 ++--
README.md | 2 +-
ci/build.sh | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/.zuul.yaml b/.zuul.yaml
index b41c490..7b92766 100644
--- a/.zuul.yaml
+++ b/.zuul.yaml
@@ -5,13 +5,13 @@
required-projects:
- name: github/CESNET/libyang
override-checkout: devel
- - name: github/onqtam/doctest
+ - name: github/doctest/doctest
override-checkout: v2.3.6
- f38-clang-asan-ubsan:
required-projects: &projects
- name: github/CESNET/libyang
override-checkout: devel
- - name: github/onqtam/doctest
+ - name: github/doctest/doctest
override-checkout: v2.4.11
- f38-clang-tsan:
required-projects: *projects
diff --git a/README.md b/README.md
index d76975b..8291e00 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ Object lifetimes are managed automatically via RAII.
- [libyang v3](https://github.com/CESNET/libyang) - the `devel` branch (even for the `master` branch of *libyang-cpp*)
- C++20 compiler (e.g., GCC 10.x+, clang 10+)
- CMake 3.19+
-- optionally for built-in tests, [Doctest](https://github.com/onqtam/doctest/) as a C++ unit test framework
+- optionally for built-in tests, [Doctest](https://github.com/doctest/doctest/) as a C++ unit test framework
- optionally for the docs, Doxygen
## Building
diff --git a/ci/build.sh b/ci/build.sh
index d06b646..0867f07 100755
--- a/ci/build.sh
+++ b/ci/build.sh
@@ -73,7 +73,7 @@ build_n_test() {
}
build_n_test github/CESNET/libyang -DENABLE_BUILD_TESTS=ON -DENABLE_VALGRIND_TESTS=OFF
-build_n_test github/onqtam/doctest -DDOCTEST_WITH_TESTS=OFF
+build_n_test github/doctest/doctest -DDOCTEST_WITH_TESTS=OFF
build_n_test ${ZUUL_PROJECT_NAME} -DBUILD_TESTING=ON
pushd ${BUILD_DIR}/${ZUUL_PROJECT_NAME}
--
2.43.0
+1 -1
View File
@@ -1,3 +1,3 @@
# Locally calculated
sha256 82e3758011ec44c78e98d0777799d6e12aec5b8a64b32ebb20d0fe50e32488bb LICENSE
sha256 43c84317ba13470f421a90bca74c062cf63a70e488d2e90c432b662c4638a14e libyang-cpp-v3.tar.gz
sha256 70fd0df940026fb930d5407abe679e064caf4701bff35d79465b9ad2c0915808 libyang-cpp-v4.tar.gz
+1 -1
View File
@@ -3,7 +3,7 @@
# CPP bindings for libyang
#
################################################################################
LIBYANG_CPP_VERSION = v3
LIBYANG_CPP_VERSION = v4
LIBYANG_CPP_SITE = $(call github,CESNET,libyang-cpp,$(LIBYANG_CPP_VERSION))
LIBYANG_CPP_LICENSE = BSD-3-Clause
LIBYANG_CPP_LICENSE_FILES = LICENSE
@@ -0,0 +1,64 @@
From a4136d889237dadb9253ea7eb668a525dd779e6d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Thu, 12 Jun 2025 10:33:42 +0100
Subject: [PATCH 01/17] Log HTTP headers and the input data payload
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
...so that we can debug the results of a nasty lockup that Michal
Hažlinský just found.
Change-Id: I2a930a02c7d30c051390fe73e6af9849edd580b4
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/restconf/Server.cpp | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/src/restconf/Server.cpp b/src/restconf/Server.cpp
index 272ecc1..9821495 100644
--- a/src/restconf/Server.cpp
+++ b/src/restconf/Server.cpp
@@ -44,6 +44,9 @@ void logRequest(const auto& request)
{
const auto& peer = http::peer_from_request(request);
spdlog::info("{}: {} {}", peer, request.method(), request.uri().raw_path);
+ for (const auto& hdr: request.header()) {
+ spdlog::trace("{}: header: {}: {}", peer, hdr.first, hdr.second.sensitive ? "<sensitive>"s : hdr.second.value);
+ }
}
template <typename T>
@@ -1079,12 +1082,14 @@ Server::Server(sysrepo::Connection conn, const std::string& address, const std::
auto requestCtx = std::make_shared<RequestContext>(req, res, dataFormat, sess, restconfRequest);
- req.on_data([requestCtx, restconfRequest /* intentional copy */, timeout](const uint8_t* data, std::size_t length) {
+ req.on_data([requestCtx, restconfRequest /* intentional copy */, timeout, peer=http::peer_from_request(req)](const uint8_t* data, std::size_t length) {
if (length > 0) { // there are still some data to be read
requestCtx->payload.append(reinterpret_cast<const char*>(data), length);
return;
}
+ spdlog::trace("{}: HTTP payload: {}", peer, requestCtx->payload);
+
if (restconfRequest.type == RestconfRequest::Type::CreateChildren) {
WITH_RESTCONF_EXCEPTIONS(processPost, rejectWithError)(requestCtx, timeout);
} else if (restconfRequest.type == RestconfRequest::Type::MergeData && isYangPatch(requestCtx->req)) {
@@ -1143,10 +1148,11 @@ Server::Server(sysrepo::Connection conn, const std::string& address, const std::
case RestconfRequest::Type::ExecuteInternal: {
auto requestCtx = std::make_shared<RequestContext>(req, res, dataFormat, sess, restconfRequest);
- req.on_data([requestCtx, timeout](const uint8_t* data, std::size_t length) {
+ req.on_data([requestCtx, timeout, peer=http::peer_from_request(req)](const uint8_t* data, std::size_t length) {
if (length > 0) {
requestCtx->payload.append(reinterpret_cast<const char*>(data), length);
} else {
+ spdlog::trace("{}: HTTP payload: {}", peer, requestCtx->payload);
WITH_RESTCONF_EXCEPTIONS(processActionOrRPC, rejectWithError)(requestCtx, timeout);
}
});
--
2.43.0
@@ -1,199 +0,0 @@
From 9622a68eba4aeaa60619b4c33d050ce91b27653d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Tue, 8 Oct 2024 12:22:49 +0200
Subject: [PATCH 01/44] restconf: report the list key values when they differ
between URI and data
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
For creating (leaf-)list instances with PUT and PATCH methods one has
to specify the list key values in the URI and in the data as well.
Those values must be the same. In case they are not, it is an error.
We reported that the values mismatch in case this happens, but the error
message did not report what the values were.
Knowing that might be beneficial when one is about to insert key values
that can be namespace qualified (like identityrefs) and that are
sometimes manipulated by libyang (e.g., when the identity belongs to the
same namespace as the list node, it is not necessary for it to be
namespace qualified by the client, but libyang adds the namespace
internally).
Change-Id: Ie0d42511bde01ab4c39d61370b6601f8808e40c5
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/restconf/Server.cpp | 29 +++++++++++++++++++++--------
tests/restconf-plain-patch.cpp | 2 +-
tests/restconf-writing.cpp | 14 +++++++-------
tests/restconf-yang-patch.cpp | 2 +-
4 files changed, 30 insertions(+), 17 deletions(-)
diff --git a/src/restconf/Server.cpp b/src/restconf/Server.cpp
index 53d6625..5f560ed 100644
--- a/src/restconf/Server.cpp
+++ b/src/restconf/Server.cpp
@@ -154,9 +154,22 @@ auto rejectYangPatch(const std::string& patchId, const std::string& editId)
};
}
+struct KeyMismatch {
+ libyang::DataNode offendingNode;
+ std::optional<std::string> uriKeyValue;
+
+ std::string message() const {
+ if (uriKeyValue) {
+ return "List key mismatch between URI path ('"s + *uriKeyValue + "') and data ('" + offendingNode.asTerm().valueStr() + "').";
+ } else {
+ return "List key mismatch (key missing in the data).";
+ }
+ }
+};
+
/** @brief In case node is a (leaf-)list check if the key values are the same as the keys specified in the lastPathSegment.
* @return The node where the mismatch occurs */
-std::optional<libyang::DataNode> checkKeysMismatch(const libyang::DataNode& node, const PathSegment& lastPathSegment)
+std::optional<KeyMismatch> checkKeysMismatch(const libyang::DataNode& node, const PathSegment& lastPathSegment)
{
if (node.schema().nodeType() == libyang::NodeType::List) {
const auto& listKeys = node.schema().asList().keys();
@@ -164,18 +177,18 @@ std::optional<libyang::DataNode> checkKeysMismatch(const libyang::DataNode& node
const auto& keyValueURI = lastPathSegment.keys[i];
auto keyNodeData = node.findPath(listKeys[i].module().name() + ':' + listKeys[i].name());
if (!keyNodeData) {
- return node;
+ return KeyMismatch{node, std::nullopt};
}
const auto& keyValueData = keyNodeData->asTerm().valueStr();
if (keyValueURI != keyValueData) {
- return keyNodeData;
+ return KeyMismatch{*keyNodeData, keyValueURI};
}
}
} else if (node.schema().nodeType() == libyang::NodeType::Leaflist) {
if (lastPathSegment.keys[0] != node.asTerm().valueStr()) {
- return node;
+ return KeyMismatch{node, lastPathSegment.keys[0]};
}
}
return std::nullopt;
@@ -350,8 +363,8 @@ libyang::CreatedNodes createEditForPutAndPatch(libyang::Context& ctx, const std:
if (isSameNode(child, lastPathSegment)) {
// 1) a single child that is created by parseSubtree(), its name is the same as `lastPathSegment`.
// It could be a list; then we need to check if the keys in provided data match the keys in URI.
- if (auto offendingNode = checkKeysMismatch(child, lastPathSegment)) {
- throw ErrorResponse(400, "protocol", "invalid-value", "List key mismatch between URI path and data.", offendingNode->path());
+ if (auto keyMismatch = checkKeysMismatch(child, lastPathSegment)) {
+ throw ErrorResponse(400, "protocol", "invalid-value", keyMismatch->message(), keyMismatch->offendingNode.path());
}
replacementNode = child;
} else if (isKeyNode(*node, child)) {
@@ -373,8 +386,8 @@ libyang::CreatedNodes createEditForPutAndPatch(libyang::Context& ctx, const std:
if (!isSameNode(*replacementNode, lastPathSegment)) {
throw ErrorResponse(400, "protocol", "invalid-value", "Data contains invalid node.", replacementNode->path());
}
- if (auto offendingNode = checkKeysMismatch(*parent, lastPathSegment)) {
- throw ErrorResponse(400, "protocol", "invalid-value", "List key mismatch between URI path and data.", offendingNode->path());
+ if (auto keyMismatch = checkKeysMismatch(*parent, lastPathSegment)) {
+ throw ErrorResponse(400, "protocol", "invalid-value", keyMismatch->message(), keyMismatch->offendingNode.path());
}
}
}
diff --git a/tests/restconf-plain-patch.cpp b/tests/restconf-plain-patch.cpp
index 10d653a..d4f3952 100644
--- a/tests/restconf-plain-patch.cpp
+++ b/tests/restconf-plain-patch.cpp
@@ -72,7 +72,7 @@ TEST_CASE("Plain patch")
"error-type": "protocol",
"error-tag": "invalid-value",
"error-path": "/example:tlc/list[name='blabla']/name",
- "error-message": "List key mismatch between URI path and data."
+ "error-message": "List key mismatch between URI path ('libyang') and data ('blabla')."
}
]
}
diff --git a/tests/restconf-writing.cpp b/tests/restconf-writing.cpp
index d46952f..96dbb25 100644
--- a/tests/restconf-writing.cpp
+++ b/tests/restconf-writing.cpp
@@ -432,7 +432,7 @@ TEST_CASE("writing data")
"error-type": "protocol",
"error-tag": "invalid-value",
"error-path": "/example:tlc/list[name='ahoj']/name",
- "error-message": "List key mismatch between URI path and data."
+ "error-message": "List key mismatch between URI path ('netconf') and data ('ahoj')."
}
]
}
@@ -447,7 +447,7 @@ TEST_CASE("writing data")
"error-type": "protocol",
"error-tag": "invalid-value",
"error-path": "/example:top-level-list[name='ahoj']/name",
- "error-message": "List key mismatch between URI path and data."
+ "error-message": "List key mismatch between URI path ('netconf') and data ('ahoj')."
}
]
}
@@ -505,7 +505,7 @@ TEST_CASE("writing data")
"error-type": "protocol",
"error-tag": "invalid-value",
"error-path": "/example:tlc/list[name='netconf']/collection[.='666']",
- "error-message": "List key mismatch between URI path and data."
+ "error-message": "List key mismatch between URI path ('667') and data ('666')."
}
]
}
@@ -520,7 +520,7 @@ TEST_CASE("writing data")
"error-type": "protocol",
"error-tag": "invalid-value",
"error-path": "/example:top-level-leaf-list[.='666']",
- "error-message": "List key mismatch between URI path and data."
+ "error-message": "List key mismatch between URI path ('667') and data ('666')."
}
]
}
@@ -535,7 +535,7 @@ TEST_CASE("writing data")
"error-type": "protocol",
"error-tag": "invalid-value",
"error-path": "/example:tlc/list[name='sysrepo']/name",
- "error-message": "List key mismatch between URI path and data."
+ "error-message": "List key mismatch between URI path ('netconf') and data ('sysrepo')."
}
]
}
@@ -550,7 +550,7 @@ TEST_CASE("writing data")
"error-type": "protocol",
"error-tag": "invalid-value",
"error-path": "/example:tlc/list[name='sysrepo']/name",
- "error-message": "List key mismatch between URI path and data."
+ "error-message": "List key mismatch between URI path ('netconf') and data ('sysrepo')."
}
]
}
@@ -565,7 +565,7 @@ TEST_CASE("writing data")
"error-type": "protocol",
"error-tag": "invalid-value",
"error-path": "/example:tlc/list[name='libyang']/collection[.='42']",
- "error-message": "List key mismatch between URI path and data."
+ "error-message": "List key mismatch between URI path ('5') and data ('42')."
}
]
}
diff --git a/tests/restconf-yang-patch.cpp b/tests/restconf-yang-patch.cpp
index 9d70912..7cc8946 100644
--- a/tests/restconf-yang-patch.cpp
+++ b/tests/restconf-yang-patch.cpp
@@ -436,7 +436,7 @@ TEST_CASE("YANG patch")
"error-type": "protocol",
"error-tag": "invalid-value",
"error-path": "/example:tlc/list[name='asdasdauisbdhaijbsdad']/name",
- "error-message": "List key mismatch between URI path and data."
+ "error-message": "List key mismatch between URI path ('libyang') and data ('asdasdauisbdhaijbsdad')."
}
]
}
--
2.43.0
@@ -0,0 +1,140 @@
From 6c5b482ea5c9fbc1149a0864b05d1bb1fa7100bf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Fri, 13 Jun 2025 10:47:55 +0200
Subject: [PATCH 02/17] restconf: prevent throwing exception in
withRestconfExceptions wrapper
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
In case the catch block of withRestconfExceptions is reached and an
exception is thrown, rousette will start behaving in a strange way
where no new connections are accepted and already connected clients are
not receiving the content [1].
This patch prevents throwing the exception that would slip through our
catch handlers.
An uncaught exception is a problem though, so expect a followup patch to
handle such situations.
[1] https://github.com/CESNET/rousette/issues/19
Bug: https://github.com/CESNET/rousette/issues/19
Change-Id: Ifbd74b9bdc0ca66c4e5449a7673ef2f12ae9215e
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/restconf/Server.cpp | 2 +-
tests/restconf-plain-patch.cpp | 24 ++++++++++++++++++++++++
tests/restconf-reading.cpp | 3 +++
tests/yang/example.yang | 19 +++++++++++++++++++
4 files changed, 47 insertions(+), 1 deletion(-)
diff --git a/src/restconf/Server.cpp b/src/restconf/Server.cpp
index 9821495..1c2123e 100644
--- a/src/restconf/Server.cpp
+++ b/src/restconf/Server.cpp
@@ -350,7 +350,7 @@ constexpr auto withRestconfExceptions(T func, U rejectWithError)
} else if (e.code() == sysrepo::ErrorCode::ItemAlreadyExists) {
rejectWithError(requestCtx->sess.getContext(), requestCtx->dataFormat.response, requestCtx->req, requestCtx->res, 409, "application", "resource-denied", "Resource already exists.", std::nullopt);
} else if (e.code() == sysrepo::ErrorCode::ValidationFailed) {
- bool isAction = requestCtx->sess.getContext().findPath(requestCtx->restconfRequest.path).nodeType() == libyang::NodeType::Action;
+ bool isAction = requestCtx->restconfRequest.path != "/" && requestCtx->sess.getContext().findPath(requestCtx->restconfRequest.path).nodeType() == libyang::NodeType::Action;
/*
* FIXME: This happens on invalid input data (e.g., missing mandatory nodes) or missing action data node.
* The former (invalid input data) should probably be validated by libyang's parseOp but it only parses.
diff --git a/tests/restconf-plain-patch.cpp b/tests/restconf-plain-patch.cpp
index b550f54..34813d1 100644
--- a/tests/restconf-plain-patch.cpp
+++ b/tests/restconf-plain-patch.cpp
@@ -179,5 +179,29 @@ TEST_CASE("Plain patch")
]
}
}
+)"});
+
+ REQUIRE(patch(RESTCONF_ROOT_DS("running"), {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({
+ "example:channel-plan": {
+ "channel": [
+ {
+ "name": "coriant",
+ "lower-frequency": 199999999,
+ "upper-frequency": 191500000
+ }
+ ]
+ }
+}
+)") == Response{400, jsonHeaders, R"({
+ "ietf-restconf:errors": {
+ "error": [
+ {
+ "error-type": "application",
+ "error-tag": "operation-failed",
+ "error-message": "Validation failed. Invalid input data."
+ }
+ ]
+ }
+}
)"});
}
diff --git a/tests/restconf-reading.cpp b/tests/restconf-reading.cpp
index f87c4f5..4f0d2ee 100644
--- a/tests/restconf-reading.cpp
+++ b/tests/restconf-reading.cpp
@@ -62,6 +62,7 @@ TEST_CASE("reading data")
// this relies on a NACM rule for anonymous access that filters out "a lot of stuff"
REQUIRE(get(RESTCONF_DATA_ROOT, {}) == Response{200, jsonHeaders, R"({
"example:top-level-leaf": "moo",
+ "example:channel-plan": {},
"example:tlc": {},
"example:a": {
"b": {
@@ -119,6 +120,7 @@ TEST_CASE("reading data")
REQUIRE(get(RESTCONF_ROOT_DS("operational"), {}) == Response{200, jsonHeaders, R"({
"example:top-level-leaf": "moo",
+ "example:channel-plan": {},
"example:tlc": {},
"example:a": {
"b": {
@@ -176,6 +178,7 @@ TEST_CASE("reading data")
REQUIRE(get(RESTCONF_ROOT_DS("running"), {}) == Response{200, jsonHeaders, R"({
"example:top-level-leaf": "moo",
+ "example:channel-plan": {},
"example:tlc": {},
"example:a": {
"b": {
diff --git a/tests/yang/example.yang b/tests/yang/example.yang
index 5d586a0..1cd12e3 100644
--- a/tests/yang/example.yang
+++ b/tests/yang/example.yang
@@ -27,6 +27,25 @@ module example {
}
leaf-list top-level-leaf-list { type int32; }
+ container channel-plan {
+ list channel {
+ key "name";
+ leaf name { type string; }
+ leaf lower-frequency {
+ type int32;
+ units "Hz";
+ }
+ leaf upper-frequency {
+ type int32;
+ units "Hz";
+ }
+
+ must "lower-frequency < upper-frequency" {
+ description "The lower frequency must be less than the upper frequency.";
+ }
+ }
+ }
+
container tlc {
if-feature f1;
list list {
--
2.43.0
@@ -1,50 +0,0 @@
From 070cffb48fda789910581930265d4624a7213e1b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Wed, 16 Oct 2024 11:02:45 +0200
Subject: [PATCH 02/44] uri: rename url-encoding to percent-encoding
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
RFC 3986 [1] calls it the percent-encoding, let's be consistent.
[1] https://datatracker.ietf.org/doc/html/rfc3986
Change-Id: Iee8b76c980b2694b6643e627b462f8bfc2c21c45
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/restconf/uri.cpp | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/restconf/uri.cpp b/src/restconf/uri.cpp
index 6e27168..b144d92 100644
--- a/src/restconf/uri.cpp
+++ b/src/restconf/uri.cpp
@@ -29,12 +29,12 @@ auto add = [](auto& ctx) {
char c = std::tolower(_attr(ctx));
_val(ctx) = _val(ctx) * 16 + (c >= 'a' ? c - 'a' + 10 : c - '0');
};
-const auto urlEncodedChar = x3::rule<class urlEncodedChar, unsigned>{"urlEncodedChar"} = x3::lit('%')[set_zero] >> x3::xdigit[add] >> x3::xdigit[add];
+const auto percentEncodedChar = x3::rule<class percentEncodedChar, unsigned>{"percentEncodedChar"} = x3::lit('%')[set_zero] >> x3::xdigit[add] >> x3::xdigit[add];
/* reserved characters according to RFC 3986, sec. 2.2 with '%' added. The '%' character is not specified as reserved but it effectively is because
* "Percent sign serves as the indicator for percent-encoded octets, it must be percent-encoded (...)" [RFC 3986, sec. 2.4]. */
const auto reservedChars = x3::lit(':') | '/' | '?' | '#' | '[' | ']' | '@' | '!' | '$' | '&' | '\'' | '(' | ')' | '*' | '+' | ',' | ',' | ';' | '=' | '%';
-const auto keyValue = x3::rule<class keyValue, std::string>{"keyValue"} = *(urlEncodedChar | (x3::char_ - reservedChars));
+const auto keyValue = x3::rule<class keyValue, std::string>{"keyValue"} = *(percentEncodedChar | (x3::char_ - reservedChars));
const auto keyList = x3::rule<class keyList, std::vector<std::string>>{"keyList"} = keyValue % ',';
const auto identifier = x3::rule<class apiIdentifier, std::string>{"identifier"} = (x3::alpha | x3::char_('_')) >> *(x3::alnum | x3::char_('_') | x3::char_('-') | x3::char_('.'));
@@ -117,7 +117,7 @@ const auto dateAndTime = x3::rule<class dateAndTime, std::string>{"dateAndTime"}
x3::repeat(4)[x3::digit] >> x3::char_('-') >> x3::repeat(2)[x3::digit] >> x3::char_('-') >> x3::repeat(2)[x3::digit] >> x3::char_('T') >>
x3::repeat(2)[x3::digit] >> x3::char_(':') >> x3::repeat(2)[x3::digit] >> x3::char_(':') >> x3::repeat(2)[x3::digit] >> -(x3::char_('.') >> +x3::digit) >>
(x3::char_('Z') | (-(x3::char_('+')|x3::char_('-')) >> x3::repeat(2)[x3::digit] >> x3::char_(':') >> x3::repeat(2)[x3::digit]));
-const auto filter = x3::rule<class filter, std::string>{"filter"} = +(urlEncodedChar | (x3::char_ - '&'));
+const auto filter = x3::rule<class filter, std::string>{"filter"} = +(percentEncodedChar | (x3::char_ - '&'));
const auto depthParam = x3::rule<class depthParam, queryParams::QueryParamValue>{"depthParam"} = x3::uint_[validDepthValues] | (x3::string("unbounded") >> x3::attr(queryParams::UnboundedDepth{}));
const auto queryParamPair = x3::rule<class queryParamPair, std::pair<std::string, queryParams::QueryParamValue>>{"queryParamPair"} =
(x3::string("depth") >> "=" >> depthParam) |
--
2.43.0
@@ -0,0 +1,31 @@
From 41c9d9cab47a88ee6c70ab8009b789226c0982fe Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Tue, 17 Jun 2025 12:46:27 +0200
Subject: [PATCH 03/17] CI: switch to the new cloud's Swift URL
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Change-Id: I69f8351394262a2a9b691422592741bfb40a8e38
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
ci/build.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/ci/build.sh b/ci/build.sh
index d49bd56..59b31ab 100755
--- a/ci/build.sh
+++ b/ci/build.sh
@@ -67,7 +67,7 @@ if [[ -z "${ARTIFACT_URL}" ]]; then
# nothing ahead in the pipeline -> fallback to the latest promoted artifact
DEPSRCDIR=$(jq < ~/zuul-env.json -e -r ".projects[] | select(.name == \"CzechLight/dependencies\").src_dir")
DEP_SUBMODULE_COMMIT=$(git --git-dir ${HOME}/${DEPSRCDIR}/.git rev-parse HEAD)
- ARTIFACT_URL="https://object-store.cloud.muni.cz/swift/v1/ci-artifacts-${ZUUL_TENANT}/${ZUUL_GERRIT_HOSTNAME}/CzechLight/dependencies/deps-${ZUUL_JOB_NAME_NO_PROJECT%%-cover?(-previous)}/${DEP_SUBMODULE_COMMIT}.tar.zst"
+ ARTIFACT_URL="https://object-store.brno.openstack.cloud.e-infra.cz/swift/v1/KEY_b637b9c937414b29b3e277b4a85cc658/ci-artifacts-${ZUUL_TENANT}/${ZUUL_GERRIT_HOSTNAME}/CzechLight/dependencies/deps-${ZUUL_JOB_NAME_NO_PROJECT%%-cover?(-previous)}/${DEP_SUBMODULE_COMMIT}.tar.zst"
fi
curl ${ARTIFACT_URL} | unzstd --stdout | tar -C ${PREFIX} -xf -
--
2.43.0
@@ -1,34 +0,0 @@
From 8a233202647f24538f2bbb8fff1e38d52e3599a4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Wed, 16 Oct 2024 11:05:09 +0200
Subject: [PATCH 03/44] uri: correct x3 rule class names
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Fixes: e06d5bf ("server: parser for data resources in URI paths")
Change-Id: Ic953e568d841032113ede1c0e896574361c0ebe2
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/restconf/uri.cpp | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/restconf/uri.cpp b/src/restconf/uri.cpp
index b144d92..8e8dc23 100644
--- a/src/restconf/uri.cpp
+++ b/src/restconf/uri.cpp
@@ -37,8 +37,8 @@ const auto reservedChars = x3::lit(':') | '/' | '?' | '#' | '[' | ']' | '@' | '!
const auto keyValue = x3::rule<class keyValue, std::string>{"keyValue"} = *(percentEncodedChar | (x3::char_ - reservedChars));
const auto keyList = x3::rule<class keyList, std::vector<std::string>>{"keyList"} = keyValue % ',';
-const auto identifier = x3::rule<class apiIdentifier, std::string>{"identifier"} = (x3::alpha | x3::char_('_')) >> *(x3::alnum | x3::char_('_') | x3::char_('-') | x3::char_('.'));
-const auto apiIdentifier = x3::rule<class identifier, ApiIdentifier>{"apiIdentifier"} = -(identifier >> ':') >> identifier;
+const auto identifier = x3::rule<class identifier, std::string>{"identifier"} = (x3::alpha | x3::char_('_')) >> *(x3::alnum | x3::char_('_') | x3::char_('-') | x3::char_('.'));
+const auto apiIdentifier = x3::rule<class apiIdentifier, ApiIdentifier>{"apiIdentifier"} = -(identifier >> ':') >> identifier;
const auto listInstance = x3::rule<class keyList, PathSegment>{"listInstance"} = apiIdentifier >> -('=' >> keyList);
const auto fullyQualifiedApiIdentifier = x3::rule<class identifier, ApiIdentifier>{"apiIdentifier"} = identifier >> ':' >> identifier;
const auto fullyQualifiedListInstance = x3::rule<class keyList, PathSegment>{"listInstance"} = fullyQualifiedApiIdentifier >> -('=' >> keyList);
--
2.43.0
@@ -0,0 +1,172 @@
From ec8673126929b6459fcd99c84a79993a725b40e1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Fri, 13 Jun 2025 10:47:55 +0200
Subject: [PATCH 04/17] restconf: crash instead of a deadlock when the handler
throws
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
In case there is an exception thrown in the handler function and it is
not caught by our code, rousette starts to behave in a strange way where
no new connections are accepted and already connected clients are not
receiving the content [1].
It turns out, that nghttp2-asio starts the boost::asio::io_service
wrapped inside std::async [2]. This means the thread terminated, and is
waiting for somebody to get the result of the future [3]. But nobody
did that in our code.
Luckily, the (hot-)fix proposed by Jan is quite simple. Rousette uses
only one io_service, so we can just call server.join() (which itself
calls future::get) instead of the pause() in the main function. If the
handler throws, we pick the result right away and rousette crashes.
(The crash is intentional and in line with the behaviour of our other
daemons in our systems. Daemon crashes, we log it and systemd restarts
it).
Note that to enable graceful shutdown, it was necessary to guard against
double call to server->join(), because future::get does not like being
called twice [3].
[1] https://github.com/CESNET/rousette/issues/19
[2] https://github.com/nghttp2/nghttp2-asio/blob/a057ffaa207fbe9584565ed503c2ee5c7e609747/lib/asio_io_service_pool.cc#L57
[3] https://en.cppreference.com/w/cpp/thread/future/get
Signed-off-by: Jan Kundrát <jan.kundrat@cesnet.cz>
Bug: https://github.com/CESNET/rousette/issues/19
Change-Id: I2c090b9a76b062101ba422a7d50e8e699779e203
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/restconf/Server.cpp | 28 ++++++++++++++++++++++++++++
src/restconf/Server.h | 4 ++++
src/restconf/main.cpp | 15 ++++++++++-----
3 files changed, 42 insertions(+), 5 deletions(-)
diff --git a/src/restconf/Server.cpp b/src/restconf/Server.cpp
index 1c2123e..4255540 100644
--- a/src/restconf/Server.cpp
+++ b/src/restconf/Server.cpp
@@ -826,6 +826,15 @@ bool isYangPatch(const nghttp2::asio_http2::server::request& req)
}
Server::~Server()
+{
+ stop();
+
+ if (!joined) {
+ server->join();
+ }
+}
+
+void Server::stop()
{
// notification to stop has to go through the asio io_context
for (const auto& service : server->io_services()) {
@@ -836,8 +845,25 @@ Server::~Server()
});
t.cancel();
}
+}
+
+void Server::join()
+{
+ /* FIXME: nghttp2-asio is calling io.run() wrapped in std::async.
+ * In case the handler function throws, the exception is not propagated to the main thread *until* someone calls server.join() which calls future.get() on all io.run() wrappers.
+ * Thankfully, we have only one thread, so we can just call join() right away. Underlying future.get() blocks until io.run() finishes, either gracefully or upon uncaught exception.
+ *
+ * !!! Will not work server uses multiple threads !!!
+ */
+ // main thread waits here
server->join();
+ joined = true;
+}
+
+std::vector<std::shared_ptr<boost::asio::io_context>> Server::io_services() const
+{
+ return server->io_services();
}
Server::Server(sysrepo::Connection conn, const std::string& address, const std::string& port, const std::chrono::milliseconds timeout)
@@ -846,6 +872,8 @@ Server::Server(sysrepo::Connection conn, const std::string& address, const std::
, server{std::make_unique<nghttp2::asio_http2::server::http2>()}
, dwdmEvents{std::make_unique<sr::OpticalEvents>(conn.sessionStart())}
{
+ server->num_threads(1); // we only use one thread for the server, so we can call join() right away
+
for (const auto& [module, version, features] : {
std::tuple<std::string, std::string, std::vector<std::string>>{"ietf-restconf", "2017-01-26", {}},
{"ietf-restconf-monitoring", "2017-01-26", {}},
diff --git a/src/restconf/Server.h b/src/restconf/Server.h
index d1b2187..ad53d4c 100644
--- a/src/restconf/Server.h
+++ b/src/restconf/Server.h
@@ -30,6 +30,9 @@ class Server {
public:
explicit Server(sysrepo::Connection conn, const std::string& address, const std::string& port, const std::chrono::milliseconds timeout = std::chrono::milliseconds{0});
~Server();
+ void join();
+ void stop();
+ std::vector<std::shared_ptr<boost::asio::io_context>> io_services() const;
private:
sysrepo::Session m_monitoringSession;
@@ -39,6 +42,7 @@ private:
std::unique_ptr<sr::OpticalEvents> dwdmEvents;
using JsonDiffSignal = boost::signals2::signal<void(const std::string& json)>;
JsonDiffSignal opticsChange;
+ bool joined = false; // true if the server has been joined, join twice is an error
};
}
}
diff --git a/src/restconf/main.cpp b/src/restconf/main.cpp
index 6029e08..477e846 100644
--- a/src/restconf/main.cpp
+++ b/src/restconf/main.cpp
@@ -5,13 +5,13 @@
*
*/
-#include <csignal>
#include <cstdio>
#include <cstdlib>
#include <inttypes.h>
#include "configure.cmake.h" /* Expose HAVE_SYSTEMD */
+#include <boost/asio.hpp>
#ifdef HAVE_SYSTEMD
#include <spdlog/sinks/systemd_sink.h>
#endif
@@ -19,7 +19,6 @@
#include <spdlog/sinks/ansicolor_sink.h>
#include <sys/types.h>
#include <sys/stat.h>
-#include <unistd.h>
#include <docopt.h>
#include <spdlog/spdlog.h>
#include <sysrepo-cpp/Session.hpp>
@@ -106,9 +105,15 @@ int main(int argc, char* argv [])
auto conn = sysrepo::Connection{};
auto server = rousette::restconf::Server{conn, "::1", "10080", timeout};
- signal(SIGTERM, [](int) {});
- signal(SIGINT, [](int) {});
- pause();
+
+ // allow graceful shutdown
+ boost::asio::signal_set signals(*server.io_services()[0], SIGTERM, SIGINT);
+ signals.async_wait([&](const boost::system::error_code& ec, int) {
+ if (!ec) {
+ server.stop();
+ }
+ });
+ server.join();
return 0;
}
--
2.43.0
@@ -1,153 +0,0 @@
From 96cbf730010ee9539d05d0d72697dc960b3a938c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Mon, 7 Oct 2024 20:46:24 +0200
Subject: [PATCH 04/44] restconf: parser should work on raw percent-encoded
paths
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
The difference between nghttp2's uri_ref::path and raw_path is that the
raw_path keeps the percent encoding, while the path converts the percent
encoded chars to their "normal" form.
Our parser expects some parts of the URI to be percent encoded so we
have to use raw_paths everywhere.
I thought about rewriting the parser not to expect percent encoded
characters but that would bring some complications. For instance when
querying lists, the RESTCONF RFC specifies that every key value is
percent encoded and individual key values are delimited by commas [1].
So, when somebody sends a request like /restconf/data/list=a%2Cb,c the
"normal" form is /restconf/data/list=a,b,c and in that case we obtain
three keys, but the client sent only two, where the first one contained
comma.
I am adding few tests to check for percent encoded values.
We realized this while working on one of the reported bugs [1]. The
query sent by the client there is wrong, the ':' char should be
percent-encoded.
[1] https://github.com/CESNET/rousette/issues/12
Bug: https://github.com/CESNET/rousette/issues/12
Change-Id: I473501cef3c8eae9af0c5d0751393cdad647e23c
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/restconf/Server.cpp | 8 ++++----
src/restconf/uri.cpp | 4 ++--
tests/restconf-reading.cpp | 15 +++++++++++++++
tests/uri-parser.cpp | 5 +++--
4 files changed, 24 insertions(+), 8 deletions(-)
diff --git a/src/restconf/Server.cpp b/src/restconf/Server.cpp
index 5f560ed..79d8ff6 100644
--- a/src/restconf/Server.cpp
+++ b/src/restconf/Server.cpp
@@ -416,7 +416,7 @@ void processActionOrRPC(std::shared_ptr<RequestContext> requestCtx, const std::c
* - The data node exists but might get deleted right after this check: Sysrepo throws an error when this happens.
* - The data node does not exist but might get created right after this check: The node was not there when the request was issues so it should not be a problem
*/
- auto [pathToParent, pathSegment] = asLibyangPathSplit(ctx, requestCtx->req.uri().path);
+ auto [pathToParent, pathSegment] = asLibyangPathSplit(ctx, requestCtx->req.uri().raw_path);
if (!requestCtx->sess.getData(pathToParent, 0, sysrepo::GetOptions::Default, timeout)) {
throw ErrorResponse(400, "application", "operation-failed", "Action data node '" + requestCtx->restconfRequest.path + "' does not exist.");
}
@@ -539,7 +539,7 @@ void processYangPatchEdit(const std::shared_ptr<RequestContext>& requestCtx, con
auto target = childLeafValue(editContainer, "target");
auto operation = childLeafValue(editContainer, "operation");
- auto [singleEdit, replacementNode] = createEditForPutAndPatch(ctx, requestCtx->req.uri().path + target, yangPatchValueAsJSON(editContainer), libyang::DataFormat::JSON);
+ auto [singleEdit, replacementNode] = createEditForPutAndPatch(ctx, requestCtx->req.uri().raw_path + target, yangPatchValueAsJSON(editContainer), libyang::DataFormat::JSON);
validateInputMetaAttributes(ctx, *singleEdit);
// insert and move are not defined in RFC6241. sec 7.3 and sysrepo does not support them directly
@@ -658,7 +658,7 @@ void processPutOrPlainPatch(std::shared_ptr<RequestContext> requestCtx, const st
throw ErrorResponse(400, "protocol", "invalid-value", "Target resource does not exist");
}
- auto [edit, replacementNode] = createEditForPutAndPatch(ctx, requestCtx->req.uri().path, requestCtx->payload, *requestCtx->dataFormat.request /* caller checks if the dataFormat.request is present */);
+ auto [edit, replacementNode] = createEditForPutAndPatch(ctx, requestCtx->req.uri().raw_path, requestCtx->payload, *requestCtx->dataFormat.request /* caller checks if the dataFormat.request is present */);
validateInputMetaAttributes(ctx, *edit);
if (requestCtx->req.method() == "PUT") {
@@ -954,7 +954,7 @@ Server::Server(sysrepo::Connection conn, const std::string& address, const std::
dataFormat = chooseDataEncoding(req.header());
authorizeRequest(nacm, sess, req);
- auto restconfRequest = asRestconfRequest(sess.getContext(), req.method(), req.uri().path, req.uri().raw_query);
+ auto restconfRequest = asRestconfRequest(sess.getContext(), req.method(), req.uri().raw_path, req.uri().raw_query);
switch (restconfRequest.type) {
case RestconfRequest::Type::RestconfRoot:
diff --git a/src/restconf/uri.cpp b/src/restconf/uri.cpp
index 8e8dc23..ac399b7 100644
--- a/src/restconf/uri.cpp
+++ b/src/restconf/uri.cpp
@@ -34,9 +34,9 @@ const auto percentEncodedChar = x3::rule<class percentEncodedChar, unsigned>{"pe
/* reserved characters according to RFC 3986, sec. 2.2 with '%' added. The '%' character is not specified as reserved but it effectively is because
* "Percent sign serves as the indicator for percent-encoded octets, it must be percent-encoded (...)" [RFC 3986, sec. 2.4]. */
const auto reservedChars = x3::lit(':') | '/' | '?' | '#' | '[' | ']' | '@' | '!' | '$' | '&' | '\'' | '(' | ')' | '*' | '+' | ',' | ',' | ';' | '=' | '%';
-const auto keyValue = x3::rule<class keyValue, std::string>{"keyValue"} = *(percentEncodedChar | (x3::char_ - reservedChars));
+const auto percentEncodedString = x3::rule<class percentEncodedString, std::string>{"percentEncodedString"} = *(percentEncodedChar | (x3::char_ - reservedChars));
-const auto keyList = x3::rule<class keyList, std::vector<std::string>>{"keyList"} = keyValue % ',';
+const auto keyList = x3::rule<class keyList, std::vector<std::string>>{"keyList"} = percentEncodedString % ',';
const auto identifier = x3::rule<class identifier, std::string>{"identifier"} = (x3::alpha | x3::char_('_')) >> *(x3::alnum | x3::char_('_') | x3::char_('-') | x3::char_('.'));
const auto apiIdentifier = x3::rule<class apiIdentifier, ApiIdentifier>{"apiIdentifier"} = -(identifier >> ':') >> identifier;
const auto listInstance = x3::rule<class keyList, PathSegment>{"listInstance"} = apiIdentifier >> -('=' >> keyList);
diff --git a/tests/restconf-reading.cpp b/tests/restconf-reading.cpp
index fa1cbcc..2898839 100644
--- a/tests/restconf-reading.cpp
+++ b/tests/restconf-reading.cpp
@@ -261,6 +261,21 @@ TEST_CASE("reading data")
}
)"});
+ // percent-encoded comma is a part of the key value, it is not a delimiter
+ REQUIRE(get(RESTCONF_DATA_ROOT "/ietf-system:system/radius/server=a%2Cb", {AUTH_DWDM}) == Response{404, jsonHeaders, R"({
+ "ietf-restconf:errors": {
+ "error": [
+ {
+ "error-type": "application",
+ "error-tag": "invalid-value",
+ "error-message": "No data from sysrepo."
+ }
+ ]
+ }
+}
+)"});
+
+ // comma is a delimiter of list key values
REQUIRE(get(RESTCONF_DATA_ROOT "/ietf-system:system/radius/server=a,b", {AUTH_DWDM}) == Response{400, jsonHeaders, R"({
"ietf-restconf:errors": {
"error": [
diff --git a/tests/uri-parser.cpp b/tests/uri-parser.cpp
index a748e09..5977afc 100644
--- a/tests/uri-parser.cpp
+++ b/tests/uri-parser.cpp
@@ -158,9 +158,9 @@ TEST_CASE("URI path parser")
{{"prefix", "lst"}, {"key1"}},
{{"prefix", "leaf"}},
}}},
- {"/restconf/data/foo:bar/lst=key1,,key3", {{
+ {"/restconf/data/foo:bar/lst=module%3Akey1,,key3", {{
{{"foo", "bar"}},
- {{"lst"}, {"key1", "", "key3"}},
+ {{"lst"}, {"module:key1", "", "key3"}},
}}},
{"/restconf/data/foo:bar/lst=key%2CWithCommas,,key2C", {{
{{"foo", "bar"}},
@@ -240,6 +240,7 @@ TEST_CASE("URI path parser")
"/restconf/data/foo:list=A%2",
"/restconf/data/foo:list=A%2,",
"/restconf/data/foo:bar/list1=%%",
+ "/restconf/data/foo:bar/list1=module:smth",
"/restconf/data/foo:bar/",
"/restconf/data/ foo : bar",
"/rest conf/data / foo:bar",
--
2.43.0
@@ -0,0 +1,29 @@
From 37ca95c387d76c3f296a4e44b211772a1ca155ab Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Wed, 18 Jun 2025 12:01:04 +0200
Subject: [PATCH 05/17] doc: let's stop calling this "an almost-RESTCONF
server"
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Change-Id: If55ace481c78d838a811ded76a564f8fb59f9233
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 0796dce..a42c64d 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# An almost-RESTCONF server
+# A RESTCONF server for sysrepo
![License](https://img.shields.io/github/license/cesnet/rousette)
[![Gerrit](https://img.shields.io/badge/patches-via%20Gerrit-blue)](https://gerrit.cesnet.cz/q/project:CzechLight/rousette)
--
2.43.0
@@ -1,428 +0,0 @@
From 8b13c1e4ccaa61a241674c27063439e257fa88de Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Wed, 2 Oct 2024 20:21:28 +0200
Subject: [PATCH 05/44] restconf: list key values checking must respect
libyang's canonicalization
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
While dealing with the issue that was fixed in the previous commit we
realized that the issue is deeper. Not only that our parser rejected
the input when someone used identityref with module prefix in the URI,
but also, our internal code for creating/modifying list entries was
wrong.
In PUT requests we have to check if user entered the same key value in
URI and yang-data payload. However, some values are canonicalized by
libyang (e.g. decimal64 type with fraction-digits=2 or even the
identityrefs) and so if the client entered two different but
canonically equivalent values, we would reject such request.
Bug: https://github.com/CESNET/rousette/issues/12
Change-Id: I44245d831e8de6d0e6f991fcd18319c095b49b1d
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
CMakeLists.txt | 3 +-
src/restconf/Server.cpp | 49 +++++++++++++++++----
src/restconf/utils/yang.cpp | 5 +++
src/restconf/utils/yang.h | 1 +
tests/restconf-reading.cpp | 59 +++++++++++++++++++++++++
tests/restconf-writing.cpp | 82 +++++++++++++++++++++++++++++++++++
tests/uri-parser.cpp | 2 +
tests/yang/example-types.yang | 13 ++++++
tests/yang/example.yang | 25 +++++++++++
9 files changed, 229 insertions(+), 10 deletions(-)
create mode 100644 tests/yang/example-types.yang
diff --git a/CMakeLists.txt b/CMakeLists.txt
index c563dcf..465bef9 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -204,7 +204,8 @@ if(BUILD_TESTING)
--install ${CMAKE_CURRENT_SOURCE_DIR}/tests/yang/example.yang --enable-feature f1
--install ${CMAKE_CURRENT_SOURCE_DIR}/tests/yang/example-delete.yang
--install ${CMAKE_CURRENT_SOURCE_DIR}/tests/yang/example-augment.yang
- --install ${CMAKE_CURRENT_SOURCE_DIR}/tests/yang/example-notif.yang)
+ --install ${CMAKE_CURRENT_SOURCE_DIR}/tests/yang/example-notif.yang
+ --install ${CMAKE_CURRENT_SOURCE_DIR}/tests/yang/example-types.yang)
rousette_test(NAME restconf-reading LIBRARIES rousette-restconf FIXTURE common-models WRAP_PAM)
rousette_test(NAME restconf-writing LIBRARIES rousette-restconf FIXTURE common-models WRAP_PAM)
rousette_test(NAME restconf-delete LIBRARIES rousette-restconf FIXTURE common-models WRAP_PAM)
diff --git a/src/restconf/Server.cpp b/src/restconf/Server.cpp
index 79d8ff6..b515d66 100644
--- a/src/restconf/Server.cpp
+++ b/src/restconf/Server.cpp
@@ -154,6 +154,15 @@ auto rejectYangPatch(const std::string& patchId, const std::string& editId)
};
}
+/** @brief Check if these two paths compare the same after path canonicalization */
+bool compareKeyValue(const libyang::Context& ctx, const std::string& pathA, const std::string& pathB)
+{
+ auto [parentA, nodeA] = ctx.newPath2(pathA, std::nullopt);
+ auto [parentB, nodeB] = ctx.newPath2(pathB, std::nullopt);
+
+ return nodeA->asTerm().valueStr() == nodeB->asTerm().valueStr();
+}
+
struct KeyMismatch {
libyang::DataNode offendingNode;
std::optional<std::string> uriKeyValue;
@@ -169,25 +178,46 @@ struct KeyMismatch {
/** @brief In case node is a (leaf-)list check if the key values are the same as the keys specified in the lastPathSegment.
* @return The node where the mismatch occurs */
-std::optional<KeyMismatch> checkKeysMismatch(const libyang::DataNode& node, const PathSegment& lastPathSegment)
+std::optional<KeyMismatch> checkKeysMismatch(libyang::Context& ctx, const libyang::DataNode& node, const std::string& lyParentPath, const PathSegment& lastPathSegment)
{
+ const auto pathPrefix = (lyParentPath.empty() ? "" : lyParentPath) + "/" + lastPathSegment.apiIdent.name();
+
if (node.schema().nodeType() == libyang::NodeType::List) {
const auto& listKeys = node.schema().asList().keys();
for (size_t i = 0; i < listKeys.size(); ++i) {
- const auto& keyValueURI = lastPathSegment.keys[i];
auto keyNodeData = node.findPath(listKeys[i].module().name() + ':' + listKeys[i].name());
if (!keyNodeData) {
return KeyMismatch{node, std::nullopt};
}
- const auto& keyValueData = keyNodeData->asTerm().valueStr();
-
- if (keyValueURI != keyValueData) {
- return KeyMismatch{*keyNodeData, keyValueURI};
+ /*
+ * If the key's value has a canonical form then libyang makes the value canonical
+ * but there is no guarantee that the client provided the value in the canonical form.
+ *
+ * Let libyang do the work. Create two data nodes, one with the key value from the data and the other
+ * with the key value from the URI. Then compare the values from the two nodes. If they are different,
+ * they certainly mismatch.
+ *
+ * This can happen in cases like
+ * * The key's type is identityref and the client provided the key value as a string without the module name. Libyang will canonicalize the value by adding the module name.
+ * * The key's type is decimal64 with fractional-digits 2; then the client can provide the value as 1.0 or 1.00 and they should be the same. Libyang will canonicalize the value.
+ */
+
+ auto keysWithValueFromData = lastPathSegment.keys;
+ keysWithValueFromData[i] = keyNodeData->asTerm().valueStr();
+
+ const auto suffix = "/" + listKeys[i].name();
+ const auto pathFromData = pathPrefix + listKeyPredicate(listKeys, keysWithValueFromData) + suffix;
+ const auto pathFromURI = pathPrefix + listKeyPredicate(listKeys, lastPathSegment.keys) + suffix;
+
+ if (!compareKeyValue(ctx, pathFromData, pathFromURI)) {
+ return KeyMismatch{*keyNodeData, lastPathSegment.keys[i]};
}
}
} else if (node.schema().nodeType() == libyang::NodeType::Leaflist) {
- if (lastPathSegment.keys[0] != node.asTerm().valueStr()) {
+ const auto pathFromData = pathPrefix + leaflistKeyPredicate(node.asTerm().valueStr());
+ const auto pathFromURI = pathPrefix + leaflistKeyPredicate(lastPathSegment.keys[0]);
+ if (!compareKeyValue(ctx, pathFromData, pathFromURI)) {
return KeyMismatch{node, lastPathSegment.keys[0]};
}
}
@@ -363,7 +393,7 @@ libyang::CreatedNodes createEditForPutAndPatch(libyang::Context& ctx, const std:
if (isSameNode(child, lastPathSegment)) {
// 1) a single child that is created by parseSubtree(), its name is the same as `lastPathSegment`.
// It could be a list; then we need to check if the keys in provided data match the keys in URI.
- if (auto keyMismatch = checkKeysMismatch(child, lastPathSegment)) {
+ if (auto keyMismatch = checkKeysMismatch(ctx, child, lyParentPath, lastPathSegment)) {
throw ErrorResponse(400, "protocol", "invalid-value", keyMismatch->message(), keyMismatch->offendingNode.path());
}
replacementNode = child;
@@ -386,7 +416,8 @@ libyang::CreatedNodes createEditForPutAndPatch(libyang::Context& ctx, const std:
if (!isSameNode(*replacementNode, lastPathSegment)) {
throw ErrorResponse(400, "protocol", "invalid-value", "Data contains invalid node.", replacementNode->path());
}
- if (auto keyMismatch = checkKeysMismatch(*parent, lastPathSegment)) {
+
+ if (auto keyMismatch = checkKeysMismatch(ctx, *parent, lyParentPath, lastPathSegment)) {
throw ErrorResponse(400, "protocol", "invalid-value", keyMismatch->message(), keyMismatch->offendingNode.path());
}
}
diff --git a/src/restconf/utils/yang.cpp b/src/restconf/utils/yang.cpp
index 15cceb6..4c4d619 100644
--- a/src/restconf/utils/yang.cpp
+++ b/src/restconf/utils/yang.cpp
@@ -50,6 +50,11 @@ std::string listKeyPredicate(const std::vector<libyang::Leaf>& listKeyLeafs, con
return res;
}
+std::string leaflistKeyPredicate(const std::string& keyValue)
+{
+ return "[.=" + escapeListKey(keyValue) + ']';
+}
+
bool isUserOrderedList(const libyang::DataNode& node)
{
if (node.schema().nodeType() == libyang::NodeType::List) {
diff --git a/src/restconf/utils/yang.h b/src/restconf/utils/yang.h
index 677d049..e91ba8a 100644
--- a/src/restconf/utils/yang.h
+++ b/src/restconf/utils/yang.h
@@ -16,6 +16,7 @@ namespace rousette::restconf {
std::string escapeListKey(const std::string& str);
std::string listKeyPredicate(const std::vector<libyang::Leaf>& listKeyLeafs, const std::vector<std::string>& keyValues);
+std::string leaflistKeyPredicate(const std::string& keyValue);
bool isUserOrderedList(const libyang::DataNode& node);
bool isKeyNode(const libyang::DataNode& maybeList, const libyang::DataNode& node);
}
diff --git a/tests/restconf-reading.cpp b/tests/restconf-reading.cpp
index 2898839..96c38ab 100644
--- a/tests/restconf-reading.cpp
+++ b/tests/restconf-reading.cpp
@@ -287,6 +287,65 @@ TEST_CASE("reading data")
]
}
}
+)"});
+
+ srSess.setItem("/example:list-with-identity-key[type='example:derived-identity'][name='name']", std::nullopt);
+ srSess.setItem("/example:list-with-identity-key[type='example-types:another-derived-identity'][name='name']", std::nullopt);
+ srSess.setItem("/example:tlc/decimal-list[.='1.00']", std::nullopt);
+ srSess.applyChanges();
+
+ // dealing with keys which can have prefixes (YANG identities)
+ REQUIRE(get(RESTCONF_DATA_ROOT "/example:list-with-identity-key=derived-identity,name", {AUTH_ROOT}) == Response{200, jsonHeaders, R"({
+ "example:list-with-identity-key": [
+ {
+ "type": "derived-identity",
+ "name": "name"
+ }
+ ]
+}
+)"});
+ REQUIRE(get(RESTCONF_DATA_ROOT "/example:list-with-identity-key=example%3Aderived-identity,name", {AUTH_ROOT}) == Response{200, jsonHeaders, R"({
+ "example:list-with-identity-key": [
+ {
+ "type": "derived-identity",
+ "name": "name"
+ }
+ ]
+}
+)"});
+
+ // an identity from another module must be namespace-qualified
+ REQUIRE(get(RESTCONF_DATA_ROOT "/example:list-with-identity-key=another-derived-identity,name", {AUTH_ROOT}) == Response{404, jsonHeaders, R"({
+ "ietf-restconf:errors": {
+ "error": [
+ {
+ "error-type": "application",
+ "error-tag": "invalid-value",
+ "error-message": "No data from sysrepo."
+ }
+ ]
+ }
+}
+)"});
+
+ REQUIRE(get(RESTCONF_DATA_ROOT "/example:list-with-identity-key=example-types%3Aanother-derived-identity,name", {AUTH_ROOT}) == Response{200, jsonHeaders, R"({
+ "example:list-with-identity-key": [
+ {
+ "type": "example-types:another-derived-identity",
+ "name": "name"
+ }
+ ]
+}
+)"});
+
+ // test canonicalization of list key values; the key value was inserted as "1.00"
+ REQUIRE(get(RESTCONF_DATA_ROOT "/example:tlc/decimal-list=1", {AUTH_ROOT}) == Response{200, jsonHeaders, R"({
+ "example:tlc": {
+ "decimal-list": [
+ "1.0"
+ ]
+ }
+}
)"});
}
diff --git a/tests/restconf-writing.cpp b/tests/restconf-writing.cpp
index 96dbb25..8418554 100644
--- a/tests/restconf-writing.cpp
+++ b/tests/restconf-writing.cpp
@@ -389,6 +389,88 @@ TEST_CASE("writing data")
REQUIRE(put(RESTCONF_DATA_ROOT "/example:tlc/list=libyang/nested=11,12,13", {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({"example:nested": [{"first": "11", "second": 12, "third": "13"}]}]})") == Response{201, noContentTypeHeaders, ""});
}
+ SECTION("Test canonicalization of keys")
+ {
+ EXPECT_CHANGE(
+ CREATED("/example:list-with-identity-key[type='example:derived-identity'][name='name']", std::nullopt),
+ CREATED("/example:list-with-identity-key[type='example:derived-identity'][name='name']/type", "example:derived-identity"),
+ CREATED("/example:list-with-identity-key[type='example:derived-identity'][name='name']/name", "name"),
+ CREATED("/example:list-with-identity-key[type='example:derived-identity'][name='name']/text", "blabla"));
+ REQUIRE(put(RESTCONF_DATA_ROOT "/example:list-with-identity-key=derived-identity,name", {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({"example:list-with-identity-key": [{"name": "name", "type": "derived-identity", "text": "blabla"}]}]})") == Response{201, noContentTypeHeaders, ""});
+
+ // prefixed in the URI, not prefixed in the data
+ EXPECT_CHANGE(
+ MODIFIED("/example:list-with-identity-key[type='example:derived-identity'][name='name']/text", "hehe"));
+ REQUIRE(put(RESTCONF_DATA_ROOT "/example:list-with-identity-key=example%3Aderived-identity,name", {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({"example:list-with-identity-key": [{"name": "name", "type": "derived-identity", "text": "hehe"}]}]})") == Response{204, noContentTypeHeaders, ""});
+
+
+ REQUIRE(put(RESTCONF_DATA_ROOT "/example:list-with-identity-key=another-derived-identity,name", {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({"example:list-with-identity-key": [{"name": "name", "type": "another-derived-identity", "text": "blabla"}]}]})") == Response{400, jsonHeaders, R"({
+ "ietf-restconf:errors": {
+ "error": [
+ {
+ "error-type": "protocol",
+ "error-tag": "invalid-value",
+ "error-message": "Validation failure: Can't parse data: LY_EVALID"
+ }
+ ]
+ }
+}
+)"});
+
+ EXPECT_CHANGE(
+ CREATED("/example:list-with-identity-key[type='example-types:another-derived-identity'][name='name']", std::nullopt),
+ CREATED("/example:list-with-identity-key[type='example-types:another-derived-identity'][name='name']/type", "example-types:another-derived-identity"),
+ CREATED("/example:list-with-identity-key[type='example-types:another-derived-identity'][name='name']/name", "name"),
+ CREATED("/example:list-with-identity-key[type='example-types:another-derived-identity'][name='name']/text", "blabla"));
+ REQUIRE(put(RESTCONF_DATA_ROOT "/example:list-with-identity-key=example-types%3Aanother-derived-identity,name", {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({"example:list-with-identity-key": [{"name": "name", "type": "example-types:another-derived-identity", "text": "blabla"}]}]})") == Response{201, noContentTypeHeaders, ""});
+
+ // missing namespace in the data
+ REQUIRE(put(RESTCONF_DATA_ROOT "/example:list-with-identity-key=example-types%3Aanother-derived-identity,name", {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({"example:list-with-identity-key": [{"name": "name", "type": "another-derived-identity", "text": "blabla"}]}]})") == Response{400, jsonHeaders, R"({
+ "ietf-restconf:errors": {
+ "error": [
+ {
+ "error-type": "protocol",
+ "error-tag": "invalid-value",
+ "error-message": "Validation failure: Can't parse data: LY_EVALID"
+ }
+ ]
+ }
+}
+)"});
+
+ EXPECT_CHANGE(CREATED("/example:leaf-list-with-identity-key[.='example-types:another-derived-identity']", "example-types:another-derived-identity"));
+ REQUIRE(put(RESTCONF_DATA_ROOT "/example:leaf-list-with-identity-key=example-types%3Aanother-derived-identity", {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({"example:leaf-list-with-identity-key": ["example-types:another-derived-identity"]})") == Response{201, noContentTypeHeaders, ""});
+
+ // missing namespace in the URI
+ EXPECT_CHANGE(CREATED("/example:leaf-list-with-identity-key[.='example:derived-identity']", "example:derived-identity"));
+ REQUIRE(put(RESTCONF_DATA_ROOT "/example:leaf-list-with-identity-key=derived-identity", {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({"example:leaf-list-with-identity-key": ["example:derived-identity"]})") == Response{201, noContentTypeHeaders, ""});
+
+ REQUIRE(put(RESTCONF_DATA_ROOT "/example:leaf-list-with-identity-key=example-types%3Aanother-derived-identity", {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({"example:leaf-list-with-identity-key": ["example:derived-identity"]})") == Response{400, jsonHeaders, R"({
+ "ietf-restconf:errors": {
+ "error": [
+ {
+ "error-type": "protocol",
+ "error-tag": "invalid-value",
+ "error-path": "/example:leaf-list-with-identity-key[.='example:derived-identity']",
+ "error-message": "List key mismatch between URI path ('example-types:another-derived-identity') and data ('example:derived-identity')."
+ }
+ ]
+ }
+}
+)"});
+
+ // value in the URI and in the data have the same canonical form
+ EXPECT_CHANGE(CREATED("/example:tlc/decimal-list[.='1.0']", "1.0"));
+ REQUIRE(put(RESTCONF_DATA_ROOT "/example:tlc/decimal-list=1.00", {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({"example:decimal-list": ["1.0"]})") == Response{201, noContentTypeHeaders, ""});
+
+ // nothing is changed, still the same value
+ REQUIRE(put(RESTCONF_DATA_ROOT "/example:tlc/decimal-list=1.000", {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({"example:decimal-list": ["1"]})") == Response{204, noContentTypeHeaders, ""});
+
+ // different value
+ EXPECT_CHANGE(CREATED("/example:tlc/decimal-list[.='1.01']", "1.01"));
+ REQUIRE(put(RESTCONF_DATA_ROOT "/example:tlc/decimal-list=1.010", {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({"example:decimal-list": ["1.01"]})") == Response{201, noContentTypeHeaders, ""});
+ }
+
SECTION("Modify a leaf in a list entry")
{
EXPECT_CHANGE(MODIFIED("/example:tlc/list[name='libyang']/choice1", "restconf"));
diff --git a/tests/uri-parser.cpp b/tests/uri-parser.cpp
index 5977afc..7320c3c 100644
--- a/tests/uri-parser.cpp
+++ b/tests/uri-parser.cpp
@@ -293,6 +293,7 @@ TEST_CASE("URI path parser")
{"/restconf/data/example:tlc/list=eth0/choice1", "/example:tlc/list[name='eth0']/choice1", std::nullopt},
{"/restconf/data/example:tlc/list=eth0/choice2", "/example:tlc/list[name='eth0']/choice2", std::nullopt},
{"/restconf/data/example:tlc/list=eth0/collection=val", "/example:tlc/list[name='eth0']/collection[.='val']", std::nullopt},
+ {"/restconf/data/example:list-with-identity-key=example-types%3Aanother-derived-identity,aaa", "/example:list-with-identity-key[type='example-types:another-derived-identity'][name='aaa']", std::nullopt},
{"/restconf/data/example:tlc/status", "/example:tlc/status", std::nullopt},
// container example:a has a container b inserted locally and also via an augment. Check that we return the correct one
{"/restconf/data/example:a/b", "/example:a/b", std::nullopt},
@@ -327,6 +328,7 @@ TEST_CASE("URI path parser")
{"/restconf/data/example:tlc/status", "/example:tlc", {{"example", "status"}}},
{"/restconf/data/example:a/example-augment:b/c", "/example:a/example-augment:b", {{"example-augment", "c"}}},
{"/restconf/ds/ietf-datastores:startup/example:a/example-augment:b/c", "/example:a/example-augment:b", {{"example-augment", "c"}}},
+ {"/restconf/data/example:list-with-identity-key=example-types%3Aanother-derived-identity,aaa", "", {{"example", "list-with-identity-key"}, {"example-types:another-derived-identity", "aaa"}}},
}) {
CAPTURE(httpMethod);
CAPTURE(expectedRequestType);
diff --git a/tests/yang/example-types.yang b/tests/yang/example-types.yang
new file mode 100644
index 0000000..5bc2fb0
--- /dev/null
+++ b/tests/yang/example-types.yang
@@ -0,0 +1,13 @@
+module example-types {
+ yang-version 1.1;
+ namespace "http://example.tld/example-types";
+ prefix ex-types;
+
+ import example {
+ prefix ex;
+ }
+
+ identity another-derived-identity {
+ base ex:base-identity;
+ }
+}
diff --git a/tests/yang/example.yang b/tests/yang/example.yang
index df1301f..c46273c 100644
--- a/tests/yang/example.yang
+++ b/tests/yang/example.yang
@@ -6,6 +6,13 @@ module example {
feature f1 { }
feature f2 { }
+ identity base-identity {
+ }
+
+ identity derived-identity {
+ base base-identity;
+ }
+
leaf top-level-leaf { type string; }
leaf top-level-leaf2 { type string; default "x"; }
@@ -50,6 +57,11 @@ module example {
config false;
leaf name { type string; }
}
+ leaf-list decimal-list {
+ type decimal64 {
+ fraction-digits 2;
+ }
+ }
leaf status {
type enumeration {
enum on { }
@@ -109,6 +121,19 @@ module example {
}
}
+ list list-with-identity-key {
+ key "type name";
+ leaf type {
+ type identityref { base base-identity; }
+ }
+ leaf name { type string; }
+ leaf text { type string; }
+ }
+
+ leaf-list leaf-list-with-identity-key {
+ type identityref { base base-identity; }
+ }
+
rpc test-rpc {
input {
leaf i {
--
2.43.0
@@ -0,0 +1,377 @@
From 4eae6200aa812950ebbac1660a1899f4edf41e11 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Wed, 7 Aug 2024 19:07:35 +0200
Subject: [PATCH 06/17] close long-lived connections on SIGTERM
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Without this patch, all the ongoing SSE streams would be left alive for
about 50s, and only then killed. Fix that by a proper signalling -- once
the intention to shutdown is known, all the EventStreams are
(asynchronously) switched to a new state, and the HTTP2 state machine is
restarted. Once the event loop gets around to processing them, the
callback will just say EOF.
The clock demo is different because it doesn't wrap the nghttp2 server
like the restconf/Server.cpp does.
I have no idea how well this works against a Slowloris attack, but I
think that stuff like that should be solved at the HTTP library level (I
hope it is).
Change-Id: If442134783ba1d699de47c51a9068378f53e8339
Co-authored-by: Tomas Pecka <tomas.pecka@cesnet.cz>
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
CMakeLists.txt | 1 +
src/clock.cpp | 7 +--
src/http/EventStream.cpp | 30 +++++++++++--
src/http/EventStream.h | 10 +++--
src/restconf/NotificationStream.cpp | 7 +--
src/restconf/NotificationStream.h | 5 ++-
src/restconf/Server.cpp | 5 ++-
src/restconf/Server.h | 1 +
tests/restconf-eventstream.cpp | 70 +++++++++++++++++++++++++++++
tests/restconf_utils.cpp | 6 +++
10 files changed, 124 insertions(+), 18 deletions(-)
create mode 100644 tests/restconf-eventstream.cpp
diff --git a/CMakeLists.txt b/CMakeLists.txt
index b99c577..c0304af 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -227,6 +227,7 @@ if(BUILD_TESTING)
rousette_test(NAME restconf-notifications LIBRARIES rousette-restconf FIXTURE common-models WRAP_PAM)
rousette_test(NAME restconf-plain-patch LIBRARIES rousette-restconf FIXTURE common-models WRAP_PAM)
rousette_test(NAME restconf-yang-patch LIBRARIES rousette-restconf FIXTURE common-models WRAP_PAM)
+ rousette_test(NAME restconf-eventstream LIBRARIES rousette-restconf FIXTURE common-models WRAP_PAM)
set(nested-models
${common-models}
--install ${CMAKE_CURRENT_SOURCE_DIR}/tests/yang/root-mod.yang)
diff --git a/src/clock.cpp b/src/clock.cpp
index 7ac6c3e..c85be19 100644
--- a/src/clock.cpp
+++ b/src/clock.cpp
@@ -18,7 +18,8 @@ int main(int argc [[maybe_unused]], char** argv [[maybe_unused]])
{
spdlog::set_level(spdlog::level::trace);
- rousette::http::EventStream::Signal sig;
+ rousette::http::EventStream::Termination shutdown;
+ rousette::http::EventStream::EventSignal sig;
std::jthread timer{[&sig]() {
for (int i = 0; /* forever */; ++i) {
std::this_thread::sleep_for(666ms);
@@ -30,8 +31,8 @@ int main(int argc [[maybe_unused]], char** argv [[maybe_unused]])
nghttp2::asio_http2::server::http2 server;
server.num_threads(4);
- server.handle("/events", [&sig](const auto& req, const auto& res) {
- auto client = std::make_shared<rousette::http::EventStream>(req, res, sig);
+ server.handle("/events", [&shutdown, &sig](const auto& req, const auto& res) {
+ auto client = std::make_shared<rousette::http::EventStream>(req, res, shutdown, sig);
client->activate();
});
diff --git a/src/http/EventStream.cpp b/src/http/EventStream.cpp
index 25dbf3f..77c282d 100644
--- a/src/http/EventStream.cpp
+++ b/src/http/EventStream.cpp
@@ -18,7 +18,7 @@ using namespace nghttp2::asio_http2;
namespace rousette::http {
/** @short After constructing, make sure to call activate() immediately. */
-EventStream::EventStream(const server::request& req, const server::response& res, Signal& signal, const std::optional<std::string>& initialEvent)
+EventStream::EventStream(const server::request& req, const server::response& res, Termination& termination, EventSignal& signal, const std::optional<std::string>& initialEvent)
: res{res}
, peer{peer_from_request(req)}
{
@@ -26,9 +26,27 @@ EventStream::EventStream(const server::request& req, const server::response& res
enqueue(*initialEvent);
}
- subscription = signal.connect([this](const auto& msg) {
+ eventSub = signal.connect([this](const auto& msg) {
enqueue(msg);
});
+
+ terminateSub = termination.connect([this]() {
+ spdlog::trace("{}: will terminate", peer);
+ std::lock_guard lock{mtx};
+ if (state == Closed) { // we are late to the party, res is already gone
+ return;
+ }
+
+ state = WantToClose;
+ boost::asio::post(this->res.io_service(), [maybeClient = std::weak_ptr<EventStream>{shared_from_this()}]() {
+ if (auto client = maybeClient.lock()) {
+ std::lock_guard lock{client->mtx};
+ if (client->state == WantToClose) { // resume unless somebody closed it before this was picked up by the event loop
+ client->res.resume();
+ }
+ }
+ });
+ });
}
/** @short Start event processing and data delivery
@@ -47,7 +65,8 @@ void EventStream::activate()
res.on_close([client](const auto ec) {
spdlog::debug("{}: closed ({})", client->peer, nghttp2_http2_strerror(ec));
std::lock_guard lock{client->mtx};
- client->subscription.disconnect();
+ client->eventSub.disconnect();
+ client->terminateSub.disconnect();
client->state = Closed;
});
@@ -85,6 +104,9 @@ ssize_t EventStream::process(uint8_t* destination, std::size_t len, uint32_t* da
case WaitingForEvents:
spdlog::trace("{}: sleeping", peer);
return NGHTTP2_ERR_DEFERRED;
+ case WantToClose:
+ *data_flags |= NGHTTP2_DATA_FLAG_EOF;
+ return 0;
case Closed:
throw std::logic_error{"response already closed"};
}
@@ -105,7 +127,7 @@ void EventStream::enqueue(const std::string& what)
buf += '\n';
std::lock_guard lock{mtx};
- if (state == Closed) {
+ if (state == Closed || state == WantToClose) {
spdlog::trace("{}: enqueue: already disconnected", peer);
return;
}
diff --git a/src/http/EventStream.h b/src/http/EventStream.h
index 735b69a..b427fb3 100644
--- a/src/http/EventStream.h
+++ b/src/http/EventStream.h
@@ -23,13 +23,14 @@ namespace rousette::http {
/** @short Event delivery via text/event-stream
-Recieve data from a Signal, and deliver them to an HTTP client via a text/event-stream streamed response.
+Recieve data from an EventSignal, and deliver them to an HTTP client via a text/event-stream streamed response.
*/
class EventStream : public std::enable_shared_from_this<EventStream> {
public:
- using Signal = boost::signals2::signal<void(const std::string& message)>;
+ using EventSignal = boost::signals2::signal<void(const std::string& message)>;
+ using Termination = boost::signals2::signal<void()>;
- EventStream(const nghttp2::asio_http2::server::request& req, const nghttp2::asio_http2::server::response& res, Signal& signal, const std::optional<std::string>& initialEvent = std::nullopt);
+ EventStream(const nghttp2::asio_http2::server::request& req, const nghttp2::asio_http2::server::response& res, Termination& terminate, EventSignal& signal, const std::optional<std::string>& initialEvent = std::nullopt);
void activate();
private:
@@ -37,13 +38,14 @@ private:
enum State {
HasEvents,
WaitingForEvents,
+ WantToClose,
Closed,
};
State state = WaitingForEvents;
std::list<std::string> queue;
mutable std::mutex mtx; // for `state` and `queue`
- boost::signals2::scoped_connection subscription;
+ boost::signals2::scoped_connection eventSub, terminateSub;
const std::string peer;
size_t send_chunk(uint8_t* destination, std::size_t len, uint32_t* data_flags);
diff --git a/src/restconf/NotificationStream.cpp b/src/restconf/NotificationStream.cpp
index e00c39d..d0ba938 100644
--- a/src/restconf/NotificationStream.cpp
+++ b/src/restconf/NotificationStream.cpp
@@ -24,7 +24,7 @@ void subscribe(
std::optional<sysrepo::Subscription>& sub,
sysrepo::Session& session,
const std::string& moduleName,
- rousette::http::EventStream::Signal& signal,
+ rousette::http::EventStream::EventSignal& signal,
libyang::DataFormat dataFormat,
const std::optional<std::string>& filter,
const std::optional<sysrepo::NotificationTimeStamp>& startTime,
@@ -87,13 +87,14 @@ namespace rousette::restconf {
NotificationStream::NotificationStream(
const nghttp2::asio_http2::server::request& req,
const nghttp2::asio_http2::server::response& res,
- std::shared_ptr<rousette::http::EventStream::Signal> signal,
+ rousette::http::EventStream::Termination& termination,
+ std::shared_ptr<rousette::http::EventStream::EventSignal> signal,
sysrepo::Session session,
libyang::DataFormat dataFormat,
const std::optional<std::string>& filter,
const std::optional<sysrepo::NotificationTimeStamp>& startTime,
const std::optional<sysrepo::NotificationTimeStamp>& stopTime)
- : EventStream(req, res, *signal)
+ : EventStream(req, res, termination, *signal)
, m_notificationSignal(signal)
, m_session(std::move(session))
, m_dataFormat(dataFormat)
diff --git a/src/restconf/NotificationStream.h b/src/restconf/NotificationStream.h
index 3029a0d..46f8416 100644
--- a/src/restconf/NotificationStream.h
+++ b/src/restconf/NotificationStream.h
@@ -30,7 +30,7 @@ namespace rousette::restconf {
* @see rousette::http::EventStream
* */
class NotificationStream : public rousette::http::EventStream {
- std::shared_ptr<rousette::http::EventStream::Signal> m_notificationSignal;
+ std::shared_ptr<rousette::http::EventStream::EventSignal> m_notificationSignal;
sysrepo::Session m_session;
libyang::DataFormat m_dataFormat;
std::optional<std::string> m_filter;
@@ -42,7 +42,8 @@ public:
NotificationStream(
const nghttp2::asio_http2::server::request& req,
const nghttp2::asio_http2::server::response& res,
- std::shared_ptr<rousette::http::EventStream::Signal> signal,
+ rousette::http::EventStream::Termination& termination,
+ std::shared_ptr<rousette::http::EventStream::EventSignal> signal,
sysrepo::Session sess,
libyang::DataFormat dataFormat,
const std::optional<std::string>& filter,
diff --git a/src/restconf/Server.cpp b/src/restconf/Server.cpp
index 4255540..2f495f9 100644
--- a/src/restconf/Server.cpp
+++ b/src/restconf/Server.cpp
@@ -845,6 +845,7 @@ void Server::stop()
});
t.cancel();
}
+ shutdownRequested();
}
void Server::join()
@@ -935,7 +936,7 @@ Server::Server(sysrepo::Connection conn, const std::string& address, const std::
server->handle("/telemetry/optics", [this](const auto& req, const auto& res) {
logRequest(req);
- auto client = std::make_shared<http::EventStream>(req, res, opticsChange, as_restconf_push_update(dwdmEvents->currentData(), std::chrono::system_clock::now()));
+ auto client = std::make_shared<http::EventStream>(req, res, shutdownRequested, opticsChange, as_restconf_push_update(dwdmEvents->currentData(), std::chrono::system_clock::now()));
client->activate();
});
@@ -972,7 +973,7 @@ Server::Server(sysrepo::Connection conn, const std::string& address, const std::
// The signal is constructed outside NotificationStream class because it is required to be passed to
// NotificationStream's parent (EventStream) constructor where it already must be constructed
// Yes, this is a hack.
- auto client = std::make_shared<NotificationStream>(req, res, std::make_shared<rousette::http::EventStream::Signal>(), sess, streamRequest.type.encoding, xpathFilter, startTime, stopTime);
+ auto client = std::make_shared<NotificationStream>(req, res, shutdownRequested, std::make_shared<rousette::http::EventStream::EventSignal>(), sess, streamRequest.type.encoding, xpathFilter, startTime, stopTime);
client->activate();
} catch (const auth::Error& e) {
processAuthError(req, res, e, [&res]() {
diff --git a/src/restconf/Server.h b/src/restconf/Server.h
index ad53d4c..112c943 100644
--- a/src/restconf/Server.h
+++ b/src/restconf/Server.h
@@ -43,6 +43,7 @@ private:
using JsonDiffSignal = boost::signals2::signal<void(const std::string& json)>;
JsonDiffSignal opticsChange;
bool joined = false; // true if the server has been joined, join twice is an error
+ boost::signals2::signal<void()> shutdownRequested;
};
}
}
diff --git a/tests/restconf-eventstream.cpp b/tests/restconf-eventstream.cpp
new file mode 100644
index 0000000..3d7fc15
--- /dev/null
+++ b/tests/restconf-eventstream.cpp
@@ -0,0 +1,70 @@
+/*
+ * Copyright (C) 2025 CESNET, https://photonics.cesnet.cz/
+ *
+ * Written by Tomáš Pecka <tomas.pecka@cesnet.cz>
+ *
+ */
+
+#include "trompeloeil_doctest.h"
+static const auto SERVER_PORT = "10091";
+#include <latch>
+#include <libyang-cpp/Time.hpp>
+#include <nghttp2/asio_http2.h>
+#include <spdlog/spdlog.h>
+#include <sysrepo-cpp/utils/utils.hpp>
+#include "restconf/Server.h"
+#include "tests/aux-utils.h"
+#include "tests/event_watchers.h"
+#include "tests/pretty_printers.h"
+
+#define SEND_NOTIFICATION(DATA) notifSession.sendNotification(*ctx.parseOp(DATA, libyang::DataFormat::JSON, libyang::OperationType::NotificationYang).op, sysrepo::Wait::No);
+
+using namespace std::chrono_literals;
+
+TEST_CASE("Termination on server shutdown")
+{
+ trompeloeil::sequence seqMod1;
+ sysrepo::setLogLevelStderr(sysrepo::LogLevel::Information);
+ spdlog::set_level(spdlog::level::trace);
+
+ std::vector<std::unique_ptr<trompeloeil::expectation>> expectations;
+
+ auto srConn = sysrepo::Connection{};
+ auto srSess = srConn.sessionStart(sysrepo::Datastore::Running);
+ srSess.sendRPC(srSess.getContext().newPath("/ietf-factory-default:factory-reset"));
+
+ auto nacmGuard = manageNacm(srSess);
+ auto server = std::make_unique<rousette::restconf::Server>(srConn, SERVER_ADDRESS, SERVER_PORT);
+ setupRealNacm(srSess);
+
+ RestconfNotificationWatcher netconfWatcher(srConn.sessionStart().getContext());
+
+ const std::string notification(R"({"example:eventA":{"message":"blabla","progress":11}})");
+ EXPECT_NOTIFICATION(notification, seqMod1);
+ EXPECT_NOTIFICATION(notification, seqMod1);
+ EXPECT_NOTIFICATION(notification, seqMod1);
+
+ auto notifSession = sysrepo::Connection{}.sessionStart();
+ auto ctx = notifSession.getContext();
+
+ PREPARE_LOOP_WITH_EXCEPTIONS;
+ auto notificationThread = std::jthread(wrap_exceptions_and_asio(bg, io, [&]() {
+ auto notifSession = sysrepo::Connection{}.sessionStart();
+ auto ctx = notifSession.getContext();
+
+ WAIT_UNTIL_SSE_CLIENT_REQUESTS;
+ SEND_NOTIFICATION(notification);
+ SEND_NOTIFICATION(notification);
+ SEND_NOTIFICATION(notification);
+ waitForCompletionAndBitMore(seqMod1);
+
+ auto beforeShutdown = std::chrono::system_clock::now();
+ server.reset();
+ auto shutdownDuration = std::chrono::system_clock::now() - beforeShutdown;
+ REQUIRE(shutdownDuration < 5s);
+ }));
+
+ SSEClient client(io, SERVER_ADDRESS, SERVER_PORT, requestSent, netconfWatcher, "/streams/NETCONF/JSON", std::map<std::string, std::string>{AUTH_ROOT});
+
+ RUN_LOOP_WITH_EXCEPTIONS;
+}
diff --git a/tests/restconf_utils.cpp b/tests/restconf_utils.cpp
index a137463..cbfce2c 100644
--- a/tests/restconf_utils.cpp
+++ b/tests/restconf_utils.cpp
@@ -205,6 +205,12 @@ SSEClient::SSEClient(
t.expires_from_now(silenceTimeout);
});
});
+
+ req->on_close([maybeClient = std::weak_ptr<ng_client::session>{client}](auto) {
+ if (auto client = maybeClient.lock()) {
+ client->shutdown();
+ }
+ });
});
client->on_error([&](const boost::system::error_code& ec) {
--
2.43.0
@@ -1,300 +0,0 @@
From fda47b6a6cfdaecc24e96c4d6138c6de3ef116e0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Mon, 7 Oct 2024 21:21:22 +0200
Subject: [PATCH 06/44] tests: test querying lists with union keys
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Test that we correctly work with keys that are unions of something
that can have a module namespace and that must not have it.
By the way, enum values are not supposed to have a namespace prefix,
good to know [1].
[1] https://www.rfc-editor.org/rfc/rfc7951#section-6.4
Change-Id: I5a70f18117bb453330b4bb2ce0d2fb47d35b6ea6
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
tests/restconf-reading.cpp | 53 ++++++++++++++++++++++++-----
tests/restconf-writing.cpp | 68 ++++++++++++++++++++++++++++----------
tests/uri-parser.cpp | 2 +-
tests/yang/example.yang | 26 +++++++++++++--
4 files changed, 120 insertions(+), 29 deletions(-)
diff --git a/tests/restconf-reading.cpp b/tests/restconf-reading.cpp
index 96c38ab..2ded3f0 100644
--- a/tests/restconf-reading.cpp
+++ b/tests/restconf-reading.cpp
@@ -289,14 +289,16 @@ TEST_CASE("reading data")
}
)"});
- srSess.setItem("/example:list-with-identity-key[type='example:derived-identity'][name='name']", std::nullopt);
- srSess.setItem("/example:list-with-identity-key[type='example-types:another-derived-identity'][name='name']", std::nullopt);
+ srSess.setItem("/example:list-with-union-keys[type='example:derived-identity'][name='name']", std::nullopt);
+ srSess.setItem("/example:list-with-union-keys[type='example-types:another-derived-identity'][name='name']", std::nullopt);
+ srSess.setItem("/example:list-with-union-keys[type='fiii'][name='name']", std::nullopt);
+ srSess.setItem("/example:list-with-union-keys[type='zero'][name='name']", std::nullopt); // enum value
srSess.setItem("/example:tlc/decimal-list[.='1.00']", std::nullopt);
srSess.applyChanges();
// dealing with keys which can have prefixes (YANG identities)
- REQUIRE(get(RESTCONF_DATA_ROOT "/example:list-with-identity-key=derived-identity,name", {AUTH_ROOT}) == Response{200, jsonHeaders, R"({
- "example:list-with-identity-key": [
+ REQUIRE(get(RESTCONF_DATA_ROOT "/example:list-with-union-keys=derived-identity,name", {AUTH_ROOT}) == Response{200, jsonHeaders, R"({
+ "example:list-with-union-keys": [
{
"type": "derived-identity",
"name": "name"
@@ -304,8 +306,8 @@ TEST_CASE("reading data")
]
}
)"});
- REQUIRE(get(RESTCONF_DATA_ROOT "/example:list-with-identity-key=example%3Aderived-identity,name", {AUTH_ROOT}) == Response{200, jsonHeaders, R"({
- "example:list-with-identity-key": [
+ REQUIRE(get(RESTCONF_DATA_ROOT "/example:list-with-union-keys=example%3Aderived-identity,name", {AUTH_ROOT}) == Response{200, jsonHeaders, R"({
+ "example:list-with-union-keys": [
{
"type": "derived-identity",
"name": "name"
@@ -315,7 +317,7 @@ TEST_CASE("reading data")
)"});
// an identity from another module must be namespace-qualified
- REQUIRE(get(RESTCONF_DATA_ROOT "/example:list-with-identity-key=another-derived-identity,name", {AUTH_ROOT}) == Response{404, jsonHeaders, R"({
+ REQUIRE(get(RESTCONF_DATA_ROOT "/example:list-with-union-keys=another-derived-identity,name", {AUTH_ROOT}) == Response{404, jsonHeaders, R"({
"ietf-restconf:errors": {
"error": [
{
@@ -328,8 +330,8 @@ TEST_CASE("reading data")
}
)"});
- REQUIRE(get(RESTCONF_DATA_ROOT "/example:list-with-identity-key=example-types%3Aanother-derived-identity,name", {AUTH_ROOT}) == Response{200, jsonHeaders, R"({
- "example:list-with-identity-key": [
+ REQUIRE(get(RESTCONF_DATA_ROOT "/example:list-with-union-keys=example-types%3Aanother-derived-identity,name", {AUTH_ROOT}) == Response{200, jsonHeaders, R"({
+ "example:list-with-union-keys": [
{
"type": "example-types:another-derived-identity",
"name": "name"
@@ -346,6 +348,39 @@ TEST_CASE("reading data")
]
}
}
+)"});
+
+ REQUIRE(get(RESTCONF_DATA_ROOT "/example:list-with-union-keys=zero,name", {AUTH_ROOT}) == Response{200, jsonHeaders, R"({
+ "example:list-with-union-keys": [
+ {
+ "type": "zero",
+ "name": "name"
+ }
+ ]
+}
+)"});
+
+ REQUIRE(get(RESTCONF_DATA_ROOT "/example:list-with-union-keys=example%3Azero,name", {AUTH_ROOT}) == Response{404, jsonHeaders, R"({
+ "ietf-restconf:errors": {
+ "error": [
+ {
+ "error-type": "application",
+ "error-tag": "invalid-value",
+ "error-message": "No data from sysrepo."
+ }
+ ]
+ }
+}
+)"});
+
+ REQUIRE(get(RESTCONF_DATA_ROOT "/example:list-with-union-keys=fiii,name", {AUTH_ROOT}) == Response{200, jsonHeaders, R"({
+ "example:list-with-union-keys": [
+ {
+ "type": "fiii",
+ "name": "name"
+ }
+ ]
+}
)"});
}
diff --git a/tests/restconf-writing.cpp b/tests/restconf-writing.cpp
index 8418554..c1a9515 100644
--- a/tests/restconf-writing.cpp
+++ b/tests/restconf-writing.cpp
@@ -392,46 +392,69 @@ TEST_CASE("writing data")
SECTION("Test canonicalization of keys")
{
EXPECT_CHANGE(
- CREATED("/example:list-with-identity-key[type='example:derived-identity'][name='name']", std::nullopt),
- CREATED("/example:list-with-identity-key[type='example:derived-identity'][name='name']/type", "example:derived-identity"),
- CREATED("/example:list-with-identity-key[type='example:derived-identity'][name='name']/name", "name"),
- CREATED("/example:list-with-identity-key[type='example:derived-identity'][name='name']/text", "blabla"));
- REQUIRE(put(RESTCONF_DATA_ROOT "/example:list-with-identity-key=derived-identity,name", {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({"example:list-with-identity-key": [{"name": "name", "type": "derived-identity", "text": "blabla"}]}]})") == Response{201, noContentTypeHeaders, ""});
+ CREATED("/example:list-with-union-keys[type='example:derived-identity'][name='name']", std::nullopt),
+ CREATED("/example:list-with-union-keys[type='example:derived-identity'][name='name']/type", "example:derived-identity"),
+ CREATED("/example:list-with-union-keys[type='example:derived-identity'][name='name']/name", "name"),
+ CREATED("/example:list-with-union-keys[type='example:derived-identity'][name='name']/text", "blabla"));
+ REQUIRE(put(RESTCONF_DATA_ROOT "/example:list-with-union-keys=derived-identity,name", {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({"example:list-with-union-keys": [{"name": "name", "type": "derived-identity", "text": "blabla"}]}]})") == Response{201, noContentTypeHeaders, ""});
// prefixed in the URI, not prefixed in the data
EXPECT_CHANGE(
- MODIFIED("/example:list-with-identity-key[type='example:derived-identity'][name='name']/text", "hehe"));
- REQUIRE(put(RESTCONF_DATA_ROOT "/example:list-with-identity-key=example%3Aderived-identity,name", {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({"example:list-with-identity-key": [{"name": "name", "type": "derived-identity", "text": "hehe"}]}]})") == Response{204, noContentTypeHeaders, ""});
+ MODIFIED("/example:list-with-union-keys[type='example:derived-identity'][name='name']/text", "hehe"));
+ REQUIRE(put(RESTCONF_DATA_ROOT "/example:list-with-union-keys=example%3Aderived-identity,name", {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({"example:list-with-union-keys": [{"name": "name", "type": "derived-identity", "text": "hehe"}]}]})") == Response{204, noContentTypeHeaders, ""});
+ // 'another-derived-identity' comes from a different module than the list itself, so this parses as string
+ EXPECT_CHANGE(
+ CREATED("/example:list-with-union-keys[type='another-derived-identity'][name='name']", std::nullopt),
+ CREATED("/example:list-with-union-keys[type='another-derived-identity'][name='name']/type", "another-derived-identity"),
+ CREATED("/example:list-with-union-keys[type='another-derived-identity'][name='name']/name", "name"),
+ CREATED("/example:list-with-union-keys[type='another-derived-identity'][name='name']/text", "blabla"));
+ REQUIRE(put(RESTCONF_DATA_ROOT "/example:list-with-union-keys=another-derived-identity,name", {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({"example:list-with-union-keys": [{"name": "name", "type": "another-derived-identity", "text": "blabla"}]}]})") == Response{201, noContentTypeHeaders, ""});
+
+ EXPECT_CHANGE(
+ CREATED("/example:list-with-union-keys[type='example-types:another-derived-identity'][name='name']", std::nullopt),
+ CREATED("/example:list-with-union-keys[type='example-types:another-derived-identity'][name='name']/type", "example-types:another-derived-identity"),
+ CREATED("/example:list-with-union-keys[type='example-types:another-derived-identity'][name='name']/name", "name"),
+ CREATED("/example:list-with-union-keys[type='example-types:another-derived-identity'][name='name']/text", "blabla"));
+ REQUIRE(put(RESTCONF_DATA_ROOT "/example:list-with-union-keys=example-types%3Aanother-derived-identity,name", {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({"example:list-with-union-keys": [{"name": "name", "type": "example-types:another-derived-identity", "text": "blabla"}]}]})") == Response{201, noContentTypeHeaders, ""});
- REQUIRE(put(RESTCONF_DATA_ROOT "/example:list-with-identity-key=another-derived-identity,name", {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({"example:list-with-identity-key": [{"name": "name", "type": "another-derived-identity", "text": "blabla"}]}]})") == Response{400, jsonHeaders, R"({
+ // missing namespace in the data
+ REQUIRE(put(RESTCONF_DATA_ROOT "/example:list-with-union-keys=example-types%3Aanother-derived-identity,name", {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({"example:list-with-union-keys": [{"name": "name", "type": "another-derived-identity", "text": "blabla"}]}]})") == Response{400, jsonHeaders, R"({
"ietf-restconf:errors": {
"error": [
{
"error-type": "protocol",
"error-tag": "invalid-value",
- "error-message": "Validation failure: Can't parse data: LY_EVALID"
+ "error-path": "/example:list-with-union-keys[type='another-derived-identity'][name='name']/type",
+ "error-message": "List key mismatch between URI path ('example-types:another-derived-identity') and data ('another-derived-identity')."
}
]
}
}
)"});
+ // zero is enum value
EXPECT_CHANGE(
- CREATED("/example:list-with-identity-key[type='example-types:another-derived-identity'][name='name']", std::nullopt),
- CREATED("/example:list-with-identity-key[type='example-types:another-derived-identity'][name='name']/type", "example-types:another-derived-identity"),
- CREATED("/example:list-with-identity-key[type='example-types:another-derived-identity'][name='name']/name", "name"),
- CREATED("/example:list-with-identity-key[type='example-types:another-derived-identity'][name='name']/text", "blabla"));
- REQUIRE(put(RESTCONF_DATA_ROOT "/example:list-with-identity-key=example-types%3Aanother-derived-identity,name", {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({"example:list-with-identity-key": [{"name": "name", "type": "example-types:another-derived-identity", "text": "blabla"}]}]})") == Response{201, noContentTypeHeaders, ""});
+ CREATED("/example:list-with-union-keys[type='zero'][name='name']", std::nullopt),
+ CREATED("/example:list-with-union-keys[type='zero'][name='name']/type", "zero"),
+ CREATED("/example:list-with-union-keys[type='zero'][name='name']/name", "name"));
+ REQUIRE(put(RESTCONF_DATA_ROOT "/example:list-with-union-keys=zero,name", {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({"example:list-with-union-keys": [{"name": "name", "type": "zero"}]}]})") == Response{201, noContentTypeHeaders, ""});
- // missing namespace in the data
- REQUIRE(put(RESTCONF_DATA_ROOT "/example:list-with-identity-key=example-types%3Aanother-derived-identity,name", {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({"example:list-with-identity-key": [{"name": "name", "type": "another-derived-identity", "text": "blabla"}]}]})") == Response{400, jsonHeaders, R"({
+ // example:zero is string, enum values are not namespace-prefixed
+ EXPECT_CHANGE(
+ CREATED("/example:list-with-union-keys[type='example:zero'][name='name']", std::nullopt),
+ CREATED("/example:list-with-union-keys[type='example:zero'][name='name']/type", "example:zero"),
+ CREATED("/example:list-with-union-keys[type='example:zero'][name='name']/name", "name"));
+ REQUIRE(put(RESTCONF_DATA_ROOT "/example:list-with-union-keys=example%3Azero,name", {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({"example:list-with-union-keys": [{"name": "name", "type": "example:zero"}]}]})") == Response{201, noContentTypeHeaders, ""});
+
+ REQUIRE(put(RESTCONF_DATA_ROOT "/example:list-with-union-keys=zero,name", {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({"example:list-with-union-keys": [{"name": "name", "type": "example:zero"}]}]})") == Response{400, jsonHeaders, R"({
"ietf-restconf:errors": {
"error": [
{
"error-type": "protocol",
"error-tag": "invalid-value",
- "error-message": "Validation failure: Can't parse data: LY_EVALID"
+ "error-path": "/example:list-with-union-keys[type='example:zero'][name='name']/type",
+ "error-message": "List key mismatch between URI path ('zero') and data ('example:zero')."
}
]
}
@@ -459,6 +482,17 @@ TEST_CASE("writing data")
}
)"});
+ EXPECT_CHANGE(CREATED("/example:fruit-list[.='example:apple']", "example:apple"));
+ REQUIRE(put(RESTCONF_DATA_ROOT "/example:fruit-list=example%3Aapple", {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({"example:fruit-list": ["apple"]})") == Response{201, noContentTypeHeaders, ""});
+
+ // leafref
+ EXPECT_CHANGE(
+ CREATED("/example:list-with-union-keys[type='example:apple'][name='name']", std::nullopt),
+ CREATED("/example:list-with-union-keys[type='example:apple'][name='name']/type", "example:apple"),
+ CREATED("/example:list-with-union-keys[type='example:apple'][name='name']/name", "name"));
+ REQUIRE(put(RESTCONF_DATA_ROOT "/example:list-with-union-keys=example%3Aapple,name", {AUTH_ROOT, CONTENT_TYPE_JSON},
+ R"({"example:list-with-union-keys": [{"name": "name", "type": "apple"}]}]})") == Response{201, noContentTypeHeaders, ""});
+
// value in the URI and in the data have the same canonical form
EXPECT_CHANGE(CREATED("/example:tlc/decimal-list[.='1.0']", "1.0"));
REQUIRE(put(RESTCONF_DATA_ROOT "/example:tlc/decimal-list=1.00", {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({"example:decimal-list": ["1.0"]})") == Response{201, noContentTypeHeaders, ""});
diff --git a/tests/uri-parser.cpp b/tests/uri-parser.cpp
index 7320c3c..000fe5d 100644
--- a/tests/uri-parser.cpp
+++ b/tests/uri-parser.cpp
@@ -293,7 +293,7 @@ TEST_CASE("URI path parser")
{"/restconf/data/example:tlc/list=eth0/choice1", "/example:tlc/list[name='eth0']/choice1", std::nullopt},
{"/restconf/data/example:tlc/list=eth0/choice2", "/example:tlc/list[name='eth0']/choice2", std::nullopt},
{"/restconf/data/example:tlc/list=eth0/collection=val", "/example:tlc/list[name='eth0']/collection[.='val']", std::nullopt},
- {"/restconf/data/example:list-with-identity-key=example-types%3Aanother-derived-identity,aaa", "/example:list-with-identity-key[type='example-types:another-derived-identity'][name='aaa']", std::nullopt},
+ {"/restconf/data/example:list-with-union-keys=example-types%3Aanother-derived-identity,aaa", "/example:list-with-union-keys[type='example-types:another-derived-identity'][name='aaa']", std::nullopt},
{"/restconf/data/example:tlc/status", "/example:tlc/status", std::nullopt},
// container example:a has a container b inserted locally and also via an augment. Check that we return the correct one
{"/restconf/data/example:a/b", "/example:a/b", std::nullopt},
diff --git a/tests/yang/example.yang b/tests/yang/example.yang
index c46273c..75cd7a6 100644
--- a/tests/yang/example.yang
+++ b/tests/yang/example.yang
@@ -13,6 +13,11 @@ module example {
base base-identity;
}
+ identity fruit { }
+ identity apple {
+ base fruit;
+ }
+
leaf top-level-leaf { type string; }
leaf top-level-leaf2 { type string; default "x"; }
@@ -121,10 +126,23 @@ module example {
}
}
- list list-with-identity-key {
+ list list-with-union-keys {
key "type name";
leaf type {
- type identityref { base base-identity; }
+ type union {
+ type identityref {
+ base base-identity;
+ }
+ type enumeration {
+ enum zero;
+ enum one;
+ }
+ type leafref {
+ require-instance true;
+ path "/fruit-list";
+ }
+ type string;
+ }
}
leaf name { type string; }
leaf text { type string; }
@@ -134,6 +152,10 @@ module example {
type identityref { base base-identity; }
}
+ leaf-list fruit-list {
+ type identityref { base fruit; }
+ }
+
rpc test-rpc {
input {
leaf i {
--
2.43.0
@@ -1,32 +0,0 @@
From 27ef5bc87fdeb70a77609da6ec18ee5c28656bb6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Tue, 29 Oct 2024 18:54:55 +0100
Subject: [PATCH 07/44] error handling in sysrepo has changed
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Depends-on: https://gerrit.cesnet.cz/c/CzechLight/dependencies/+/7969
Change-Id: Id028806ed49114cba4c55e2874bcf3fc98308bdc
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
tests/restconf-rpc.cpp | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/restconf-rpc.cpp b/tests/restconf-rpc.cpp
index 4f66f10..9bc1dbc 100644
--- a/tests/restconf-rpc.cpp
+++ b/tests/restconf-rpc.cpp
@@ -301,7 +301,7 @@ TEST_CASE("invoking actions and rpcs")
{
"error-type": "application",
"error-tag": "operation-failed",
- "error-message": "Internal server error due to sysrepo exception: Couldn't send RPC: SR_ERR_CALLBACK_FAILED\u000A Operation failed (SR_ERR_OPERATION_FAILED)\u000A User callback failed. (SR_ERR_CALLBACK_FAILED)"
+ "error-message": "Internal server error due to sysrepo exception: Couldn't send RPC: SR_ERR_OPERATION_FAILED\u000A Operation failed (SR_ERR_OPERATION_FAILED)"
}
]
}
--
2.43.0
@@ -0,0 +1,54 @@
From ca2894d4888c673d227fc196a25f83ded20e8f04 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Mon, 30 Jun 2025 15:38:02 +0200
Subject: [PATCH 07/17] restconf: refactor server stop
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
In 94b24795 ("server: simplify listening/stopping") we needed to call
server->stop() through event loop and it was proposed to use the
approach introduced in [1], i.e., construct a timer, then cancel it
right away which would invoke the timer handler.
I find that quite complicated, why not just simply call post()? [2]
(Also, by looking at the http2::asio_http2::server::http2::stop(), it
does not seem like it should be called from *every* io_service.)
[1] https://stackoverflow.com/questions/17005258/why-does-boost-asio-not-support-an-event-based-interface/17029022#17029022
[2] https://www.boost.org/doc/libs/1_85_0/doc/html/boost_asio/reference/post.html
Change-Id: I2f33c38a78dce4081a03326c9a9bb25817fc9d2f
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/restconf/Server.cpp | 13 +++++--------
1 file changed, 5 insertions(+), 8 deletions(-)
diff --git a/src/restconf/Server.cpp b/src/restconf/Server.cpp
index 2f495f9..bd2ce0d 100644
--- a/src/restconf/Server.cpp
+++ b/src/restconf/Server.cpp
@@ -837,14 +837,11 @@ Server::~Server()
void Server::stop()
{
// notification to stop has to go through the asio io_context
- for (const auto& service : server->io_services()) {
- boost::asio::deadline_timer t{*service, boost::posix_time::pos_infin};
- t.async_wait([server = this->server.get()](const boost::system::error_code&) {
- spdlog::trace("Stoping HTTP/2 server");
- server->stop();
- });
- t.cancel();
- }
+ boost::asio::post(*server->io_services().front(), [server = this->server.get()]() {
+ spdlog::trace("Stoping HTTP/2 server");
+ server->stop();
+ });
+
shutdownRequested();
}
--
2.43.0
@@ -1,721 +0,0 @@
From 6819561d97e38569c319e36ca2e99768036b4032 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Wed, 21 Aug 2024 19:18:08 +0200
Subject: [PATCH 08/44] restconf: support fields query parameter
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
This patch adds support for fields query parameter [1].
I had to modify the original grammar for fields parameter a bit to
allow for the lowest precedence while parsing `;` expression. Also we
allow for more strings than the original grammar specifies to make the
syntax more user friendly.
The fields expression is parsed into an AST which corresponds 1:1
to the parse tree. The tree representing the expression could be
simplified but I chose not to as it would complicate the code even
more (although the translation to XPath would be simpler).
The tree is then transformed into a valid XPath 1.0 expression.
The XPath 1.0 expressions are limited and I could not find a way how to
transform the fields string into a valid XPath. I realized that the
easiest way will be to "unwrap" the expression into individual paths
and join them via union operator.
For example, input `a(b;c/d)` would result into `a/b | a/c/d` XPath.
[1] https://datatracker.ietf.org/doc/html/rfc8040#section-4.8.3
[2] https://www.w3.org/TR/1999/REC-xpath-19991116/#section-Expressions
Change-Id: I3c96bbcf49b38ecf08f56912afd3a8f50c15cd44
Signed-off-by: Jan Kundrát <jan.kundrat@cesnet.cz>
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
README.md | 1 -
src/restconf/Server.cpp | 9 ++-
src/restconf/uri.cpp | 95 +++++++++++++++++++++++-
src/restconf/uri.h | 57 ++++++++++++++-
src/restconf/uri_impl.h | 4 ++
tests/pretty_printers.h | 23 ++++++
tests/restconf-reading.cpp | 144 ++++++++++++++++++++++++++++++++++++-
tests/restconf-writing.cpp | 12 +++-
tests/uri-parser.cpp | 93 ++++++++++++++++++++++++
tests/yang/example.yang | 10 +++
10 files changed, 438 insertions(+), 10 deletions(-)
diff --git a/README.md b/README.md
index 1689584..3fbfd21 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,6 @@ This is a [RESTCONF](https://datatracker.ietf.org/doc/html/rfc8040.html) server
- TLS certificate authentication (see [Access control model](#access-control-model) below)
- the [`Last-Modified`](https://datatracker.ietf.org/doc/html/rfc8040.html#section-3.4.1.1) and [`ETag`](https://datatracker.ietf.org/doc/html/rfc8040.html#section-3.4.1.2) headers for [edit collision prevention](https://datatracker.ietf.org/doc/html/rfc8040.html#section-3.4.1) in the datastore resource
- the [`Last-Modified`](https://datatracker.ietf.org/doc/html/rfc8040.html#section-3.5.1) and [`ETag`](https://datatracker.ietf.org/doc/html/rfc8040.html#section-3.5.2) headers in the data resource
- - The [`fields`](https://datatracker.ietf.org/doc/html/rfc8040.html#section-4.8.3) query parameter
- [NMDA](https://datatracker.ietf.org/doc/html/rfc8527.html) datastore access
- no [`with-operational-default`](https://datatracker.ietf.org/doc/html/rfc8527#section-3.2.1) capability
- no [`with-origin`](https://datatracker.ietf.org/doc/html/rfc8527#section-3.2.2) capability
diff --git a/src/restconf/Server.cpp b/src/restconf/Server.cpp
index b515d66..7c66ea4 100644
--- a/src/restconf/Server.cpp
+++ b/src/restconf/Server.cpp
@@ -834,6 +834,7 @@ Server::Server(sysrepo::Connection conn, const std::string& address, const std::
m_monitoringSession.setItem("/ietf-restconf-monitoring:restconf-state/capabilities/capability[2]", "urn:ietf:params:restconf:capability:depth:1.0");
m_monitoringSession.setItem("/ietf-restconf-monitoring:restconf-state/capabilities/capability[3]", "urn:ietf:params:restconf:capability:with-defaults:1.0");
m_monitoringSession.setItem("/ietf-restconf-monitoring:restconf-state/capabilities/capability[4]", "urn:ietf:params:restconf:capability:filter:1.0");
+ m_monitoringSession.setItem("/ietf-restconf-monitoring:restconf-state/capabilities/capability[5]", "urn:ietf:params:restconf:capability:fields:1.0");
m_monitoringSession.applyChanges();
m_monitoringOperSub = m_monitoringSession.onOperGet(
@@ -1017,7 +1018,13 @@ Server::Server(sysrepo::Connection conn, const std::string& address, const std::
}
}
- if (auto data = sess.getData(restconfRequest.path, maxDepth, getOptions, timeout); data) {
+ auto xpath = restconfRequest.path;
+ if (auto it = restconfRequest.queryParams.find("fields"); it != restconfRequest.queryParams.end()) {
+ auto fields = std::get<queryParams::fields::Expr>(it->second);
+ xpath = fieldsToXPath(sess.getContext(), xpath == "/*" ? "" : xpath, fields);
+ }
+
+ if (auto data = sess.getData(xpath, maxDepth, getOptions, timeout); data) {
res.write_head(
200,
{
diff --git a/src/restconf/uri.cpp b/src/restconf/uri.cpp
index ac399b7..da1e3a5 100644
--- a/src/restconf/uri.cpp
+++ b/src/restconf/uri.cpp
@@ -4,7 +4,10 @@
* Written by Tomáš Pecka <tomas.pecka@cesnet.cz>
*/
+#include <boost/algorithm/string/join.hpp>
+#include <boost/fusion/adapted/struct/adapt_struct.hpp>
#include <boost/fusion/include/std_pair.hpp>
+#include <boost/spirit/home/x3/support/ast/variant.hpp>
#include <experimental/iterator>
#include <libyang-cpp/Enum.hpp>
#include <map>
@@ -112,6 +115,28 @@ struct insertTable: x3::symbols<queryParams::QueryParamValue> {
}
} const insertParam;
+/* This grammar is implemented a little bit differently than the RFC states. The ABNF from RFC is:
+ *
+ * fields-expr = path "(" fields-expr ")" / path ";" fields-expr / path
+ * path = api-identifier [ "/" path ]
+ *
+ * Firstly, the grammar from the RFC doesn't allow for expression like `a(b);c` but allows for `c;a(b)`.
+ * I think both should be valid (as user I would expect that the order of such expression does not matter).
+ * Hence our grammar allows for more strings than the grammar from RFC.
+ * This issue was already raised on IETF mailing list: https://mailarchive.ietf.org/arch/msg/netconf/TYBpTE_ELzzMOe6amrw6fQF07nE/
+ * but neither a formal errata was issued nor there was a resolution on the mailing list.
+ */
+const auto fieldsExpr = x3::rule<class fieldsExpr, queryParams::fields::Expr>{"fieldsExpr"};
+const auto fieldsSemi = x3::rule<class fieldsSemiExpr, queryParams::fields::SemiExpr>{"fieldsSemi"};
+const auto fieldsSlash = x3::rule<class fieldsSlashExpr, queryParams::fields::SlashExpr>{"fieldsSlash"};
+const auto fieldsParen = x3::rule<class fieldsParen, queryParams::fields::ParenExpr>{"fieldsParen"};
+
+const auto fieldsSemi_def = fieldsParen >> -(x3::lit(";") >> fieldsSemi);
+const auto fieldsParen_def = fieldsSlash >> -(x3::lit("(") >> fieldsExpr >> x3::lit(")"));
+const auto fieldsSlash_def = apiIdentifier >> -(x3::lit("/") >> fieldsSlash);
+const auto fieldsExpr_def = fieldsSemi;
+BOOST_SPIRIT_DEFINE(fieldsParen, fieldsExpr, fieldsSlash, fieldsSemi);
+
// early sanity check, this timestamp will be parsed by libyang::fromYangTimeFormat anyways
const auto dateAndTime = x3::rule<class dateAndTime, std::string>{"dateAndTime"} =
x3::repeat(4)[x3::digit] >> x3::char_('-') >> x3::repeat(2)[x3::digit] >> x3::char_('-') >> x3::repeat(2)[x3::digit] >> x3::char_('T') >>
@@ -127,7 +152,8 @@ const auto queryParamPair = x3::rule<class queryParamPair, std::pair<std::string
(x3::string("point") >> "=" >> uriPath) |
(x3::string("filter") >> "=" >> filter) |
(x3::string("start-time") >> "=" >> dateAndTime) |
- (x3::string("stop-time") >> "=" >> dateAndTime);
+ (x3::string("stop-time") >> "=" >> dateAndTime) |
+ (x3::string("fields") >> "=" >> fieldsExpr);
const auto queryParamGrammar = x3::rule<class grammar, queryParams::QueryParams>{"queryParamGrammar"} = queryParamPair % "&" | x3::eps;
@@ -384,7 +410,7 @@ void validateQueryParameters(const std::multimap<std::string, queryParams::Query
}
}
- for (const auto& param : {"depth", "with-defaults", "content"}) {
+ for (const auto& param : {"depth", "with-defaults", "content", "fields"}) {
if (auto it = params.find(param); it != params.end() && httpMethod != "GET" && httpMethod != "HEAD") {
throw ErrorResponse(400, "protocol", "invalid-value", "Query parameter '"s + param + "' can be used only with GET and HEAD methods");
}
@@ -658,4 +684,69 @@ std::set<std::string> allowedHttpMethodsForUri(const libyang::Context& ctx, cons
return allowedHttpMethods;
}
+
+/** @brief Traverses the AST of the fields input expression and collects all the possible paths
+ *
+ * @param expr The fields expressions
+ * @param currentPath The current path in the AST, it serves as a stack for the DFS
+ * @param output The collection of all collected paths
+ * @param end If this is the terminal node, i.e., the last node in the expression. This is needed for the correct handling of the leafs under paren expression, which does not "split" the paths but rather concatenates.
+ * */
+void fieldsToXPath(const queryParams::fields::Expr& expr, std::vector<std::string>& currentPath, std::vector<std::string>& output, bool end = false)
+{
+ boost::apply_visitor([&](auto&& node) {
+ using T = std::decay_t<decltype(node)>;
+
+ if constexpr (std::is_same_v<T, queryParams::fields::ParenExpr>) {
+ // the paths from left and right subtree are concatenated, i.e., the nodes we collect in the left tree
+ // are joined together with the nodes from the right tree
+ fieldsToXPath(node.lhs, currentPath, output, !node.rhs.has_value());
+ if (node.rhs) {
+ fieldsToXPath(*node.rhs, currentPath, output, end);
+ }
+ } else if constexpr (std::is_same_v<T, queryParams::fields::SemiExpr>) {
+ // the two paths are now independent and nodes from left subtree do not affect the right subtree
+ // hence we need to copy the current path
+ auto pathCopy = currentPath;
+ fieldsToXPath(node.lhs, currentPath, output, !node.rhs.has_value());
+ if (node.rhs) {
+ fieldsToXPath(*node.rhs, pathCopy, output, false);
+ }
+ } else if constexpr (std::is_same_v<T, queryParams::fields::SlashExpr>) {
+ // the paths from left and right subtree are concatenated, i.e., the the nodes we collect in the left tree
+ // are joined together with the nodes from the right tree, but if this is the terminal node, we need to
+ // add it to the collection of all the gathered paths
+ currentPath.push_back(node.lhs.name());
+
+ if (node.rhs) {
+ fieldsToXPath(*node.rhs, currentPath, output, end);
+ } else if (end) {
+ output.emplace_back(boost::algorithm::join(currentPath, "/"));
+ }
+ }
+ }, expr);
+}
+
+/** @brief Translates the fields expression into a XPath expression and checks for schema validity of the resulting nodes
+ *
+ * The expressions are "unwrapped" into a linear structure and then a union of such paths is made.
+ * E.g., the expression "a(b;c)" is translated into "a/b | a/c".
+ * */
+std::string fieldsToXPath(const libyang::Context& ctx, const std::string& prefix, const queryParams::fields::Expr& expr)
+{
+ std::vector<std::string> currentPath{prefix};
+ std::vector<std::string> paths;
+
+ fieldsToXPath(expr, currentPath, paths);
+
+ for (auto& xpath : paths) {
+ try {
+ validateMethodForNode("GET", impl::URIPrefix{impl::URIPrefix::Type::BasicRestconfData}, ctx.findPath(xpath));
+ } catch (const libyang::Error& e) {
+ throw ErrorResponse(400, "application", "operation-failed", "Can't find schema node for '" + xpath + "'");
+ }
+ }
+
+ return boost::algorithm::join(paths, " | ");
+}
}
diff --git a/src/restconf/uri.h b/src/restconf/uri.h
index 5e079ef..f6df724 100644
--- a/src/restconf/uri.h
+++ b/src/restconf/uri.h
@@ -6,6 +6,7 @@
#pragma once
#include <boost/optional.hpp>
+#include <boost/variant.hpp>
#include <libyang-cpp/Module.hpp>
#include <libyang-cpp/SchemaNode.hpp>
#include <map>
@@ -101,6 +102,57 @@ struct After {
using PointParsed = std::vector<PathSegment>;
}
+namespace fields {
+struct ParenExpr;
+struct SemiExpr;
+struct SlashExpr;
+
+using Expr = boost::variant<boost::recursive_wrapper<SlashExpr>, boost::recursive_wrapper<ParenExpr>, boost::recursive_wrapper<SemiExpr>>;
+
+struct ParenExpr {
+ Expr lhs;
+ boost::optional<Expr> rhs;
+
+ ParenExpr() = default;
+ ParenExpr(const Expr& lhs, const Expr& rhs) : ParenExpr(lhs, boost::optional<Expr>(rhs)) {}
+ ParenExpr(const Expr& lhs, const boost::optional<Expr>& rhs = boost::none)
+ : lhs(lhs)
+ , rhs(rhs)
+ {
+ }
+
+ bool operator==(const ParenExpr&) const = default;
+};
+struct SemiExpr {
+ Expr lhs;
+ boost::optional<Expr> rhs;
+
+ SemiExpr() = default;
+ SemiExpr(const Expr& lhs, const Expr& rhs) : SemiExpr(lhs, boost::optional<Expr>(rhs)) {}
+ SemiExpr(const Expr& lhs, const boost::optional<Expr>& rhs = boost::none)
+ : lhs(lhs)
+ , rhs(rhs)
+ {
+ }
+
+ bool operator==(const SemiExpr&) const = default;
+};
+struct SlashExpr {
+ ApiIdentifier lhs;
+ boost::optional<Expr> rhs;
+
+ SlashExpr() = default;
+ SlashExpr(const ApiIdentifier& lhs, const Expr& rhs) : SlashExpr(lhs, boost::optional<Expr>(rhs)) {}
+ SlashExpr(const ApiIdentifier& lhs, const boost::optional<Expr>& rhs = boost::none)
+ : lhs(lhs)
+ , rhs(rhs)
+ {
+ }
+
+ bool operator==(const SlashExpr&) const = default;
+};
+}
+
using QueryParamValue = std::variant<
UnboundedDepth,
unsigned int,
@@ -116,7 +168,8 @@ using QueryParamValue = std::variant<
insert::Last,
insert::Before,
insert::After,
- insert::PointParsed>;
+ insert::PointParsed,
+ fields::Expr>;
using QueryParams = std::multimap<std::string, QueryParamValue>;
}
@@ -159,4 +212,6 @@ std::vector<PathSegment> asPathSegments(const std::string& uriPath);
std::optional<std::variant<libyang::Module, libyang::SubmoduleParsed>> asYangModule(const libyang::Context& ctx, const std::string& uriPath);
RestconfStreamRequest asRestconfStreamRequest(const std::string& httpMethod, const std::string& uriPath, const std::string& uriQueryString);
std::set<std::string> allowedHttpMethodsForUri(const libyang::Context& ctx, const std::string& uriPath);
+
+std::string fieldsToXPath(const libyang::Context& ctx, const std::string& prefix, const queryParams::fields::Expr& expr);
}
diff --git a/src/restconf/uri_impl.h b/src/restconf/uri_impl.h
index 8a2e166..2bcdb3f 100644
--- a/src/restconf/uri_impl.h
+++ b/src/restconf/uri_impl.h
@@ -65,3 +65,7 @@ BOOST_FUSION_ADAPT_STRUCT(rousette::restconf::impl::URIPath, prefix, segments);
BOOST_FUSION_ADAPT_STRUCT(rousette::restconf::impl::YangModule, name, revision);
BOOST_FUSION_ADAPT_STRUCT(rousette::restconf::PathSegment, apiIdent, keys);
BOOST_FUSION_ADAPT_STRUCT(rousette::restconf::ApiIdentifier, prefix, identifier);
+
+BOOST_FUSION_ADAPT_STRUCT(rousette::restconf::queryParams::fields::ParenExpr, lhs, rhs);
+BOOST_FUSION_ADAPT_STRUCT(rousette::restconf::queryParams::fields::SlashExpr, lhs, rhs);
+BOOST_FUSION_ADAPT_STRUCT(rousette::restconf::queryParams::fields::SemiExpr, lhs, rhs);
diff --git a/tests/pretty_printers.h b/tests/pretty_printers.h
index ec87250..a2befeb 100644
--- a/tests/pretty_printers.h
+++ b/tests/pretty_printers.h
@@ -8,6 +8,7 @@
#pragma once
#include "trompeloeil_doctest.h"
+#include <boost/variant.hpp>
#include <experimental/iterator>
#include <optional>
#include <sstream>
@@ -159,6 +160,28 @@ struct StringMaker<rousette::restconf::queryParams::QueryParamValue> {
[](const rousette::restconf::queryParams::insert::PointParsed& p) -> std::string {
return ("PointParsed{" + StringMaker<decltype(p)>::convert(p) + "}").c_str();
},
+ [](const rousette::restconf::queryParams::fields::Expr& expr) -> std::string {
+ return boost::apply_visitor([&](auto&& next) {
+ using T = std::decay_t<decltype(next)>;
+ std::string res;
+
+ if constexpr (std::is_same_v<T, rousette::restconf::queryParams::fields::ParenExpr> || std::is_same_v<T, rousette::restconf::queryParams::fields::SemiExpr>) {
+ if constexpr (std::is_same_v<T, rousette::restconf::queryParams::fields::ParenExpr>) {
+ res = "ParenExpr{";
+ } else {
+ res = "SemiExpr{";
+ }
+ res += StringMaker<rousette::restconf::queryParams::QueryParamValue>::convert(next.lhs).c_str();
+ } else if constexpr (std::is_same_v<T, rousette::restconf::queryParams::fields::SlashExpr>) {
+ res = "SlashExpr{" + next.lhs.name();
+ }
+
+ if (next.rhs) {
+ res += std::string(", ") + StringMaker<rousette::restconf::queryParams::QueryParamValue>::convert(*next.rhs).c_str();
+ }
+ return res += "}";
+ }, expr);
+ },
}, obj).c_str();
}
};
diff --git a/tests/restconf-reading.cpp b/tests/restconf-reading.cpp
index 2ded3f0..d7d507b 100644
--- a/tests/restconf-reading.cpp
+++ b/tests/restconf-reading.cpp
@@ -72,7 +72,8 @@ TEST_CASE("reading data")
"urn:ietf:params:restconf:capability:defaults:1.0?basic-mode=explicit",
"urn:ietf:params:restconf:capability:depth:1.0",
"urn:ietf:params:restconf:capability:with-defaults:1.0",
- "urn:ietf:params:restconf:capability:filter:1.0"
+ "urn:ietf:params:restconf:capability:filter:1.0",
+ "urn:ietf:params:restconf:capability:fields:1.0"
]
},
"streams": {
@@ -116,7 +117,8 @@ TEST_CASE("reading data")
"urn:ietf:params:restconf:capability:defaults:1.0?basic-mode=explicit",
"urn:ietf:params:restconf:capability:depth:1.0",
"urn:ietf:params:restconf:capability:with-defaults:1.0",
- "urn:ietf:params:restconf:capability:filter:1.0"
+ "urn:ietf:params:restconf:capability:filter:1.0",
+ "urn:ietf:params:restconf:capability:fields:1.0"
]
},
"streams": {
@@ -672,7 +674,8 @@ TEST_CASE("reading data")
"urn:ietf:params:restconf:capability:defaults:1.0?basic-mode=explicit",
"urn:ietf:params:restconf:capability:depth:1.0",
"urn:ietf:params:restconf:capability:with-defaults:1.0",
- "urn:ietf:params:restconf:capability:filter:1.0"
+ "urn:ietf:params:restconf:capability:filter:1.0",
+ "urn:ietf:params:restconf:capability:fields:1.0"
]
},
"streams": {
@@ -894,6 +897,141 @@ TEST_CASE("reading data")
)"});
}
+ SECTION("fields filtering")
+ {
+ srSess.switchDatastore(sysrepo::Datastore::Running);
+ srSess.setItem("/example:tlc/list[name='blabla']/choice1", "c1");
+ srSess.setItem("/example:tlc/list[name='blabla']/collection[.='42']", std::nullopt);
+ srSess.setItem("/example:tlc/list[name='blabla']/nested[first='1'][second='2'][third='3']/fourth", "4");
+ srSess.setItem("/example:tlc/list[name='blabla']/nested[first='1'][second='2'][third='3']/data/a", "a");
+ srSess.setItem("/example:tlc/list[name='blabla']/nested[first='1'][second='2'][third='3']/data/other-data/b", "b");
+ srSess.setItem("/example:tlc/list[name='blabla']/nested[first='1'][second='2'][third='3']/data/other-data/c", "c");
+ srSess.applyChanges();
+
+ REQUIRE(get(RESTCONF_DATA_ROOT "/example:tlc/list=blabla?fields=choice1;collection", {}) == Response{200, jsonHeaders, R"({
+ "example:tlc": {
+ "list": [
+ {
+ "name": "blabla",
+ "collection": [
+ 42
+ ],
+ "choice1": "c1"
+ }
+ ]
+ }
+}
+)"});
+ REQUIRE(get(RESTCONF_DATA_ROOT "/example:tlc/list=blabla?fields=choice1;choice2;nested/data(a;other-data/b)", {}) == Response{200, jsonHeaders, R"({
+ "example:tlc": {
+ "list": [
+ {
+ "name": "blabla",
+ "nested": [
+ {
+ "first": "1",
+ "second": 2,
+ "third": "3",
+ "data": {
+ "a": "a",
+ "other-data": {
+ "b": "b"
+ }
+ }
+ }
+ ],
+ "choice1": "c1"
+ }
+ ]
+ }
+}
+)"});
+ REQUIRE(get(RESTCONF_DATA_ROOT "/example:tlc/list=blabla?fields=hehe", {}) == Response{400, jsonHeaders, R"({
+ "ietf-restconf:errors": {
+ "error": [
+ {
+ "error-type": "application",
+ "error-tag": "operation-failed",
+ "error-message": "Can't find schema node for '/example:tlc/list[name='blabla']/hehe'"
+ }
+ ]
+ }
+}
+)"});
+ REQUIRE(get(RESTCONF_DATA_ROOT "/example:tlc/list=blabla?fields=nested/data&depth=1", {}) == Response{200, jsonHeaders, R"({
+ "example:tlc": {
+ "list": [
+ {
+ "name": "blabla",
+ "nested": [
+ {
+ "first": "1",
+ "second": 2,
+ "third": "3",
+ "data": {
+ "a": "a",
+ "other-data": {}
+ }
+ }
+ ]
+ }
+ ]
+ }
+}
+)"});
+
+ // whole datastore with fields filtering
+ REQUIRE(get(RESTCONF_DATA_ROOT "?fields=example:tlc/list/nested/data&depth=1", {}) == Response{200, jsonHeaders, R"({
+ "example:tlc": {
+ "list": [
+ {
+ "name": "blabla",
+ "nested": [
+ {
+ "first": "1",
+ "second": 2,
+ "third": "3",
+ "data": {
+ "a": "a",
+ "other-data": {}
+ }
+ }
+ ]
+ }
+ ]
+ }
+}
+)"});
+
+ // nonexistent schema node in fields: missing prefix in tlc because we query root node so libyang can't infer the prefix from the path
+ REQUIRE(get(RESTCONF_DATA_ROOT "?fields=tlc", {}) == Response{400, jsonHeaders, R"({
+ "ietf-restconf:errors": {
+ "error": [
+ {
+ "error-type": "application",
+ "error-tag": "operation-failed",
+ "error-message": "Can't find schema node for '/tlc'"
+ }
+ ]
+ }
+}
+)"});
+
+ // nonexistent schema node in fields
+ REQUIRE(get(RESTCONF_DATA_ROOT "?fields=example:tlc/ob-la-di-ob-la-da", {}) == Response{400, jsonHeaders, R"({
+ "ietf-restconf:errors": {
+ "error": [
+ {
+ "error-type": "application",
+ "error-tag": "operation-failed",
+ "error-message": "Can't find schema node for '/example:tlc/ob-la-di-ob-la-da'"
+ }
+ ]
+ }
+}
+)"});
+ }
+
SECTION("OPTIONS method")
{
// RPC node
diff --git a/tests/restconf-writing.cpp b/tests/restconf-writing.cpp
index c1a9515..582a262 100644
--- a/tests/restconf-writing.cpp
+++ b/tests/restconf-writing.cpp
@@ -375,6 +375,8 @@ TEST_CASE("writing data")
CREATED("/example:tlc/list[name='large']/nested[first='1'][second='2'][third='3']/first", "1"),
CREATED("/example:tlc/list[name='large']/nested[first='1'][second='2'][third='3']/second", "2"),
CREATED("/example:tlc/list[name='large']/nested[first='1'][second='2'][third='3']/third", "3"),
+ CREATED("/example:tlc/list[name='large']/nested[first='1'][second='2'][third='3']/data", std::nullopt),
+ CREATED("/example:tlc/list[name='large']/nested[first='1'][second='2'][third='3']/data/other-data", std::nullopt),
CREATED("/example:tlc/list[name='large']/choice2", "large"));
REQUIRE(put(RESTCONF_DATA_ROOT "/example:tlc/list=large", {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({"example:list":[{"name": "large", "choice2": "large", "example:nested": [{"first": "1", "second": 2, "third": "3"}]}]})") == Response{201, noContentTypeHeaders, ""});
}
@@ -385,7 +387,9 @@ TEST_CASE("writing data")
CREATED("/example:tlc/list[name='libyang']/nested[first='11'][second='12'][third='13']", std::nullopt),
CREATED("/example:tlc/list[name='libyang']/nested[first='11'][second='12'][third='13']/first", "11"),
CREATED("/example:tlc/list[name='libyang']/nested[first='11'][second='12'][third='13']/second", "12"),
- CREATED("/example:tlc/list[name='libyang']/nested[first='11'][second='12'][third='13']/third", "13"));
+ CREATED("/example:tlc/list[name='libyang']/nested[first='11'][second='12'][third='13']/third", "13"),
+ CREATED("/example:tlc/list[name='libyang']/nested[first='11'][second='12'][third='13']/data", std::nullopt),
+ CREATED("/example:tlc/list[name='libyang']/nested[first='11'][second='12'][third='13']/data/other-data", std::nullopt));
REQUIRE(put(RESTCONF_DATA_ROOT "/example:tlc/list=libyang/nested=11,12,13", {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({"example:nested": [{"first": "11", "second": 12, "third": "13"}]}]})") == Response{201, noContentTypeHeaders, ""});
}
@@ -1339,6 +1343,8 @@ TEST_CASE("writing data")
CREATED("/example:tlc/list[name='large']/nested[first='1'][second='2'][third='3']/first", "1"),
CREATED("/example:tlc/list[name='large']/nested[first='1'][second='2'][third='3']/second", "2"),
CREATED("/example:tlc/list[name='large']/nested[first='1'][second='2'][third='3']/third", "3"),
+ CREATED("/example:tlc/list[name='large']/nested[first='1'][second='2'][third='3']/data", std::nullopt),
+ CREATED("/example:tlc/list[name='large']/nested[first='1'][second='2'][third='3']/data/other-data", std::nullopt),
CREATED("/example:tlc/list[name='large']/choice2", "large"));
REQUIRE(post(RESTCONF_DATA_ROOT "/example:tlc", {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({"example:list":[{"name": "large", "choice2": "large", "example:nested": [{"first": "1", "second": 2, "third": "3"}]}]})") == Response{201, jsonHeaders, ""});
}
@@ -1349,7 +1355,9 @@ TEST_CASE("writing data")
CREATED("/example:tlc/list[name='libyang']/nested[first='11'][second='12'][third='13']", std::nullopt),
CREATED("/example:tlc/list[name='libyang']/nested[first='11'][second='12'][third='13']/first", "11"),
CREATED("/example:tlc/list[name='libyang']/nested[first='11'][second='12'][third='13']/second", "12"),
- CREATED("/example:tlc/list[name='libyang']/nested[first='11'][second='12'][third='13']/third", "13"));
+ CREATED("/example:tlc/list[name='libyang']/nested[first='11'][second='12'][third='13']/third", "13"),
+ CREATED("/example:tlc/list[name='libyang']/nested[first='11'][second='12'][third='13']/data", std::nullopt),
+ CREATED("/example:tlc/list[name='libyang']/nested[first='11'][second='12'][third='13']/data/other-data", std::nullopt));
REQUIRE(post(RESTCONF_DATA_ROOT "/example:tlc/list=libyang", {AUTH_ROOT, CONTENT_TYPE_JSON}, R"({"example:nested": [{"first": "11", "second": 12, "third": "13"}]}]})") == Response{201, jsonHeaders, ""});
}
diff --git a/tests/uri-parser.cpp b/tests/uri-parser.cpp
index 000fe5d..fd5dd8b 100644
--- a/tests/uri-parser.cpp
+++ b/tests/uri-parser.cpp
@@ -828,6 +828,65 @@ TEST_CASE("URI path parser")
REQUIRE(parseQueryParams("stop-time=2023-05-20T18:30:00") == std::nullopt);
REQUIRE(parseQueryParams("stop-time=20230520T18:30:00Z") == std::nullopt);
REQUIRE(parseQueryParams("stop-time=2023-05-a0T18:30:00+05:30") == std::nullopt);
+ REQUIRE(parseQueryParams("fields=mod:leaf") == QueryParams{{"fields", fields::SemiExpr{fields::ParenExpr{fields::SlashExpr{{"mod", "leaf"}}}}}});
+ REQUIRE(parseQueryParams("fields=b(c;d);e(f)") == QueryParams{{"fields",
+ fields::SemiExpr{
+ fields::ParenExpr{
+ fields::SlashExpr{{"b"}},
+ fields::SemiExpr{
+ fields::ParenExpr{
+ fields::SlashExpr{{"c"}}
+ },
+ fields::SemiExpr{
+ fields::ParenExpr{
+ fields::SlashExpr{{"d"}}
+ }
+ }
+ }
+ },
+ fields::SemiExpr{
+ fields::ParenExpr{
+ fields::SlashExpr{{"e"}},
+ fields::SemiExpr{
+ fields::ParenExpr{
+ fields::SlashExpr{{"f"}}
+ }
+ }
+ }
+ }
+ }
+ }});
+ REQUIRE(parseQueryParams("fields=(xyz)") == std::nullopt);
+ REQUIRE(parseQueryParams("fields=a;(xyz)") == std::nullopt);
+ REQUIRE(parseQueryParams("fields=") == std::nullopt);
+
+ for (const auto& [prefix, fields, xpath] : {
+ std::tuple<std::string, std::string, std::string>{"/example:a", "b", "/example:a/b"},
+ {"/example:a", "b/c", "/example:a/b/c"},
+ {"/example:a/b", "c(enabled;blower)", "/example:a/b/c/enabled | /example:a/b/c/blower"},
+ {"/example:a", "b(c(enabled;blower))", "/example:a/b/c/enabled | /example:a/b/c/blower"},
+ {"/example:a", "b(c)", "/example:a/b/c"},
+ {"/example:a", "example:b;something", "/example:a/example:b | /example:a/something"},
+ {"/example:a", "something;b1;b(c/enabled;c/blower)", "/example:a/something | /example:a/b1 | /example:a/b/c/enabled | /example:a/b/c/blower"},
+ {"/example:a", "b(c/enabled;c/blower);something;b1", "/example:a/b/c/enabled | /example:a/b/c/blower | /example:a/something | /example:a/b1"}, // not allowed by RFC 8040
+ {"", "example:a(b;b1)", "/example:a/b | /example:a/b1"},
+ }) {
+ CAPTURE(fields);
+ CAPTURE(xpath);
+ auto qp = parseQueryParams("fields=" + fields);
+ REQUIRE(qp);
+ REQUIRE(qp->count("fields") == 1);
+ auto fieldExpr = qp->find("fields")->second;
+ REQUIRE(std::holds_alternative<fields::Expr>(fieldExpr));
+ REQUIRE(rousette::restconf::fieldsToXPath(ctx, prefix, std::get<fields::Expr>(fieldExpr)) == xpath);
+ }
+
+ auto qp = parseQueryParams("fields=xxx/xyz(a;b)");
+ REQUIRE(qp);
+ REQUIRE_THROWS_WITH_AS(
+ rousette::restconf::fieldsToXPath(ctx, "/example:a", std::get<fields::Expr>(qp->find("fields")->second)),
+ serializeErrorResponse(400, "application", "operation-failed", "Can't find schema node for '/example:a/xxx/xyz/a'").c_str(),
+ rousette::restconf::ErrorResponse);
}
SECTION("Full requests with validation")
@@ -885,6 +944,40 @@ TEST_CASE("URI path parser")
rousette::restconf::ErrorResponse);
}
+ SECTION("fields")
+ {
+ auto resp = asRestconfRequest(ctx, "GET", "/restconf/data/example:a", "fields=b/c(enabled;blower)");
+ REQUIRE(resp.queryParams == QueryParams({{"fields",
+ fields::SemiExpr{
+ fields::ParenExpr{
+ fields::SlashExpr{
+ {"b"},
+ fields::SlashExpr{{"c"}}
+ },
+ fields::SemiExpr{
+ fields::ParenExpr{
+ fields::SlashExpr{{"enabled"}}
+ },
+ fields::SemiExpr{
+ fields::ParenExpr{
+ fields::SlashExpr{{"blower"}}
+ }
+ }
+ }
+ }
+ }
+ }
+ }));
+
+ REQUIRE_THROWS_WITH_AS(asRestconfRequest(ctx, "POST", "/restconf/data/example:a", "fields=b/c(enabled;blower)"),
+ serializeErrorResponse(400, "protocol", "invalid-value", "Query parameter 'fields' can be used only with GET and HEAD methods").c_str(),
+ rousette::restconf::ErrorResponse);
+
+ REQUIRE_THROWS_WITH_AS(asRestconfStreamRequest("GET", "/streams/NETCONF/XML", "fields=a"),
+ serializeErrorResponse(400, "protocol", "invalid-value", "Query parameter 'fields' can't be used with streams").c_str(),
+ rousette::restconf::ErrorResponse);
+ }
+
SECTION("insert first/last")
{
auto resp = asRestconfRequest(ctx, "PUT", "/restconf/data/example:tlc", "insert=first");
diff --git a/tests/yang/example.yang b/tests/yang/example.yang
index 75cd7a6..5d586a0 100644
--- a/tests/yang/example.yang
+++ b/tests/yang/example.yang
@@ -38,6 +38,14 @@ module example {
leaf first { type string; }
leaf second { type int32; }
leaf third { type string; }
+ leaf fourth { type string; }
+ container data {
+ leaf a { type string; }
+ container other-data {
+ leaf b { type string; }
+ leaf c { type string; }
+ }
+ }
}
choice choose {
mandatory true;
@@ -92,6 +100,8 @@ module example {
}
}
}
+ container b1 { }
+ leaf something { type string; }
}
container two-leafs {
--
2.43.0
@@ -0,0 +1,33 @@
From 9961430bacdd8dbac64a01b2a2ffb6a4b7e806b6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Tue, 13 May 2025 13:48:35 +0200
Subject: [PATCH 08/17] tests: use std::string::starts_with
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
We are C++20, so we can use line, which is more readable.
Change-Id: I40d4038b421f6bc1fcf320f609b50d5ce7018a45
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
tests/restconf_utils.cpp | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/restconf_utils.cpp b/tests/restconf_utils.cpp
index cbfce2c..08b9623 100644
--- a/tests/restconf_utils.cpp
+++ b/tests/restconf_utils.cpp
@@ -228,7 +228,7 @@ std::vector<std::string> SSEClient::parseEvents(const std::string& msg)
std::string event;
while (std::getline(iss, line)) {
- if (line.compare(0, prefix.size(), prefix) == 0) {
+ if (line.starts_with(prefix)) {
event += line.substr(prefix.size());
} else if (line.empty()) {
res.emplace_back(std::move(event));
--
2.43.0
@@ -1,36 +0,0 @@
From 48d9b6ba3f3f892b9060b76b505d2f9a3aeb9e02 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Mon, 25 Nov 2024 09:15:55 +0100
Subject: [PATCH 09/44] cmake: adhere to CMP0167
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
After locally updating to cmake 3.30 I have seen a warning that our way
of finding boost library is deprecated [1].
[1] https://cmake.org/cmake/help/latest/policy/CMP0167.html
Change-Id: I0cfc6cd0077fac48723487a280daac5fe8218ebb
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
CMakeLists.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 465bef9..01dd2c2 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -72,7 +72,7 @@ find_package(spdlog REQUIRED)
find_package(date REQUIRED) # FIXME: Remove when we have STL with __cpp_lib_chrono >= 201907 (gcc 14)
find_package(PkgConfig)
pkg_check_modules(nghttp2 REQUIRED IMPORTED_TARGET libnghttp2_asio>=0.0.90 libnghttp2)
-find_package(Boost REQUIRED COMPONENTS system thread)
+find_package(Boost REQUIRED CONFIG COMPONENTS system thread)
pkg_check_modules(SYSREPO-CPP REQUIRED IMPORTED_TARGET sysrepo-cpp>=3)
pkg_check_modules(LIBYANG-CPP REQUIRED IMPORTED_TARGET libyang-cpp>=3)
--
2.43.0
@@ -0,0 +1,172 @@
From 746c0cdfef6808f393be7946630b8acbb0636706 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Tue, 22 Jul 2025 17:47:50 +0200
Subject: [PATCH 09/17] tests: add SSE event watcher for comments
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
I want to test whether client receives even the Server Sent Events
which should be ignored, i.e., lines starting with colon.
Change-Id: If54f0af05b4884aab01325f12fd0a6859791b41b
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
tests/event_watchers.cpp | 7 ++++++-
tests/event_watchers.h | 4 +++-
tests/restconf_utils.cpp | 36 ++++++++++++++++++++----------------
tests/restconf_utils.h | 12 +++++++++---
4 files changed, 38 insertions(+), 21 deletions(-)
diff --git a/tests/event_watchers.cpp b/tests/event_watchers.cpp
index 690338e..26ba951 100644
--- a/tests/event_watchers.cpp
+++ b/tests/event_watchers.cpp
@@ -64,7 +64,7 @@ void RestconfNotificationWatcher::setDataFormat(const libyang::DataFormat dataFo
this->dataFormat = dataFormat;
}
-void RestconfNotificationWatcher::operator()(const std::string& msg) const
+void RestconfNotificationWatcher::dataEvent(const std::string& msg) const
{
spdlog::trace("Client received data: {}", msg);
auto notifDataNode = ctx.parseOp(msg,
@@ -79,3 +79,8 @@ void RestconfNotificationWatcher::operator()(const std::string& msg) const
data(*dataRoot->printStr(libyang::DataFormat::JSON, libyang::PrintFlags::Shrink));
}
+
+void RestconfNotificationWatcher::commentEvent(const std::string& msg) const
+{
+ comment(msg);
+}
diff --git a/tests/event_watchers.h b/tests/event_watchers.h
index 3b533ba..7022b64 100644
--- a/tests/event_watchers.h
+++ b/tests/event_watchers.h
@@ -39,8 +39,10 @@ struct RestconfNotificationWatcher {
RestconfNotificationWatcher(const libyang::Context& ctx);
void setDataFormat(const libyang::DataFormat dataFormat);
- void operator()(const std::string& msg) const;
+ void dataEvent(const std::string& msg) const;
+ void commentEvent(const std::string& msg) const;
+ MAKE_CONST_MOCK1(comment, void(const std::string&));
MAKE_CONST_MOCK1(data, void(const std::string&));
};
diff --git a/tests/restconf_utils.cpp b/tests/restconf_utils.cpp
index 08b9623..5b1ebe3 100644
--- a/tests/restconf_utils.cpp
+++ b/tests/restconf_utils.cpp
@@ -169,10 +169,11 @@ SSEClient::SSEClient(
const std::string& server_address,
const std::string& server_port,
std::binary_semaphore& requestSent,
- const RestconfNotificationWatcher& notification,
+ const RestconfNotificationWatcher& eventWatcher,
const std::string& uri,
const std::map<std::string, std::string>& headers,
- const boost::posix_time::seconds silenceTimeout)
+ const boost::posix_time::seconds silenceTimeout,
+ const ReportIgnoredLines reportIgnoredLines)
: client(std::make_shared<ng_client::session>(io, server_address, server_port))
, t(io, silenceTimeout)
{
@@ -191,17 +192,15 @@ SSEClient::SSEClient(
}
});
- client->on_connect([&, uri, reqHeaders, silenceTimeout, server_address, server_port](auto) {
+ client->on_connect([&, uri, reqHeaders, silenceTimeout, server_address, server_port, reportIgnoredLines](auto) {
boost::system::error_code ec;
auto req = client->submit(ec, "GET", serverAddressAndPort(server_address, server_port) + uri, "", reqHeaders);
- req->on_response([&, silenceTimeout](const ng_client::response& res) {
+ req->on_response([&, silenceTimeout, reportIgnoredLines](const ng_client::response& res) {
requestSent.release();
- res.on_data([&, silenceTimeout](const uint8_t* data, std::size_t len) {
+ res.on_data([&, silenceTimeout, reportIgnoredLines](const uint8_t* data, std::size_t len) {
// not a production-ready code. In real-life condition the data received in one callback might probably be incomplete
- for (const auto& event : parseEvents(std::string(reinterpret_cast<const char*>(data), len))) {
- notification(event);
- }
+ parseEvents(std::string(reinterpret_cast<const char*>(data), len), eventWatcher, reportIgnoredLines);
t.expires_from_now(silenceTimeout);
});
});
@@ -218,25 +217,30 @@ SSEClient::SSEClient(
});
}
-std::vector<std::string> SSEClient::parseEvents(const std::string& msg)
+void SSEClient::parseEvents(const std::string& msg, const RestconfNotificationWatcher& eventWatcher, const ReportIgnoredLines reportIgnoredLines)
{
- static const std::string prefix = "data:";
+ static const std::string dataPrefix = "data:";
+ static const std::string ignorePrefix = ":";
- std::vector<std::string> res;
std::istringstream iss(msg);
std::string line;
std::string event;
while (std::getline(iss, line)) {
- if (line.starts_with(prefix)) {
- event += line.substr(prefix.size());
- } else if (line.empty()) {
- res.emplace_back(std::move(event));
+ if (line.starts_with(ignorePrefix) && reportIgnoredLines == ReportIgnoredLines::Yes) {
+ eventWatcher.commentEvent(line);
+ } else if (line.starts_with(ignorePrefix)) {
+ continue;
+ } else if (line.starts_with(dataPrefix)) {
+ event += line.substr(dataPrefix.size());
+ } else if (line.empty() && !event.empty()) {
+ eventWatcher.dataEvent(event);
event.clear();
+ } else if (line.empty()) {
+ continue;
} else {
CAPTURE(msg);
FAIL("Unprefixed response");
}
}
- return res;
}
diff --git a/tests/restconf_utils.h b/tests/restconf_utils.h
index 9efe398..9dde10b 100644
--- a/tests/restconf_utils.h
+++ b/tests/restconf_utils.h
@@ -88,17 +88,23 @@ struct SSEClient {
std::shared_ptr<ng_client::session> client;
boost::asio::deadline_timer t;
+ enum class ReportIgnoredLines {
+ No,
+ Yes,
+ };
+
SSEClient(
boost::asio::io_service& io,
const std::string& server_address,
const std::string& server_port,
std::binary_semaphore& requestSent,
- const RestconfNotificationWatcher& notification,
+ const RestconfNotificationWatcher& eventWatcher,
const std::string& uri,
const std::map<std::string, std::string>& headers,
- const boost::posix_time::seconds silenceTimeout = boost::posix_time::seconds(1)); // test code; the server should respond "soon"
+ const boost::posix_time::seconds silenceTimeout = boost::posix_time::seconds(1), // test code; the server should respond "soon"
+ const ReportIgnoredLines reportIgnoredLines = ReportIgnoredLines::No);
- static std::vector<std::string> parseEvents(const std::string& msg);
+ static void parseEvents(const std::string& msg, const RestconfNotificationWatcher& eventWatcher, const ReportIgnoredLines reportIgnoredLines);
};
#define PREPARE_LOOP_WITH_EXCEPTIONS \
--
2.43.0
@@ -1,45 +0,0 @@
From 64997543d48236cd2aae417568bc54d32c54df21 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Mon, 2 Dec 2024 14:43:36 +0100
Subject: [PATCH 10/44] Fix compatibility with pam_wrapper 1.1.6+
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
An year ago I reported a bug that the pam_wrapper project says that they
use a variable called `PAM_WRAPPER_DISABLE_DEEPBIND`, but in fact they
check `UID_WRAPPER_DISABLE_DEEPBIND`. The upstream listened to me, and
they fixed it [1]. Unfortunately, the old variable name is not read from
as of pam_wrapper 1.1.6, so we require setting *both* variables for
random version compatibility.
[1] https://git.samba.org/?p=pam_wrapper.git;a=commitdiff;h=9f0cccf7432dd9be1de953f9b13a7f9b06c40442
Change-Id: I2959f505f5325950606c68b0b324be7181dd6e4f
Reported-by: Tomáš Pecka <tomas.pecka@cesnet.cz>
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
CMakeLists.txt | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 01dd2c2..731d7cb 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -179,10 +179,11 @@ if(BUILD_TESTING)
endif()
if(TEST_WRAP_PAM)
+ # FIXME: remove UID_WRAPPER_... (and keep PAM_WRAPPER_...) once we require pam_wrapper 1.1.6+
set(TEST_COMMAND
${UNSHARE_EXECUTABLE} -r -m sh -c "set -ex $<SEMICOLON>
${MOUNT_EXECUTABLE} -t tmpfs none /tmp $<SEMICOLON>
- export LD_PRELOAD=${pam_wrapper_LDFLAGS} PAM_WRAPPER_SERVICE_DIR=${CMAKE_CURRENT_BINARY_DIR}/tests/pam PAM_WRAPPER=1 UID_WRAPPER_DISABLE_DEEPBIND=1 $<SEMICOLON>
+ export LD_PRELOAD=${pam_wrapper_LDFLAGS} PAM_WRAPPER_SERVICE_DIR=${CMAKE_CURRENT_BINARY_DIR}/tests/pam PAM_WRAPPER=1 UID_WRAPPER_DISABLE_DEEPBIND=1 PAM_WRAPPER_DISABLE_DEEPBIND=1 $<SEMICOLON>
$<TARGET_FILE:test-${TEST_NAME}>")
else()
set(TEST_COMMAND test-${TEST_NAME})
--
2.43.0
@@ -0,0 +1,428 @@
From 5becffe8a1dd47c8836ce1800a1b72acdf86021f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Mon, 12 May 2025 14:56:54 +0200
Subject: [PATCH 10/17] http: send keep-alive pings from EventStream
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
The nghttp2-asio server disconnects client after 60 seconds of no
activity in the stream [1]. In our use-case I can imagine it can be
quite common that no data will flow for a long period of time. If no
data are updated (i.e., no notifications, or no YANG data changes in
sysrepo when we have YANG push), then nothing flows forth and back.
The 60 second value is configurable [2], but I am not sure about the
value that should be used. 5 minutes? hours? days?
Let's approach this differently. We can instead send a comment into the
stream every few seconds which will make sure that the connection will
not be dropped by nghttp2-asio. This approach is described in the
HTML Living Standard [3].
For now, let's resort to sending a ping-alive every 55 seconds and
hardcode 60 seconds no activity timeout.
I have verified that using this method a curl client keeps connected
to the server when the server sends these "keep-alive comments".
[1] https://github.com/nghttp2/nghttp2/issues/555
[2] https://github.com/nghttp2/nghttp2-asio/blob/e877868abe06a83ed0a6ac6e245c07f6f20866b5/lib/asio_server_http2_impl.h#L51
[3] https://html.spec.whatwg.org/multipage/server-sent-events.html#authoring-notes
Change-Id: I57e510d0b61ac7ed032c582779780c64768b7d53
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/clock.cpp | 4 +-
src/http/EventStream.cpp | 44 +++++++++++++--
src/http/EventStream.h | 13 ++++-
src/restconf/NotificationStream.cpp | 3 +-
src/restconf/NotificationStream.h | 1 +
src/restconf/Server.cpp | 21 +++++--
src/restconf/Server.h | 6 +-
tests/restconf-eventstream.cpp | 85 +++++++++++++++++++++--------
8 files changed, 139 insertions(+), 38 deletions(-)
diff --git a/src/clock.cpp b/src/clock.cpp
index c85be19..24e9efb 100644
--- a/src/clock.cpp
+++ b/src/clock.cpp
@@ -14,6 +14,8 @@
using namespace std::literals;
+constexpr auto keepAlivePingInterval = std::chrono::seconds{55};
+
int main(int argc [[maybe_unused]], char** argv [[maybe_unused]])
{
spdlog::set_level(spdlog::level::trace);
@@ -32,7 +34,7 @@ int main(int argc [[maybe_unused]], char** argv [[maybe_unused]])
server.num_threads(4);
server.handle("/events", [&shutdown, &sig](const auto& req, const auto& res) {
- auto client = std::make_shared<rousette::http::EventStream>(req, res, shutdown, sig);
+ auto client = std::make_shared<rousette::http::EventStream>(req, res, shutdown, sig, keepAlivePingInterval);
client->activate();
});
diff --git a/src/http/EventStream.cpp b/src/http/EventStream.cpp
index 77c282d..82f8b38 100644
--- a/src/http/EventStream.cpp
+++ b/src/http/EventStream.cpp
@@ -17,17 +17,26 @@ using namespace nghttp2::asio_http2;
namespace rousette::http {
+constexpr auto FIELD_DATA = "data";
+
/** @short After constructing, make sure to call activate() immediately. */
-EventStream::EventStream(const server::request& req, const server::response& res, Termination& termination, EventSignal& signal, const std::optional<std::string>& initialEvent)
+EventStream::EventStream(const server::request& req,
+ const server::response& res,
+ Termination& termination,
+ EventSignal& signal,
+ const std::chrono::seconds keepAlivePingInterval,
+ const std::optional<std::string>& initialEvent)
: res{res}
+ , ping{res.io_service()}
, peer{peer_from_request(req)}
+ , m_keepAlivePingInterval(keepAlivePingInterval)
{
if (initialEvent) {
- enqueue(*initialEvent);
+ enqueue(FIELD_DATA, *initialEvent);
}
eventSub = signal.connect([this](const auto& msg) {
- enqueue(msg);
+ enqueue(FIELD_DATA, msg);
});
terminateSub = termination.connect([this]() {
@@ -56,6 +65,8 @@ shared_from_this() throws bad_weak_ptr, so we need a two-phase construction.
*/
void EventStream::activate()
{
+ start_ping();
+
auto client = shared_from_this();
res.write_head(200, {
{"content-type", {"text/event-stream", false}},
@@ -65,6 +76,7 @@ void EventStream::activate()
res.on_close([client](const auto ec) {
spdlog::debug("{}: closed ({})", client->peer, nghttp2_http2_strerror(ec));
std::lock_guard lock{client->mtx};
+ client->ping.cancel();
client->eventSub.disconnect();
client->terminateSub.disconnect();
client->state = Closed;
@@ -113,14 +125,15 @@ ssize_t EventStream::process(uint8_t* destination, std::size_t len, uint32_t* da
__builtin_unreachable();
}
-void EventStream::enqueue(const std::string& what)
+void EventStream::enqueue(const std::string& fieldName, const std::string& what)
{
std::string buf;
buf.reserve(what.size());
const std::regex newline{"\n"};
for (auto it = std::sregex_token_iterator{what.begin(), what.end(), newline, -1};
it != std::sregex_token_iterator{}; ++it) {
- buf += "data: ";
+ buf += fieldName;
+ buf += ": ";
buf += *it;
buf += '\n';
}
@@ -139,4 +152,25 @@ void EventStream::enqueue(const std::string& what)
state = HasEvents;
boost::asio::post(res.io_service(), [&res = this->res]() { res.resume(); });
}
+
+void EventStream::start_ping()
+{
+ ping.expires_from_now(boost::posix_time::seconds(m_keepAlivePingInterval.count()));
+ ping.async_wait([maybeClient = weak_from_this()](const boost::system::error_code& ec) {
+ auto client = maybeClient.lock();
+ if (!client) {
+ spdlog::trace("ping: client already gone");
+ return;
+ }
+
+ if (ec == boost::asio::error::operation_aborted) {
+ spdlog::trace("{}: ping scheduler cancelled", client->peer);
+ return;
+ }
+
+ client->enqueue("", "\n");
+ spdlog::trace("{}: keep-alive ping enqueued", client->peer);
+ client->start_ping();
+ });
+}
}
diff --git a/src/http/EventStream.h b/src/http/EventStream.h
index b427fb3..4b3578a 100644
--- a/src/http/EventStream.h
+++ b/src/http/EventStream.h
@@ -7,6 +7,7 @@
#pragma once
+#include <boost/asio/deadline_timer.hpp>
#include <boost/signals2.hpp>
#include <list>
#include <memory>
@@ -30,7 +31,12 @@ public:
using EventSignal = boost::signals2::signal<void(const std::string& message)>;
using Termination = boost::signals2::signal<void()>;
- EventStream(const nghttp2::asio_http2::server::request& req, const nghttp2::asio_http2::server::response& res, Termination& terminate, EventSignal& signal, const std::optional<std::string>& initialEvent = std::nullopt);
+ EventStream(const nghttp2::asio_http2::server::request& req,
+ const nghttp2::asio_http2::server::response& res,
+ Termination& terminate,
+ EventSignal& signal,
+ const std::chrono::seconds keepAlivePingInterval,
+ const std::optional<std::string>& initialEvent = std::nullopt);
void activate();
private:
@@ -43,13 +49,16 @@ private:
};
State state = WaitingForEvents;
+ boost::asio::deadline_timer ping;
std::list<std::string> queue;
mutable std::mutex mtx; // for `state` and `queue`
boost::signals2::scoped_connection eventSub, terminateSub;
const std::string peer;
+ const std::chrono::seconds m_keepAlivePingInterval;
size_t send_chunk(uint8_t* destination, std::size_t len, uint32_t* data_flags);
ssize_t process(uint8_t* destination, std::size_t len, uint32_t* data_flags);
- void enqueue(const std::string& what);
+ void enqueue(const std::string& fieldName, const std::string& what);
+ void start_ping();
};
}
diff --git a/src/restconf/NotificationStream.cpp b/src/restconf/NotificationStream.cpp
index d0ba938..5017f3e 100644
--- a/src/restconf/NotificationStream.cpp
+++ b/src/restconf/NotificationStream.cpp
@@ -89,12 +89,13 @@ NotificationStream::NotificationStream(
const nghttp2::asio_http2::server::response& res,
rousette::http::EventStream::Termination& termination,
std::shared_ptr<rousette::http::EventStream::EventSignal> signal,
+ const std::chrono::seconds keepAlivePingInterval,
sysrepo::Session session,
libyang::DataFormat dataFormat,
const std::optional<std::string>& filter,
const std::optional<sysrepo::NotificationTimeStamp>& startTime,
const std::optional<sysrepo::NotificationTimeStamp>& stopTime)
- : EventStream(req, res, termination, *signal)
+ : EventStream(req, res, termination, *signal, keepAlivePingInterval)
, m_notificationSignal(signal)
, m_session(std::move(session))
, m_dataFormat(dataFormat)
diff --git a/src/restconf/NotificationStream.h b/src/restconf/NotificationStream.h
index 46f8416..daa79d6 100644
--- a/src/restconf/NotificationStream.h
+++ b/src/restconf/NotificationStream.h
@@ -44,6 +44,7 @@ public:
const nghttp2::asio_http2::server::response& res,
rousette::http::EventStream::Termination& termination,
std::shared_ptr<rousette::http::EventStream::EventSignal> signal,
+ const std::chrono::seconds keepAlivePingInterval,
sysrepo::Session sess,
libyang::DataFormat dataFormat,
const std::optional<std::string>& filter,
diff --git a/src/restconf/Server.cpp b/src/restconf/Server.cpp
index bd2ce0d..31b3d0f 100644
--- a/src/restconf/Server.cpp
+++ b/src/restconf/Server.cpp
@@ -864,13 +864,14 @@ std::vector<std::shared_ptr<boost::asio::io_context>> Server::io_services() cons
return server->io_services();
}
-Server::Server(sysrepo::Connection conn, const std::string& address, const std::string& port, const std::chrono::milliseconds timeout)
+Server::Server(sysrepo::Connection conn, const std::string& address, const std::string& port, const std::chrono::milliseconds timeout, const std::chrono::seconds keepAlivePingInterval)
: m_monitoringSession(conn.sessionStart(sysrepo::Datastore::Operational))
, nacm(conn)
, server{std::make_unique<nghttp2::asio_http2::server::http2>()}
, dwdmEvents{std::make_unique<sr::OpticalEvents>(conn.sessionStart())}
{
server->num_threads(1); // we only use one thread for the server, so we can call join() right away
+ server->read_timeout(boost::posix_time::seconds{60}); // terminate connection after 60 seconds of inactivity (this is explicitly setting the default value)
for (const auto& [module, version, features] : {
std::tuple<std::string, std::string, std::vector<std::string>>{"ietf-restconf", "2017-01-26", {}},
@@ -930,14 +931,14 @@ Server::Server(sysrepo::Connection conn, const std::string& address, const std::
res.end("<XRD xmlns='http://docs.oasis-open.org/ns/xri/xrd-1.0'><Link rel='restconf' href='"s + restconfRoot + "'></XRD>"s);
});
- server->handle("/telemetry/optics", [this](const auto& req, const auto& res) {
+ server->handle("/telemetry/optics", [this, keepAlivePingInterval](const auto& req, const auto& res) {
logRequest(req);
- auto client = std::make_shared<http::EventStream>(req, res, shutdownRequested, opticsChange, as_restconf_push_update(dwdmEvents->currentData(), std::chrono::system_clock::now()));
+ auto client = std::make_shared<http::EventStream>(req, res, shutdownRequested, opticsChange, keepAlivePingInterval, as_restconf_push_update(dwdmEvents->currentData(), std::chrono::system_clock::now()));
client->activate();
});
- server->handle(netconfStreamRoot, [this, conn](const auto& req, const auto& res) mutable {
+ server->handle(netconfStreamRoot, [this, conn, keepAlivePingInterval](const auto& req, const auto& res) mutable {
logRequest(req);
std::optional<std::string> xpathFilter;
@@ -970,7 +971,17 @@ Server::Server(sysrepo::Connection conn, const std::string& address, const std::
// The signal is constructed outside NotificationStream class because it is required to be passed to
// NotificationStream's parent (EventStream) constructor where it already must be constructed
// Yes, this is a hack.
- auto client = std::make_shared<NotificationStream>(req, res, shutdownRequested, std::make_shared<rousette::http::EventStream::EventSignal>(), sess, streamRequest.type.encoding, xpathFilter, startTime, stopTime);
+ auto client = std::make_shared<NotificationStream>(
+ req,
+ res,
+ shutdownRequested,
+ std::make_shared<rousette::http::EventStream::EventSignal>(),
+ keepAlivePingInterval,
+ sess,
+ streamRequest.type.encoding,
+ xpathFilter,
+ startTime,
+ stopTime);
client->activate();
} catch (const auth::Error& e) {
processAuthError(req, res, e, [&res]() {
diff --git a/src/restconf/Server.h b/src/restconf/Server.h
index 112c943..0720c8d 100644
--- a/src/restconf/Server.h
+++ b/src/restconf/Server.h
@@ -28,7 +28,11 @@ std::optional<std::string> as_subtree_path(const std::string& path);
/** @short A RESTCONF-ish server */
class Server {
public:
- explicit Server(sysrepo::Connection conn, const std::string& address, const std::string& port, const std::chrono::milliseconds timeout = std::chrono::milliseconds{0});
+ explicit Server(sysrepo::Connection conn,
+ const std::string& address,
+ const std::string& port,
+ const std::chrono::milliseconds timeout = std::chrono::milliseconds{0},
+ const std::chrono::seconds keepAlivePingInterval = std::chrono::seconds{55});
~Server();
void join();
void stop();
diff --git a/tests/restconf-eventstream.cpp b/tests/restconf-eventstream.cpp
index 3d7fc15..ad1040d 100644
--- a/tests/restconf-eventstream.cpp
+++ b/tests/restconf-eventstream.cpp
@@ -21,9 +21,9 @@ static const auto SERVER_PORT = "10091";
using namespace std::chrono_literals;
-TEST_CASE("Termination on server shutdown")
+TEST_CASE("Event stream tests")
{
- trompeloeil::sequence seqMod1;
+ trompeloeil::sequence seq1, seq2;
sysrepo::setLogLevelStderr(sysrepo::LogLevel::Information);
spdlog::set_level(spdlog::level::trace);
@@ -34,37 +34,76 @@ TEST_CASE("Termination on server shutdown")
srSess.sendRPC(srSess.getContext().newPath("/ietf-factory-default:factory-reset"));
auto nacmGuard = manageNacm(srSess);
- auto server = std::make_unique<rousette::restconf::Server>(srConn, SERVER_ADDRESS, SERVER_PORT);
setupRealNacm(srSess);
+ const std::string notification(R"({"example:eventA":{"message":"blabla","progress":11}})");
RestconfNotificationWatcher netconfWatcher(srConn.sessionStart().getContext());
- const std::string notification(R"({"example:eventA":{"message":"blabla","progress":11}})");
- EXPECT_NOTIFICATION(notification, seqMod1);
- EXPECT_NOTIFICATION(notification, seqMod1);
- EXPECT_NOTIFICATION(notification, seqMod1);
+ SECTION("Termination on server shutdown")
+ {
+ auto server = std::make_unique<rousette::restconf::Server>(srConn, SERVER_ADDRESS, SERVER_PORT);
+
+ EXPECT_NOTIFICATION(notification, seq1);
+ EXPECT_NOTIFICATION(notification, seq1);
+ EXPECT_NOTIFICATION(notification, seq1);
+
+ auto notifSession = sysrepo::Connection{}.sessionStart();
+ auto ctx = notifSession.getContext();
+
+ PREPARE_LOOP_WITH_EXCEPTIONS;
+ auto notificationThread = std::jthread(wrap_exceptions_and_asio(bg, io, [&]() {
+ auto notifSession = sysrepo::Connection{}.sessionStart();
+ auto ctx = notifSession.getContext();
+
+ WAIT_UNTIL_SSE_CLIENT_REQUESTS;
+ SEND_NOTIFICATION(notification);
+ SEND_NOTIFICATION(notification);
+ SEND_NOTIFICATION(notification);
+ waitForCompletionAndBitMore(seq1);
+
+ auto beforeShutdown = std::chrono::system_clock::now();
+ server.reset();
+ auto shutdownDuration = std::chrono::system_clock::now() - beforeShutdown;
+ REQUIRE(shutdownDuration < 5s);
+ }));
+
+ SSEClient client(io, SERVER_ADDRESS, SERVER_PORT, requestSent, netconfWatcher, "/streams/NETCONF/JSON", std::map<std::string, std::string>{AUTH_ROOT});
+
+ RUN_LOOP_WITH_EXCEPTIONS;
+ }
+
+ SECTION("Keep-alive pings")
+ {
+ constexpr auto pingInterval = 1s;
- auto notifSession = sysrepo::Connection{}.sessionStart();
- auto ctx = notifSession.getContext();
+ RestconfNotificationWatcher netconfWatcher(srConn.sessionStart().getContext());
+ auto server = std::make_unique<rousette::restconf::Server>(srConn, SERVER_ADDRESS, SERVER_PORT, std::chrono::milliseconds{0}, pingInterval);
- PREPARE_LOOP_WITH_EXCEPTIONS;
- auto notificationThread = std::jthread(wrap_exceptions_and_asio(bg, io, [&]() {
auto notifSession = sysrepo::Connection{}.sessionStart();
auto ctx = notifSession.getContext();
- WAIT_UNTIL_SSE_CLIENT_REQUESTS;
- SEND_NOTIFICATION(notification);
- SEND_NOTIFICATION(notification);
- SEND_NOTIFICATION(notification);
- waitForCompletionAndBitMore(seqMod1);
+ EXPECT_NOTIFICATION(notification, seq1);
+ expectations.emplace_back(NAMED_REQUIRE_CALL(netconfWatcher, comment(": ")).IN_SEQUENCE(seq2).TIMES(AT_LEAST(1)));
- auto beforeShutdown = std::chrono::system_clock::now();
- server.reset();
- auto shutdownDuration = std::chrono::system_clock::now() - beforeShutdown;
- REQUIRE(shutdownDuration < 5s);
- }));
+ PREPARE_LOOP_WITH_EXCEPTIONS;
+ auto notificationThread = std::jthread(wrap_exceptions_and_asio(bg, io, [&]() {
+ WAIT_UNTIL_SSE_CLIENT_REQUESTS;
+ SEND_NOTIFICATION(notification);
+ std::this_thread::sleep_for(3s); // Wait for the server to send at least one keep-alive ping
+ server.reset();
+ }));
- SSEClient client(io, SERVER_ADDRESS, SERVER_PORT, requestSent, netconfWatcher, "/streams/NETCONF/JSON", std::map<std::string, std::string>{AUTH_ROOT});
+ SSEClient client(
+ io,
+ SERVER_ADDRESS,
+ SERVER_PORT,
+ requestSent,
+ netconfWatcher,
+ "/streams/NETCONF/JSON",
+ std::map<std::string, std::string>{AUTH_ROOT},
+ boost::posix_time::seconds{5},
+ SSEClient::ReportIgnoredLines::Yes);
- RUN_LOOP_WITH_EXCEPTIONS;
+ RUN_LOOP_WITH_EXCEPTIONS;
+ }
}
--
2.43.0
@@ -0,0 +1,205 @@
From ff4ff1c193083feca76d9f0f4485e4b175c373c2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Wed, 23 Jul 2025 14:27:26 +0200
Subject: [PATCH 11/17] refactor: event streams use named constructors
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
This commit refactors the EventStream and NotificationStream to use
named constructors.
Both of these classes are required to be initialized with invoking the
constructor and then the activate method. The NotificationStream class
also required to be passed with a newly constructed Event object.
I find this pattern a bit confusing, so I refactored the code to use
named constructors which perform the double initialization themselves.
This way, the code is more readable and the intention should be clearer.
Change-Id: Iac96c49c20670dfe924d7c8db33328ed9c2fc9dd
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/clock.cpp | 3 +--
src/http/EventStream.cpp | 20 ++++++++++++++++++++
src/http/EventStream.h | 22 +++++++++++++++-------
src/restconf/NotificationStream.cpp | 22 ++++++++++++++++++++++
src/restconf/NotificationStream.h | 12 ++++++++++++
src/restconf/Server.cpp | 10 ++--------
6 files changed, 72 insertions(+), 17 deletions(-)
diff --git a/src/clock.cpp b/src/clock.cpp
index 24e9efb..aa72849 100644
--- a/src/clock.cpp
+++ b/src/clock.cpp
@@ -34,8 +34,7 @@ int main(int argc [[maybe_unused]], char** argv [[maybe_unused]])
server.num_threads(4);
server.handle("/events", [&shutdown, &sig](const auto& req, const auto& res) {
- auto client = std::make_shared<rousette::http::EventStream>(req, res, shutdown, sig, keepAlivePingInterval);
- client->activate();
+ rousette::http::EventStream::create(req, res, shutdown, sig, keepAlivePingInterval);
});
server.handle("/", [](const auto& req, const auto& resp) {
diff --git a/src/http/EventStream.cpp b/src/http/EventStream.cpp
index 82f8b38..0893653 100644
--- a/src/http/EventStream.cpp
+++ b/src/http/EventStream.cpp
@@ -173,4 +173,24 @@ void EventStream::start_ping()
client->start_ping();
});
}
+
+/** @brief Create a new EventStream instance and activate it immediately.
+ *
+ * The stream is created with the given parameters and activated as if the activate() method was called.
+ * ```
+ * auto a = make_shared<EventStream>(...);
+ * a->activate();
+ * ```
+ */
+std::shared_ptr<EventStream> EventStream::create(const nghttp2::asio_http2::server::request& req,
+ const nghttp2::asio_http2::server::response& res,
+ Termination& terminate,
+ EventSignal& signal,
+ const std::chrono::seconds keepAlivePingInterval,
+ const std::optional<std::string>& initialEvent)
+{
+ auto stream = std::shared_ptr<EventStream>(new EventStream(req, res, terminate, signal, keepAlivePingInterval, initialEvent));
+ stream->activate();
+ return stream;
+}
}
diff --git a/src/http/EventStream.h b/src/http/EventStream.h
index 4b3578a..ef0a002 100644
--- a/src/http/EventStream.h
+++ b/src/http/EventStream.h
@@ -31,13 +31,12 @@ public:
using EventSignal = boost::signals2::signal<void(const std::string& message)>;
using Termination = boost::signals2::signal<void()>;
- EventStream(const nghttp2::asio_http2::server::request& req,
- const nghttp2::asio_http2::server::response& res,
- Termination& terminate,
- EventSignal& signal,
- const std::chrono::seconds keepAlivePingInterval,
- const std::optional<std::string>& initialEvent = std::nullopt);
- void activate();
+ static std::shared_ptr<EventStream> create(const nghttp2::asio_http2::server::request& req,
+ const nghttp2::asio_http2::server::response& res,
+ Termination& terminate,
+ EventSignal& signal,
+ const std::chrono::seconds keepAlivePingInterval,
+ const std::optional<std::string>& initialEvent = std::nullopt);
private:
const nghttp2::asio_http2::server::response& res;
@@ -60,5 +59,14 @@ private:
ssize_t process(uint8_t* destination, std::size_t len, uint32_t* data_flags);
void enqueue(const std::string& fieldName, const std::string& what);
void start_ping();
+
+protected:
+ EventStream(const nghttp2::asio_http2::server::request& req,
+ const nghttp2::asio_http2::server::response& res,
+ Termination& terminate,
+ EventSignal& signal,
+ const std::chrono::seconds keepAlivePingInterval,
+ const std::optional<std::string>& initialEvent = std::nullopt);
+ void activate();
};
}
diff --git a/src/restconf/NotificationStream.cpp b/src/restconf/NotificationStream.cpp
index 5017f3e..fe21809 100644
--- a/src/restconf/NotificationStream.cpp
+++ b/src/restconf/NotificationStream.cpp
@@ -183,4 +183,26 @@ libyang::DataNode replaceStreamLocations(const std::optional<std::string>& schem
return node;
}
+
+/** @brief Create a new NotificationStream instance and activate it immediately.
+ *
+ * The stream is created with the given parameters and activated, which means it starts listening for
+ * NETCONF notifications and sending them to the client.
+ */
+std::shared_ptr<NotificationStream> NotificationStream::create(
+ const nghttp2::asio_http2::server::request& req,
+ const nghttp2::asio_http2::server::response& res,
+ rousette::http::EventStream::Termination& termination,
+ const std::chrono::seconds keepAlivePingInterval,
+ sysrepo::Session sess,
+ libyang::DataFormat dataFormat,
+ const std::optional<std::string>& filter,
+ const std::optional<sysrepo::NotificationTimeStamp>& startTime,
+ const std::optional<sysrepo::NotificationTimeStamp>& stopTime)
+{
+ auto signal = std::make_shared<rousette::http::EventStream::EventSignal>();
+ auto stream = std::shared_ptr<NotificationStream>(new NotificationStream(req, res, termination, signal, keepAlivePingInterval, std::move(sess), dataFormat, filter, startTime, stopTime));
+ stream->activate();
+ return stream;
+}
}
diff --git a/src/restconf/NotificationStream.h b/src/restconf/NotificationStream.h
index daa79d6..91c5dde 100644
--- a/src/restconf/NotificationStream.h
+++ b/src/restconf/NotificationStream.h
@@ -39,6 +39,18 @@ class NotificationStream : public rousette::http::EventStream {
std::optional<sysrepo::Subscription> m_notifSubs;
public:
+ static std::shared_ptr<NotificationStream> create(
+ const nghttp2::asio_http2::server::request& req,
+ const nghttp2::asio_http2::server::response& res,
+ rousette::http::EventStream::Termination& termination,
+ const std::chrono::seconds keepAlivePingInterval,
+ sysrepo::Session sess,
+ libyang::DataFormat dataFormat,
+ const std::optional<std::string>& filter,
+ const std::optional<sysrepo::NotificationTimeStamp>& startTime,
+ const std::optional<sysrepo::NotificationTimeStamp>& stopTime);
+
+protected:
NotificationStream(
const nghttp2::asio_http2::server::request& req,
const nghttp2::asio_http2::server::response& res,
diff --git a/src/restconf/Server.cpp b/src/restconf/Server.cpp
index 31b3d0f..8544210 100644
--- a/src/restconf/Server.cpp
+++ b/src/restconf/Server.cpp
@@ -934,8 +934,7 @@ Server::Server(sysrepo::Connection conn, const std::string& address, const std::
server->handle("/telemetry/optics", [this, keepAlivePingInterval](const auto& req, const auto& res) {
logRequest(req);
- auto client = std::make_shared<http::EventStream>(req, res, shutdownRequested, opticsChange, keepAlivePingInterval, as_restconf_push_update(dwdmEvents->currentData(), std::chrono::system_clock::now()));
- client->activate();
+ http::EventStream::create(req, res, shutdownRequested, opticsChange, keepAlivePingInterval, as_restconf_push_update(dwdmEvents->currentData(), std::chrono::system_clock::now()));
});
server->handle(netconfStreamRoot, [this, conn, keepAlivePingInterval](const auto& req, const auto& res) mutable {
@@ -968,21 +967,16 @@ Server::Server(sysrepo::Connection conn, const std::string& address, const std::
stopTime = libyang::fromYangTimeFormat<std::chrono::system_clock>(std::get<std::string>(it->second));
}
- // The signal is constructed outside NotificationStream class because it is required to be passed to
- // NotificationStream's parent (EventStream) constructor where it already must be constructed
- // Yes, this is a hack.
- auto client = std::make_shared<NotificationStream>(
+ NotificationStream::create(
req,
res,
shutdownRequested,
- std::make_shared<rousette::http::EventStream::EventSignal>(),
keepAlivePingInterval,
sess,
streamRequest.type.encoding,
xpathFilter,
startTime,
stopTime);
- client->activate();
} catch (const auth::Error& e) {
processAuthError(req, res, e, [&res]() {
res.write_head(401, {TEXT_PLAIN, CORS});
--
2.43.0
@@ -1,30 +0,0 @@
From 849aa35274d5e07726cb849fe724962754b3fa29 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Mon, 2 Dec 2024 20:20:48 +0100
Subject: [PATCH 11/44] tests: add missing pragma once
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Change-Id: I269c20e5a914aa8c7bc9431147dd9785ea1aedda
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
tests/aux-utils.h | 1 +
1 file changed, 1 insertion(+)
diff --git a/tests/aux-utils.h b/tests/aux-utils.h
index a3923bb..7482945 100644
--- a/tests/aux-utils.h
+++ b/tests/aux-utils.h
@@ -6,6 +6,7 @@
*
*/
+#pragma once
#include "trompeloeil_doctest.h"
#include <iostream>
#include <nghttp2/asio_http2_client.h>
--
2.43.0
@@ -0,0 +1,104 @@
From f4602a03adc9134a9b7a9d338e900b40557da6c4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Thu, 7 Aug 2025 12:14:02 +0200
Subject: [PATCH 12/17] refactor: a better convention for weak_from_this->lock
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
It is not a "client", so let's stop calling it a "client". My bad.
Change-Id: Id8dc4d92c3ade8d86697366d0102e84bd466f504
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/http/EventStream.cpp | 44 ++++++++++++++++++++--------------------
1 file changed, 22 insertions(+), 22 deletions(-)
diff --git a/src/http/EventStream.cpp b/src/http/EventStream.cpp
index 0893653..3bdd55e 100644
--- a/src/http/EventStream.cpp
+++ b/src/http/EventStream.cpp
@@ -47,11 +47,11 @@ EventStream::EventStream(const server::request& req,
}
state = WantToClose;
- boost::asio::post(this->res.io_service(), [maybeClient = std::weak_ptr<EventStream>{shared_from_this()}]() {
- if (auto client = maybeClient.lock()) {
- std::lock_guard lock{client->mtx};
- if (client->state == WantToClose) { // resume unless somebody closed it before this was picked up by the event loop
- client->res.resume();
+ boost::asio::post(this->res.io_service(), [weak = std::weak_ptr<EventStream>{shared_from_this()}]() {
+ if (auto myself = weak.lock()) {
+ std::lock_guard lock{myself->mtx};
+ if (myself->state == WantToClose) { // resume unless somebody closed it before this was picked up by the event loop
+ myself->res.resume();
}
}
});
@@ -67,23 +67,23 @@ void EventStream::activate()
{
start_ping();
- auto client = shared_from_this();
+ auto myself = shared_from_this();
res.write_head(200, {
{"content-type", {"text/event-stream", false}},
{"access-control-allow-origin", {"*", false}},
});
- res.on_close([client](const auto ec) {
- spdlog::debug("{}: closed ({})", client->peer, nghttp2_http2_strerror(ec));
- std::lock_guard lock{client->mtx};
- client->ping.cancel();
- client->eventSub.disconnect();
- client->terminateSub.disconnect();
- client->state = Closed;
+ res.on_close([myself](const auto ec) {
+ spdlog::debug("{}: closed ({})", myself->peer, nghttp2_http2_strerror(ec));
+ std::lock_guard lock{myself->mtx};
+ myself->ping.cancel();
+ myself->eventSub.disconnect();
+ myself->terminateSub.disconnect();
+ myself->state = Closed;
});
- res.end([client](uint8_t* destination, std::size_t len, uint32_t* data_flags) {
- return client->process(destination, len, data_flags);
+ res.end([myself](uint8_t* destination, std::size_t len, uint32_t* data_flags) {
+ return myself->process(destination, len, data_flags);
});
}
@@ -156,21 +156,21 @@ void EventStream::enqueue(const std::string& fieldName, const std::string& what)
void EventStream::start_ping()
{
ping.expires_from_now(boost::posix_time::seconds(m_keepAlivePingInterval.count()));
- ping.async_wait([maybeClient = weak_from_this()](const boost::system::error_code& ec) {
- auto client = maybeClient.lock();
- if (!client) {
+ ping.async_wait([weak = weak_from_this()](const boost::system::error_code& ec) {
+ auto myself = weak.lock();
+ if (!myself) {
spdlog::trace("ping: client already gone");
return;
}
if (ec == boost::asio::error::operation_aborted) {
- spdlog::trace("{}: ping scheduler cancelled", client->peer);
+ spdlog::trace("{}: ping scheduler cancelled", myself->peer);
return;
}
- client->enqueue("", "\n");
- spdlog::trace("{}: keep-alive ping enqueued", client->peer);
- client->start_ping();
+ myself->enqueue("", "\n");
+ spdlog::trace("{}: keep-alive ping enqueued", myself->peer);
+ myself->start_ping();
});
}
--
2.43.0
@@ -1,205 +0,0 @@
From 60eac2b2d60f8f1918a0914272975dd53f527c01 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Mon, 2 Dec 2024 19:36:10 +0100
Subject: [PATCH 12/44] tests: extend clientRequest wrappers interface and use
it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
This is a preparation for refactoring in the next few commits.
I will generalize the clientRequest interface to accept server
address and port too.
The head/get/put/post/... helper methods will not require those server
port and address parameters.
Change-Id: Iee54a3b3017ef9875fcd20640b74c7aa42813b9f
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
tests/aux-utils.h | 34 +++++++++++++++++---------------
tests/restconf-nacm.cpp | 14 +++++--------
tests/restconf-notifications.cpp | 12 +++++------
tests/restconf-yang-schema.cpp | 19 ++++++++++--------
4 files changed, 40 insertions(+), 39 deletions(-)
diff --git a/tests/aux-utils.h b/tests/aux-utils.h
index 7482945..9afd7bc 100644
--- a/tests/aux-utils.h
+++ b/tests/aux-utils.h
@@ -153,12 +153,14 @@ const ng::header_map eventStreamHeaders {
#define ACCESS_CONTROL_ALLOW_ORIGIN {"access-control-allow-origin", "*"}
#define ACCEPT_PATCH {"accept-patch", "application/yang-data+json, application/yang-data+xml, application/yang-patch+xml, application/yang-patch+json"}
+// this is a test, and the server is expected to reply "soon"
+static const boost::posix_time::time_duration CLIENT_TIMEOUT = boost::posix_time::seconds(3);
+
Response clientRequest(auto method,
auto uri,
const std::string& data,
const std::map<std::string, std::string>& headers,
- // this is a test, and the server is expected to reply "soon"
- const boost::posix_time::time_duration timeout=boost::posix_time::seconds(3))
+ const boost::posix_time::time_duration timeout = CLIENT_TIMEOUT)
{
boost::asio::io_service io_service;
auto client = std::make_shared<ng_client::session>(io_service, SERVER_ADDRESS, SERVER_PORT);
@@ -199,39 +201,39 @@ Response clientRequest(auto method,
return {statusCode, resHeaders, oss.str()};
}
-Response get(auto uri, const std::map<std::string, std::string>& headers)
+Response get(auto uri, const std::map<std::string, std::string>& headers, const boost::posix_time::time_duration timeout = CLIENT_TIMEOUT)
{
- return clientRequest("GET", uri, "", headers);
+ return clientRequest("GET", uri, "", headers, timeout);
}
-Response options(auto uri, const std::map<std::string, std::string>& headers)
+Response options(auto uri, const std::map<std::string, std::string>& headers, const boost::posix_time::time_duration timeout = CLIENT_TIMEOUT)
{
- return clientRequest("OPTIONS", uri, "", headers);
+ return clientRequest("OPTIONS", uri, "", headers, timeout);
}
-Response head(auto uri, const std::map<std::string, std::string>& headers)
+Response head(auto uri, const std::map<std::string, std::string>& headers, const boost::posix_time::time_duration timeout = CLIENT_TIMEOUT)
{
- return clientRequest("HEAD", uri, "", headers);
+ return clientRequest("HEAD", uri, "", headers, timeout);
}
-Response put(auto xpath, const std::map<std::string, std::string>& headers, const std::string& data)
+Response put(auto xpath, const std::map<std::string, std::string>& headers, const std::string& data, const boost::posix_time::time_duration timeout = CLIENT_TIMEOUT)
{
- return clientRequest("PUT", xpath, data, headers);
+ return clientRequest("PUT", xpath, data, headers, timeout);
}
-Response post(auto xpath, const std::map<std::string, std::string>& headers, const std::string& data)
+Response post(auto xpath, const std::map<std::string, std::string>& headers, const std::string& data, const boost::posix_time::time_duration timeout = CLIENT_TIMEOUT)
{
- return clientRequest("POST", xpath, data, headers);
+ return clientRequest("POST", xpath, data, headers, timeout);
}
-Response patch(auto uri, const std::map<std::string, std::string>& headers, const std::string& data)
+Response patch(auto uri, const std::map<std::string, std::string>& headers, const std::string& data, const boost::posix_time::time_duration timeout = CLIENT_TIMEOUT)
{
- return clientRequest("PATCH", uri, data, headers);
+ return clientRequest("PATCH", uri, data, headers, timeout);
}
-Response httpDelete(auto uri, const std::map<std::string, std::string>& headers)
+Response httpDelete(auto uri, const std::map<std::string, std::string>& headers, const boost::posix_time::time_duration timeout = CLIENT_TIMEOUT)
{
- return clientRequest("DELETE", uri, "", headers);
+ return clientRequest("DELETE", uri, "", headers, timeout);
}
auto manageNacm(sysrepo::Session session)
diff --git a/tests/restconf-nacm.cpp b/tests/restconf-nacm.cpp
index 68497c9..29d7723 100644
--- a/tests/restconf-nacm.cpp
+++ b/tests/restconf-nacm.cpp
@@ -225,9 +225,7 @@ TEST_CASE("NACM")
{
// wrong password: the server should delay its response, so let the client wait "long enough"
const auto start = std::chrono::steady_clock::now();
- REQUIRE(clientRequest("GET",
- RESTCONF_DATA_ROOT "/ietf-system:system",
- "",
+ REQUIRE(get(RESTCONF_DATA_ROOT "/ietf-system:system",
{AUTH_WRONG_PASSWORD},
boost::posix_time::seconds(5))
== Response{401, jsonHeaders, R"({
@@ -251,12 +249,10 @@ TEST_CASE("NACM")
// wrong password: the server should delay its response, in this case let the client terminate its
// request and check that the server doesn't crash
const auto start = std::chrono::steady_clock::now();
- REQUIRE_THROWS_WITH(clientRequest("GET",
- RESTCONF_DATA_ROOT "/ietf-system:system",
- "",
- {AUTH_WRONG_PASSWORD},
- boost::posix_time::milliseconds(100)),
- "HTTP client error: Connection timed out");
+ REQUIRE_THROWS_WITH(get(RESTCONF_DATA_ROOT "/ietf-system:system",
+ {AUTH_WRONG_PASSWORD},
+ boost::posix_time::milliseconds(100)),
+ "HTTP client error: Connection timed out");
auto processingMS = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - start).count();
REQUIRE(processingMS <= 500);
}
diff --git a/tests/restconf-notifications.cpp b/tests/restconf-notifications.cpp
index 905ae01..d479f3c 100644
--- a/tests/restconf-notifications.cpp
+++ b/tests/restconf-notifications.cpp
@@ -277,18 +277,18 @@ TEST_CASE("NETCONF notification streams")
SECTION("Other methods")
{
- REQUIRE(clientRequest("HEAD", "/streams/NETCONF/XML", "", {AUTH_ROOT}) == Response{200, eventStreamHeaders, ""});
- REQUIRE(clientRequest("OPTIONS", "/streams/NETCONF/XML", "", {AUTH_ROOT}) == Response{200, Response::Headers{ACCESS_CONTROL_ALLOW_ORIGIN, {"allow", "GET, HEAD, OPTIONS"}}, ""});
+ REQUIRE(head("/streams/NETCONF/XML", {AUTH_ROOT}) == Response{200, eventStreamHeaders, ""});
+ REQUIRE(options("/streams/NETCONF/XML", {AUTH_ROOT}) == Response{200, Response::Headers{ACCESS_CONTROL_ALLOW_ORIGIN, {"allow", "GET, HEAD, OPTIONS"}}, ""});
const std::multimap<std::string, std::string> headers = {
{"access-control-allow-origin", "*"},
{"allow", "GET, HEAD, OPTIONS"},
{"content-type", "text/plain"},
};
- REQUIRE(clientRequest("PUT", "/streams/NETCONF/XML", "", {AUTH_ROOT}) == Response{405, headers, "Method not allowed."});
- REQUIRE(clientRequest("POST", "/streams/NETCONF/XML", "", {AUTH_ROOT}) == Response{405, headers, "Method not allowed."});
- REQUIRE(clientRequest("PATCH", "/streams/NETCONF/XML", "", {AUTH_ROOT}) == Response{405, headers, "Method not allowed."});
- REQUIRE(clientRequest("DELETE", "/streams/NETCONF/XML", "", {AUTH_ROOT}) == Response{405, headers, "Method not allowed."});
+ REQUIRE(put("/streams/NETCONF/XML", {AUTH_ROOT}, "") == Response{405, headers, "Method not allowed."});
+ REQUIRE(post("/streams/NETCONF/XML", {AUTH_ROOT}, "") == Response{405, headers, "Method not allowed."});
+ REQUIRE(patch("/streams/NETCONF/XML", {AUTH_ROOT}, "") == Response{405, headers, "Method not allowed."});
+ REQUIRE(httpDelete("/streams/NETCONF/XML", {AUTH_ROOT}) == Response{405, headers, "Method not allowed."});
}
SECTION("Invalid URLs")
diff --git a/tests/restconf-yang-schema.cpp b/tests/restconf-yang-schema.cpp
index 73821e0..6e374b1 100644
--- a/tests/restconf-yang-schema.cpp
+++ b/tests/restconf-yang-schema.cpp
@@ -156,11 +156,14 @@ TEST_CASE("obtaining YANG schemas")
{
SECTION("unsupported methods")
{
- for (const std::string httpMethod : {"POST", "PUT", "PATCH", "DELETE"}) {
- CAPTURE(httpMethod);
- REQUIRE(clientRequest(httpMethod, YANG_ROOT "/ietf-yang-library@2019-01-04", "", {AUTH_ROOT})
- == Response{405, Response::Headers{ACCESS_CONTROL_ALLOW_ORIGIN, {"allow", "GET, HEAD, OPTIONS"}}, ""});
- }
+ REQUIRE(post(YANG_ROOT "/ietf-yang-library@2019-01-04", {AUTH_ROOT}, "")
+ == Response{405, Response::Headers{ACCESS_CONTROL_ALLOW_ORIGIN, {"allow", "GET, HEAD, OPTIONS"}}, ""});
+ REQUIRE(put(YANG_ROOT "/ietf-yang-library@2019-01-04", {AUTH_ROOT}, "")
+ == Response{405, Response::Headers{ACCESS_CONTROL_ALLOW_ORIGIN, {"allow", "GET, HEAD, OPTIONS"}}, ""});
+ REQUIRE(patch(YANG_ROOT "/ietf-yang-library@2019-01-04", {AUTH_ROOT}, "")
+ == Response{405, Response::Headers{ACCESS_CONTROL_ALLOW_ORIGIN, {"allow", "GET, HEAD, OPTIONS"}}, ""});
+ REQUIRE(httpDelete(YANG_ROOT "/ietf-yang-library@2019-01-04", {AUTH_ROOT})
+ == Response{405, Response::Headers{ACCESS_CONTROL_ALLOW_ORIGIN, {"allow", "GET, HEAD, OPTIONS"}}, ""});
}
REQUIRE(options(YANG_ROOT "/ietf-yang-library@2019-01-04", {}) == Response{200, Response::Headers{ACCESS_CONTROL_ALLOW_ORIGIN, {"allow", "GET, HEAD, OPTIONS"}}, ""});
@@ -190,12 +193,12 @@ TEST_CASE("obtaining YANG schemas")
SECTION("auth failure")
{
// wrong password
- REQUIRE(clientRequest("GET", YANG_ROOT "/ietf-system@2014-08-06", "", {AUTH_WRONG_PASSWORD}, boost::posix_time::seconds{5})
+ REQUIRE(get(YANG_ROOT "/ietf-system@2014-08-06", {AUTH_WRONG_PASSWORD}, boost::posix_time::seconds{5})
== Response{401, plaintextHeaders, "Access denied."});
- REQUIRE(clientRequest("HEAD", YANG_ROOT "/ietf-system@2014-08-06", "", {AUTH_WRONG_PASSWORD}, boost::posix_time::seconds{5})
+ REQUIRE(head(YANG_ROOT "/ietf-system@2014-08-06", {AUTH_WRONG_PASSWORD}, boost::posix_time::seconds{5})
== Response{401, plaintextHeaders, ""});
// anonymous request
- REQUIRE(clientRequest("HEAD", YANG_ROOT "/ietf-system@2014-08-06", "", {FORWARDED}, boost::posix_time::seconds{5})
+ REQUIRE(head(YANG_ROOT "/ietf-system@2014-08-06", {FORWARDED}, boost::posix_time::seconds{5})
== Response{401, plaintextHeaders, ""});
}
}
--
2.43.0
@@ -0,0 +1,32 @@
From 4e9b535a59861f25c0602eaa1fc39126d7cd9899 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Thu, 7 Aug 2025 12:21:54 +0200
Subject: [PATCH 13/17] fix a possible bad_weak_ptr exception
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Change-Id: I8c7f7a943a1d848f15527988cb76c2a0a10089e6
Fixes: 4eae6200 (close long-lived connections on SIGTERM)
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/http/EventStream.cpp | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/http/EventStream.cpp b/src/http/EventStream.cpp
index 3bdd55e..8703dad 100644
--- a/src/http/EventStream.cpp
+++ b/src/http/EventStream.cpp
@@ -47,7 +47,7 @@ EventStream::EventStream(const server::request& req,
}
state = WantToClose;
- boost::asio::post(this->res.io_service(), [weak = std::weak_ptr<EventStream>{shared_from_this()}]() {
+ boost::asio::post(this->res.io_service(), [weak = weak_from_this()]() {
if (auto myself = weak.lock()) {
std::lock_guard lock{myself->mtx};
if (myself->state == WantToClose) { // resume unless somebody closed it before this was picked up by the event loop
--
2.43.0
@@ -1,586 +0,0 @@
From 0beaee041fd4fcfbebcbb2912eb6b26ec71f50c7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Mon, 2 Dec 2024 20:01:54 +0100
Subject: [PATCH 13/44] tests: move stuff from the header file into cpp file
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Allright, I have had enough. I am no longer waiting for a minute for a
recompilation of one test.
This patch splits the aux-utils.h file into a cpp file and two headers.
One of the headers is only for declarations of the functions and
datatypes in the cpp file, the second header implements all the helper
functions that require SERVER_PORT and further helper constants for the
restconf tests.
Compile times are a little better. I have measured compilation times of
two arbitrary restconf tests with asan+ubsan (and ccache disabled):
- restconf-reading: 17.05s -> 13.85s
- restconf-delete: 15.18s -> 11.87s
Not ideal, but it is certainly better.
Change-Id: If529cbc8954d50494711a408231ea4c2c4daf072
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
CMakeLists.txt | 1 +
tests/aux-utils.h | 214 ++-----------------------------
tests/restconf-notifications.cpp | 3 +-
tests/restconf_utils.cpp | 157 +++++++++++++++++++++++
tests/restconf_utils.h | 83 ++++++++++++
5 files changed, 256 insertions(+), 202 deletions(-)
create mode 100644 tests/restconf_utils.cpp
create mode 100644 tests/restconf_utils.h
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 731d7cb..b8a41a7 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -158,6 +158,7 @@ if(BUILD_TESTING)
add_library(DoctestIntegration STATIC
tests/datastoreUtils.cpp
tests/doctest_integration.cpp
+ tests/restconf_utils.cpp
tests/trompeloeil_doctest.h
tests/wait-a-bit-longer.cpp
)
diff --git a/tests/aux-utils.h b/tests/aux-utils.h
index 9afd7bc..d99b3f9 100644
--- a/tests/aux-utils.h
+++ b/tests/aux-utils.h
@@ -8,99 +8,16 @@
#pragma once
#include "trompeloeil_doctest.h"
-#include <iostream>
#include <nghttp2/asio_http2_client.h>
-#include <optional>
-#include <sstream>
-#include <sysrepo-cpp/Session.hpp>
-#include "tests/UniqueResource.h"
+#include "restconf_utils.h"
-using namespace std::string_literals;
-namespace ng = nghttp2::asio_http2;
-namespace ng_client = ng::client;
-
-struct Response {
- int statusCode;
- ng::header_map headers;
- std::string data;
-
- using Headers = std::multimap<std::string, std::string>;
-
- Response(int statusCode, const Headers& headers, const std::string& data)
- : Response(statusCode, transformHeaders(headers), data)
- {
- }
-
- Response(int statusCode, const ng::header_map& headers, const std::string& data)
- : statusCode(statusCode)
- , headers(headers)
- , data(data)
- {
- }
-
- bool equalStatusCodeAndHeaders(const Response& o) const
- {
- // Skipping 'date' header. Its value will not be reproducible in simple tests
- ng::header_map myHeaders(headers);
- ng::header_map otherHeaders(o.headers);
- myHeaders.erase("date");
- otherHeaders.erase("date");
-
- return statusCode == o.statusCode && std::equal(myHeaders.begin(), myHeaders.end(), otherHeaders.begin(), otherHeaders.end(), [](const auto& a, const auto& b) {
- return a.first == b.first && a.second.value == b.second.value; // Skipping 'sensitive' field from ng::header_value which does not seem important for us.
- });
- }
-
- bool operator==(const Response& o) const
- {
- return equalStatusCodeAndHeaders(o) && data == o.data;
- }
-
- static ng::header_map transformHeaders(const Headers& headers)
- {
- ng::header_map res;
- std::transform(headers.begin(), headers.end(), std::inserter(res, res.end()), [](const auto& h) -> std::pair<std::string, ng::header_value> { return {h.first, {h.second, false}}; });
- return res;
- }
-};
-
-namespace doctest {
-
-template <>
-struct StringMaker<ng::header_map> {
- static String convert(const ng::header_map& m)
- {
- std::ostringstream oss;
- oss << "{\n";
- for (const auto& [k, v] : m) {
- oss << "\t"
- << "{\"" << k << "\", "
- << "{\"" << v.value << "\", " << std::boolalpha << v.sensitive << "}},\n";
- }
- oss << "}";
- return oss.str().c_str();
- }
-};
-
-template <>
-struct StringMaker<Response> {
- static String convert(const Response& o)
- {
- std::ostringstream oss;
-
- oss << "{"
- << std::to_string(o.statusCode) << ", "
- << StringMaker<decltype(o.headers)>::convert(o.headers) << ",\n"
- << "\"" << o.data << "\",\n"
- << "}";
-
- return oss.str().c_str();
- }
-};
+namespace sysrepo {
+class Session;
}
+namespace ng = nghttp2::asio_http2;
+
static const auto SERVER_ADDRESS = "::1";
-static const auto SERVER_ADDRESS_AND_PORT = "http://["s + SERVER_ADDRESS + "]" + ":" + SERVER_PORT;
#define AUTH_DWDM {"authorization", "Basic ZHdkbTpEV0RN"}
#define AUTH_NORULES {"authorization", "Basic bm9ydWxlczplbXB0eQ=="}
@@ -145,7 +62,7 @@ const ng::header_map plaintextHeaders{
{"content-type", {"text/plain", false}},
};
-const ng::header_map eventStreamHeaders {
+const ng::header_map eventStreamHeaders{
{"access-control-allow-origin", {"*", false}},
{"content-type", {"text/event-stream", false}},
};
@@ -153,142 +70,37 @@ const ng::header_map eventStreamHeaders {
#define ACCESS_CONTROL_ALLOW_ORIGIN {"access-control-allow-origin", "*"}
#define ACCEPT_PATCH {"accept-patch", "application/yang-data+json, application/yang-data+xml, application/yang-patch+xml, application/yang-patch+json"}
-// this is a test, and the server is expected to reply "soon"
-static const boost::posix_time::time_duration CLIENT_TIMEOUT = boost::posix_time::seconds(3);
-
-Response clientRequest(auto method,
- auto uri,
- const std::string& data,
- const std::map<std::string, std::string>& headers,
- const boost::posix_time::time_duration timeout = CLIENT_TIMEOUT)
-{
- boost::asio::io_service io_service;
- auto client = std::make_shared<ng_client::session>(io_service, SERVER_ADDRESS, SERVER_PORT);
-
- client->read_timeout(timeout);
-
- std::ostringstream oss;
- ng::header_map resHeaders;
- int statusCode;
-
- client->on_connect([&](auto) {
- boost::system::error_code ec;
-
- ng::header_map reqHeaders;
- for (const auto& [name, value] : headers) {
- reqHeaders.insert({name, {value, false}});
- }
-
- auto req = client->submit(ec, method, SERVER_ADDRESS_AND_PORT + uri, data, reqHeaders);
- req->on_response([&](const ng_client::response& res) {
- res.on_data([&oss](const uint8_t* data, std::size_t len) {
- oss.write(reinterpret_cast<const char*>(data), len);
- });
- statusCode = res.status_code();
- resHeaders = res.header();
- });
- req->on_close([maybeClient = std::weak_ptr<ng_client::session>{client}](auto) {
- if (auto client = maybeClient.lock()) {
- client->shutdown();
- }
- });
- });
- client->on_error([](const boost::system::error_code& ec) {
- throw std::runtime_error{"HTTP client error: " + ec.message()};
- });
- io_service.run();
-
- return {statusCode, resHeaders, oss.str()};
-}
-
Response get(auto uri, const std::map<std::string, std::string>& headers, const boost::posix_time::time_duration timeout = CLIENT_TIMEOUT)
{
- return clientRequest("GET", uri, "", headers, timeout);
+ return clientRequest(SERVER_ADDRESS, SERVER_PORT, "GET", uri, "", headers, timeout);
}
Response options(auto uri, const std::map<std::string, std::string>& headers, const boost::posix_time::time_duration timeout = CLIENT_TIMEOUT)
{
- return clientRequest("OPTIONS", uri, "", headers, timeout);
+ return clientRequest(SERVER_ADDRESS, SERVER_PORT, "OPTIONS", uri, "", headers, timeout);
}
Response head(auto uri, const std::map<std::string, std::string>& headers, const boost::posix_time::time_duration timeout = CLIENT_TIMEOUT)
{
- return clientRequest("HEAD", uri, "", headers, timeout);
+ return clientRequest(SERVER_ADDRESS, SERVER_PORT, "HEAD", uri, "", headers, timeout);
}
Response put(auto xpath, const std::map<std::string, std::string>& headers, const std::string& data, const boost::posix_time::time_duration timeout = CLIENT_TIMEOUT)
{
- return clientRequest("PUT", xpath, data, headers, timeout);
+ return clientRequest(SERVER_ADDRESS, SERVER_PORT, "PUT", xpath, data, headers, timeout);
}
Response post(auto xpath, const std::map<std::string, std::string>& headers, const std::string& data, const boost::posix_time::time_duration timeout = CLIENT_TIMEOUT)
{
- return clientRequest("POST", xpath, data, headers, timeout);
+ return clientRequest(SERVER_ADDRESS, SERVER_PORT, "POST", xpath, data, headers, timeout);
}
Response patch(auto uri, const std::map<std::string, std::string>& headers, const std::string& data, const boost::posix_time::time_duration timeout = CLIENT_TIMEOUT)
{
- return clientRequest("PATCH", uri, data, headers, timeout);
+ return clientRequest(SERVER_ADDRESS, SERVER_PORT, "PATCH", uri, data, headers, timeout);
}
Response httpDelete(auto uri, const std::map<std::string, std::string>& headers, const boost::posix_time::time_duration timeout = CLIENT_TIMEOUT)
{
- return clientRequest("DELETE", uri, "", headers, timeout);
-}
-
-auto manageNacm(sysrepo::Session session)
-{
- return make_unique_resource(
- [session]() mutable {
- session.switchDatastore(sysrepo::Datastore::Running);
- session.copyConfig(sysrepo::Datastore::Startup, "ietf-netconf-acm");
- },
- [session]() mutable {
- session.switchDatastore(sysrepo::Datastore::Running);
-
- /* cleanup running DS of ietf-netconf-acm module
- because it contains XPaths to other modules that we
- can't uninstall because the running DS content would be invalid
- */
- session.copyConfig(sysrepo::Datastore::Startup, "ietf-netconf-acm");
- });
-}
-
-void setupRealNacm(sysrepo::Session session)
-{
- session.switchDatastore(sysrepo::Datastore::Running);
- session.setItem("/ietf-netconf-acm:nacm/enable-external-groups", "false");
- session.setItem("/ietf-netconf-acm:nacm/groups/group[name='optics']/user-name[.='dwdm']", "");
- session.setItem("/ietf-netconf-acm:nacm/groups/group[name='yangnobody']/user-name[.='yangnobody']", "");
- session.setItem("/ietf-netconf-acm:nacm/groups/group[name='norules']/user-name[.='norules']", "");
-
- session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/group[.='yangnobody']", "");
- session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='10']/module-name", "ietf-system");
- session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='10']/action", "permit");
- session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='10']/access-operations", "read");
- session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='10']/path", "/ietf-system:system/contact");
- session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='11']/module-name", "ietf-system");
- session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='11']/action", "permit");
- session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='11']/access-operations", "read");
- session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='11']/path", "/ietf-system:system/hostname");
- session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='12']/module-name", "ietf-system");
- session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='12']/action", "permit");
- session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='12']/access-operations", "read");
- session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='12']/path", "/ietf-system:system/location");
- session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='13']/module-name", "example");
- session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='13']/action", "permit");
- session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='13']/access-operations", "read");
- session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='14']/module-name", "ietf-restconf-monitoring");
- session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='14']/action", "permit");
- session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='14']/access-operations", "read");
- session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='15']/module-name", "example-delete");
- session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='15']/action", "permit");
- session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='15']/access-operations", "read");
- session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='15']/path", "/example-delete:immutable");
- session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='99']/module-name", "*");
- session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='99']/action", "deny");
- session.setItem("/ietf-netconf-acm:nacm/rule-list[name='dwdm rule']/group[.='optics']", "");
- session.setItem("/ietf-netconf-acm:nacm/rule-list[name='dwdm rule']/rule[name='1']/module-name", "ietf-system");
- session.setItem("/ietf-netconf-acm:nacm/rule-list[name='dwdm rule']/rule[name='1']/action", "permit"); // overrides nacm:default-deny-* rules in ietf-system model
- session.applyChanges();
+ return clientRequest(SERVER_ADDRESS, SERVER_PORT, "DELETE", uri, "", headers, timeout);
}
diff --git a/tests/restconf-notifications.cpp b/tests/restconf-notifications.cpp
index d479f3c..db9a3f5 100644
--- a/tests/restconf-notifications.cpp
+++ b/tests/restconf-notifications.cpp
@@ -87,7 +87,8 @@ struct SSEClient {
client->on_connect([&, uri, reqHeaders, silenceTimeout](auto) {
boost::system::error_code ec;
- auto req = client->submit(ec, "GET", SERVER_ADDRESS_AND_PORT + uri, "", reqHeaders);
+ static const auto server_address_and_port = std::string("http://[") + SERVER_ADDRESS + "]" + ":" + SERVER_PORT;
+ auto req = client->submit(ec, "GET", server_address_and_port + uri, "", reqHeaders);
req->on_response([&, silenceTimeout](const ng_client::response& res) {
requestSent.count_down();
res.on_data([&, silenceTimeout](const uint8_t* data, std::size_t len) {
diff --git a/tests/restconf_utils.cpp b/tests/restconf_utils.cpp
new file mode 100644
index 0000000..ea9a18d
--- /dev/null
+++ b/tests/restconf_utils.cpp
@@ -0,0 +1,157 @@
+/*
+ * Copyright (C) 2023 CESNET, https://photonics.cesnet.cz/
+ *
+ * Written by Tomáš Pecka <tomas.pecka@cesnet.cz>
+ * Written by Jan Kundrát <jan.kundrat@cesnet.cz>
+ *
+ */
+
+#include "restconf_utils.h"
+#include "sysrepo-cpp/Session.hpp"
+
+using namespace std::string_literals;
+namespace ng = nghttp2::asio_http2;
+namespace ng_client = ng::client;
+
+Response::Response(int statusCode, const Response::Headers& headers, const std::string& data)
+ : Response(statusCode, transformHeaders(headers), data)
+{
+}
+
+Response::Response(int statusCode, const ng::header_map& headers, const std::string& data)
+ : statusCode(statusCode)
+ , headers(headers)
+ , data(data)
+{
+}
+
+bool Response::equalStatusCodeAndHeaders(const Response& o) const
+{
+ // Skipping 'date' header. Its value will not be reproducible in simple tests
+ ng::header_map myHeaders(headers);
+ ng::header_map otherHeaders(o.headers);
+ myHeaders.erase("date");
+ otherHeaders.erase("date");
+
+ return statusCode == o.statusCode && std::equal(myHeaders.begin(), myHeaders.end(), otherHeaders.begin(), otherHeaders.end(), [](const auto& a, const auto& b) {
+ return a.first == b.first && a.second.value == b.second.value; // Skipping 'sensitive' field from ng::header_value which does not seem important for us.
+ });
+}
+
+bool Response::operator==(const Response& o) const
+{
+ return equalStatusCodeAndHeaders(o) && data == o.data;
+}
+
+ng::header_map Response::transformHeaders(const Response::Headers& headers)
+{
+ ng::header_map res;
+ std::transform(headers.begin(), headers.end(), std::inserter(res, res.end()), [](const auto& h) -> std::pair<std::string, ng::header_value> { return {h.first, {h.second, false}}; });
+ return res;
+}
+
+Response clientRequest(const std::string& server_address,
+ const std::string& server_port,
+ const std::string& method,
+ const std::string& uri,
+ const std::string& data,
+ const std::map<std::string, std::string>& headers,
+ const boost::posix_time::time_duration timeout)
+{
+ boost::asio::io_service io_service;
+ auto client = std::make_shared<ng_client::session>(io_service, server_address, server_port);
+
+ client->read_timeout(timeout);
+
+ std::ostringstream oss;
+ ng::header_map resHeaders;
+ int statusCode;
+
+ client->on_connect([&](auto) {
+ boost::system::error_code ec;
+
+ ng::header_map reqHeaders;
+ for (const auto& [name, value] : headers) {
+ reqHeaders.insert({name, {value, false}});
+ }
+
+ const auto server_address_and_port = std::string("http://[") + server_address + "]" + ":" + server_port;
+ auto req = client->submit(ec, method, server_address_and_port + uri, data, reqHeaders);
+ req->on_response([&](const ng_client::response& res) {
+ res.on_data([&oss](const uint8_t* data, std::size_t len) {
+ oss.write(reinterpret_cast<const char*>(data), len);
+ });
+ statusCode = res.status_code();
+ resHeaders = res.header();
+ });
+ req->on_close([maybeClient = std::weak_ptr<ng_client::session>{client}](auto) {
+ if (auto client = maybeClient.lock()) {
+ client->shutdown();
+ }
+ });
+ });
+ client->on_error([](const boost::system::error_code& ec) {
+ throw std::runtime_error{"HTTP client error: " + ec.message()};
+ });
+ io_service.run();
+
+ return {statusCode, resHeaders, oss.str()};
+}
+
+UniqueResource manageNacm(sysrepo::Session session)
+{
+ return make_unique_resource(
+ [session]() mutable {
+ session.switchDatastore(sysrepo::Datastore::Running);
+ session.copyConfig(sysrepo::Datastore::Startup, "ietf-netconf-acm");
+ },
+ [session]() mutable {
+ session.switchDatastore(sysrepo::Datastore::Running);
+
+ /* cleanup running DS of ietf-netconf-acm module
+ because it contains XPaths to other modules that we
+ can't uninstall because the running DS content would be invalid
+ */
+ session.copyConfig(sysrepo::Datastore::Startup, "ietf-netconf-acm");
+ });
+}
+
+void setupRealNacm(sysrepo::Session session)
+{
+ session.switchDatastore(sysrepo::Datastore::Running);
+ session.setItem("/ietf-netconf-acm:nacm/enable-external-groups", "false");
+ session.setItem("/ietf-netconf-acm:nacm/groups/group[name='optics']/user-name[.='dwdm']", "");
+ session.setItem("/ietf-netconf-acm:nacm/groups/group[name='yangnobody']/user-name[.='yangnobody']", "");
+ session.setItem("/ietf-netconf-acm:nacm/groups/group[name='norules']/user-name[.='norules']", "");
+
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/group[.='yangnobody']", "");
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='10']/module-name", "ietf-system");
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='10']/action", "permit");
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='10']/access-operations", "read");
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='10']/path", "/ietf-system:system/contact");
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='11']/module-name", "ietf-system");
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='11']/action", "permit");
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='11']/access-operations", "read");
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='11']/path", "/ietf-system:system/hostname");
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='12']/module-name", "ietf-system");
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='12']/action", "permit");
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='12']/access-operations", "read");
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='12']/path", "/ietf-system:system/location");
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='13']/module-name", "example");
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='13']/action", "permit");
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='13']/access-operations", "read");
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='14']/module-name", "ietf-restconf-monitoring");
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='14']/action", "permit");
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='14']/access-operations", "read");
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='15']/module-name", "example-delete");
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='15']/action", "permit");
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='15']/access-operations", "read");
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='15']/path", "/example-delete:immutable");
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='99']/module-name", "*");
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='99']/action", "deny");
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='dwdm rule']/group[.='optics']", "");
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='dwdm rule']/rule[name='1']/module-name", "ietf-system");
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='dwdm rule']/rule[name='1']/action", "permit"); // overrides nacm:default-deny-* rules in ietf-system model
+ session.applyChanges();
+}
+
diff --git a/tests/restconf_utils.h b/tests/restconf_utils.h
new file mode 100644
index 0000000..26f0803
--- /dev/null
+++ b/tests/restconf_utils.h
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2023 CESNET, https://photonics.cesnet.cz/
+ *
+ * Written by Tomáš Pecka <tomas.pecka@cesnet.cz>
+ * Written by Jan Kundrát <jan.kundrat@cesnet.cz>
+ *
+ */
+
+#pragma once
+#include "trompeloeil_doctest.h"
+#include <nghttp2/asio_http2_client.h>
+#include "UniqueResource.h"
+
+namespace sysrepo {
+class Session;
+}
+
+namespace ng = nghttp2::asio_http2;
+namespace ng_client = ng::client;
+
+struct Response {
+ int statusCode;
+ ng::header_map headers;
+ std::string data;
+
+ using Headers = std::multimap<std::string, std::string>;
+
+ Response(int statusCode, const Headers& headers, const std::string& data);
+ Response(int statusCode, const ng::header_map& headers, const std::string& data);
+ bool equalStatusCodeAndHeaders(const Response& o) const;
+ bool operator==(const Response& o) const;
+ static ng::header_map transformHeaders(const Headers& headers);
+};
+
+namespace doctest {
+
+template <>
+struct StringMaker<ng::header_map> {
+ static String convert(const ng::header_map& m)
+ {
+ std::ostringstream oss;
+ oss << "{\n";
+ for (const auto& [k, v] : m) {
+ oss << "\t"
+ << "{\"" << k << "\", "
+ << "{\"" << v.value << "\", " << std::boolalpha << v.sensitive << "}},\n";
+ }
+ oss << "}";
+ return oss.str().c_str();
+ }
+};
+
+template <>
+struct StringMaker<Response> {
+ static String convert(const Response& o)
+ {
+ std::ostringstream oss;
+
+ oss << "{"
+ << std::to_string(o.statusCode) << ", "
+ << StringMaker<decltype(o.headers)>::convert(o.headers) << ",\n"
+ << "\"" << o.data << "\",\n"
+ << "}";
+
+ return oss.str().c_str();
+ }
+};
+}
+
+// this is a test, and the server is expected to reply "soon"
+static const boost::posix_time::time_duration CLIENT_TIMEOUT = boost::posix_time::seconds(3);
+
+Response clientRequest(
+ const std::string& server_address,
+ const std::string& server_port,
+ const std::string& method,
+ const std::string& uri,
+ const std::string& data,
+ const std::map<std::string, std::string>& headers,
+ const boost::posix_time::time_duration timeout = CLIENT_TIMEOUT);
+
+UniqueResource manageNacm(sysrepo::Session session);
+void setupRealNacm(sysrepo::Session session);
--
2.43.0
@@ -0,0 +1,115 @@
From 6bca750f866b5b14c4d9c3da68e5c8f1e4eee36c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Mon, 19 May 2025 12:11:09 +0200
Subject: [PATCH 14/17] http: add optional callbacks to EventStream
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
In some cases, it might be useful to have a callback that is called when
the client disconnects or when the connection is lost.
For us, this will be useful in the future subscribed notifications
implementation where we would like to clean up stuff after client
disconnects.
Change-Id: Icfc2959e38b812b7c18f45976415209b29151c7b
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/http/EventStream.cpp | 18 +++++++++++++++---
src/http/EventStream.h | 10 ++++++++--
2 files changed, 23 insertions(+), 5 deletions(-)
diff --git a/src/http/EventStream.cpp b/src/http/EventStream.cpp
index 8703dad..0a974d4 100644
--- a/src/http/EventStream.cpp
+++ b/src/http/EventStream.cpp
@@ -25,11 +25,15 @@ EventStream::EventStream(const server::request& req,
Termination& termination,
EventSignal& signal,
const std::chrono::seconds keepAlivePingInterval,
- const std::optional<std::string>& initialEvent)
+ const std::optional<std::string>& initialEvent,
+ const std::function<void()>& onTerminationCb,
+ const std::function<void()>& onClientDisconnectedCb)
: res{res}
, ping{res.io_service()}
, peer{peer_from_request(req)}
, m_keepAlivePingInterval(keepAlivePingInterval)
+ , onTerminationCb(onTerminationCb)
+ , onClientDisconnectedCb(onClientDisconnectedCb)
{
if (initialEvent) {
enqueue(FIELD_DATA, *initialEvent);
@@ -41,6 +45,9 @@ EventStream::EventStream(const server::request& req,
terminateSub = termination.connect([this]() {
spdlog::trace("{}: will terminate", peer);
+ if (this->onTerminationCb) {
+ this->onTerminationCb();
+ }
std::lock_guard lock{mtx};
if (state == Closed) { // we are late to the party, res is already gone
return;
@@ -80,6 +87,9 @@ void EventStream::activate()
myself->eventSub.disconnect();
myself->terminateSub.disconnect();
myself->state = Closed;
+ if (myself->onClientDisconnectedCb) {
+ myself->onClientDisconnectedCb();
+ }
});
res.end([myself](uint8_t* destination, std::size_t len, uint32_t* data_flags) {
@@ -187,9 +197,11 @@ std::shared_ptr<EventStream> EventStream::create(const nghttp2::asio_http2::serv
Termination& terminate,
EventSignal& signal,
const std::chrono::seconds keepAlivePingInterval,
- const std::optional<std::string>& initialEvent)
+ const std::optional<std::string>& initialEvent,
+ const std::function<void()>& onTerminationCb,
+ const std::function<void()>& onClientDisconnectedCb)
{
- auto stream = std::shared_ptr<EventStream>(new EventStream(req, res, terminate, signal, keepAlivePingInterval, initialEvent));
+ auto stream = std::shared_ptr<EventStream>(new EventStream(req, res, terminate, signal, keepAlivePingInterval, initialEvent, onTerminationCb, onClientDisconnectedCb));
stream->activate();
return stream;
}
diff --git a/src/http/EventStream.h b/src/http/EventStream.h
index ef0a002..87d8c82 100644
--- a/src/http/EventStream.h
+++ b/src/http/EventStream.h
@@ -36,7 +36,9 @@ public:
Termination& terminate,
EventSignal& signal,
const std::chrono::seconds keepAlivePingInterval,
- const std::optional<std::string>& initialEvent = std::nullopt);
+ const std::optional<std::string>& initialEvent = std::nullopt,
+ const std::function<void()>& onTerminationCb = std::function<void()>(),
+ const std::function<void()>& onClientDisconnectedCb = std::function<void()>());
private:
const nghttp2::asio_http2::server::response& res;
@@ -54,6 +56,8 @@ private:
boost::signals2::scoped_connection eventSub, terminateSub;
const std::string peer;
const std::chrono::seconds m_keepAlivePingInterval;
+ std::function<void()> onTerminationCb; ///< optional callback when the stream is terminated
+ std::function<void()> onClientDisconnectedCb; ///< optional callback invoked in client.on_close()
size_t send_chunk(uint8_t* destination, std::size_t len, uint32_t* data_flags);
ssize_t process(uint8_t* destination, std::size_t len, uint32_t* data_flags);
@@ -66,7 +70,9 @@ protected:
Termination& terminate,
EventSignal& signal,
const std::chrono::seconds keepAlivePingInterval,
- const std::optional<std::string>& initialEvent = std::nullopt);
+ const std::optional<std::string>& initialEvent = std::nullopt,
+ const std::function<void()>& onTerminationCb = std::function<void()>(),
+ const std::function<void()>& onClientDisconnectedCb = std::function<void()>());
void activate();
};
}
--
2.43.0
@@ -1,169 +0,0 @@
From 53aa2e23ee8acc1881487f3969c2c58fb29437be Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Mon, 2 Dec 2024 20:08:53 +0100
Subject: [PATCH 14/44] tests: rename datastoreUtils to event_watchers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
The next commit will move RESTCONF notification watchers there, I think
the new name is more appropriate.
Change-Id: Ia8e8cd5fe89bd827fcde4531fe801298bd6f71d2
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
CMakeLists.txt | 2 +-
tests/{datastoreUtils.cpp => event_watchers.cpp} | 2 +-
tests/{datastoreUtils.h => event_watchers.h} | 0
tests/pretty_printers.h | 2 +-
tests/restconf-defaults.cpp | 2 +-
tests/restconf-delete.cpp | 2 +-
tests/restconf-plain-patch.cpp | 2 +-
tests/restconf-reading.cpp | 2 +-
tests/restconf-rpc.cpp | 2 +-
tests/restconf-writing.cpp | 2 +-
tests/restconf-yang-patch.cpp | 2 +-
11 files changed, 10 insertions(+), 10 deletions(-)
rename tests/{datastoreUtils.cpp => event_watchers.cpp} (98%)
rename tests/{datastoreUtils.h => event_watchers.h} (100%)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index b8a41a7..22bce32 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -156,8 +156,8 @@ if(BUILD_TESTING)
include(cmake/SysrepoTest.cmake)
add_library(DoctestIntegration STATIC
- tests/datastoreUtils.cpp
tests/doctest_integration.cpp
+ tests/event_watchers.cpp
tests/restconf_utils.cpp
tests/trompeloeil_doctest.h
tests/wait-a-bit-longer.cpp
diff --git a/tests/datastoreUtils.cpp b/tests/event_watchers.cpp
similarity index 98%
rename from tests/datastoreUtils.cpp
rename to tests/event_watchers.cpp
index 56d8090..c696bc5 100644
--- a/tests/datastoreUtils.cpp
+++ b/tests/event_watchers.cpp
@@ -1,5 +1,5 @@
#include "UniqueResource.h"
-#include "datastoreUtils.h"
+#include "event_watchers.h"
namespace {
void datastoreChanges(auto session, auto& dsChangesMock, auto path)
diff --git a/tests/datastoreUtils.h b/tests/event_watchers.h
similarity index 100%
rename from tests/datastoreUtils.h
rename to tests/event_watchers.h
diff --git a/tests/pretty_printers.h b/tests/pretty_printers.h
index a2befeb..ce64e91 100644
--- a/tests/pretty_printers.h
+++ b/tests/pretty_printers.h
@@ -13,7 +13,7 @@
#include <optional>
#include <sstream>
#include <trompeloeil.hpp>
-#include "datastoreUtils.h"
+#include "event_watchers.h"
#include "restconf/uri.h"
#include "restconf/uri_impl.h"
diff --git a/tests/restconf-defaults.cpp b/tests/restconf-defaults.cpp
index 129dae2..dd8b4da 100644
--- a/tests/restconf-defaults.cpp
+++ b/tests/restconf-defaults.cpp
@@ -11,7 +11,7 @@ static const auto SERVER_PORT = "10087";
#include <spdlog/spdlog.h>
#include "restconf/Server.h"
#include "tests/aux-utils.h"
-#include "tests/datastoreUtils.h"
+#include "tests/event_watchers.h"
TEST_CASE("default handling")
{
diff --git a/tests/restconf-delete.cpp b/tests/restconf-delete.cpp
index 75a6916..4818ff3 100644
--- a/tests/restconf-delete.cpp
+++ b/tests/restconf-delete.cpp
@@ -10,7 +10,7 @@ static const auto SERVER_PORT = "10086";
#include <spdlog/spdlog.h>
#include "restconf/Server.h"
#include "tests/aux-utils.h"
-#include "tests/datastoreUtils.h"
+#include "tests/event_watchers.h"
#include "tests/pretty_printers.h"
TEST_CASE("deleting data")
diff --git a/tests/restconf-plain-patch.cpp b/tests/restconf-plain-patch.cpp
index d4f3952..b550f54 100644
--- a/tests/restconf-plain-patch.cpp
+++ b/tests/restconf-plain-patch.cpp
@@ -10,7 +10,7 @@ static const auto SERVER_PORT = "10089";
#include <spdlog/spdlog.h>
#include "restconf/Server.h"
#include "tests/aux-utils.h"
-#include "tests/datastoreUtils.h"
+#include "tests/event_watchers.h"
#include "tests/pretty_printers.h"
TEST_CASE("Plain patch")
diff --git a/tests/restconf-reading.cpp b/tests/restconf-reading.cpp
index d7d507b..e709486 100644
--- a/tests/restconf-reading.cpp
+++ b/tests/restconf-reading.cpp
@@ -11,7 +11,7 @@ static const auto SERVER_PORT = "10081";
#include <nghttp2/asio_http2.h>
#include <spdlog/spdlog.h>
#include "restconf/Server.h"
-#include "tests/datastoreUtils.h"
+#include "tests/event_watchers.h"
TEST_CASE("reading data")
{
diff --git a/tests/restconf-rpc.cpp b/tests/restconf-rpc.cpp
index 9bc1dbc..c4229a0 100644
--- a/tests/restconf-rpc.cpp
+++ b/tests/restconf-rpc.cpp
@@ -10,7 +10,7 @@ static const auto SERVER_PORT = "10084";
#include <spdlog/spdlog.h>
#include "restconf/Server.h"
#include "tests/aux-utils.h"
-#include "tests/datastoreUtils.h"
+#include "tests/event_watchers.h"
#include "tests/pretty_printers.h"
struct RpcCall {
diff --git a/tests/restconf-writing.cpp b/tests/restconf-writing.cpp
index 582a262..0932984 100644
--- a/tests/restconf-writing.cpp
+++ b/tests/restconf-writing.cpp
@@ -10,7 +10,7 @@ static const auto SERVER_PORT = "10083";
#include <spdlog/spdlog.h>
#include "restconf/Server.h"
#include "tests/aux-utils.h"
-#include "tests/datastoreUtils.h"
+#include "tests/event_watchers.h"
#include "tests/pretty_printers.h"
TEST_CASE("writing data")
diff --git a/tests/restconf-yang-patch.cpp b/tests/restconf-yang-patch.cpp
index 7cc8946..2b35c59 100644
--- a/tests/restconf-yang-patch.cpp
+++ b/tests/restconf-yang-patch.cpp
@@ -10,7 +10,7 @@ static const auto SERVER_PORT = "10090";
#include <spdlog/spdlog.h>
#include "restconf/Server.h"
#include "tests/aux-utils.h"
-#include "tests/datastoreUtils.h"
+#include "tests/event_watchers.h"
#include "tests/pretty_printers.h"
TEST_CASE("YANG patch")
--
2.43.0
@@ -0,0 +1,95 @@
From f6ee629de8abef42a24c42b185052b0c8e78bd6b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Mon, 19 May 2025 12:23:16 +0200
Subject: [PATCH 15/17] restconf: add internal RPC handler dispatcher
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
This code adds a new internal RPC handler dispatcher that checks if user
is authorized to call the RPC (we are bypassing sysrepo, so we have to
check this manually) and then calls the RPC handler.
So far, there are no RPCs processed, but support for the
ietf-subscribed-notifications:establish-subscription RPC is coming soon.
Change-Id: I99121a511011229e4098f95e91601b39d333444a
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/restconf/Server.cpp | 24 ++++++++++++++++++++----
tests/restconf-rpc.cpp | 18 ++++++++++++++++++
2 files changed, 38 insertions(+), 4 deletions(-)
diff --git a/src/restconf/Server.cpp b/src/restconf/Server.cpp
index 8544210..74c06f7 100644
--- a/src/restconf/Server.cpp
+++ b/src/restconf/Server.cpp
@@ -438,10 +438,26 @@ libyang::CreatedNodes createEditForPutAndPatch(libyang::Context& ctx, const std:
return {editNode, replacementNode};
}
-std::optional<libyang::DataNode> processInternalRPC(sysrepo::Session&, const libyang::DataNode&)
+std::optional<libyang::DataNode> processInternalRPC(sysrepo::Session& sess, const libyang::DataNode& rpcInput, const libyang::DataFormat requestEncoding)
{
- // TODO: Implement internal RPCs
- throw ErrorResponse(501, "application", "operation-not-supported", "Internal RPCs are not yet supported.");
+ using InternalRPCHandler = std::function<void(sysrepo::Session&, const libyang::DataFormat, const libyang::DataNode&, libyang::DataNode&)>;
+ const std::map<std::string, InternalRPCHandler> handlers;
+
+ const auto rpcPath = rpcInput.path();
+
+ // Is the user authorized to call the operation?
+ auto [parent, rpcNode] = sess.getContext().newPath2(rpcPath, std::nullopt);
+ if (!sess.checkNacmOperation(*rpcNode)) {
+ throw ErrorResponse(403, "application", "access-denied", "Access denied.", rpcNode->path());
+ }
+
+ if (auto it = handlers.find(rpcPath); it != handlers.end()) {
+ auto [parent, rpcOutput] = sess.getContext().newPath2(rpcPath, std::nullopt);
+ it->second(sess, requestEncoding, rpcInput, *rpcOutput);
+ return *parent;
+ }
+
+ throw ErrorResponse(501, "application", "operation-not-supported", "Unsupported RPC call to " + rpcPath, rpcPath);
}
void processActionOrRPC(std::shared_ptr<RequestContext> requestCtx, const std::chrono::milliseconds timeout)
@@ -478,7 +494,7 @@ void processActionOrRPC(std::shared_ptr<RequestContext> requestCtx, const std::c
if (requestCtx->restconfRequest.type == RestconfRequest::Type::Execute) {
rpcReply = requestCtx->sess.sendRPC(*rpcNode, timeout);
} else if (requestCtx->restconfRequest.type == RestconfRequest::Type::ExecuteInternal) {
- rpcReply = processInternalRPC(requestCtx->sess, *rpcNode);
+ rpcReply = processInternalRPC(requestCtx->sess, *rpcNode, *requestCtx->dataFormat.request);
}
if (!rpcReply || rpcReply->immediateChildren().empty()) {
diff --git a/tests/restconf-rpc.cpp b/tests/restconf-rpc.cpp
index a3e9309..43aaed4 100644
--- a/tests/restconf-rpc.cpp
+++ b/tests/restconf-rpc.cpp
@@ -420,4 +420,22 @@ TEST_CASE("invoking actions and rpcs")
)"});
}
}
+
+ SECTION("Internal RPC handlers")
+ {
+ // check NACM access
+ REQUIRE(post(RESTCONF_OPER_ROOT "/ietf-subscribed-notifications:kill-subscription", {CONTENT_TYPE_JSON}, R"({"ietf-subscribed-notifications:input": {}})") == Response{403, jsonHeaders, R"({
+ "ietf-restconf:errors": {
+ "error": [
+ {
+ "error-type": "application",
+ "error-tag": "access-denied",
+ "error-path": "/ietf-subscribed-notifications:kill-subscription",
+ "error-message": "Access denied."
+ }
+ ]
+ }
+}
+)"});
+ }
}
--
2.43.0
@@ -1,57 +0,0 @@
From f86df829979a26d5192a86c3b43c1393dcba7140 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Tue, 3 Dec 2024 11:45:41 +0100
Subject: [PATCH 15/44] tests: rename NotificationWatcher
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
This watches notifications going through RESTCONF, so I think
RestconfNotificatonWatcher is better name for this.
Change-Id: Ieb8d7ce489e683348f065e9e84aae06af7c8ccc4
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
tests/restconf-notifications.cpp | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/tests/restconf-notifications.cpp b/tests/restconf-notifications.cpp
index db9a3f5..d873512 100644
--- a/tests/restconf-notifications.cpp
+++ b/tests/restconf-notifications.cpp
@@ -21,11 +21,11 @@ static const auto SERVER_PORT = "10088";
using namespace std::chrono_literals;
-struct NotificationWatcher {
+struct RestconfNotificationWatcher {
libyang::Context ctx;
libyang::DataFormat dataFormat;
- NotificationWatcher(const libyang::Context& ctx)
+ RestconfNotificationWatcher(const libyang::Context& ctx)
: ctx(ctx)
, dataFormat(libyang::DataFormat::JSON)
{
@@ -62,7 +62,7 @@ struct SSEClient {
SSEClient(
boost::asio::io_service& io,
std::latch& requestSent,
- const NotificationWatcher& notification,
+ const RestconfNotificationWatcher& notification,
const std::string& uri,
const std::map<std::string, std::string>& headers,
const boost::posix_time::seconds silenceTimeout = boost::posix_time::seconds(1)) // test code; the server should respond "soon"
@@ -186,7 +186,7 @@ TEST_CASE("NETCONF notification streams")
R"({"example:tlc":{"list":[{"name":"k1","notif":{"message":"nested"}}]}})",
};
- NotificationWatcher netconfWatcher(srConn.sessionStart().getContext());
+ RestconfNotificationWatcher netconfWatcher(srConn.sessionStart().getContext());
SECTION("NETCONF streams")
{
--
2.43.0
@@ -1,133 +0,0 @@
From 30d704588fa7eb9d32f66296ec5f6784f082869e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Mon, 2 Dec 2024 20:11:32 +0100
Subject: [PATCH 16/44] tests: make NotificationWatcher reusable
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
We will need some parts of this in yang push tests
Change-Id: I72e60d993f75bbc848af096ac325a75fa3dea61a
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
tests/event_watchers.cpp | 28 +++++++++++++++++++++++++
tests/event_watchers.h | 13 ++++++++++++
tests/restconf-notifications.cpp | 35 --------------------------------
3 files changed, 41 insertions(+), 35 deletions(-)
diff --git a/tests/event_watchers.cpp b/tests/event_watchers.cpp
index c696bc5..bde96ec 100644
--- a/tests/event_watchers.cpp
+++ b/tests/event_watchers.cpp
@@ -1,3 +1,4 @@
+#include <spdlog/spdlog.h>
#include "UniqueResource.h"
#include "event_watchers.h"
@@ -50,3 +51,30 @@ sysrepo::Subscription datastoreNewStateSubscription(sysrepo::Session& session, D
0,
sysrepo::SubscribeOptions::DoneOnly);
}
+
+RestconfNotificationWatcher::RestconfNotificationWatcher(const libyang::Context& ctx)
+ : ctx(ctx)
+ , dataFormat(libyang::DataFormat::JSON)
+{
+}
+
+void RestconfNotificationWatcher::setDataFormat(const libyang::DataFormat dataFormat)
+{
+ this->dataFormat = dataFormat;
+}
+
+void RestconfNotificationWatcher::operator()(const std::string& msg) const
+{
+ spdlog::trace("Client received data: {}", msg);
+ auto notifDataNode = ctx.parseOp(msg,
+ dataFormat,
+ dataFormat == libyang::DataFormat::JSON ? libyang::OperationType::NotificationRestconf : libyang::OperationType::NotificationNetconf);
+
+ // parsing nested notifications does not return the data tree root node but the notification data node
+ auto dataRoot = notifDataNode.op;
+ while (dataRoot->parent()) {
+ dataRoot = *dataRoot->parent();
+ }
+
+ data(*dataRoot->printStr(libyang::DataFormat::JSON, libyang::PrintFlags::Shrink));
+}
diff --git a/tests/event_watchers.h b/tests/event_watchers.h
index 5b2429c..b6f2dd2 100644
--- a/tests/event_watchers.h
+++ b/tests/event_watchers.h
@@ -32,3 +32,16 @@ static DatastoreChangesMock testMockForUntrackedModuleWrites;
#define SUBSCRIBE_MODULE(SUBNAME, SESSION, MODULE) \
ALLOW_CALL(testMockForUntrackedModuleWrites, change(trompeloeil::_)); \
auto SUBNAME = datastoreChangesSubscription(SESSION, testMockForUntrackedModuleWrites, MODULE);
+
+struct RestconfNotificationWatcher {
+ libyang::Context ctx;
+ libyang::DataFormat dataFormat;
+
+ RestconfNotificationWatcher(const libyang::Context& ctx);
+ void setDataFormat(const libyang::DataFormat dataFormat);
+ void operator()(const std::string& msg) const;
+
+ MAKE_CONST_MOCK1(data, void(const std::string&));
+};
+
+#define EXPECT_NOTIFICATION(DATA, SEQ) expectations.emplace_back(NAMED_REQUIRE_CALL(netconfWatcher, data(DATA)).IN_SEQUENCE(SEQ));
diff --git a/tests/restconf-notifications.cpp b/tests/restconf-notifications.cpp
index d873512..6c8c51a 100644
--- a/tests/restconf-notifications.cpp
+++ b/tests/restconf-notifications.cpp
@@ -16,45 +16,10 @@ static const auto SERVER_PORT = "10088";
#include "tests/aux-utils.h"
#include "tests/pretty_printers.h"
-#define EXPECT_NOTIFICATION(DATA, SEQ) expectations.emplace_back(NAMED_REQUIRE_CALL(netconfWatcher, data(DATA)).IN_SEQUENCE(SEQ));
#define SEND_NOTIFICATION(DATA) notifSession.sendNotification(*ctx.parseOp(DATA, libyang::DataFormat::JSON, libyang::OperationType::NotificationYang).op, sysrepo::Wait::No);
using namespace std::chrono_literals;
-struct RestconfNotificationWatcher {
- libyang::Context ctx;
- libyang::DataFormat dataFormat;
-
- RestconfNotificationWatcher(const libyang::Context& ctx)
- : ctx(ctx)
- , dataFormat(libyang::DataFormat::JSON)
- {
- }
-
- void setDataFormat(const libyang::DataFormat dataFormat)
- {
- this->dataFormat = dataFormat;
- }
-
- void operator()(const std::string& msg) const
- {
- spdlog::trace("Client received data: {}", msg);
- auto notifDataNode = ctx.parseOp(msg,
- dataFormat,
- dataFormat == libyang::DataFormat::JSON ? libyang::OperationType::NotificationRestconf : libyang::OperationType::NotificationNetconf);
-
- // parsing nested notifications does not return the data tree root node but the notification data node
- auto dataRoot = notifDataNode.op;
- while (dataRoot->parent()) {
- dataRoot = *dataRoot->parent();
- }
-
- data(*dataRoot->printStr(libyang::DataFormat::JSON, libyang::PrintFlags::Shrink));
- }
-
- MAKE_CONST_MOCK1(data, void(const std::string&));
-};
-
struct SSEClient {
std::shared_ptr<ng_client::session> client;
boost::asio::deadline_timer t;
--
2.43.0
@@ -0,0 +1,120 @@
From b7966613b43b01402c9f0af286a0b3237161779d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Tue, 2 Sep 2025 15:33:43 +0200
Subject: [PATCH 16/17] tests: processing incomplete events in SSE client
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
During my testing I noticed that I obtained incomplete message in
on_data callback. The message was split into two callbacks.
This commit fixes the FIXME by buffering the data and processing
a complete event, once available.
Change-Id: Ied07e69e8b518f20fcc82134a4c041e7ec3a06d6
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
tests/restconf_utils.cpp | 53 +++++++++++++++++++++++-----------------
tests/restconf_utils.h | 3 ++-
2 files changed, 33 insertions(+), 23 deletions(-)
diff --git a/tests/restconf_utils.cpp b/tests/restconf_utils.cpp
index 5b1ebe3..740b1fa 100644
--- a/tests/restconf_utils.cpp
+++ b/tests/restconf_utils.cpp
@@ -199,8 +199,8 @@ SSEClient::SSEClient(
req->on_response([&, silenceTimeout, reportIgnoredLines](const ng_client::response& res) {
requestSent.release();
res.on_data([&, silenceTimeout, reportIgnoredLines](const uint8_t* data, std::size_t len) {
- // not a production-ready code. In real-life condition the data received in one callback might probably be incomplete
- parseEvents(std::string(reinterpret_cast<const char*>(data), len), eventWatcher, reportIgnoredLines);
+ dataBuffer.append(std::string(reinterpret_cast<const char*>(data), len));
+ parseEvents(eventWatcher, reportIgnoredLines);
t.expires_from_now(silenceTimeout);
});
});
@@ -217,30 +217,39 @@ SSEClient::SSEClient(
});
}
-void SSEClient::parseEvents(const std::string& msg, const RestconfNotificationWatcher& eventWatcher, const ReportIgnoredLines reportIgnoredLines)
+void SSEClient::parseEvents(const RestconfNotificationWatcher& eventWatcher, const ReportIgnoredLines reportIgnoredLines)
{
static const std::string dataPrefix = "data:";
static const std::string ignorePrefix = ":";
- std::istringstream iss(msg);
- std::string line;
- std::string event;
-
- while (std::getline(iss, line)) {
- if (line.starts_with(ignorePrefix) && reportIgnoredLines == ReportIgnoredLines::Yes) {
- eventWatcher.commentEvent(line);
- } else if (line.starts_with(ignorePrefix)) {
- continue;
- } else if (line.starts_with(dataPrefix)) {
- event += line.substr(dataPrefix.size());
- } else if (line.empty() && !event.empty()) {
- eventWatcher.dataEvent(event);
- event.clear();
- } else if (line.empty()) {
- continue;
- } else {
- CAPTURE(msg);
- FAIL("Unprefixed response");
+ std::size_t pos = 0;
+ constexpr auto EVENT_SEPARATOR = "\n\n"; // FIXME: Not a production-ready code; does not deal with all possible newline combinations of CR and LF
+
+ while ((pos = dataBuffer.find(EVENT_SEPARATOR)) != std::string::npos) {
+ // extract event
+ auto rawEvent = dataBuffer.substr(0, pos + std::char_traits<char>::length(EVENT_SEPARATOR));
+ std::istringstream stream(rawEvent);
+ dataBuffer.erase(0, pos + std::char_traits<char>::length(EVENT_SEPARATOR));
+
+ // split on newlines
+ std::string line;
+ std::string event;
+ while (std::getline(stream, line)) {
+ if (line.starts_with(ignorePrefix) && reportIgnoredLines == ReportIgnoredLines::Yes) {
+ eventWatcher.commentEvent(line);
+ } else if (line.starts_with(ignorePrefix)) {
+ continue;
+ } else if (line.starts_with(dataPrefix)) {
+ event += line.substr(dataPrefix.size());
+ } else if (line.empty() && !event.empty()) {
+ eventWatcher.dataEvent(event);
+ event.clear();
+ } else if (line.empty()) {
+ continue;
+ } else {
+ CAPTURE(rawEvent);
+ FAIL("Unprefixed response");
+ }
}
}
}
diff --git a/tests/restconf_utils.h b/tests/restconf_utils.h
index 9dde10b..c35df54 100644
--- a/tests/restconf_utils.h
+++ b/tests/restconf_utils.h
@@ -87,6 +87,7 @@ void setupRealNacm(sysrepo::Session session);
struct SSEClient {
std::shared_ptr<ng_client::session> client;
boost::asio::deadline_timer t;
+ std::string dataBuffer;
enum class ReportIgnoredLines {
No,
@@ -104,7 +105,7 @@ struct SSEClient {
const boost::posix_time::seconds silenceTimeout = boost::posix_time::seconds(1), // test code; the server should respond "soon"
const ReportIgnoredLines reportIgnoredLines = ReportIgnoredLines::No);
- static void parseEvents(const std::string& msg, const RestconfNotificationWatcher& eventWatcher, const ReportIgnoredLines reportIgnoredLines);
+ void parseEvents(const RestconfNotificationWatcher& eventWatcher, const ReportIgnoredLines reportIgnoredLines);
};
#define PREPARE_LOOP_WITH_EXCEPTIONS \
--
2.43.0
@@ -0,0 +1,102 @@
From 1067e05674633b97d64b428686aff44822230c5f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Mon, 8 Sep 2025 20:24:15 +0200
Subject: [PATCH 17/17] EventStream: fix possible heap-use-after-free
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
I have started getting heap-use-after-free errors from sanitizers (see
below). The problem is that EventStream::enqueue posted res.resume()
call to the asio event loop, but sometimes when the function was called,
the response object was already destroyed.
The fix is to check if the response is still valid (and not closed)
before actually resuming the response.
WARNING: ThreadSanitizer: heap-use-after-free (pid=633338)
Read of size 8 at 0x72080000f3d0 by thread T134:
#0 std::__uniq_ptr_impl<nghttp2::asio_http2::server::response_impl, std::default_delete<nghttp2::asio_http2::server::response_impl>>::_M_ptr() const /usr/bin/../lib64/gcc/x86_64-pc-linux-gnu/15.1.1/../../../../include/c++/15.1.1/bits/unique_ptr.h:193:51 (libnghttp2_asio.so.0+0x205011) (BuildId: d31f110d6b82f3b5923f6e08b6ae7d409d710cc4)
#1 std::unique_ptr<nghttp2::asio_http2::server::response_impl, std::default_delete<nghttp2::asio_http2::server::response_impl>>::get() const /usr/bin/../lib64/gcc/x86_64-pc-linux-gnu/15.1.1/../../../../include/c++/15.1.1/bits/unique_ptr.h:473:21 (libnghttp2_asio.so.0+0x204fc5) (BuildId: d31f110d6b82f3b5923f6e08b6ae7d409d710cc4)
#2 std::unique_ptr<nghttp2::asio_http2::server::response_impl, std::default_delete<nghttp2::asio_http2::server::response_impl>>::operator->() const /usr/bin/../lib64/gcc/x86_64-pc-linux-gnu/15.1.1/../../../../include/c++/15.1.1/bits/unique_ptr.h:466:9 (libnghttp2_asio.so.0+0x2046c5) (BuildId: d31f110d6b82f3b5923f6e08b6ae7d409d710cc4)
#3 nghttp2::asio_http2::server::response::resume() const /home/tomas/zdrojaky/cesnet/nghttp2-asio/lib/asio_server_response.cc:63:33 (libnghttp2_asio.so.0+0x204425) (BuildId: d31f110d6b82f3b5923f6e08b6ae7d409d710cc4)
#4 rousette::http::EventStream::enqueue(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char>> const&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char>> const&)::$_1::operator()() const /home/tomas/zdrojaky/cesnet/rousette/src/http/EventStream.cpp:167:68 (test-restconf-subscribed-notifications+0x2a084d) (BuildId: 9270c9f5d6da566b5b7f223994c283ecf35f0e43)
#5 boost::asio::detail::binder0<rousette::http::EventStream::enqueue(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char>> const&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char>> const&)::$_1>::operator()() /usr/include/boost/asio/detail/bind_handler.hpp:56:5 (test-restconf-subscribed-notifications+0x2a084d)
#6 boost::asio::detail::executor_op<boost::asio::detail::binder0<rousette::http::EventStream::enqueue(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char>> const&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char>> const&)::$_1>, std::allocator<void>, boost::asio::detail::scheduler_operation>::do_complete(void*, boost::asio::detail::scheduler_operation*, boost::system::error_code const&, unsigned long) /usr/include/boost/asio/detail/executor_op.hpp:70:7 (test-restconf-subscribed-notifications+0x2a084d)
#7 boost::asio::detail::scheduler_operation::complete(void*, boost::system::error_code const&, unsigned long) /usr/include/boost/asio/detail/scheduler_operation.hpp:40:5 (test-restconf-subscribed-notifications+0x165ce9) (BuildId: 9270c9f5d6da566b5b7f223994c283ecf35f0e43)
#8 boost::asio::detail::scheduler::do_run_one(boost::asio::detail::conditionally_enabled_mutex::scoped_lock&, boost::asio::detail::scheduler_thread_info&, boost::system::error_code const&) /usr/include/boost/asio/detail/impl/scheduler.ipp:492:12 (test-restconf-subscribed-notifications+0x165ce9)
#9 boost::asio::detail::scheduler::run(boost::system::error_code&) /usr/include/boost/asio/detail/impl/scheduler.ipp:208:10 (test-restconf-subscribed-notifications+0x165292) (BuildId: 9270c9f5d6da566b5b7f223994c283ecf35f0e43)
#10 boost::asio::io_context::run() /usr/include/boost/asio/impl/io_context.ipp:71:24 (libnghttp2_asio.so.0+0x1675bf) (BuildId: d31f110d6b82f3b5923f6e08b6ae7d409d710cc4)
(...)
Previous write of size 8 at 0x72080000f3d0 by thread T134:
#0 operator delete(void*, unsigned long) <null> (test-restconf-subscribed-notifications+0x13f7d3) (BuildId: 9270c9f5d6da566b5b7f223994c283ecf35f0e43)
#1 std::default_delete<nghttp2::asio_http2::server::stream>::operator()(nghttp2::asio_http2::server::stream*) const /usr/bin/../lib64/gcc/x86_64-pc-linux-gnu/15.1.1/../../../../include/c++/15.1.1/bits/unique_ptr.h:93:2 (libnghttp2_asio.so.0+0x1fc71d) (BuildId: d31f110d6b82f3b5923f6e08b6ae7d409d710cc4)
#2 std::_Sp_counted_deleter<nghttp2::asio_http2::server::stream*, std::default_delete<nghttp2::asio_http2::server::stream>, std::allocator<void>, (__gnu_cxx::_Lock_policy)2>::_M_dispose() /usr/bin/../lib64/gcc/x86_64-pc-linux-gnu/15.1.1/../../../../include/c++/15.1.1/bits/shared_ptr_base.h:526:9 (libnghttp2_asio.so.0+0x1fddaf) (BuildId: d31f110d6b82f3b5923f6e08b6ae7d409d710cc4)
#3 std::_Sp_counted_base<(__gnu_cxx::_Lock_policy)2>::_M_release_last_use() /usr/bin/../lib64/gcc/x86_64-pc-linux-gnu/15.1.1/../../../../include/c++/15.1.1/bits/shared_ptr_base.h:174:2 (libnghttp2_asio.so.0+0x160e46) (BuildId: d31f110d6b82f3b5923f6e08b6ae7d409d710cc4)
#4 std::_Sp_counted_base<(__gnu_cxx::_Lock_policy)2>::_M_release() /usr/bin/../lib64/gcc/x86_64-pc-linux-gnu/15.1.1/../../../../include/c++/15.1.1/bits/shared_ptr_base.h:360:4 (libnghttp2_asio.so.0+0x160dfd) (BuildId: d31f110d6b82f3b5923f6e08b6ae7d409d710cc4)
#5 std::__shared_count<(__gnu_cxx::_Lock_policy)2>::~__shared_count() /usr/bin/../lib64/gcc/x86_64-pc-linux-gnu/15.1.1/../../../../include/c++/15.1.1/bits/shared_ptr_base.h:1069:11 (libnghttp2_asio.so.0+0x160d08) (BuildId: d31f110d6b82f3b5923f6e08b6ae7d409d710cc4)
#6 std::__shared_ptr<nghttp2::asio_http2::server::stream, (__gnu_cxx::_Lock_policy)2>::~__shared_ptr() /usr/bin/../lib64/gcc/x86_64-pc-linux-gnu/15.1.1/../../../../include/c++/15.1.1/bits/shared_ptr_base.h:1531:31 (libnghttp2_asio.so.0+0x1f82e9) (BuildId: d31f110d6b82f3b5923f6e08b6ae7d409d710cc4)
#7 std::shared_ptr<nghttp2::asio_http2::server::stream>::~shared_ptr() /usr/bin/../lib64/gcc/x86_64-pc-linux-gnu/15.1.1/../../../../include/c++/15.1.1/bits/shared_ptr.h:175:11 (libnghttp2_asio.so.0+0x1f82a5) (BuildId: d31f110d6b82f3b5923f6e08b6ae7d409d710cc4)
#8 std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>::~pair() /usr/bin/../lib64/gcc/x86_64-pc-linux-gnu/15.1.1/../../../../include/c++/15.1.1/bits/stl_pair.h:302:12 (libnghttp2_asio.so.0+0x1f8269) (BuildId: d31f110d6b82f3b5923f6e08b6ae7d409d710cc4)
#9 void std::__new_allocator<std::_Rb_tree_node<std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>>>::destroy<std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>>(std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>*) /usr/bin/../lib64/gcc/x86_64-pc-linux-gnu/15.1.1/../../../../include/c++/15.1.1/bits/new_allocator.h:198:10 (libnghttp2_asio.so.0+0x1f815d) (BuildId: d31f110d6b82f3b5923f6e08b6ae7d409d710cc4)
#10 void std::allocator_traits<std::allocator<std::_Rb_tree_node<std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>>>>::destroy<std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>>(std::allocator<std::_Rb_tree_node<std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>>>&, std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>*) /usr/bin/../lib64/gcc/x86_64-pc-linux-gnu/15.1.1/../../../../include/c++/15.1.1/bits/alloc_traits.h:696:8 (libnghttp2_asio.so.0+0x1f815d)
#11 std::_Rb_tree<int, std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>, std::_Select1st<std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>>, std::less<int>, std::allocator<std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>>>::_M_destroy_node(std::_Rb_tree_node<std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>>*) /usr/bin/../lib64/gcc/x86_64-pc-linux-gnu/15.1.1/../../../../include/c++/15.1.1/bits/stl_tree.h:1265:2 (libnghttp2_asio.so.0+0x1f815d)
#12 std::_Rb_tree<int, std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>, std::_Select1st<std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>>, std::less<int>, std::allocator<std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>>>::_M_drop_node(std::_Rb_tree_node<std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>>*) /usr/bin/../lib64/gcc/x86_64-pc-linux-gnu/15.1.1/../../../../include/c++/15.1.1/bits/stl_tree.h:1273:2 (libnghttp2_asio.so.0+0x1f80b9) (BuildId: d31f110d6b82f3b5923f6e08b6ae7d409d710cc4)
#13 std::_Rb_tree<int, std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>, std::_Select1st<std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>>, std::less<int>, std::allocator<std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>>>::_M_erase(std::_Rb_tree_node<std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>>*) /usr/bin/../lib64/gcc/x86_64-pc-linux-gnu/15.1.1/../../../../include/c++/15.1.1/bits/stl_tree.h:2590:4 (libnghttp2_asio.so.0+0x1f7e98) (BuildId: d31f110d6b82f3b5923f6e08b6ae7d409d710cc4)
#14 std::_Rb_tree<int, std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>, std::_Select1st<std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>>, std::less<int>, std::allocator<std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>>>::clear() /usr/bin/../lib64/gcc/x86_64-pc-linux-gnu/15.1.1/../../../../include/c++/15.1.1/bits/stl_tree.h:1878:2 (libnghttp2_asio.so.0+0x1ff035) (BuildId: d31f110d6b82f3b5923f6e08b6ae7d409d710cc4)
#15 std::_Rb_tree<int, std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>, std::_Select1st<std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>>, std::less<int>, std::allocator<std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>>>::_M_erase_aux(std::_Rb_tree_const_iterator<std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>>, std::_Rb_tree_const_iterator<std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>>) /usr/bin/../lib64/gcc/x86_64-pc-linux-gnu/15.1.1/../../../../include/c++/15.1.1/bits/stl_tree.h:3127:2 (libnghttp2_asio.so.0+0x1feca6) (BuildId: d31f110d6b82f3b5923f6e08b6ae7d409d710cc4)
#16 std::_Rb_tree<int, std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>, std::_Select1st<std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>>, std::less<int>, std::allocator<std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>>>::erase(int const&) /usr/bin/../lib64/gcc/x86_64-pc-linux-gnu/15.1.1/../../../../include/c++/15.1.1/bits/stl_tree.h:3141:7 (libnghttp2_asio.so.0+0x1fe8a3) (BuildId: d31f110d6b82f3b5923f6e08b6ae7d409d710cc4)
#17 std::map<int, std::shared_ptr<nghttp2::asio_http2::server::stream>, std::less<int>, std::allocator<std::pair<int const, std::shared_ptr<nghttp2::asio_http2::server::stream>>>>::erase(int const&) /usr/bin/../lib64/gcc/x86_64-pc-linux-gnu/15.1.1/../../../../include/c++/15.1.1/bits/stl_map.h:1159:21 (libnghttp2_asio.so.0+0x1f68d5) (BuildId: d31f110d6b82f3b5923f6e08b6ae7d409d710cc4)
#18 nghttp2::asio_http2::server::http2_handler::close_stream(int) /home/tomas/zdrojaky/cesnet/nghttp2-asio/lib/asio_server_http2_handler.cc:315:12 (libnghttp2_asio.so.0+0x1f34b0) (BuildId: d31f110d6b82f3b5923f6e08b6ae7d409d710cc4)
#19 nghttp2::asio_http2::server::(anonymous namespace)::on_stream_close_callback(nghttp2_session*, int, unsigned int, void*) /home/tomas/zdrojaky/cesnet/nghttp2-asio/lib/asio_server_http2_handler.cc:193:12 (libnghttp2_asio.so.0+0x1f3074) (BuildId: d31f110d6b82f3b5923f6e08b6ae7d409d710cc4)
#20 nghttp2_session_close_stream /usr/src/debug/libnghttp2/nghttp2/lib/nghttp2_session.c:1289:9 (libnghttp2.so.14+0x62ac) (BuildId: 8a424dc80fadd83dfdd8c51eebe47046c626d95e)
#21 nghttp2::asio_http2::server::connection<boost::asio::basic_stream_socket<boost::asio::ip::tcp, boost::asio::any_io_executor>>::do_write() /home/tomas/zdrojaky/cesnet/nghttp2-asio/lib/asio_server_connection.h:177:20 (libnghttp2_asio.so.0+0x1ebb1c) (BuildId: d31f110d6b82f3b5923f6e08b6ae7d409d710cc4)
#22 nghttp2::asio_http2::server::connection<boost::asio::basic_stream_socket<boost::asio::ip::tcp, boost::asio::any_io_executor>>::do_write()::'lambda'(boost::system::error_code const&, unsigned long)::operator()(boost::system::error_code const&, unsigned long) const /home/tomas/zdrojaky/cesnet/nghttp2-asio/lib/asio_server_connection.h:207:11 (libnghttp2_asio.so.0+0x1ec7a8) (BuildId: d31f110d6b82f3b5923f6e08b6ae7d409d710cc4)
#23 boost::asio::detail::write_op<boost::asio::basic_stream_socket<boost::asio::ip::tcp, boost::asio::any_io_executor>, boost::asio::mutable_buffer, boost::asio::mutable_buffer const*, boost::asio::detail::transfer_all_t, nghttp2::asio_http2::server::connection<boost::asio::basic_stream_socket<boost::asio::ip::tcp, boost::asio::any_io_executor>>::do_write()::'lambda'(boost::system::error_code const&, unsigned long)>::operator()(boost::system::error_code, unsigned long, int) /usr/include/boost/asio/impl/write.hpp:380:9 (libnghttp2_asio.so.0+0x1ec4e5) (BuildId: d31f110d6b82f3b5923f6e08b6ae7d409d710cc4)
#24 boost::asio::detail::binder2<boost::asio::detail::write_op<boost::asio::basic_stream_socket<boost::asio::ip::tcp, boost::asio::any_io_executor>, boost::asio::mutable_buffer, boost::asio::mutable_buffer const*, boost::asio::detail::transfer_all_t, nghttp2::asio_http2::server::connection<boost::asio::basic_stream_socket<boost::asio::ip::tcp, boost::asio::any_io_executor>>::do_write()::'lambda'(boost::system::error_code const&, unsigned long)>, boost::system::error_code, unsigned long>::operator()() /usr/include/boost/asio/detail/bind_handler.hpp:181:5 (libnghttp2_asio.so.0+0x1edd41) (BuildId: d31f110d6b82f3b5923f6e08b6ae7d409d710cc4)
#25 void boost::asio::detail::handler_work<boost::asio::detail::write_op<boost::asio::basic_stream_socket<boost::asio::ip::tcp, boost::asio::any_io_executor>, boost::asio::mutable_buffer, boost::asio::mutable_buffer const*, boost::asio::detail::transfer_all_t, nghttp2::asio_http2::server::connection<boost::asio::basic_stream_socket<boost::asio::ip::tcp, boost::asio::any_io_executor>>::do_write()::'lambda'(boost::system::error_code const&, unsigned long)>, boost::asio::any_io_executor, void>::complete<boost::asio::detail::binder2<boost::asio::detail::write_op<boost::asio::basic_stream_socket<boost::asio::ip::tcp, boost::asio::any_io_executor>, boost::asio::mutable_buffer, boost::asio::mutable_buffer const*, boost::asio::detail::transfer_all_t, nghttp2::asio_http2::server::connection<boost::asio::basic_stream_socket<boost::asio::ip::tcp, boost::asio::any_io_executor>>::do_write()::'lambda'(boost::system::error_code const&, unsigned long)>, boost::system::error_code, unsigned long>>(boost::asio::detail::binder2<boost::asio::detail::write_op<boost::asio::basic_stream_socket<boost::asio::ip::tcp, boost::asio::any_io_executor>, boost::asio::mutable_buffer, boost::asio::mutable_buffer const*, boost::asio::detail::transfer_all_t, nghttp2::asio_http2::server::connection<boost::asio::basic_stream_socket<boost::asio::ip::tcp, boost::asio::any_io_executor>>::do_write()::'lambda'(boost::system::error_code const&, unsigned long)>, boost::system::error_code, unsigned long>&, boost::asio::detail::write_op<boost::asio::basic_stream_socket<boost::asio::ip::tcp, boost::asio::any_io_executor>, boost::asio::mutable_buffer, boost::asio::mutable_buffer const*, boost::asio::detail::transfer_all_t, nghttp2::asio_http2::server::connection<boost::asio::basic_stream_socket<boost::asio::ip::tcp, boost::asio::any_io_executor>>::do_write()::'lambda'(boost::system::error_code const&, unsigned long)>&) /usr/include/boost/asio/detail/handler_work.hpp:470:7 (libnghttp2_asio.so.0+0x1edb2e) (BuildId: d31f110d6b82f3b5923f6e08b6ae7d409d710cc4)
#26 boost::asio::detail::reactive_socket_send_op<boost::asio::const_buffer, boost::asio::detail::write_op<boost::asio::basic_stream_socket<boost::asio::ip::tcp, boost::asio::any_io_executor>, boost::asio::mutable_buffer, boost::asio::mutable_buffer const*, boost::asio::detail::transfer_all_t, nghttp2::asio_http2::server::connection<boost::asio::basic_stream_socket<boost::asio::ip::tcp, boost::asio::any_io_executor>>::do_write()::'lambda'(boost::system::error_code const&, unsigned long)>, boost::asio::any_io_executor>::do_complete(void*, boost::asio::detail::scheduler_operation*, boost::system::error_code const&, unsigned long) /usr/include/boost/asio/detail/reactive_socket_send_op.hpp:154:9 (libnghttp2_asio.so.0+0x1ed66c) (BuildId: d31f110d6b82f3b5923f6e08b6ae7d409d710cc4)
#27 boost::asio::detail::scheduler_operation::complete(void*, boost::system::error_code const&, unsigned long) /usr/include/boost/asio/detail/scheduler_operation.hpp:40:5 (test-restconf-subscribed-notifications+0x165ce9) (BuildId: 9270c9f5d6da566b5b7f223994c283ecf35f0e43)
#28 boost::asio::detail::scheduler::do_run_one(boost::asio::detail::conditionally_enabled_mutex::scoped_lock&, boost::asio::detail::scheduler_thread_info&, boost::system::error_code const&) /usr/include/boost/asio/detail/impl/scheduler.ipp:492:12 (test-restconf-subscribed-notifications+0x165ce9)
#29 boost::asio::detail::scheduler::run(boost::system::error_code&) /usr/include/boost/asio/detail/impl/scheduler.ipp:208:10 (test-restconf-subscribed-notifications+0x165292) (BuildId: 9270c9f5d6da566b5b7f223994c283ecf35f0e43)
#30 boost::asio::io_context::run() /usr/include/boost/asio/impl/io_context.ipp:71:24 (libnghttp2_asio.so.0+0x1675bf) (BuildId: d31f110d6b82f3b5923f6e08b6ae7d409d710cc4)
(...)
Change-Id: Ifdb1f8610cacffca3bb49da17aa9b1d267cdd472
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/http/EventStream.cpp | 17 ++++++++++++++++-
1 file changed, 16 insertions(+), 1 deletion(-)
diff --git a/src/http/EventStream.cpp b/src/http/EventStream.cpp
index 0a974d4..f97d953 100644
--- a/src/http/EventStream.cpp
+++ b/src/http/EventStream.cpp
@@ -160,7 +160,22 @@ void EventStream::enqueue(const std::string& fieldName, const std::string& what)
spdlog::trace("{}: new event, ∑ queue size = {}", peer, len);
queue.push_back(buf);
state = HasEvents;
- boost::asio::post(res.io_service(), [&res = this->res]() { res.resume(); });
+ boost::asio::post(res.io_service(), [weak = weak_from_this()]() {
+ auto myself = weak.lock();
+ if (!myself) {
+ spdlog::trace("enqueue: already disconnected");
+ return;
+ }
+
+ std::lock_guard lock{myself->mtx};
+ // if state is Closed, then res.on_close() already finished, which means we must not use res anymore
+ if (myself->state == Closed) {
+ spdlog::trace("{}: enqueue: not resuming, the response's on_close handler already finished", myself->peer);
+ return;
+ }
+
+ myself->res.resume();
+ });
}
void EventStream::start_ping()
--
2.43.0
@@ -1,45 +0,0 @@
From 36943051d8563bee0c9cf89eec28acf5d3617272 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Tue, 3 Dec 2024 12:02:24 +0100
Subject: [PATCH 17/44] tests: helper function to construct server URI
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Change-Id: Ic7c03b32a29b07464291f99027530010a7902f77
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
tests/restconf_utils.cpp | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/tests/restconf_utils.cpp b/tests/restconf_utils.cpp
index ea9a18d..83f568f 100644
--- a/tests/restconf_utils.cpp
+++ b/tests/restconf_utils.cpp
@@ -13,6 +13,12 @@ using namespace std::string_literals;
namespace ng = nghttp2::asio_http2;
namespace ng_client = ng::client;
+namespace {
+std::string serverAddressAndPort(const std::string& server_address, const std::string& server_port) {
+ return "http://["s + server_address + "]" + ":" + server_port;
+}
+}
+
Response::Response(int statusCode, const Response::Headers& headers, const std::string& data)
: Response(statusCode, transformHeaders(headers), data)
{
@@ -75,8 +81,7 @@ Response clientRequest(const std::string& server_address,
reqHeaders.insert({name, {value, false}});
}
- const auto server_address_and_port = std::string("http://[") + server_address + "]" + ":" + server_port;
- auto req = client->submit(ec, method, server_address_and_port + uri, data, reqHeaders);
+ auto req = client->submit(ec, method, serverAddressAndPort(server_address, server_port) + uri, data, reqHeaders);
req->on_response([&](const ng_client::response& res) {
res.on_data([&oss](const uint8_t* data, std::size_t len) {
oss.write(reinterpret_cast<const char*>(data), len);
--
2.43.0
@@ -1,307 +0,0 @@
From 7d15f59d20079ba94224e0bc308682aa5a004483 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Mon, 2 Dec 2024 20:15:05 +0100
Subject: [PATCH 18/44] tests: make SSEClient reusable
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
We will need it in yang push tests
Change-Id: I22432553f3abff0de91b3c406abc5567de656065
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
tests/restconf-notifications.cpp | 108 +------------------------------
tests/restconf_utils.cpp | 69 ++++++++++++++++++++
tests/restconf_utils.h | 47 ++++++++++++++
3 files changed, 119 insertions(+), 105 deletions(-)
diff --git a/tests/restconf-notifications.cpp b/tests/restconf-notifications.cpp
index 6c8c51a..4496b63 100644
--- a/tests/restconf-notifications.cpp
+++ b/tests/restconf-notifications.cpp
@@ -7,121 +7,19 @@
#include "trompeloeil_doctest.h"
static const auto SERVER_PORT = "10088";
-#include <latch>
#include <libyang-cpp/Time.hpp>
#include <nghttp2/asio_http2.h>
#include <spdlog/spdlog.h>
#include <sysrepo-cpp/utils/utils.hpp>
#include "restconf/Server.h"
#include "tests/aux-utils.h"
+#include "tests/event_watchers.h"
#include "tests/pretty_printers.h"
#define SEND_NOTIFICATION(DATA) notifSession.sendNotification(*ctx.parseOp(DATA, libyang::DataFormat::JSON, libyang::OperationType::NotificationYang).op, sysrepo::Wait::No);
using namespace std::chrono_literals;
-struct SSEClient {
- std::shared_ptr<ng_client::session> client;
- boost::asio::deadline_timer t;
-
- SSEClient(
- boost::asio::io_service& io,
- std::latch& requestSent,
- const RestconfNotificationWatcher& notification,
- const std::string& uri,
- const std::map<std::string, std::string>& headers,
- const boost::posix_time::seconds silenceTimeout = boost::posix_time::seconds(1)) // test code; the server should respond "soon"
- : client(std::make_shared<ng_client::session>(io, SERVER_ADDRESS, SERVER_PORT))
- , t(io, silenceTimeout)
- {
- ng::header_map reqHeaders;
- for (const auto& [name, value] : headers) {
- reqHeaders.insert({name, {value, false}});
- }
-
- // shutdown the client after a period of no traffic
- t.async_wait([maybeClient = std::weak_ptr<ng_client::session>{client}](const boost::system::error_code& ec) {
- if (ec == boost::asio::error::operation_aborted) {
- return;
- }
- if (auto client = maybeClient.lock()) {
- client->shutdown();
- }
- });
-
- client->on_connect([&, uri, reqHeaders, silenceTimeout](auto) {
- boost::system::error_code ec;
-
- static const auto server_address_and_port = std::string("http://[") + SERVER_ADDRESS + "]" + ":" + SERVER_PORT;
- auto req = client->submit(ec, "GET", server_address_and_port + uri, "", reqHeaders);
- req->on_response([&, silenceTimeout](const ng_client::response& res) {
- requestSent.count_down();
- res.on_data([&, silenceTimeout](const uint8_t* data, std::size_t len) {
- // not a production-ready code. In real-life condition the data received in one callback might probably be incomplete
- for (const auto& event : parseEvents(std::string(reinterpret_cast<const char*>(data), len))) {
- notification(event);
- }
- t.expires_from_now(silenceTimeout);
- });
- });
- });
-
- client->on_error([&](const boost::system::error_code& ec) {
- throw std::runtime_error{"HTTP client error: " + ec.message()};
- });
- }
-
- static std::vector<std::string> parseEvents(const std::string& msg)
- {
- static const std::string prefix = "data:";
-
- std::vector<std::string> res;
- std::istringstream iss(msg);
- std::string line;
- std::string event;
-
- while (std::getline(iss, line)) {
- if (line.compare(0, prefix.size(), prefix) == 0) {
- event += line.substr(prefix.size());
- } else if (line.empty()) {
- res.emplace_back(std::move(event));
- event.clear();
- } else {
- FAIL("Unprefixed response");
- }
- }
- return res;
- }
-};
-
-#define PREPARE_LOOP_WITH_EXCEPTIONS \
- boost::asio::io_service io; \
- std::promise<void> bg; \
- std::latch requestSent(1);
-
-#define RUN_LOOP_WITH_EXCEPTIONS \
- do { \
- io.run(); \
- auto fut = bg.get_future(); \
- REQUIRE(fut.wait_for(666ms /* "plenty of time" for the notificationThread to exit after it has called io.stop() */) == std::future_status::ready); \
- fut.get(); \
- } while (false)
-
-auto wrap_exceptions_and_asio(std::promise<void>& bg, boost::asio::io_service& io, std::function<void()> func)
-{
- return [&bg, &io, func]()
- {
- try {
- func();
- } catch (...) {
- bg.set_exception(std::current_exception());
- return;
- }
- bg.set_value();
- io.stop();
- };
-}
-
TEST_CASE("NETCONF notification streams")
{
trompeloeil::sequence seqMod1, seqMod2;
@@ -237,7 +135,7 @@ TEST_CASE("NETCONF notification streams")
waitForCompletionAndBitMore(seqMod2);
}));
- SSEClient cli(io, requestSent, netconfWatcher, uri, headers);
+ SSEClient cli(io, SERVER_ADDRESS, SERVER_PORT, requestSent, netconfWatcher, uri, headers);
RUN_LOOP_WITH_EXCEPTIONS;
}
@@ -399,7 +297,7 @@ TEST_CASE("NETCONF notification streams")
}
oldNotificationsDone.wait();
- SSEClient cli(io, requestSent, netconfWatcher, uri, {AUTH_ROOT});
+ SSEClient cli(io, SERVER_ADDRESS, SERVER_PORT, requestSent, netconfWatcher, uri, {AUTH_ROOT});
RUN_LOOP_WITH_EXCEPTIONS;
}
}
diff --git a/tests/restconf_utils.cpp b/tests/restconf_utils.cpp
index 83f568f..8252bba 100644
--- a/tests/restconf_utils.cpp
+++ b/tests/restconf_utils.cpp
@@ -160,3 +160,72 @@ void setupRealNacm(sysrepo::Session session)
session.applyChanges();
}
+SSEClient::SSEClient(
+ boost::asio::io_service& io,
+ const std::string& server_address,
+ const std::string& server_port,
+ std::latch& requestSent,
+ const RestconfNotificationWatcher& notification,
+ const std::string& uri,
+ const std::map<std::string, std::string>& headers,
+ const boost::posix_time::seconds silenceTimeout)
+ : client(std::make_shared<ng_client::session>(io, server_address, server_port))
+ , t(io, silenceTimeout)
+{
+ ng::header_map reqHeaders;
+ for (const auto& [name, value] : headers) {
+ reqHeaders.insert({name, {value, false}});
+ }
+
+ // shutdown the client after a period of no traffic
+ t.async_wait([maybeClient = std::weak_ptr<ng_client::session>{client}](const boost::system::error_code& ec) {
+ if (ec == boost::asio::error::operation_aborted) {
+ return;
+ }
+ if (auto client = maybeClient.lock()) {
+ client->shutdown();
+ }
+ });
+
+ client->on_connect([&, uri, reqHeaders, silenceTimeout, server_address, server_port](auto) {
+ boost::system::error_code ec;
+
+ auto req = client->submit(ec, "GET", serverAddressAndPort(server_address, server_port) + uri, "", reqHeaders);
+ req->on_response([&, silenceTimeout](const ng_client::response& res) {
+ requestSent.count_down();
+ res.on_data([&, silenceTimeout](const uint8_t* data, std::size_t len) {
+ // not a production-ready code. In real-life condition the data received in one callback might probably be incomplete
+ for (const auto& event : parseEvents(std::string(reinterpret_cast<const char*>(data), len))) {
+ notification(event);
+ }
+ t.expires_from_now(silenceTimeout);
+ });
+ });
+ });
+
+ client->on_error([&](const boost::system::error_code& ec) {
+ throw std::runtime_error{"HTTP client error: " + ec.message()};
+ });
+}
+
+std::vector<std::string> SSEClient::parseEvents(const std::string& msg)
+{
+ static const std::string prefix = "data:";
+
+ std::vector<std::string> res;
+ std::istringstream iss(msg);
+ std::string line;
+ std::string event;
+
+ while (std::getline(iss, line)) {
+ if (line.compare(0, prefix.size(), prefix) == 0) {
+ event += line.substr(prefix.size());
+ } else if (line.empty()) {
+ res.emplace_back(std::move(event));
+ event.clear();
+ } else {
+ FAIL("Unprefixed response");
+ }
+ }
+ return res;
+}
diff --git a/tests/restconf_utils.h b/tests/restconf_utils.h
index 26f0803..8b7386e 100644
--- a/tests/restconf_utils.h
+++ b/tests/restconf_utils.h
@@ -8,7 +8,9 @@
#pragma once
#include "trompeloeil_doctest.h"
+#include <latch>
#include <nghttp2/asio_http2_client.h>
+#include "event_watchers.h"
#include "UniqueResource.h"
namespace sysrepo {
@@ -81,3 +83,48 @@ Response clientRequest(
UniqueResource manageNacm(sysrepo::Session session);
void setupRealNacm(sysrepo::Session session);
+
+struct SSEClient {
+ std::shared_ptr<ng_client::session> client;
+ boost::asio::deadline_timer t;
+
+ SSEClient(
+ boost::asio::io_service& io,
+ const std::string& server_address,
+ const std::string& server_port,
+ std::latch& requestSent,
+ const RestconfNotificationWatcher& notification,
+ const std::string& uri,
+ const std::map<std::string, std::string>& headers,
+ const boost::posix_time::seconds silenceTimeout = boost::posix_time::seconds(1)); // test code; the server should respond "soon"
+
+ static std::vector<std::string> parseEvents(const std::string& msg);
+};
+
+#define PREPARE_LOOP_WITH_EXCEPTIONS \
+ boost::asio::io_service io; \
+ std::promise<void> bg; \
+ std::latch requestSent(1);
+
+#define RUN_LOOP_WITH_EXCEPTIONS \
+ do { \
+ io.run(); \
+ auto fut = bg.get_future(); \
+ REQUIRE(fut.wait_for(666ms /* "plenty of time" for the notificationThread to exit after it has called io.stop() */) == std::future_status::ready); \
+ fut.get(); \
+ } while (false)
+
+inline auto wrap_exceptions_and_asio(std::promise<void>& bg, boost::asio::io_service& io, std::function<void()> func)
+{
+ return [&bg, &io, func]()
+ {
+ try {
+ func();
+ } catch (...) {
+ bg.set_exception(std::current_exception());
+ return;
+ }
+ bg.set_value();
+ io.stop();
+ };
+}
--
2.43.0
@@ -1,139 +0,0 @@
From 3e3e492f4801df56226a5aff5d82bfa86c9d3812 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Tue, 3 Dec 2024 13:59:52 +0100
Subject: [PATCH 19/44] tests: fix deadlock in tests
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
In the case the client never requests (because of misconfiguration or
something else) the test would still be waiting for somebody to
decrement the latch.
Unfortunately std::latch does not allow waiting for specified amount of
time, so this patch replaces it with std::binary_semaphore in which it
is possible to fail the acquiring operation after some time.
Change-Id: I38c007555bb5ab543329d724a16f024a9d80903e
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
tests/restconf-notifications.cpp | 10 +++++-----
tests/restconf_utils.cpp | 4 ++--
tests/restconf_utils.h | 8 +++++---
3 files changed, 12 insertions(+), 10 deletions(-)
diff --git a/tests/restconf-notifications.cpp b/tests/restconf-notifications.cpp
index 4496b63..04131d7 100644
--- a/tests/restconf-notifications.cpp
+++ b/tests/restconf-notifications.cpp
@@ -7,6 +7,7 @@
#include "trompeloeil_doctest.h"
static const auto SERVER_PORT = "10088";
+#include <latch>
#include <libyang-cpp/Time.hpp>
#include <nghttp2/asio_http2.h>
#include <spdlog/spdlog.h>
@@ -119,8 +120,7 @@ TEST_CASE("NETCONF notification streams")
auto notifSession = sysrepo::Connection{}.sessionStart();
auto ctx = notifSession.getContext();
- // wait until the client sends its HTTP request
- requestSent.wait();
+ WAIT_UNTIL_SSE_CLIENT_REQUESTS;
SEND_NOTIFICATION(notificationsJSON[0]);
SEND_NOTIFICATION(notificationsJSON[1]);
@@ -239,7 +239,7 @@ TEST_CASE("NETCONF notification streams")
SEND_NOTIFICATION(notificationsJSON[3]);
SEND_NOTIFICATION(notificationsJSON[4]);
oldNotificationsDone.count_down();
- requestSent.wait();
+ WAIT_UNTIL_SSE_CLIENT_REQUESTS;
waitForCompletionAndBitMore(seqMod1);
waitForCompletionAndBitMore(seqMod2);
@@ -256,7 +256,7 @@ TEST_CASE("NETCONF notification streams")
SEND_NOTIFICATION(notificationsJSON[1]);
oldNotificationsDone.count_down();
- requestSent.wait();
+ WAIT_UNTIL_SSE_CLIENT_REQUESTS;
SEND_NOTIFICATION(notificationsJSON[2]);
SEND_NOTIFICATION(notificationsJSON[3]);
@@ -290,7 +290,7 @@ TEST_CASE("NETCONF notification streams")
SEND_NOTIFICATION(notificationsJSON[4]);
oldNotificationsDone.count_down();
- requestSent.wait();
+ WAIT_UNTIL_SSE_CLIENT_REQUESTS;
waitForCompletionAndBitMore(seqMod1);
waitForCompletionAndBitMore(seqMod2);
}));
diff --git a/tests/restconf_utils.cpp b/tests/restconf_utils.cpp
index 8252bba..c3ff1de 100644
--- a/tests/restconf_utils.cpp
+++ b/tests/restconf_utils.cpp
@@ -164,7 +164,7 @@ SSEClient::SSEClient(
boost::asio::io_service& io,
const std::string& server_address,
const std::string& server_port,
- std::latch& requestSent,
+ std::binary_semaphore& requestSent,
const RestconfNotificationWatcher& notification,
const std::string& uri,
const std::map<std::string, std::string>& headers,
@@ -192,7 +192,7 @@ SSEClient::SSEClient(
auto req = client->submit(ec, "GET", serverAddressAndPort(server_address, server_port) + uri, "", reqHeaders);
req->on_response([&, silenceTimeout](const ng_client::response& res) {
- requestSent.count_down();
+ requestSent.release();
res.on_data([&, silenceTimeout](const uint8_t* data, std::size_t len) {
// not a production-ready code. In real-life condition the data received in one callback might probably be incomplete
for (const auto& event : parseEvents(std::string(reinterpret_cast<const char*>(data), len))) {
diff --git a/tests/restconf_utils.h b/tests/restconf_utils.h
index 8b7386e..9efe398 100644
--- a/tests/restconf_utils.h
+++ b/tests/restconf_utils.h
@@ -8,8 +8,8 @@
#pragma once
#include "trompeloeil_doctest.h"
-#include <latch>
#include <nghttp2/asio_http2_client.h>
+#include <semaphore>
#include "event_watchers.h"
#include "UniqueResource.h"
@@ -92,7 +92,7 @@ struct SSEClient {
boost::asio::io_service& io,
const std::string& server_address,
const std::string& server_port,
- std::latch& requestSent,
+ std::binary_semaphore& requestSent,
const RestconfNotificationWatcher& notification,
const std::string& uri,
const std::map<std::string, std::string>& headers,
@@ -104,7 +104,7 @@ struct SSEClient {
#define PREPARE_LOOP_WITH_EXCEPTIONS \
boost::asio::io_service io; \
std::promise<void> bg; \
- std::latch requestSent(1);
+ std::binary_semaphore requestSent(0);
#define RUN_LOOP_WITH_EXCEPTIONS \
do { \
@@ -114,6 +114,8 @@ struct SSEClient {
fut.get(); \
} while (false)
+#define WAIT_UNTIL_SSE_CLIENT_REQUESTS requestSent.try_acquire_for(std::chrono::seconds(3))
+
inline auto wrap_exceptions_and_asio(std::promise<void>& bg, boost::asio::io_service& io, std::function<void()> func)
{
return [&bg, &io, func]()
--
2.43.0
@@ -1,159 +0,0 @@
From ed0ff23f7ad341d663484f0b2a617cd3bc4923c8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Tue, 3 Dec 2024 19:27:49 +0100
Subject: [PATCH 20/44] restconf: make as_restconf_notification reusable
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
The YANG-PUSH notifications are supposed to be wrapped in this too, so
this needs to be accessible from multiple places.
Change-Id: Icf25caf5f3be3917c524bf6111b5d92db6e287b0
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/restconf/NotificationStream.cpp | 37 +--------------------------
src/restconf/utils/yang.cpp | 39 +++++++++++++++++++++++++++++
src/restconf/utils/yang.h | 4 +++
3 files changed, 44 insertions(+), 36 deletions(-)
diff --git a/src/restconf/NotificationStream.cpp b/src/restconf/NotificationStream.cpp
index 001d19c..eeddc04 100644
--- a/src/restconf/NotificationStream.cpp
+++ b/src/restconf/NotificationStream.cpp
@@ -20,41 +20,6 @@ namespace {
const auto streamListXPath = "/ietf-restconf-monitoring:restconf-state/streams/stream"s;
-/** @brief Wraps a notification data tree with RESTCONF notification envelope. */
-std::string as_restconf_notification(const libyang::Context& ctx, libyang::DataFormat dataFormat, libyang::DataNode notification, const sysrepo::NotificationTimeStamp& time)
-{
- static const auto jsonNamespace = "ietf-restconf";
- static const auto xmlNamespace = "urn:ietf:params:xml:ns:netconf:notification:1.0";
-
- std::optional<libyang::DataNode> envelope;
- std::optional<libyang::DataNode> eventTime;
- std::string timeStr = libyang::yangTimeFormat(time, libyang::TimezoneInterpretation::Local);
-
- /* The namespaces for XML and JSON envelopes are different. See https://datatracker.ietf.org/doc/html/rfc8040#section-6.4 */
- if (dataFormat == libyang::DataFormat::JSON) {
- envelope = ctx.newOpaqueJSON(jsonNamespace, "notification", std::nullopt);
- eventTime = ctx.newOpaqueJSON(jsonNamespace, "eventTime", libyang::JSON{timeStr});
- } else {
- envelope = ctx.newOpaqueXML(xmlNamespace, "notification", std::nullopt);
- eventTime = ctx.newOpaqueXML(xmlNamespace, "eventTime", libyang::XML{timeStr});
- }
-
- // the notification data node holds only the notification data tree but for nested notification we should print the whole YANG data tree
- while (notification.parent()) {
- notification = *notification.parent();
- }
-
- envelope->insertChild(*eventTime);
- envelope->insertChild(notification);
-
- auto res = *envelope->printStr(dataFormat, libyang::PrintFlags::WithSiblings);
-
- // notification node comes from sysrepo and sysrepo will free this; if not unlinked then envelope destructor would try to free this as well
- notification.unlink();
-
- return res;
-}
-
void subscribe(
std::optional<sysrepo::Subscription>& sub,
sysrepo::Session& session,
@@ -70,7 +35,7 @@ void subscribe(
return;
}
- signal(as_restconf_notification(session.getContext(), dataFormat, *notificationTree, time));
+ signal(rousette::restconf::as_restconf_notification(session.getContext(), dataFormat, *notificationTree, time));
};
if (!sub) {
diff --git a/src/restconf/utils/yang.cpp b/src/restconf/utils/yang.cpp
index 4c4d619..30661fc 100644
--- a/src/restconf/utils/yang.cpp
+++ b/src/restconf/utils/yang.cpp
@@ -6,8 +6,11 @@
*/
#include <algorithm>
+#include <libyang-cpp/Context.hpp>
#include <libyang-cpp/DataNode.hpp>
#include <libyang-cpp/SchemaNode.hpp>
+#include <libyang-cpp/Time.hpp>
+#include <sysrepo-cpp/Subscription.hpp>
namespace rousette::restconf {
@@ -79,4 +82,40 @@ bool isKeyNode(const libyang::DataNode& maybeList, const libyang::DataNode& node
}
return false;
}
+
+
+/** @brief Wraps a notification data tree with RESTCONF notification envelope. */
+std::string as_restconf_notification(const libyang::Context& ctx, libyang::DataFormat dataFormat, libyang::DataNode notification, const sysrepo::NotificationTimeStamp& time)
+{
+ static const auto jsonNamespace = "ietf-restconf";
+ static const auto xmlNamespace = "urn:ietf:params:xml:ns:netconf:notification:1.0";
+
+ std::optional<libyang::DataNode> envelope;
+ std::optional<libyang::DataNode> eventTime;
+ std::string timeStr = libyang::yangTimeFormat(time, libyang::TimezoneInterpretation::Local);
+
+ /* The namespaces for XML and JSON envelopes are different. See https://datatracker.ietf.org/doc/html/rfc8040#section-6.4 */
+ if (dataFormat == libyang::DataFormat::JSON) {
+ envelope = ctx.newOpaqueJSON(jsonNamespace, "notification", std::nullopt);
+ eventTime = ctx.newOpaqueJSON(jsonNamespace, "eventTime", libyang::JSON{timeStr});
+ } else {
+ envelope = ctx.newOpaqueXML(xmlNamespace, "notification", std::nullopt);
+ eventTime = ctx.newOpaqueXML(xmlNamespace, "eventTime", libyang::XML{timeStr});
+ }
+
+ // the notification data node holds only the notification data tree but for nested notification we should print the whole YANG data tree
+ while (notification.parent()) {
+ notification = *notification.parent();
+ }
+
+ envelope->insertChild(*eventTime);
+ envelope->insertChild(notification);
+
+ auto res = *envelope->printStr(dataFormat, libyang::PrintFlags::WithSiblings);
+
+ // notification node comes from sysrepo and sysrepo will free this; if not unlinked then envelope destructor would try to free this as well
+ notification.unlink();
+
+ return res;
+}
}
diff --git a/src/restconf/utils/yang.h b/src/restconf/utils/yang.h
index e91ba8a..a558eae 100644
--- a/src/restconf/utils/yang.h
+++ b/src/restconf/utils/yang.h
@@ -6,10 +6,13 @@
*/
#include <chrono>
+#include <sysrepo-cpp/Subscription.hpp>
namespace libyang {
class Leaf;
class DataNode;
+class Context;
+enum class DataFormat;
}
namespace rousette::restconf {
@@ -19,4 +22,5 @@ std::string listKeyPredicate(const std::vector<libyang::Leaf>& listKeyLeafs, con
std::string leaflistKeyPredicate(const std::string& keyValue);
bool isUserOrderedList(const libyang::DataNode& node);
bool isKeyNode(const libyang::DataNode& maybeList, const libyang::DataNode& node);
+std::string as_restconf_notification(const libyang::Context& ctx, libyang::DataFormat dataFormat, libyang::DataNode notification, const sysrepo::NotificationTimeStamp& time);
}
--
2.43.0
@@ -1,73 +0,0 @@
From f7307481fc4ca167592acf925136e5f8a51e2150 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Wed, 18 Dec 2024 17:26:15 +0100
Subject: [PATCH 21/44] Update dependencies
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Depends-on: https://gerrit.cesnet.cz/c/CzechLight/dependencies/+/8134
Change-Id: Ib170aa61600ffb9089460247f762ff5a947dd6c6
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
CMakeLists.txt | 2 +-
tests/restconf-writing.cpp | 8 ++++----
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 22bce32..5ae8062 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -74,7 +74,7 @@ find_package(PkgConfig)
pkg_check_modules(nghttp2 REQUIRED IMPORTED_TARGET libnghttp2_asio>=0.0.90 libnghttp2)
find_package(Boost REQUIRED CONFIG COMPONENTS system thread)
-pkg_check_modules(SYSREPO-CPP REQUIRED IMPORTED_TARGET sysrepo-cpp>=3)
+pkg_check_modules(SYSREPO-CPP REQUIRED IMPORTED_TARGET sysrepo-cpp>=4)
pkg_check_modules(LIBYANG-CPP REQUIRED IMPORTED_TARGET libyang-cpp>=3)
pkg_check_modules(SYSTEMD IMPORTED_TARGET libsystemd)
pkg_check_modules(PAM REQUIRED IMPORTED_TARGET pam)
diff --git a/tests/restconf-writing.cpp b/tests/restconf-writing.cpp
index 0932984..d37ab03 100644
--- a/tests/restconf-writing.cpp
+++ b/tests/restconf-writing.cpp
@@ -772,7 +772,7 @@ TEST_CASE("writing data")
{
"error-type": "protocol",
"error-tag": "invalid-value",
- "error-message": "Session::applyChanges: Couldn't apply changes: SR_ERR_NOT_FOUND\u000A Node \"lst\" instance to insert next to not found. (SR_ERR_NOT_FOUND)\u000A Applying operation \"replace\" failed. (SR_ERR_NOT_FOUND)"
+ "error-message": "Session::applyChanges: Couldn't apply changes: SR_ERR_NOT_FOUND\u000A Node \"lst\" instance to insert next to not found. (SR_ERR_NOT_FOUND)"
}
]
}
@@ -855,7 +855,7 @@ TEST_CASE("writing data")
{
"error-type": "protocol",
"error-tag": "invalid-value",
- "error-message": "Session::applyChanges: Couldn't apply changes: SR_ERR_NOT_FOUND\u000A Node \"ll\" instance to insert next to not found. (SR_ERR_NOT_FOUND)\u000A Applying operation \"replace\" failed. (SR_ERR_NOT_FOUND)"
+ "error-message": "Session::applyChanges: Couldn't apply changes: SR_ERR_NOT_FOUND\u000A Node \"ll\" instance to insert next to not found. (SR_ERR_NOT_FOUND)"
}
]
}
@@ -1560,7 +1560,7 @@ TEST_CASE("writing data")
{
"error-type": "protocol",
"error-tag": "invalid-value",
- "error-message": "Session::applyChanges: Couldn't apply changes: SR_ERR_NOT_FOUND\u000A Node \"lst\" instance to insert next to not found. (SR_ERR_NOT_FOUND)\u000A Applying operation \"create\" failed. (SR_ERR_NOT_FOUND)"
+ "error-message": "Session::applyChanges: Couldn't apply changes: SR_ERR_NOT_FOUND\u000A Node \"lst\" instance to insert next to not found. (SR_ERR_NOT_FOUND)"
}
]
}
@@ -1626,7 +1626,7 @@ TEST_CASE("writing data")
{
"error-type": "protocol",
"error-tag": "invalid-value",
- "error-message": "Session::applyChanges: Couldn't apply changes: SR_ERR_NOT_FOUND\u000A Node \"ll\" instance to insert next to not found. (SR_ERR_NOT_FOUND)\u000A Applying operation \"create\" failed. (SR_ERR_NOT_FOUND)"
+ "error-message": "Session::applyChanges: Couldn't apply changes: SR_ERR_NOT_FOUND\u000A Node \"ll\" instance to insert next to not found. (SR_ERR_NOT_FOUND)"
}
]
}
--
2.43.0
@@ -1,104 +0,0 @@
From c967e688c53000519ceb6030e8282296e4846d3c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Mon, 13 Jan 2025 18:29:29 +0100
Subject: [PATCH 22/44] restconf: add RAII datastore switch
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Copied from sysrepo-ietf-alarms[1].
[1] https://github.com/CESNET/sysrepo-ietf-alarms/blob/4870dbee30a5ec56135d4f4cb5818f191c089c34/src/utils/sysrepo.cpp
Change-Id: I51738d06187e141ebd662e7b9421ca995380cafc
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
CMakeLists.txt | 1 +
src/restconf/utils/sysrepo.cpp | 25 +++++++++++++++++++++++++
src/restconf/utils/sysrepo.h | 29 +++++++++++++++++++++++++++++
3 files changed, 55 insertions(+)
create mode 100644 src/restconf/utils/sysrepo.cpp
create mode 100644 src/restconf/utils/sysrepo.h
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 5ae8062..7f3a9c0 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -114,6 +114,7 @@ add_library(rousette-restconf STATIC
src/restconf/YangSchemaLocations.cpp
src/restconf/uri.cpp
src/restconf/utils/dataformat.cpp
+ src/restconf/utils/sysrepo.cpp
src/restconf/utils/yang.cpp
)
target_link_libraries(rousette-restconf PUBLIC rousette-http rousette-sysrepo rousette-auth Boost::system Threads::Threads PRIVATE date::date-tz)
diff --git a/src/restconf/utils/sysrepo.cpp b/src/restconf/utils/sysrepo.cpp
new file mode 100644
index 0000000..622ab27
--- /dev/null
+++ b/src/restconf/utils/sysrepo.cpp
@@ -0,0 +1,25 @@
+/*
+ * Copyright (C) 2020, 2022 CESNET, https://photonics.cesnet.cz/
+ *
+ * Written by Jan Kundrát <jan.kundrat@cesnet.cz>
+ * Written by Tomáš Pecka <tomas.pecka@cesnet.cz>
+ */
+
+#include <sysrepo-cpp/Connection.hpp>
+#include "sysrepo.h"
+
+namespace rousette::restconf {
+
+ScopedDatastoreSwitch::ScopedDatastoreSwitch(sysrepo::Session session, sysrepo::Datastore ds)
+ : m_session(std::move(session))
+ , m_oldDatastore(m_session.activeDatastore())
+{
+ m_session.switchDatastore(ds);
+}
+
+ScopedDatastoreSwitch::~ScopedDatastoreSwitch()
+{
+ m_session.switchDatastore(m_oldDatastore);
+}
+
+}
diff --git a/src/restconf/utils/sysrepo.h b/src/restconf/utils/sysrepo.h
new file mode 100644
index 0000000..da6c359
--- /dev/null
+++ b/src/restconf/utils/sysrepo.h
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2020, 2022 CESNET, https://photonics.cesnet.cz/
+ *
+ * Written by Jan Kundrát <jan.kundrat@cesnet.cz>
+ * Written by Tomáš Pecka <tomas.pecka@cesnet.cz>
+ */
+
+#pragma once
+
+#include <libyang-cpp/Context.hpp>
+#include <sysrepo-cpp/Session.hpp>
+
+namespace rousette::restconf {
+
+/** @brief Ensures that session switches to provided datastore and when the object gets destroyed the session switches back to the original datastore. */
+class ScopedDatastoreSwitch {
+ sysrepo::Session m_session;
+ sysrepo::Datastore m_oldDatastore;
+
+public:
+ ScopedDatastoreSwitch(sysrepo::Session session, sysrepo::Datastore ds);
+ ~ScopedDatastoreSwitch();
+ ScopedDatastoreSwitch(const ScopedDatastoreSwitch&) = delete;
+ ScopedDatastoreSwitch(ScopedDatastoreSwitch&&) = delete;
+ ScopedDatastoreSwitch& operator=(const ScopedDatastoreSwitch&) = delete;
+ ScopedDatastoreSwitch& operator=(ScopedDatastoreSwitch&&) = delete;
+};
+
+}
--
2.43.0
@@ -1,30 +0,0 @@
From 05274bd27b886e67accc86be7153ed76465b156e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Wed, 15 Jan 2025 17:56:26 +0100
Subject: [PATCH 23/44] restconf: add missing pragma once
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Change-Id: I6d337c99351e24226fb322a40ba2e96655104860
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/restconf/NotificationStream.h | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/restconf/NotificationStream.h b/src/restconf/NotificationStream.h
index 6840f11..3029a0d 100644
--- a/src/restconf/NotificationStream.h
+++ b/src/restconf/NotificationStream.h
@@ -5,6 +5,7 @@
*
*/
+#pragma once
#include <optional>
#include <sysrepo-cpp/Session.hpp>
#include <sysrepo-cpp/Subscription.hpp>
--
2.43.0
@@ -1,108 +0,0 @@
From bd7def1768e360cc491d56805f7c16784c2f4fbe Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Wed, 15 Jan 2025 18:27:25 +0100
Subject: [PATCH 24/44] restconf: make fetching replay info reusable
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
We are querying sysrepo for replay support in our modules for NETCONF
stream. This part of code will be reused for dynamic subscriptions to
NETCONF stream as well, so let's make it reusable.
Change-Id: Id5bc656619acb8e56dfafde23b03107139d34e94
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/restconf/NotificationStream.cpp | 57 +++++++++++++++++------------
1 file changed, 34 insertions(+), 23 deletions(-)
diff --git a/src/restconf/NotificationStream.cpp b/src/restconf/NotificationStream.cpp
index eeddc04..e00c39d 100644
--- a/src/restconf/NotificationStream.cpp
+++ b/src/restconf/NotificationStream.cpp
@@ -50,6 +50,36 @@ bool canBeSubscribed(const libyang::Module& mod)
{
return mod.implemented() && mod.name() != "sysrepo";
}
+
+struct SysrepoReplayInfo {
+ bool enabled;
+ std::optional<sysrepo::NotificationTimeStamp> earliestNotification;
+};
+
+SysrepoReplayInfo sysrepoReplayInfo(sysrepo::Session& session)
+{
+ decltype(sysrepo::ModuleReplaySupport::earliestNotification) globalEarliestNotification;
+ bool replayEnabled = false;
+
+ for (const auto& mod : session.getContext().modules()) {
+ if (!canBeSubscribed(mod)) {
+ continue;
+ }
+
+ auto replay = session.getConnection().getModuleReplaySupport(mod.name());
+ replayEnabled |= replay.enabled;
+
+ if (replay.earliestNotification) {
+ if (!globalEarliestNotification) {
+ globalEarliestNotification = replay.earliestNotification;
+ } else {
+ globalEarliestNotification = std::min(*replay.earliestNotification, *globalEarliestNotification);
+ }
+ }
+ }
+
+ return {replayEnabled, globalEarliestNotification};
+}
}
namespace rousette::restconf {
@@ -114,28 +144,9 @@ void NotificationStream::activate()
/** @brief Creates and fills ietf-restconf-monitoring:restconf-state/stream. To be called in oper callback. */
void notificationStreamList(sysrepo::Session& session, std::optional<libyang::DataNode>& parent, const std::string& streamsPrefix)
{
+ const auto replayInfo = sysrepoReplayInfo(session);
static const auto prefix = "/ietf-restconf-monitoring:restconf-state/streams/stream[name='NETCONF']"s;
- decltype(sysrepo::ModuleReplaySupport::earliestNotification) globalEarliestNotification;
- bool replayEnabled = false;
-
- for (const auto& mod : session.getContext().modules()) {
- if (!canBeSubscribed(mod)) {
- continue;
- }
-
- auto replay = session.getConnection().getModuleReplaySupport(mod.name());
- replayEnabled |= replay.enabled;
-
- if (replay.earliestNotification) {
- if (!globalEarliestNotification) {
- globalEarliestNotification = replay.earliestNotification;
- } else {
- globalEarliestNotification = std::min(*replay.earliestNotification, *globalEarliestNotification);
- }
- }
- }
-
if (!parent) {
parent = session.getContext().newPath(prefix + "/description", "Default NETCONF notification stream");
} else {
@@ -144,11 +155,11 @@ void notificationStreamList(sysrepo::Session& session, std::optional<libyang::Da
parent->newPath(prefix + "/access[encoding='xml']/location", streamsPrefix + "NETCONF/XML");
parent->newPath(prefix + "/access[encoding='json']/location", streamsPrefix + "NETCONF/JSON");
- if (replayEnabled) {
+ if (replayInfo.enabled) {
parent->newPath(prefix + "/replay-support", "true");
- if (globalEarliestNotification) {
- parent->newPath(prefix + "/replay-log-creation-time", libyang::yangTimeFormat(*globalEarliestNotification, libyang::TimezoneInterpretation::Local));
+ if (replayInfo.earliestNotification) {
+ parent->newPath(prefix + "/replay-log-creation-time", libyang::yangTimeFormat(*replayInfo.earliestNotification, libyang::TimezoneInterpretation::Local));
}
}
}
--
2.43.0
@@ -1,53 +0,0 @@
From 08458725211e707dec7bfebf7c5188e766136677 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Fri, 7 Feb 2025 17:34:04 +0100
Subject: [PATCH 25/44] sysrepo upstream API change: NULL output on RPCs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Depends-on: https://gerrit.cesnet.cz/c/CzechLight/dependencies/+/8289
Change-Id: I8db21637246492b0f79d1adf7df1704df5173e39
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
CMakeLists.txt | 2 +-
src/restconf/Server.cpp | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 7f3a9c0..cdd0eb4 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -74,7 +74,7 @@ find_package(PkgConfig)
pkg_check_modules(nghttp2 REQUIRED IMPORTED_TARGET libnghttp2_asio>=0.0.90 libnghttp2)
find_package(Boost REQUIRED CONFIG COMPONENTS system thread)
-pkg_check_modules(SYSREPO-CPP REQUIRED IMPORTED_TARGET sysrepo-cpp>=4)
+pkg_check_modules(SYSREPO-CPP REQUIRED IMPORTED_TARGET sysrepo-cpp>=5)
pkg_check_modules(LIBYANG-CPP REQUIRED IMPORTED_TARGET libyang-cpp>=3)
pkg_check_modules(SYSTEMD IMPORTED_TARGET libsystemd)
pkg_check_modules(PAM REQUIRED IMPORTED_TARGET pam)
diff --git a/src/restconf/Server.cpp b/src/restconf/Server.cpp
index 7c66ea4..daf23ed 100644
--- a/src/restconf/Server.cpp
+++ b/src/restconf/Server.cpp
@@ -462,13 +462,13 @@ void processActionOrRPC(std::shared_ptr<RequestContext> requestCtx, const std::c
auto rpcReply = requestCtx->sess.sendRPC(*rpcNode, timeout);
- if (rpcReply.immediateChildren().empty()) {
+ if (!rpcReply || rpcReply->immediateChildren().empty()) {
requestCtx->res.write_head(204, {CORS});
requestCtx->res.end();
return;
}
- auto responseNode = rpcReply.child();
+ auto responseNode = rpcReply->child();
responseNode->unlinkWithSiblings();
auto envelope = ctx.newOpaqueJSON(rpcNode->schema().module().name(), "output", std::nullopt);
--
2.43.0
@@ -1,82 +0,0 @@
From d69697a719781ef06ecb0545a58c2d055ca53c6d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Mon, 3 Mar 2025 13:32:51 +0100
Subject: [PATCH 26/44] boost 1.87 support
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
In Asio 1.33 (boost 1.87) some of the deprecated functionalities were
removed [1]. For rousette there are two changes that needed to be
addressed:
* A removed include in clock.cpp. But actually, we do not need that
header at all.
* boost::asio::io_service is no longer around, it was actually renamed
to boost::asio::io_context a while ago. Its post() method is no
longer around as well, it was deprecated in favour of post()
function [2].
This function is there since boost 1.66, so let's set minimal boost
version.
A bigger problem is that our dependency nghttp2-asio seems to be a dead
project. At the time of writing this, there are no patches for more than
3 years. However, we are not the only consumers of that project and
somebody else already took care of that before us [3,4].
[1] https://www.boost.org/doc/libs/1_87_0/doc/html/boost_asio/history.html
[2] https://live.boost.org/doc/libs/1_86_0/doc/html/boost_asio/reference/io_context/post.html
[3] https://github.com/nghttp2/nghttp2-asio/issues/23
[3] https://github.com/microsoft/vcpkg/commit/8eecddf7f18792d41c58a404604eed7f87b4b462
Change-Id: Ia59bb06e5f8ed222aa6e9f1c9b3947b05afeb9ec
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
CMakeLists.txt | 4 ++--
src/clock.cpp | 1 -
src/http/EventStream.cpp | 2 +-
3 files changed, 3 insertions(+), 4 deletions(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index cdd0eb4..ca41ef3 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -71,8 +71,8 @@ include_directories(${CMAKE_CURRENT_BINARY_DIR})
find_package(spdlog REQUIRED)
find_package(date REQUIRED) # FIXME: Remove when we have STL with __cpp_lib_chrono >= 201907 (gcc 14)
find_package(PkgConfig)
-pkg_check_modules(nghttp2 REQUIRED IMPORTED_TARGET libnghttp2_asio>=0.0.90 libnghttp2)
-find_package(Boost REQUIRED CONFIG COMPONENTS system thread)
+pkg_check_modules(nghttp2 REQUIRED IMPORTED_TARGET libnghttp2_asio>=0.0.90 libnghttp2) # To compile under boost 1.87 you have to patch nghttp2-asio using https://github.com/nghttp2/nghttp2-asio/issues/23
+find_package(Boost 1.66 REQUIRED CONFIG COMPONENTS system thread)
pkg_check_modules(SYSREPO-CPP REQUIRED IMPORTED_TARGET sysrepo-cpp>=5)
pkg_check_modules(LIBYANG-CPP REQUIRED IMPORTED_TARGET libyang-cpp>=3)
diff --git a/src/clock.cpp b/src/clock.cpp
index 16a49b2..7ac6c3e 100644
--- a/src/clock.cpp
+++ b/src/clock.cpp
@@ -5,7 +5,6 @@
*
*/
-#include <boost/asio/io_service.hpp>
#include <boost/lexical_cast.hpp>
#include <chrono>
#include <nghttp2/asio_http2_server.h>
diff --git a/src/http/EventStream.cpp b/src/http/EventStream.cpp
index 34038f4..6c765c5 100644
--- a/src/http/EventStream.cpp
+++ b/src/http/EventStream.cpp
@@ -117,6 +117,6 @@ void EventStream::enqueue(const std::string& what)
spdlog::trace("{}: new event, ∑ queue size = {}", peer, len);
queue.push_back(buf);
state = HasEvents;
- res.io_service().post([&res = this->res]() { res.resume(); });
+ boost::asio::post(res.io_service(), [&res = this->res]() { res.resume(); });
}
}
--
2.43.0
@@ -1,196 +0,0 @@
From 97ceef119c900c37bbaa27860c3b43cfa6d69f95 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Mon, 13 Jan 2025 13:01:07 +0100
Subject: [PATCH 27/44] restconf: refactor uri parser for stream URIs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
This is a preparation for the upcoming patch that will add support for
subscribed streams. I will use boost spirit x3 parser to parse such
URIs so I need to refactor the existing parser, which is not really a
parser, but we only match for the two known URIs.
Change-Id: Ib76973cd967fc3e9558ceb6be8f51239ce4c5ef3
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/restconf/Server.cpp | 15 +--------------
src/restconf/uri.cpp | 36 +++++++++++++++++++++++++++---------
src/restconf/uri.h | 11 ++++++-----
src/restconf/uri_impl.h | 2 ++
tests/uri-parser.cpp | 4 ++--
5 files changed, 38 insertions(+), 30 deletions(-)
diff --git a/src/restconf/Server.cpp b/src/restconf/Server.cpp
index daf23ed..d356f7e 100644
--- a/src/restconf/Server.cpp
+++ b/src/restconf/Server.cpp
@@ -874,7 +874,6 @@ Server::Server(sysrepo::Connection conn, const std::string& address, const std::
server->handle(netconfStreamRoot, [this, conn](const auto& req, const auto& res) mutable {
auto sess = conn.sessionStart();
- libyang::DataFormat dataFormat;
std::optional<std::string> xpathFilter;
std::optional<sysrepo::NotificationTimeStamp> startTime;
std::optional<sysrepo::NotificationTimeStamp> stopTime;
@@ -890,18 +889,6 @@ Server::Server(sysrepo::Connection conn, const std::string& address, const std::
auto streamRequest = asRestconfStreamRequest(req.method(), req.uri().path, req.uri().raw_query);
- switch(streamRequest.type) {
- case RestconfStreamRequest::Type::NetconfNotificationJSON:
- dataFormat = libyang::DataFormat::JSON;
- break;
- case RestconfStreamRequest::Type::NetconfNotificationXML:
- dataFormat = libyang::DataFormat::XML;
- break;
- default:
- // GCC 14 complains about uninitialized variable, but asRestconfStreamRequest() would have thrown
- __builtin_unreachable();
- }
-
if (auto it = streamRequest.queryParams.find("filter"); it != streamRequest.queryParams.end()) {
xpathFilter = std::get<std::string>(it->second);
}
@@ -916,7 +903,7 @@ Server::Server(sysrepo::Connection conn, const std::string& address, const std::
// The signal is constructed outside NotificationStream class because it is required to be passed to
// NotificationStream's parent (EventStream) constructor where it already must be constructed
// Yes, this is a hack.
- auto client = std::make_shared<NotificationStream>(req, res, std::make_shared<rousette::http::EventStream::Signal>(), sess, dataFormat, xpathFilter, startTime, stopTime);
+ auto client = std::make_shared<NotificationStream>(req, res, std::make_shared<rousette::http::EventStream::Signal>(), sess, streamRequest.type.encoding, xpathFilter, startTime, stopTime);
client->activate();
} catch (const auth::Error& e) {
processAuthError(req, res, e, [&res]() {
diff --git a/src/restconf/uri.cpp b/src/restconf/uri.cpp
index da1e3a5..e95d70e 100644
--- a/src/restconf/uri.cpp
+++ b/src/restconf/uri.cpp
@@ -57,6 +57,13 @@ const auto resources = x3::rule<class resources, URIPath>{"resources"} =
((x3::lit("/") | x3::eps) /* /restconf/ and /restconf */ >> x3::attr(URIPrefix{URIPrefix::Type::RestconfRoot}) >> x3::attr(std::vector<PathSegment>{}));
const auto uriGrammar = x3::rule<class grammar, URIPath>{"grammar"} = x3::lit("/restconf") >> resources;
+
+const auto netconfStream = x3::rule<class netconfStream, RestconfStreamRequest::NetconfStream>{"netconfStream"} =
+ x3::lit("/NETCONF") >>
+ ((x3::lit("/XML") >> x3::attr(libyang::DataFormat::XML)) |
+ (x3::lit("/JSON") >> x3::attr(libyang::DataFormat::JSON)));
+const auto streamUriGrammar = x3::rule<class grammar, RestconfStreamRequest::NetconfStream>{"streamsGrammar"} = x3::lit("/streams") >> netconfStream;
+
// clang-format on
}
@@ -197,6 +204,17 @@ std::optional<queryParams::QueryParams> parseQueryParams(const std::string& quer
return ret;
}
+std::optional<RestconfStreamRequest::NetconfStream> parseStreamUri(const std::string& uriPath)
+{
+ std::optional<RestconfStreamRequest::NetconfStream> ret;
+
+ if (!x3::parse(std::begin(uriPath), std::end(uriPath), streamUriGrammar >> x3::eoi, ret)) {
+ return std::nullopt;
+ }
+
+ return ret;
+}
+
URIPrefix::URIPrefix()
: resourceType(URIPrefix::Type::BasicRestconfData)
{
@@ -637,20 +655,20 @@ std::optional<std::variant<libyang::Module, libyang::SubmoduleParsed>> asYangMod
return std::nullopt;
}
-RestconfStreamRequest asRestconfStreamRequest(const std::string& httpMethod, const std::string& uriPath, const std::string& uriQueryString)
+RestconfStreamRequest::NetconfStream::NetconfStream() = default;
+RestconfStreamRequest::NetconfStream::NetconfStream(const libyang::DataFormat& encoding)
+ : encoding(encoding)
{
- static const auto netconfStreamRoot = "/streams/NETCONF/";
- RestconfStreamRequest::Type type;
+}
+RestconfStreamRequest asRestconfStreamRequest(const std::string& httpMethod, const std::string& uriPath, const std::string& uriQueryString)
+{
if (httpMethod != "GET" && httpMethod != "HEAD") {
throw ErrorResponse(405, "application", "operation-not-supported", "Method not allowed.");
}
- if (uriPath == netconfStreamRoot + "XML"s) {
- type = RestconfStreamRequest::Type::NetconfNotificationXML;
- } else if (uriPath == netconfStreamRoot + "JSON"s) {
- type = RestconfStreamRequest::Type::NetconfNotificationJSON;
- } else {
+ auto type = impl::parseStreamUri(uriPath);
+ if (!type) {
throw ErrorResponse(404, "application", "invalid-value", "Invalid stream");
}
@@ -661,7 +679,7 @@ RestconfStreamRequest asRestconfStreamRequest(const std::string& httpMethod, con
validateQueryParametersForStream(*queryParameters);
- return {type, *queryParameters};
+ return {*type, *queryParameters};
}
/** @brief Returns a set of allowed HTTP methods for given URI. Usable for the 'allow' header */
diff --git a/src/restconf/uri.h b/src/restconf/uri.h
index f6df724..6f1f69f 100644
--- a/src/restconf/uri.h
+++ b/src/restconf/uri.h
@@ -197,11 +197,12 @@ struct RestconfRequest {
};
struct RestconfStreamRequest {
- enum class Type {
- NetconfNotificationJSON,
- NetconfNotificationXML,
- };
- Type type;
+ struct NetconfStream {
+ libyang::DataFormat encoding;
+
+ NetconfStream();
+ NetconfStream(const libyang::DataFormat& encoding);
+ } type;
queryParams::QueryParams queryParams;
};
diff --git a/src/restconf/uri_impl.h b/src/restconf/uri_impl.h
index 2bcdb3f..00b22f6 100644
--- a/src/restconf/uri_impl.h
+++ b/src/restconf/uri_impl.h
@@ -66,6 +66,8 @@ BOOST_FUSION_ADAPT_STRUCT(rousette::restconf::impl::YangModule, name, revision);
BOOST_FUSION_ADAPT_STRUCT(rousette::restconf::PathSegment, apiIdent, keys);
BOOST_FUSION_ADAPT_STRUCT(rousette::restconf::ApiIdentifier, prefix, identifier);
+BOOST_FUSION_ADAPT_STRUCT(rousette::restconf::RestconfStreamRequest::NetconfStream, encoding);
+
BOOST_FUSION_ADAPT_STRUCT(rousette::restconf::queryParams::fields::ParenExpr, lhs, rhs);
BOOST_FUSION_ADAPT_STRUCT(rousette::restconf::queryParams::fields::SlashExpr, lhs, rhs);
BOOST_FUSION_ADAPT_STRUCT(rousette::restconf::queryParams::fields::SemiExpr, lhs, rhs);
diff --git a/tests/uri-parser.cpp b/tests/uri-parser.cpp
index fd5dd8b..53d1fbb 100644
--- a/tests/uri-parser.cpp
+++ b/tests/uri-parser.cpp
@@ -1072,13 +1072,13 @@ TEST_CASE("URI path parser")
{
auto [type, queryParams] = asRestconfStreamRequest("GET", "/streams/NETCONF/XML", "");
- REQUIRE(type == RestconfStreamRequest::Type::NetconfNotificationXML);
+ REQUIRE(type.encoding == libyang::DataFormat::XML);
REQUIRE(queryParams.empty());
}
{
auto [type, queryParams] = asRestconfStreamRequest("GET", "/streams/NETCONF/JSON", "");
- REQUIRE(type == RestconfStreamRequest::Type::NetconfNotificationJSON);
+ REQUIRE(type.encoding == libyang::DataFormat::JSON);
REQUIRE(queryParams.empty());
}
--
2.43.0
@@ -1,54 +0,0 @@
From cc815dac4ea17ccb09a0481ad82745a194efe95f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Mon, 27 Jan 2025 19:17:07 +0100
Subject: [PATCH 28/44] server: prepare checking if yang features are enabled
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
We are not requiring any now, but next commits will need it. I am
splitting this into a separate commit for clarity.
Change-Id: I0a8874b55b21d5ed6c7222f0c36a36c3c5ff52c5
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/restconf/Server.cpp | 22 ++++++++++++++--------
1 file changed, 14 insertions(+), 8 deletions(-)
diff --git a/src/restconf/Server.cpp b/src/restconf/Server.cpp
index d356f7e..55e504a 100644
--- a/src/restconf/Server.cpp
+++ b/src/restconf/Server.cpp
@@ -817,14 +817,20 @@ Server::Server(sysrepo::Connection conn, const std::string& address, const std::
, server{std::make_unique<nghttp2::asio_http2::server::http2>()}
, dwdmEvents{std::make_unique<sr::OpticalEvents>(conn.sessionStart())}
{
- for (const auto& [module, version] : {
- std::pair<std::string, std::string>{"ietf-restconf", "2017-01-26"},
- {"ietf-restconf-monitoring", "2017-01-26"},
- {"ietf-netconf", ""},
- {"ietf-yang-library", "2019-01-04"},
- {"ietf-yang-patch", "2017-02-22"},
- }) {
- if (!conn.sessionStart().getContext().getModuleImplemented(module)) {
+ for (const auto& [module, version, features] : {
+ std::tuple<std::string, std::string, std::vector<std::string>>{"ietf-restconf", "2017-01-26", {}},
+ {"ietf-restconf-monitoring", "2017-01-26", {}},
+ {"ietf-netconf", "", {}},
+ {"ietf-yang-library", "2019-01-04", {}},
+ {"ietf-yang-patch", "2017-02-22", {}},
+ }) {
+ if (auto mod = conn.sessionStart().getContext().getModuleImplemented(module)) {
+ for (const auto& feature : features) {
+ if (!mod->featureEnabled(feature)) {
+ throw std::runtime_error("Module "s + module + "@" + version + " does not implement feature " + feature);
+ }
+ }
+ } else {
throw std::runtime_error("Module "s + module + "@" + version + " is not implemented in sysrepo");
}
}
--
2.43.0
@@ -1,54 +0,0 @@
From f45d7cab63431c94a4db859b07ea6f503214ebaa Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Thu, 13 Mar 2025 17:10:17 +0100
Subject: [PATCH 29/44] tests: refactor default handling
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
...so that a followup commit which uses
libyang::PrintFlags::KeepEmptyCont introduces less noise.
Change-Id: I587cf8a5fc0e87b37f4bb91a74d848f58b61e336
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
tests/restconf-defaults.cpp | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/tests/restconf-defaults.cpp b/tests/restconf-defaults.cpp
index dd8b4da..d5c6004 100644
--- a/tests/restconf-defaults.cpp
+++ b/tests/restconf-defaults.cpp
@@ -32,7 +32,7 @@ TEST_CASE("default handling")
auto server = rousette::restconf::Server{srConn, SERVER_ADDRESS, SERVER_PORT};
// default value of /example:a/b/c/enabled is implicitly set so it should not be printed
- REQUIRE(get(RESTCONF_DATA_ROOT "/example:a", {}) == Response{200, jsonHeaders, R"({
+ REQUIRE(get(RESTCONF_DATA_ROOT "/example:a/b/c", {}) == Response{200, jsonHeaders, R"({
}
)"});
@@ -72,7 +72,7 @@ TEST_CASE("default handling")
)"});
// default value is explicitly set so it should be printed
- REQUIRE(get(RESTCONF_DATA_ROOT "/example:a", {}) == Response{200, jsonHeaders, R"({
+ REQUIRE(get(RESTCONF_DATA_ROOT "/example:a/b/c", {}) == Response{200, jsonHeaders, R"({
"example:a": {
"b": {
"c": {
@@ -86,8 +86,8 @@ TEST_CASE("default handling")
// RFC 6243, sec. 2.3.3: A valid 'delete' operation attribute for a data node that has been set by a client to its schema default value MUST succeed.
REQUIRE(httpDelete(RESTCONF_DATA_ROOT "/example:a/b/c/enabled", {AUTH_ROOT}) == Response{204, noContentTypeHeaders, ""});
- // default value is implicitly set so it should be printed
- REQUIRE(get(RESTCONF_DATA_ROOT "/example:a", {}) == Response{200, jsonHeaders, R"({
+ // default value is only there implicitly, so it should *not* be printed
+ REQUIRE(get(RESTCONF_DATA_ROOT "/example:a/b/c", {}) == Response{200, jsonHeaders, R"({
}
)"});
--
2.43.0
@@ -1,356 +0,0 @@
From 22442d322bda3d83457dd0f1da45d95a10e93081 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Thu, 13 Mar 2025 17:32:59 +0100
Subject: [PATCH 30/44] Always print empty containers in user-defined content
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
There's some subtle interaction between depth-limited printing, default
values, and empty non-presence containers. In order to prepare for an
upstream change in sysrepo which fixed depth-limited printing, let's
stick to "always" printing the empty non-presence containers.
(It actually isn't "always" because the data such as error reports,
patches, etc., are still printed using the default settings. But the
data which is subject to user-defined constraints is now consistent.)
Change-Id: Ie2d6ad2e851d667e7e7582fcd0ac75dabce2c05e
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/restconf/Server.cpp | 4 +-
src/restconf/utils/yang.cpp | 2 +-
src/sr/AllEvents.cpp | 2 +-
src/sr/OpticalEvents.cpp | 2 +-
tests/restconf-defaults.cpp | 12 +++++-
tests/restconf-nacm.cpp | 12 +++++-
tests/restconf-reading.cpp | 79 ++++++++++++++++++++++++++++++++++---
7 files changed, 98 insertions(+), 15 deletions(-)
diff --git a/src/restconf/Server.cpp b/src/restconf/Server.cpp
index 55e504a..bae3992 100644
--- a/src/restconf/Server.cpp
+++ b/src/restconf/Server.cpp
@@ -478,7 +478,7 @@ void processActionOrRPC(std::shared_ptr<RequestContext> requestCtx, const std::c
contentType(requestCtx->dataFormat.response),
CORS,
});
- requestCtx->res.end(*envelope->printStr(requestCtx->dataFormat.response, libyang::PrintFlags::WithSiblings));
+ requestCtx->res.end(*envelope->printStr(requestCtx->dataFormat.response, libyang::PrintFlags::WithSiblings | libyang::PrintFlags::KeepEmptyCont));
}
void processPost(std::shared_ptr<RequestContext> requestCtx, const std::chrono::milliseconds timeout)
@@ -768,7 +768,7 @@ libyang::PrintFlags libyangPrintFlags(const libyang::DataNode& dataNode, const s
} catch(const libyang::Error& e) {
}
- libyang::PrintFlags ret = libyang::PrintFlags::WithSiblings;
+ libyang::PrintFlags ret = libyang::PrintFlags::WithSiblings | libyang::PrintFlags::KeepEmptyCont;
if (!withDefaults && node && (node->schema().nodeType() == libyang::NodeType::Leaf || node->schema().nodeType() == libyang::NodeType::Leaflist) && node->asTerm().isImplicitDefault()) {
return ret | libyang::PrintFlags::WithDefaultsAll;
diff --git a/src/restconf/utils/yang.cpp b/src/restconf/utils/yang.cpp
index 30661fc..8acb160 100644
--- a/src/restconf/utils/yang.cpp
+++ b/src/restconf/utils/yang.cpp
@@ -111,7 +111,7 @@ std::string as_restconf_notification(const libyang::Context& ctx, libyang::DataF
envelope->insertChild(*eventTime);
envelope->insertChild(notification);
- auto res = *envelope->printStr(dataFormat, libyang::PrintFlags::WithSiblings);
+ auto res = *envelope->printStr(dataFormat, libyang::PrintFlags::WithSiblings | libyang::PrintFlags::KeepEmptyCont);
// notification node comes from sysrepo and sysrepo will free this; if not unlinked then envelope destructor would try to free this as well
notification.unlink();
diff --git a/src/sr/AllEvents.cpp b/src/sr/AllEvents.cpp
index 6e2a21c..2aca4c7 100644
--- a/src/sr/AllEvents.cpp
+++ b/src/sr/AllEvents.cpp
@@ -109,7 +109,7 @@ sysrepo::ErrorCode AllEvents::onChange(sysrepo::Session session, const std::stri
break;
}
};
- auto json = *copy.printStr(libyang::DataFormat::JSON, libyang::PrintFlags::WithSiblings);
+ auto json = *copy.printStr(libyang::DataFormat::JSON, libyang::PrintFlags::WithSiblings | libyang::PrintFlags::KeepEmptyCont);
spdlog::info("JSON: {}", json);
spdlog::warn("FULL JSON: {}",
*session.getData('/' + module + ":*")->printStr(libyang::DataFormat::JSON, libyang::PrintFlags::WithSiblings));
diff --git a/src/sr/OpticalEvents.cpp b/src/sr/OpticalEvents.cpp
index 8c5feff..25906a8 100644
--- a/src/sr/OpticalEvents.cpp
+++ b/src/sr/OpticalEvents.cpp
@@ -15,7 +15,7 @@
namespace {
std::string dumpDataFrom(sysrepo::Session session, const std::string& module)
{
- return *session.getData('/' + module + ":*")->printStr(libyang::DataFormat::JSON, libyang::PrintFlags::WithSiblings);
+ return *session.getData('/' + module + ":*")->printStr(libyang::DataFormat::JSON, libyang::PrintFlags::WithSiblings | libyang::PrintFlags::KeepEmptyCont);
}
}
diff --git a/tests/restconf-defaults.cpp b/tests/restconf-defaults.cpp
index d5c6004..89083f1 100644
--- a/tests/restconf-defaults.cpp
+++ b/tests/restconf-defaults.cpp
@@ -33,7 +33,11 @@ TEST_CASE("default handling")
// default value of /example:a/b/c/enabled is implicitly set so it should not be printed
REQUIRE(get(RESTCONF_DATA_ROOT "/example:a/b/c", {}) == Response{200, jsonHeaders, R"({
-
+ "example:a": {
+ "b": {
+ "c": {}
+ }
+ }
}
)"});
@@ -88,7 +92,11 @@ TEST_CASE("default handling")
// default value is only there implicitly, so it should *not* be printed
REQUIRE(get(RESTCONF_DATA_ROOT "/example:a/b/c", {}) == Response{200, jsonHeaders, R"({
-
+ "example:a": {
+ "b": {
+ "c": {}
+ }
+ }
}
)"});
}
diff --git a/tests/restconf-nacm.cpp b/tests/restconf-nacm.cpp
index 29d7723..51328f3 100644
--- a/tests/restconf-nacm.cpp
+++ b/tests/restconf-nacm.cpp
@@ -130,6 +130,9 @@ TEST_CASE("NACM")
"clock": {
"timezone-utc-offset": 2
},
+ "dns-resolver": {
+ "options": {}
+ },
"radius": {
"server": [
{
@@ -139,7 +142,8 @@ TEST_CASE("NACM")
"shared-secret": "shared-secret"
}
}
- ]
+ ],
+ "options": {}
}
}
}
@@ -207,6 +211,9 @@ TEST_CASE("NACM")
"clock": {
"timezone-utc-offset": 2
},
+ "dns-resolver": {
+ "options": {}
+ },
"radius": {
"server": [
{
@@ -216,7 +223,8 @@ TEST_CASE("NACM")
"shared-secret": "shared-secret"
}
}
- ]
+ ],
+ "options": {}
}
}
}
diff --git a/tests/restconf-reading.cpp b/tests/restconf-reading.cpp
index e709486..ffdb047 100644
--- a/tests/restconf-reading.cpp
+++ b/tests/restconf-reading.cpp
@@ -62,10 +62,22 @@ TEST_CASE("reading data")
// this relies on a NACM rule for anonymous access that filters out "a lot of stuff"
REQUIRE(get(RESTCONF_DATA_ROOT, {}) == Response{200, jsonHeaders, R"({
"example:top-level-leaf": "moo",
+ "example:tlc": {},
+ "example:a": {
+ "b": {
+ "c": {}
+ },
+ "b1": {},
+ "example-augment:b": {
+ "c": {}
+ }
+ },
+ "example:two-leafs": {},
"example:config-nonconfig": {
"config-node": "foo-config-true",
"nonconfig-node": "foo-config-false"
},
+ "example:ordered-lists": {},
"ietf-restconf-monitoring:restconf-state": {
"capabilities": {
"capability": [
@@ -107,10 +119,22 @@ TEST_CASE("reading data")
REQUIRE(get(RESTCONF_ROOT_DS("operational"), {}) == Response{200, jsonHeaders, R"({
"example:top-level-leaf": "moo",
+ "example:tlc": {},
+ "example:a": {
+ "b": {
+ "c": {}
+ },
+ "b1": {},
+ "example-augment:b": {
+ "c": {}
+ }
+ },
+ "example:two-leafs": {},
"example:config-nonconfig": {
"config-node": "foo-config-true",
"nonconfig-node": "foo-config-false"
},
+ "example:ordered-lists": {},
"ietf-restconf-monitoring:restconf-state": {
"capabilities": {
"capability": [
@@ -152,9 +176,21 @@ TEST_CASE("reading data")
REQUIRE(get(RESTCONF_ROOT_DS("running"), {}) == Response{200, jsonHeaders, R"({
"example:top-level-leaf": "moo",
+ "example:tlc": {},
+ "example:a": {
+ "b": {
+ "c": {}
+ },
+ "b1": {},
+ "example-augment:b": {
+ "c": {}
+ }
+ },
+ "example:two-leafs": {},
"example:config-nonconfig": {
"config-node": "foo-config-true"
- }
+ },
+ "example:ordered-lists": {}
}
)"});
}
@@ -213,7 +249,8 @@ TEST_CASE("reading data")
{
"name": "a"
}
- ]
+ ],
+ "options": {}
}
}
}
@@ -244,7 +281,8 @@ TEST_CASE("reading data")
"shared-secret": "shared-secret"
}
}
- ]
+ ],
+ "options": {}
}
}
}
@@ -714,6 +752,7 @@ TEST_CASE("reading data")
"enabled": true
}
},
+ "b1": {},
"example-augment:b": {
"c": {
"enabled": true
@@ -723,9 +762,18 @@ TEST_CASE("reading data")
}
)"});
REQUIRE(get(RESTCONF_DATA_ROOT "/example:a?with-defaults=explicit", {}) == Response{200, jsonHeaders, R"({
-
+ "example:a": {
+ "b": {
+ "c": {}
+ },
+ "b1": {},
+ "example-augment:b": {
+ "c": {}
+ }
+ }
}
)"});
+ // FIXME: libyang is not really consistent in printing of NP-containers when trimming away the defaults...
REQUIRE(get(RESTCONF_DATA_ROOT "/example:a?with-defaults=trim", {}) == Response{200, jsonHeaders, R"({
}
@@ -740,6 +788,7 @@ TEST_CASE("reading data")
}
}
},
+ "b1": {},
"example-augment:b": {
"c": {
"enabled": true,
@@ -766,6 +815,7 @@ TEST_CASE("reading data")
"enabled": true
}
},
+ "b1": {},
"example-augment:b": {
"c": {
"enabled": true
@@ -781,13 +831,24 @@ TEST_CASE("reading data")
"c": {
"enabled": true
}
+ },
+ "b1": {},
+ "example-augment:b": {
+ "c": {}
}
}
}
)"});
+ // FIXME: libyang is not consistent here:
+ // - /example:a/b/c NP-container *is* printed,
+ // - /example:a/example-augment:b/c NP-container is *not* printed
REQUIRE(get(RESTCONF_DATA_ROOT "/example:a?with-defaults=trim", {}) == Response{200, jsonHeaders, R"({
-
+ "example:a": {
+ "b": {
+ "c": {}
+ }
+ }
}
)"});
@@ -801,6 +862,7 @@ TEST_CASE("reading data")
}
}
},
+ "b1": {},
"example-augment:b": {
"c": {
"enabled": true,
@@ -830,10 +892,15 @@ TEST_CASE("reading data")
)"});
REQUIRE(get(RESTCONF_DATA_ROOT "/example:a/b/c/enabled?with-defaults=explicit", {}) == Response{200, jsonHeaders, R"({
-
+ "example:a": {
+ "b": {
+ "c": {}
+ }
+ }
}
)"});
+ // again, libyang is not 100% consistent in the `trim` mode
REQUIRE(get(RESTCONF_DATA_ROOT "/example:a/b/c/enabled?with-defaults=trim", {}) == Response{200, jsonHeaders, R"({
}
--
2.43.0
@@ -1,31 +0,0 @@
From 818af7ec1dc890774603dbf11ed81cf64d89a628 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Thu, 13 Mar 2025 17:56:50 +0100
Subject: [PATCH 31/44] fix a typo in RFC number
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Change-Id: I81b68b3920c41129cb98728481580699e44e8c20
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
tests/restconf-reading.cpp | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/restconf-reading.cpp b/tests/restconf-reading.cpp
index ffdb047..38f5496 100644
--- a/tests/restconf-reading.cpp
+++ b/tests/restconf-reading.cpp
@@ -879,7 +879,7 @@ TEST_CASE("reading data")
SECTION("Implicit node with default value")
{
- // RFC 4080, 3.5.4: If target of the query is implicitly created node with default value, ignore basic mode
+ // RFC 8040, 3.5.4: If target of the query is implicitly created node with default value, ignore basic mode
REQUIRE(get(RESTCONF_DATA_ROOT "/example:a/b/c/enabled", {}) == Response{200, jsonHeaders, R"({
"example:a": {
"b": {
--
2.43.0
@@ -1,104 +0,0 @@
From 9997d61dc43775444e4d78c37054a30b602c2603 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Thu, 13 Mar 2025 18:11:39 +0100
Subject: [PATCH 32/44] Fix bug in depth= processing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
...which got fixed upstream in sysrepo.
Change-Id: Ida37c48058488be32230165b8b4f45c6bd5a4d3a
Depends-on: https://gerrit.cesnet.cz/c/CzechLight/dependencies/+/8429
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
CMakeLists.txt | 1 +
tests/restconf-reading.cpp | 45 ++++++++++++++++++++++++++++++++++++++
2 files changed, 46 insertions(+)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index ca41ef3..272caad 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -74,6 +74,7 @@ find_package(PkgConfig)
pkg_check_modules(nghttp2 REQUIRED IMPORTED_TARGET libnghttp2_asio>=0.0.90 libnghttp2) # To compile under boost 1.87 you have to patch nghttp2-asio using https://github.com/nghttp2/nghttp2-asio/issues/23
find_package(Boost 1.66 REQUIRED CONFIG COMPONENTS system thread)
+pkg_check_modules(SYSREPO REQUIRED sysrepo>=3.6.5 IMPORTED_TARGET)
pkg_check_modules(SYSREPO-CPP REQUIRED IMPORTED_TARGET sysrepo-cpp>=5)
pkg_check_modules(LIBYANG-CPP REQUIRED IMPORTED_TARGET libyang-cpp>=3)
pkg_check_modules(SYSTEMD IMPORTED_TARGET libsystemd)
diff --git a/tests/restconf-reading.cpp b/tests/restconf-reading.cpp
index 38f5496..f87c4f5 100644
--- a/tests/restconf-reading.cpp
+++ b/tests/restconf-reading.cpp
@@ -243,6 +243,13 @@ TEST_CASE("reading data")
)"});
REQUIRE(get(RESTCONF_DATA_ROOT "/ietf-system:system/radius?depth=1", {AUTH_DWDM}) == Response{200, jsonHeaders, R"({
+ "ietf-system:system": {
+ "radius": {}
+ }
+}
+)"});
+
+ REQUIRE(get(RESTCONF_DATA_ROOT "/ietf-system:system/radius?depth=2", {AUTH_DWDM}) == Response{200, jsonHeaders, R"({
"ietf-system:system": {
"radius": {
"server": [
@@ -1026,6 +1033,25 @@ TEST_CASE("reading data")
}
)"});
REQUIRE(get(RESTCONF_DATA_ROOT "/example:tlc/list=blabla?fields=nested/data&depth=1", {}) == Response{200, jsonHeaders, R"({
+ "example:tlc": {
+ "list": [
+ {
+ "name": "blabla",
+ "nested": [
+ {
+ "first": "1",
+ "second": 2,
+ "third": "3",
+ "data": {}
+ }
+ ]
+ }
+ ]
+ }
+}
+)"});
+
+ REQUIRE(get(RESTCONF_DATA_ROOT "/example:tlc/list=blabla?fields=nested/data&depth=2", {}) == Response{200, jsonHeaders, R"({
"example:tlc": {
"list": [
{
@@ -1049,6 +1075,25 @@ TEST_CASE("reading data")
// whole datastore with fields filtering
REQUIRE(get(RESTCONF_DATA_ROOT "?fields=example:tlc/list/nested/data&depth=1", {}) == Response{200, jsonHeaders, R"({
+ "example:tlc": {
+ "list": [
+ {
+ "name": "blabla",
+ "nested": [
+ {
+ "first": "1",
+ "second": 2,
+ "third": "3",
+ "data": {}
+ }
+ ]
+ }
+ ]
+ }
+}
+)"});
+
+ REQUIRE(get(RESTCONF_DATA_ROOT "?fields=example:tlc/list/nested/data&depth=2", {}) == Response{200, jsonHeaders, R"({
"example:tlc": {
"list": [
{
--
2.43.0
@@ -1,89 +0,0 @@
From 009b2b01fb4a1ca854402debd66d899a264db8f2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Fri, 14 Mar 2025 13:57:26 +0100
Subject: [PATCH 33/44] Adapt to libyang-cpp API break in opaque nodes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
I was wondering for a while whether libyang will do the right thing when
printing the RPC's output nodes in XML, but apparently it does (and we
have a test for that).
Depends-on: https://gerrit.cesnet.cz/c/CzechLight/dependencies/+/8438
Change-Id: I82425743fd2613fcbe2b696848e2feb1c47fc658
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
CMakeLists.txt | 2 +-
src/restconf/Server.cpp | 5 +++--
src/restconf/utils/yang.cpp | 9 +++++----
3 files changed, 9 insertions(+), 7 deletions(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 272caad..bcabe84 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -76,7 +76,7 @@ find_package(Boost 1.66 REQUIRED CONFIG COMPONENTS system thread)
pkg_check_modules(SYSREPO REQUIRED sysrepo>=3.6.5 IMPORTED_TARGET)
pkg_check_modules(SYSREPO-CPP REQUIRED IMPORTED_TARGET sysrepo-cpp>=5)
-pkg_check_modules(LIBYANG-CPP REQUIRED IMPORTED_TARGET libyang-cpp>=3)
+pkg_check_modules(LIBYANG-CPP REQUIRED IMPORTED_TARGET libyang-cpp>=4)
pkg_check_modules(SYSTEMD IMPORTED_TARGET libsystemd)
pkg_check_modules(PAM REQUIRED IMPORTED_TARGET pam)
pkg_check_modules(DOCOPT REQUIRED IMPORTED_TARGET docopt)
diff --git a/src/restconf/Server.cpp b/src/restconf/Server.cpp
index bae3992..28e2e4c 100644
--- a/src/restconf/Server.cpp
+++ b/src/restconf/Server.cpp
@@ -471,7 +471,8 @@ void processActionOrRPC(std::shared_ptr<RequestContext> requestCtx, const std::c
auto responseNode = rpcReply->child();
responseNode->unlinkWithSiblings();
- auto envelope = ctx.newOpaqueJSON(rpcNode->schema().module().name(), "output", std::nullopt);
+ // libyang auto-resolves the XML namespace when the result is printed into XML
+ auto envelope = ctx.newOpaqueJSON({rpcNode->schema().module().name(), rpcNode->schema().module().name(), "output"}, std::nullopt);
envelope->insertChild(*responseNode);
requestCtx->res.write_head(200, {
@@ -735,7 +736,7 @@ libyang::DataNode apiResource(const libyang::Context& ctx, const RestconfRequest
}
for (const auto& rpc : mod.actionRpcs()) {
- operations.insertChild(*ctx.newOpaqueJSON(rpc.module().name(), rpc.name(), libyang::JSON{"[null]"}));
+ operations.insertChild(*ctx.newOpaqueJSON({rpc.module().name(), rpc.module().name(), rpc.name()}, libyang::JSON{"[null]"}));
}
}
} else {
diff --git a/src/restconf/utils/yang.cpp b/src/restconf/utils/yang.cpp
index 8acb160..9358840 100644
--- a/src/restconf/utils/yang.cpp
+++ b/src/restconf/utils/yang.cpp
@@ -89,6 +89,7 @@ std::string as_restconf_notification(const libyang::Context& ctx, libyang::DataF
{
static const auto jsonNamespace = "ietf-restconf";
static const auto xmlNamespace = "urn:ietf:params:xml:ns:netconf:notification:1.0";
+ static const auto xmlPrefix = "ietf-netconf-notifications";
std::optional<libyang::DataNode> envelope;
std::optional<libyang::DataNode> eventTime;
@@ -96,11 +97,11 @@ std::string as_restconf_notification(const libyang::Context& ctx, libyang::DataF
/* The namespaces for XML and JSON envelopes are different. See https://datatracker.ietf.org/doc/html/rfc8040#section-6.4 */
if (dataFormat == libyang::DataFormat::JSON) {
- envelope = ctx.newOpaqueJSON(jsonNamespace, "notification", std::nullopt);
- eventTime = ctx.newOpaqueJSON(jsonNamespace, "eventTime", libyang::JSON{timeStr});
+ envelope = ctx.newOpaqueJSON({jsonNamespace, jsonNamespace, "notification"}, std::nullopt);
+ eventTime = ctx.newOpaqueJSON({jsonNamespace, jsonNamespace, "eventTime"}, libyang::JSON{timeStr});
} else {
- envelope = ctx.newOpaqueXML(xmlNamespace, "notification", std::nullopt);
- eventTime = ctx.newOpaqueXML(xmlNamespace, "eventTime", libyang::XML{timeStr});
+ envelope = ctx.newOpaqueXML({xmlNamespace, xmlPrefix, "notification"}, std::nullopt);
+ eventTime = ctx.newOpaqueXML({xmlNamespace, xmlPrefix, "eventTime"}, libyang::XML{timeStr});
}
// the notification data node holds only the notification data tree but for nested notification we should print the whole YANG data tree
--
2.43.0
@@ -1,122 +0,0 @@
From 061d960d9435018aaba3b2d1b4d857ddb8836d1b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Fri, 14 Mar 2025 15:38:44 +0100
Subject: [PATCH 34/44] Fix XML serialization when listing RPCs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
The RFC says [1] that each RPC entry that's listed in the reply should
look like this when serialized into the XML format:
<system-restart xmlns='urn:ietf:params:xml:ns:yang:ietf-system'/>
Our old code would, due to the way how libyang deals with opaque nodes,
just try to dump the JSON-ish `[null]` bits in there, which is wrong.
[1] https://datatracker.ietf.org/doc/html/rfc8040#page-84
Change-Id: I6ea433dbd3fcf3ca2883d3fa00c9d215c0e1bb4f
Depends-on: https://gerrit.cesnet.cz/c/CzechLight/br2-external/+/8443
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/restconf/Server.cpp | 16 +++++++++++++---
tests/restconf-rpc.cpp | 33 ++++++++++++++++++++++++++++++++-
2 files changed, 45 insertions(+), 4 deletions(-)
diff --git a/src/restconf/Server.cpp b/src/restconf/Server.cpp
index 28e2e4c..05239e9 100644
--- a/src/restconf/Server.cpp
+++ b/src/restconf/Server.cpp
@@ -712,7 +712,7 @@ void processPutOrPlainPatch(std::shared_ptr<RequestContext> requestCtx, const st
}
/** @brief Build data trees for endpoints returning ietf-restconf:restconf data */
-libyang::DataNode apiResource(const libyang::Context& ctx, const RestconfRequest::Type& type)
+libyang::DataNode apiResource(const libyang::Context& ctx, const RestconfRequest::Type& type, libyang::DataFormat dataFormat)
{
const auto yangLib = *ctx.getModuleLatest("ietf-yang-library");
const auto yangApiExt = ctx.getModuleImplemented("ietf-restconf")->extensionInstance("yang-api");
@@ -736,7 +736,16 @@ libyang::DataNode apiResource(const libyang::Context& ctx, const RestconfRequest
}
for (const auto& rpc : mod.actionRpcs()) {
- operations.insertChild(*ctx.newOpaqueJSON({rpc.module().name(), rpc.module().name(), rpc.name()}, libyang::JSON{"[null]"}));
+ switch (dataFormat) {
+ case libyang::DataFormat::JSON:
+ operations.insertChild(*ctx.newOpaqueJSON({rpc.module().name(), rpc.module().name(), rpc.name()}, libyang::JSON{"[null]"}));
+ break;
+ case libyang::DataFormat::XML:
+ operations.insertChild(*ctx.newOpaqueXML({rpc.module().ns(), rpc.module().name(), rpc.name()}, std::nullopt));
+ break;
+ default:
+ throw std::logic_error{"Invalid data format for apiResource()"};
+ }
}
}
} else {
@@ -987,7 +996,8 @@ Server::Server(sysrepo::Connection conn, const std::string& address, const std::
case RestconfRequest::Type::YangLibraryVersion:
case RestconfRequest::Type::ListRPC:
res.write_head(200, {contentType(dataFormat.response), CORS});
- res.end(*apiResource(sess.getContext(), restconfRequest.type).printStr(dataFormat.response, libyang::PrintFlags::WithSiblings | libyang::PrintFlags::KeepEmptyCont));
+ res.end(*apiResource(sess.getContext(), restconfRequest.type, dataFormat.response)
+ .printStr(dataFormat.response, libyang::PrintFlags::WithSiblings | libyang::PrintFlags::KeepEmptyCont));
break;
case RestconfRequest::Type::GetData: {
diff --git a/tests/restconf-rpc.cpp b/tests/restconf-rpc.cpp
index c4229a0..d49b121 100644
--- a/tests/restconf-rpc.cpp
+++ b/tests/restconf-rpc.cpp
@@ -79,7 +79,9 @@ TEST_CASE("invoking actions and rpcs")
SECTION("List RPCs")
{
- REQUIRE(get(RESTCONF_OPER_ROOT, {AUTH_ROOT}) == Response{200, jsonHeaders, R"({
+ SECTION("JSON")
+ {
+ REQUIRE(get(RESTCONF_OPER_ROOT, {AUTH_ROOT}) == Response{200, jsonHeaders, R"({
"ietf-restconf:restconf": {
"operations": {
"ietf-factory-default:factory-reset": [null],
@@ -103,6 +105,35 @@ TEST_CASE("invoking actions and rpcs")
}
}
)"});
+ }
+
+ SECTION("XML")
+ {
+ REQUIRE(get(RESTCONF_OPER_ROOT, {AUTH_ROOT, CONTENT_TYPE_XML})
+ == Response{200, xmlHeaders,
+ R"(<restconf xmlns="urn:ietf:params:xml:ns:yang:ietf-restconf">
+ <operations>
+ <factory-reset xmlns="urn:ietf:params:xml:ns:yang:ietf-factory-default"/>
+ <get-config xmlns="urn:ietf:params:xml:ns:netconf:base:1.0"/>
+ <edit-config xmlns="urn:ietf:params:xml:ns:netconf:base:1.0"/>
+ <copy-config xmlns="urn:ietf:params:xml:ns:netconf:base:1.0"/>
+ <delete-config xmlns="urn:ietf:params:xml:ns:netconf:base:1.0"/>
+ <lock xmlns="urn:ietf:params:xml:ns:netconf:base:1.0"/>
+ <unlock xmlns="urn:ietf:params:xml:ns:netconf:base:1.0"/>
+ <get xmlns="urn:ietf:params:xml:ns:netconf:base:1.0"/>
+ <close-session xmlns="urn:ietf:params:xml:ns:netconf:base:1.0"/>
+ <kill-session xmlns="urn:ietf:params:xml:ns:netconf:base:1.0"/>
+ <set-current-datetime xmlns="urn:ietf:params:xml:ns:yang:ietf-system"/>
+ <system-restart xmlns="urn:ietf:params:xml:ns:yang:ietf-system"/>
+ <system-shutdown xmlns="urn:ietf:params:xml:ns:yang:ietf-system"/>
+ <test-rpc xmlns="http://example.tld/example"/>
+ <test-rpc-no-output xmlns="http://example.tld/example"/>
+ <test-rpc-no-input xmlns="http://example.tld/example"/>
+ <test-rpc-no-input-no-output xmlns="http://example.tld/example"/>
+ </operations>
+</restconf>
+)"});
+ }
}
SECTION("RPC")
--
2.43.0
@@ -1,39 +0,0 @@
From 6ad606cb9ad49ca7bfb8056b9695ab271a33e790 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Wed, 26 Mar 2025 18:56:06 +0100
Subject: [PATCH 35/44] make sure that edit tree is the first sibling
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
After a bug in velia [1], where we passed to Session::editBatch a tree
which was not the first sibling (and this caused that all the left
siblings of the node were not considered in the edit), I am making sure
that we do not lose any data in this project due to this too.
[1] https://github.com/CESNET/velia/commit/2b7187405740fe61e8b37e0efab8c3173d398a26
Fixes: 25f3e3b ("restconf: support for YANG patch")
Change-Id: I7211168325fbfddd75dc1520c9dd83e31138d3bc
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/restconf/Server.cpp | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/restconf/Server.cpp b/src/restconf/Server.cpp
index 05239e9..c454541 100644
--- a/src/restconf/Server.cpp
+++ b/src/restconf/Server.cpp
@@ -600,7 +600,7 @@ void processYangPatchEdit(const std::shared_ptr<RequestContext>& requestCtx, con
if (!mergedEdits) {
mergedEdits = *singleEdit;
} else {
- mergedEdits->insertSibling(*singleEdit);
+ mergedEdits = mergedEdits->insertSibling(*singleEdit); // make sure we point to the first sibling, sysrepo::editBatch requires that
}
}
--
2.43.0
File diff suppressed because it is too large Load Diff
@@ -1,31 +0,0 @@
From 10c6ae778724e5c15a12b58bc95c814cb17b772e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Tue, 8 Apr 2025 16:59:48 +0200
Subject: [PATCH 37/44] don't create throwaway sysrepo sessions during startup
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Change-Id: Ic3fc71c08c589a8e87bbdc1aa1b1089c88c7cb1e
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/restconf/Server.cpp | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/restconf/Server.cpp b/src/restconf/Server.cpp
index 9fd3fa9..a840e6e 100644
--- a/src/restconf/Server.cpp
+++ b/src/restconf/Server.cpp
@@ -847,7 +847,7 @@ Server::Server(sysrepo::Connection conn, const std::string& address, const std::
{"ietf-subscribed-notifications", "2019-09-09", {}},
{"ietf-restconf-subscribed-notifications", "2019-11-17", {}},
}) {
- if (auto mod = conn.sessionStart().getContext().getModuleImplemented(module)) {
+ if (auto mod = m_monitoringSession.getContext().getModuleImplemented(module)) {
for (const auto& feature : features) {
if (!mod->featureEnabled(feature)) {
throw std::runtime_error("Module "s + module + "@" + version + " does not implement feature " + feature);
--
2.43.0
@@ -1,59 +0,0 @@
From 4e287e1e2af95c1ec077689e4f353f657469e5e5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Tue, 8 Apr 2025 17:00:44 +0200
Subject: [PATCH 38/44] requests: create sysrepo session later
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
I was looking at all places where a sysrepo session is used, and tried
to defer their creations until the last moment possible, just to
(potentially) save some resources when it isn't needed. Some of these
changes are cosmetic, but I was touching that code anyway.
This cannot be done for the generic `restconfRoot` handler because of
its associated error handling which requires a libyang context. Well, it
*could* be done, but I don't feel like doing some m_monitoringSession
magic for little to no purpose.
Change-Id: Ia84c90abc1464e4a0f9682779d7471aefad9e2de
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/restconf/Server.cpp | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/src/restconf/Server.cpp b/src/restconf/Server.cpp
index a840e6e..f3c515b 100644
--- a/src/restconf/Server.cpp
+++ b/src/restconf/Server.cpp
@@ -902,7 +902,6 @@ Server::Server(sysrepo::Connection conn, const std::string& address, const std::
});
server->handle(netconfStreamRoot, [this, conn](const auto& req, const auto& res) mutable {
- auto sess = conn.sessionStart();
std::optional<std::string> xpathFilter;
std::optional<sysrepo::NotificationTimeStamp> startTime;
std::optional<sysrepo::NotificationTimeStamp> stopTime;
@@ -914,6 +913,7 @@ Server::Server(sysrepo::Connection conn, const std::string& address, const std::
}
try {
+ auto sess = conn.sessionStart();
authorizeRequest(nacm, sess, req);
auto streamRequest = asRestconfStreamRequest(req.method(), req.uri().path, req.uri().raw_query);
@@ -962,9 +962,8 @@ Server::Server(sysrepo::Connection conn, const std::string& address, const std::
return;
}
- auto sess = conn.sessionStart(sysrepo::Datastore::Operational);
-
try {
+ auto sess = conn.sessionStart(sysrepo::Datastore::Operational);
authorizeRequest(nacm, sess, req);
if (auto mod = asYangModule(sess.getContext(), req.uri().path); mod && hasAccessToYangSchema(sess, *mod)) {
--
2.43.0
@@ -1,31 +0,0 @@
From 422eae2ae879872bd618e3ba19418d20c197650d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Wed, 9 Apr 2025 15:52:34 +0200
Subject: [PATCH 39/44] docs: doctest has moved
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Change-Id: I45d605f080434a0726d729c7b186ab1bbeb8d8e1
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 3fbfd21..646976d 100644
--- a/README.md
+++ b/README.md
@@ -94,7 +94,7 @@ In practical terms, this means that the NACM access rules for the following XPat
- C++20 compiler (e.g., GCC 10.x+, clang 10+)
- CMake 3.19+
- optionally systemd - the shared library for logging to `sd-journal`
-- optionally for built-in tests, [Doctest](https://github.com/onqtam/doctest/) as a C++ unit test framework
+- optionally for built-in tests, [Doctest](https://github.com/doctest/doctest/) as a C++ unit test framework
- optionally for built-in tests, [trompeloeil](https://github.com/rollbear/trompeloeil) for mock objects in C++
- optionally for built-in tests, [`pam_matrix` and `pam_wrapper`](https://cwrap.org/pam_wrapper.html) for PAM mocking
--
2.43.0
@@ -1,35 +0,0 @@
From b60c30e4eb482ce7b72ba95c8e6edc94f232ca37 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Tue, 8 Apr 2025 18:12:18 +0200
Subject: [PATCH 40/44] tests: capture wrongly-formatted SSE messages
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Sometimes during development I do things wrong(TM) and the Server Sent
Events data stream is not prefixed.
The log failure is unhelpful, so this patch should make the failures
more clear in such cases.
Change-Id: I0d4643a6d7fe56ea5ce694c734a76b6dd471ff4c
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
tests/restconf_utils.cpp | 1 +
1 file changed, 1 insertion(+)
diff --git a/tests/restconf_utils.cpp b/tests/restconf_utils.cpp
index c3ff1de..72c2c5a 100644
--- a/tests/restconf_utils.cpp
+++ b/tests/restconf_utils.cpp
@@ -224,6 +224,7 @@ std::vector<std::string> SSEClient::parseEvents(const std::string& msg)
res.emplace_back(std::move(event));
event.clear();
} else {
+ CAPTURE(msg);
FAIL("Unprefixed response");
}
}
--
2.43.0
@@ -1,32 +0,0 @@
From 2e866f4076df5cf68e7f8015e088baf45b45ddb3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Mon, 14 Apr 2025 13:15:29 +0200
Subject: [PATCH 41/44] tests: perform factory-reset between individual
sections of NACM test
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Change-Id: I3b60439a8235e2a0efed58fcdb2ad8eb95f21714
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
tests/restconf-nacm.cpp | 2 ++
1 file changed, 2 insertions(+)
diff --git a/tests/restconf-nacm.cpp b/tests/restconf-nacm.cpp
index 51328f3..546d694 100644
--- a/tests/restconf-nacm.cpp
+++ b/tests/restconf-nacm.cpp
@@ -20,6 +20,8 @@ TEST_CASE("NACM")
auto nacmGuard = manageNacm(srSess);
auto server = rousette::restconf::Server{srConn, SERVER_ADDRESS, SERVER_PORT};
+ srSess.sendRPC(srSess.getContext().newPath("/ietf-factory-default:factory-reset"));
+
// something we can read
srSess.switchDatastore(sysrepo::Datastore::Operational);
srSess.setItem("/ietf-system:system/contact", "contact");
--
2.43.0
@@ -1,70 +0,0 @@
From 0ef0307d6664affc0547c2e65c8c33900bd7cfc9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Mon, 14 Apr 2025 15:12:00 +0200
Subject: [PATCH 42/44] restconf: simplify condition in NACM anonymous access
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
The condition required that all access-operations values are "read". But
access-operations is a union, so it is enough to check that we have only
one value in the union and that it is "read".
Also test what happens if anonymous rule access-operation is a union of
read and something else.
Change-Id: I291243d9e2167601b24d49684d53c361407f66be
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/auth/Nacm.cpp | 4 +---
tests/restconf-nacm.cpp | 18 ++++++++++++++++++
2 files changed, 19 insertions(+), 3 deletions(-)
diff --git a/src/auth/Nacm.cpp b/src/auth/Nacm.cpp
index 61a7dcc..d0dc4a2 100644
--- a/src/auth/Nacm.cpp
+++ b/src/auth/Nacm.cpp
@@ -12,9 +12,7 @@ namespace {
bool isRuleReadOnly(const libyang::DataNode& rule)
{
auto accessOperations = rule.findXPath("access-operations");
- return !accessOperations.empty() && std::all_of(accessOperations.begin(), accessOperations.end(), [](const auto& e) {
- return e.asTerm().valueStr() == "read";
- });
+ return accessOperations.size() == 1 && accessOperations.begin()->asTerm().valueStr() == "read";
}
bool isRuleWildcardDeny(const libyang::DataNode& rule)
diff --git a/tests/restconf-nacm.cpp b/tests/restconf-nacm.cpp
index 546d694..30f4210 100644
--- a/tests/restconf-nacm.cpp
+++ b/tests/restconf-nacm.cpp
@@ -95,6 +95,24 @@ TEST_CASE("NACM")
srSess.applyChanges();
}
+ DOCTEST_SUBCASE("Anonymous has one rule with both read and update access operations")
+ {
+ srSess.deleteItem("/ietf-netconf-acm:nacm/rule-list");
+ srSess.applyChanges();
+ srSess.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/group[.='yangnobody']", "");
+ srSess.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='1']/module-name", "ietf-system");
+ srSess.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='1']/action", "permit");
+ srSess.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='1']/access-operations", "read update");
+ srSess.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='1']/path", "/ietf-system:system");
+ srSess.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='2']/module-name", "*");
+ srSess.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='2']/action", "deny");
+
+ srSess.setItem("/ietf-netconf-acm:nacm/rule-list[name='dwdm rule']/group[.='optics']", "");
+ srSess.setItem("/ietf-netconf-acm:nacm/rule-list[name='dwdm rule']/rule[name='1']/module-name", "ietf-system");
+ srSess.setItem("/ietf-netconf-acm:nacm/rule-list[name='dwdm rule']/rule[name='1']/action", "permit");
+ srSess.applyChanges();
+ }
+
DOCTEST_SUBCASE("Anonymous rulelist OK, but not at first place")
{
srSess.deleteItem("/ietf-netconf-acm:nacm/rule-list");
--
2.43.0
@@ -1,140 +0,0 @@
From f97dcabf8bf74c463d3291d31e9b36fabec0654f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Wed, 9 Apr 2025 21:17:19 +0200
Subject: [PATCH 43/44] restconf: allow specifying exec permissions for
anonymous user access
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
We want to be able to allow anonymous users to call the RPC
ietf-subscribed-notifications:establish-subscription. Our current NACM
anonymous setup did not allow specifying exec permissions.
After this patch admins are allowed to setup a rule in the anonymous
user rule-list that allows anonymous users to call the
establish-subscriptions RPC. We allow only this one RPC, specifying any
other will result in disabling the anonymous user access.
Change-Id: I5aa6fee2bbdabc0b7deac7fb9afecac6c411aca0
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
README.md | 2 +-
src/auth/Nacm.cpp | 23 ++++++++++++++++++-----
tests/restconf-nacm.cpp | 21 +++++++++++++++++++++
tests/restconf_utils.cpp | 4 ++++
4 files changed, 44 insertions(+), 6 deletions(-)
diff --git a/README.md b/README.md
index 646976d..0796dce 100644
--- a/README.md
+++ b/README.md
@@ -66,7 +66,7 @@ When certain conditions are met, the anonymous access will be mapped to a NACM a
There must be some specific access rights set up in `ietf-netconf-acm` model (these are currently very opinionated for our use-case):
1. The first entry of `rule-list` list must be configured for `ANONYMOUS_USER_GROUP`.
-2. All the rules except the last one in this rule-list entry must enable only "read" access operation.
+2. All the rules except the last one in this rule-list entry must enable either only "read" access operation or only "exec" operation on `ietf-subscribed-notifications:establish-subscription` RPC.
3. The last rule in the first rule-set must be a wildcard rule that disables all operations over all modules.
The anonymous user access is disabled whenever these rules are not met.
diff --git a/src/auth/Nacm.cpp b/src/auth/Nacm.cpp
index d0dc4a2..284fa85 100644
--- a/src/auth/Nacm.cpp
+++ b/src/auth/Nacm.cpp
@@ -9,10 +9,23 @@
#include "NacmIdentities.h"
namespace {
-bool isRuleReadOnly(const libyang::DataNode& rule)
+bool isWhitelistedRPC(const std::optional<libyang::DataNode>& moduleNameNode, const std::optional<libyang::DataNode>& rpcNameNode)
+{
+ if (!moduleNameNode || !rpcNameNode) {
+ return false;
+ }
+
+ return moduleNameNode->asTerm().valueStr() == "ietf-subscribed-notifications" && rpcNameNode->asTerm().valueStr() == "establish-subscription";
+}
+
+bool isRuleForAnonymousAccess(const libyang::DataNode& rule)
{
auto accessOperations = rule.findXPath("access-operations");
- return accessOperations.size() == 1 && accessOperations.begin()->asTerm().valueStr() == "read";
+ auto rpcName = rule.findPath("rpc-name");
+ return accessOperations.size() == 1 && // Combining access operations is not allowed in anonymous user rules
+ (accessOperations.begin()->asTerm().valueStr() == "read" || // Either read...
+ (accessOperations.begin()->asTerm().valueStr() == "exec" && isWhitelistedRPC(rule.findPath("module-name"), rule.findPath("rpc-name"))) // ...or exec on whitelisted RPC.
+ );
}
bool isRuleWildcardDeny(const libyang::DataNode& rule)
@@ -25,7 +38,7 @@ bool isRuleWildcardDeny(const libyang::DataNode& rule)
*
* The first rule-list element contains rules for anonymous user access, i.e.:
* - The group is set to @p anonGroup (this one should contain the anonymous user)
- * - In rules (except the last one) the access-operation allowed is "read"
+ * - In rules (except the last one) the access-operation allowed is either "read" or "exec" on a whitelisted RPC.
* - The last rule has module-name="*" and action "deny".
*
* @return boolean indicating whether the rules are configured properly for anonymous user access
@@ -60,8 +73,8 @@ bool validAnonymousNacmRules(sysrepo::Session session, const std::string& anonGr
return false;
}
- if (!std::all_of(rules.begin(), rules.end() - 1, isRuleReadOnly)) {
- spdlog::debug("NACM config validation: First n-1 rules in the anonymous rule-list must be configured for read-access only");
+ if (!std::all_of(rules.begin(), rules.end() - 1, isRuleForAnonymousAccess)) {
+ spdlog::debug("NACM config validation: First n-1 rules in the anonymous rule-list must be configured either for read-access only or exec on listed RPC paths");
return false;
}
diff --git a/tests/restconf-nacm.cpp b/tests/restconf-nacm.cpp
index 30f4210..7799fe8 100644
--- a/tests/restconf-nacm.cpp
+++ b/tests/restconf-nacm.cpp
@@ -113,6 +113,27 @@ TEST_CASE("NACM")
srSess.applyChanges();
}
+ DOCTEST_SUBCASE("This RPC is not allowed")
+ {
+ srSess.deleteItem("/ietf-netconf-acm:nacm/rule-list");
+ srSess.applyChanges();
+ srSess.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/group[.='yangnobody']", "");
+ srSess.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='1']/module-name", "ietf-system");
+ srSess.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='1']/action", "permit");
+ srSess.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='1']/access-operations", "read");
+ srSess.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='1']/path", "/ietf-system:system");
+ srSess.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='2']/module-name", "ietf-subscribed-notifications");
+ srSess.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='2']/action", "permit");
+ srSess.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='2']/access-operations", "read update");
+ srSess.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='2']/rpc-name", "/ietf-subscribed-notifications:modify-subscription");
+ srSess.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='3']/module-name", "*");
+ srSess.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='3']/action", "deny");
+ srSess.setItem("/ietf-netconf-acm:nacm/rule-list[name='dwdm rule']/group[.='optics']", "");
+ srSess.setItem("/ietf-netconf-acm:nacm/rule-list[name='dwdm rule']/rule[name='1']/module-name", "ietf-system");
+ srSess.setItem("/ietf-netconf-acm:nacm/rule-list[name='dwdm rule']/rule[name='1']/action", "permit");
+ srSess.applyChanges();
+ }
+
DOCTEST_SUBCASE("Anonymous rulelist OK, but not at first place")
{
srSess.deleteItem("/ietf-netconf-acm:nacm/rule-list");
diff --git a/tests/restconf_utils.cpp b/tests/restconf_utils.cpp
index 72c2c5a..a137463 100644
--- a/tests/restconf_utils.cpp
+++ b/tests/restconf_utils.cpp
@@ -152,6 +152,10 @@ void setupRealNacm(sysrepo::Session session)
session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='15']/action", "permit");
session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='15']/access-operations", "read");
session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='15']/path", "/example-delete:immutable");
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='16']/module-name", "ietf-subscribed-notifications");
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='16']/action", "permit");
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='16']/access-operations", "exec");
+ session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='16']/rpc-name", "establish-subscription");
session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='99']/module-name", "*");
session.setItem("/ietf-netconf-acm:nacm/rule-list[name='anon rule']/rule[name='99']/action", "deny");
session.setItem("/ietf-netconf-acm:nacm/rule-list[name='dwdm rule']/group[.='optics']", "");
--
2.43.0
@@ -1,32 +0,0 @@
From 438a18f5884ace3063f00a2dfc53dd2b4034f53e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Wed, 16 Apr 2025 10:56:50 +0200
Subject: [PATCH 44/44] restconf: add access log to stream root
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Change-Id: I5e1db3f40bc6a7f294e6a5fef9402e0855055258
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/restconf/Server.cpp | 3 +++
1 file changed, 3 insertions(+)
diff --git a/src/restconf/Server.cpp b/src/restconf/Server.cpp
index f3c515b..5b1b2f9 100644
--- a/src/restconf/Server.cpp
+++ b/src/restconf/Server.cpp
@@ -902,6 +902,9 @@ Server::Server(sysrepo::Connection conn, const std::string& address, const std::
});
server->handle(netconfStreamRoot, [this, conn](const auto& req, const auto& res) mutable {
+ const auto& peer = http::peer_from_request(req);
+ spdlog::info("{}: {} {}", peer, req.method(), req.uri().raw_path);
+
std::optional<std::string> xpathFilter;
std::optional<sysrepo::NotificationTimeStamp> startTime;
std::optional<sysrepo::NotificationTimeStamp> stopTime;
--
2.43.0
+1 -1
View File
@@ -1,3 +1,3 @@
# Locally calculated
sha256 cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 LICENSE
sha256 400929f9c6550b8f6efd639b56ea76b49ef850a9f2716830637f3eef8c144a89 rousette-v1.tar.gz
sha256 e6aa0b6a4c883e2ba62cbd87c510e198797663ff26e660a01410c015e13ddaa4 rousette-v2.tar.gz
+1 -1
View File
@@ -3,7 +3,7 @@
# Rousette RESTconf server
#
################################################################################
ROUSETTE_VERSION = v1
ROUSETTE_VERSION = v2
ROUSETTE_SITE = $(call github,CESNET,rousette,$(ROUSETTE_VERSION))
ROUSETTE_LICENSE = Apache-2.0
ROUSETTE_LICENSE_FILES = LICENSE
@@ -1,73 +0,0 @@
From 23fa270a747c35d65aeeba691599ed6b21b501f0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Tue, 29 Oct 2024 15:15:52 +0100
Subject: [PATCH 01/20] error handling changes in upstream sysrepo
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Upstream commit 396e331e4634a417420a71ad723567b42d75c443 removed these
extra error entries.
Change-Id: Ifeda21194db7b7f7fdae8b6ae13c1e2d1f6b8d3d
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
CMakeLists.txt | 2 +-
tests/subscriptions.cpp | 16 ++++++----------
2 files changed, 7 insertions(+), 11 deletions(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index eb5f900..1eb625f 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -28,7 +28,7 @@ option(WITH_EXAMPLES "Build examples" ON)
find_package(PkgConfig)
pkg_check_modules(LIBYANG_CPP REQUIRED libyang-cpp>=3 IMPORTED_TARGET)
-pkg_check_modules(SYSREPO REQUIRED sysrepo>=2.11.7 IMPORTED_TARGET)
+pkg_check_modules(SYSREPO REQUIRED sysrepo>=2.12.0 IMPORTED_TARGET)
set(SYSREPO_CPP_PKG_VERSION "3")
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/include)
diff --git a/tests/subscriptions.cpp b/tests/subscriptions.cpp
index 0e14fc0..36fd61c 100644
--- a/tests/subscriptions.cpp
+++ b/tests/subscriptions.cpp
@@ -494,11 +494,11 @@ TEST_CASE("subscriptions")
sess.applyChanges();
} catch (const sysrepo::ErrorWithCode&) {
auto errors = sess.getErrors();
- REQUIRE(errors.size() == 2);
- REQUIRE(errors.at(0).errorMessage == message);
- REQUIRE(errors.at(0).code == sysrepo::ErrorCode::OperationFailed);
- REQUIRE(errors.at(1).errorMessage == "User callback failed.");
- REQUIRE(errors.at(1).code == sysrepo::ErrorCode::CallbackFailed);
+ REQUIRE(errors.size() == 1);
+ REQUIRE(errors.at(0) == sysrepo::ErrorInfo{
+ .code = sysrepo::ErrorCode::OperationFailed,
+ .errorMessage = message
+ });
}
// The callback does not fail the second time.
@@ -548,15 +548,11 @@ TEST_CASE("subscriptions")
sess.setItem("/test_module:leafInt32", "123");
REQUIRE_THROWS_AS(sess.applyChanges(), sysrepo::ErrorWithCode);
auto errors = sess.getErrors();
- REQUIRE(errors.size() == 2);
+ REQUIRE(errors.size() == 1);
REQUIRE(errors.at(0) == sysrepo::ErrorInfo{
.code = sysrepo::ErrorCode::OperationFailed,
.errorMessage = "Test callback failure.",
});
- REQUIRE(errors.at(1) == sysrepo::ErrorInfo{
- .code = sysrepo::ErrorCode::CallbackFailed,
- .errorMessage = "User callback failed."
- });
auto ncErrors = sess.getNetconfErrors();
REQUIRE(ncErrors.size() == 1);
REQUIRE(ncErrors.front() == errToSet);
--
2.43.0
@@ -1,35 +0,0 @@
From a73ffe1a5bbab51a67cf56dd2864c71a29c6685b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Thu, 21 Nov 2024 11:18:25 +0100
Subject: [PATCH 02/20] Fix error message when sr_session_start fails
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Thanks to Jason Patterson for sending a bugreport; its investigation led
to discovering this bug.
See-also: https://github.com/CESNET/rousette/issues/15
Change-Id: I082987d60380f860e08f26b436d0f9db4a231bec
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/Connection.cpp | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/Connection.cpp b/src/Connection.cpp
index de65d64..716fd1a 100644
--- a/src/Connection.cpp
+++ b/src/Connection.cpp
@@ -53,7 +53,7 @@ Session Connection::sessionStart(sysrepo::Datastore datastore)
sr_session_ctx_t* sess;
auto res = sr_session_start(ctx.get(), toDatastore(datastore), &sess);
- throwIfError(res, "Couldn't connect to sysrepo");
+ throwIfError(res, "Couldn't start sysrepo session");
return Session{sess, ctx};
}
--
2.43.0
@@ -1,73 +0,0 @@
From 552a366990fe1fe2cdd8d155fa5cecb3ff3fbc13 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Wed, 4 Dec 2024 15:51:41 +0100
Subject: [PATCH 03/20] We don't support sysrepo v3 yet
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
That version introduced some backward-incompatible changes to the way
how operational data are treated, especially in presence of "discards"
of this data. It's a breaking behavior, and before we release a fix,
let's pin the version to something which still works. The latest sysrepo
semi-release is v 2.12.0, which is unfortunately not tagged.
Change-Id: I3673de9893d6e806926767a72b1ef83ee62bd610
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
.zuul.yaml | 4 ++--
CMakeLists.txt | 2 +-
README.md | 1 +
3 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/.zuul.yaml b/.zuul.yaml
index dc87c6f..c3aea22 100644
--- a/.zuul.yaml
+++ b/.zuul.yaml
@@ -6,7 +6,7 @@
- name: github/CESNET/libyang
override-checkout: devel
- name: github/sysrepo/sysrepo
- override-checkout: devel
+ override-checkout: cesnet/2024-10-before-oper-changes
- name: github/onqtam/doctest
override-checkout: v2.4.8
- name: github/rollbear/trompeloeil
@@ -17,7 +17,7 @@
- name: github/CESNET/libyang
override-checkout: devel
- name: github/sysrepo/sysrepo
- override-checkout: devel
+ override-checkout: cesnet/2024-10-before-oper-changes
- name: github/onqtam/doctest
override-checkout: v2.4.11
- name: github/rollbear/trompeloeil
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 1eb625f..119bdd7 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -28,7 +28,7 @@ option(WITH_EXAMPLES "Build examples" ON)
find_package(PkgConfig)
pkg_check_modules(LIBYANG_CPP REQUIRED libyang-cpp>=3 IMPORTED_TARGET)
-pkg_check_modules(SYSREPO REQUIRED sysrepo>=2.12.0 IMPORTED_TARGET)
+pkg_check_modules(SYSREPO REQUIRED sysrepo>=2.12.0 sysrepo<3 IMPORTED_TARGET)
set(SYSREPO_CPP_PKG_VERSION "3")
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/include)
diff --git a/README.md b/README.md
index 27dc748..e14b772 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@ It uses RAII for automatic memory management.
## Dependencies
- [sysrepo](https://github.com/sysrepo/sysrepo) - the `devel` branch (even for the `master` branch of *sysrepo-cpp*)
+ - we temporarily require pre-v3 sysrepo which introduced backward-incompatible changes to operational data handling
- [libyang-cpp](https://github.com/CESNET/libyang-cpp) - C++ bindings for *libyang*
- C++20 compiler (e.g., GCC 10.x+, clang 10+)
- CMake 3.19+
--
2.43.0
@@ -1,85 +0,0 @@
From f2db30721d909d127645721f4149de0cd36d2b1c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Tue, 17 Dec 2024 11:41:10 +0100
Subject: [PATCH 04/20] refactor: don't use `module` as a variable name
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
...to unify stuff with all other occurrences, and to not use that magic
identifier.
Change-Id: I8c38777a925271e7659560d0380a7f7968f4cfa7
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
include/sysrepo-cpp/Session.hpp | 4 ++--
src/Session.cpp | 12 ++++++------
2 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/include/sysrepo-cpp/Session.hpp b/include/sysrepo-cpp/Session.hpp
index 7da11cb..477e604 100644
--- a/include/sysrepo-cpp/Session.hpp
+++ b/include/sysrepo-cpp/Session.hpp
@@ -95,7 +95,7 @@ public:
void copyConfig(const Datastore source, const std::optional<std::string>& moduleName = std::nullopt, std::chrono::milliseconds timeout = std::chrono::milliseconds{0});
libyang::DataNode sendRPC(libyang::DataNode input, std::chrono::milliseconds timeout = std::chrono::milliseconds{0});
void sendNotification(libyang::DataNode notification, const Wait wait, std::chrono::milliseconds timeout = std::chrono::milliseconds{0});
- void replaceConfig(std::optional<libyang::DataNode> config, const std::optional<std::string>& module = std::nullopt, std::chrono::milliseconds timeout = std::chrono::milliseconds{0});
+ void replaceConfig(std::optional<libyang::DataNode> config, const std::optional<std::string>& moduleName = std::nullopt, std::chrono::milliseconds timeout = std::chrono::milliseconds{0});
void setNacmUser(const std::string& user);
[[nodiscard]] Subscription initNacm(
@@ -172,7 +172,7 @@ public:
*
* Wraps `sr_lock`.
*/
- explicit Lock(Session session, std::optional<std::string> module = std::nullopt, std::optional<std::chrono::milliseconds> timeout = std::nullopt);
+ explicit Lock(Session session, std::optional<std::string> moduleName = std::nullopt, std::optional<std::chrono::milliseconds> timeout = std::nullopt);
/** @brief Release the lock
*
* Wraps `sr_unlock`.
diff --git a/src/Session.cpp b/src/Session.cpp
index 2706e1c..94a0fad 100644
--- a/src/Session.cpp
+++ b/src/Session.cpp
@@ -331,17 +331,17 @@ void Session::sendNotification(libyang::DataNode notification, const Wait wait,
* Wraps `sr_replace_config`.
*
* @param config Libyang tree to use as a complete datastore content, or nullopt
- * @param module If provided, a module name to limit the operation to
+ * @param moduleName If provided, a module name to limit the operation to
* @param timeout Optional timeout to wait for
*/
-void Session::replaceConfig(std::optional<libyang::DataNode> config, const std::optional<std::string>& module, std::chrono::milliseconds timeout)
+void Session::replaceConfig(std::optional<libyang::DataNode> config, const std::optional<std::string>& moduleName, std::chrono::milliseconds timeout)
{
std::optional<libyang::DataNode> thrashable;
if (config) {
thrashable = config->duplicateWithSiblings(libyang::DuplicationOptions::Recursive | libyang::DuplicationOptions::WithParents);
}
auto res = sr_replace_config(
- m_sess.get(), module ? module->c_str() : nullptr,
+ m_sess.get(), moduleName ? moduleName->c_str() : nullptr,
config ? libyang::releaseRawNode(*thrashable) : nullptr,
timeout.count());
throwIfError(res, "sr_replace_config failed", m_sess.get());
@@ -723,12 +723,12 @@ uint32_t Session::getId() const
return sr_session_get_id(m_sess.get());
}
-Lock::Lock(Session session, std::optional<std::string> module, std::optional<std::chrono::milliseconds> timeout)
+Lock::Lock(Session session, std::optional<std::string> moduleName, std::optional<std::chrono::milliseconds> timeout)
: m_session(session)
, m_lockedDs(m_session.activeDatastore())
- , m_module(module)
+ , m_module(moduleName)
{
- auto res = sr_lock(getRawSession(m_session), module ? module->c_str() : nullptr, timeout ? timeout->count() : 0);
+ auto res = sr_lock(getRawSession(m_session), moduleName ? moduleName->c_str() : nullptr, timeout ? timeout->count() : 0);
throwIfError(res, "Cannot lock session", getRawSession(m_session));
}
--
2.43.0
@@ -1,253 +0,0 @@
From a923f490350dc61b9f3e6000c06c1d7950a78a61 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Wed, 18 Dec 2024 16:12:14 +0100
Subject: [PATCH 05/20] DRY: a dummy leaf XPath
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Change-Id: I47fca61d27380af07b2327f3ee5b984a8a8afb66
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
tests/session.cpp | 81 ++++++++++++++++++++++++-----------------------
1 file changed, 41 insertions(+), 40 deletions(-)
diff --git a/tests/session.cpp b/tests/session.cpp
index 5b384bb..f762f10 100644
--- a/tests/session.cpp
+++ b/tests/session.cpp
@@ -20,6 +20,7 @@ TEST_CASE("session")
std::optional<sysrepo::Connection> conn{std::in_place};
auto sess = conn->sessionStart();
sess.copyConfig(sysrepo::Datastore::Startup);
+ const auto leaf = "/test_module:leafInt32"s;
DOCTEST_SUBCASE("Session should be still valid even after the Connection class gets freed")
{
@@ -29,39 +30,39 @@ TEST_CASE("session")
DOCTEST_SUBCASE("Session lifetime is prolonged with data from getData")
{
- sess.setItem("/test_module:leafInt32", "123");
+ sess.setItem(leaf, "123");
sess.applyChanges();
- auto data = sysrepo::Connection{}.sessionStart().getData("/test_module:leafInt32");
+ auto data = sysrepo::Connection{}.sessionStart().getData(leaf);
REQUIRE(data->asTerm().valueStr() == "123");
}
DOCTEST_SUBCASE("basic data manipulation")
{
- auto data = sess.getData("/test_module:leafInt32");
+ auto data = sess.getData(leaf);
REQUIRE(!data);
- sess.setItem("/test_module:leafInt32", "123");
+ sess.setItem(leaf, "123");
sess.applyChanges();
- data = sess.getData("/test_module:leafInt32");
+ data = sess.getData(leaf);
REQUIRE(data->asTerm().valueStr() == "123");
- auto node = sess.getOneNode("/test_module:leafInt32");
+ auto node = sess.getOneNode(leaf);
REQUIRE(node.asTerm().valueStr() == "123");
- sess.setItem("/test_module:leafInt32", "420");
+ sess.setItem(leaf, "420");
sess.applyChanges();
- data = sess.getData("/test_module:leafInt32");
+ data = sess.getData(leaf);
REQUIRE(data->asTerm().valueStr() == "420");
- sess.deleteItem("/test_module:leafInt32");
+ sess.deleteItem(leaf);
sess.applyChanges();
- data = sess.getData("/test_module:leafInt32");
+ data = sess.getData(leaf);
REQUIRE(!data);
- sess.setItem("/test_module:leafInt32", "123");
+ sess.setItem(leaf, "123");
sess.discardChanges();
- data = sess.getData("/test_module:leafInt32");
+ data = sess.getData(leaf);
REQUIRE(!data);
- REQUIRE_THROWS_WITH_AS(sess.getOneNode("/test_module:leafInt32"),
+ REQUIRE_THROWS_WITH_AS(sess.getOneNode(leaf),
"Session::getOneNode: Couldn't get '/test_module:leafInt32': SR_ERR_NOT_FOUND",
sysrepo::ErrorWithCode);
@@ -175,7 +176,7 @@ TEST_CASE("session")
{
sess.switchDatastore(sysrepo::Datastore::Operational);
sess.setItem("/test_module:stateLeaf", "42");
- sess.setItem("/test_module:leafInt32", "1");
+ sess.setItem(leaf, "1");
sess.applyChanges();
DOCTEST_SUBCASE("Default options")
@@ -183,7 +184,7 @@ TEST_CASE("session")
auto data = sess.getData("/test_module:*");
REQUIRE(data);
REQUIRE(data->findPath("/test_module:stateLeaf"));
- REQUIRE(data->findPath("/test_module:leafInt32"));
+ REQUIRE(data->findPath(leaf));
}
DOCTEST_SUBCASE("No state data")
@@ -191,7 +192,7 @@ TEST_CASE("session")
auto data = sess.getData("/test_module:*", 0, sysrepo::GetOptions::OperNoState);
REQUIRE(data);
REQUIRE(!data->findPath("/test_module:stateLeaf"));
- REQUIRE(data->findPath("/test_module:leafInt32"));
+ REQUIRE(data->findPath(leaf));
}
}
@@ -213,42 +214,42 @@ TEST_CASE("session")
DOCTEST_SUBCASE("Session::deleteOperItem")
{
// Set some arbitrary leaf.
- sess.setItem("/test_module:leafInt32", "123");
+ sess.setItem(leaf, "123");
sess.applyChanges();
// The leaf is accesible from the running datastore.
- REQUIRE(sess.getData("/test_module:leafInt32")->asTerm().valueStr() == "123");
+ REQUIRE(sess.getData(leaf)->asTerm().valueStr() == "123");
// The leaf is NOT accesible from the operational datastore without a subscription.
sess.switchDatastore(sysrepo::Datastore::Operational);
- REQUIRE(!sess.getData("/test_module:leafInt32"));
+ REQUIRE(!sess.getData(leaf));
// When we create a subscription, the leaf is accesible from the operational datastore.
sess.switchDatastore(sysrepo::Datastore::Running);
auto sub = sess.onModuleChange("test_module", [] (auto, auto, auto, auto, auto, auto) { return sysrepo::ErrorCode::Ok; });
sess.switchDatastore(sysrepo::Datastore::Operational);
- REQUIRE(sess.getData("/test_module:leafInt32")->asTerm().valueStr() == "123");
+ REQUIRE(sess.getData(leaf)->asTerm().valueStr() == "123");
// After using deleteItem, the leaf is no longer accesible from the operational datastore.
- sess.deleteItem("/test_module:leafInt32");
+ sess.deleteItem(leaf);
sess.applyChanges();
- REQUIRE(!sess.getData("/test_module:leafInt32"));
+ REQUIRE(!sess.getData(leaf));
// Using discardItems makes the leaf visible again (in the operational datastore).
- sess.discardItems("/test_module:leafInt32");
+ sess.discardItems(leaf);
sess.applyChanges();
- REQUIRE(sess.getData("/test_module:leafInt32")->asTerm().valueStr() == "123");
+ REQUIRE(sess.getData(leaf)->asTerm().valueStr() == "123");
}
DOCTEST_SUBCASE("edit batch")
{
- auto data = sess.getData("/test_module:leafInt32");
+ auto data = sess.getData(leaf);
REQUIRE(!data);
- auto batch = sess.getContext().newPath("/test_module:leafInt32", "1230");
+ auto batch = sess.getContext().newPath(leaf, "1230");
sess.editBatch(batch, sysrepo::DefaultOperation::Merge);
sess.applyChanges();
- data = sess.getData("/test_module:leafInt32");
+ data = sess.getData(leaf);
REQUIRE(data->asTerm().valueStr() == "1230");
}
@@ -307,8 +308,8 @@ TEST_CASE("session")
DOCTEST_SUBCASE("Session::getPendingChanges")
{
REQUIRE(sess.getPendingChanges() == std::nullopt);
- sess.setItem("/test_module:leafInt32", "123");
- REQUIRE(sess.getPendingChanges().value().findPath("/test_module:leafInt32")->asTerm().valueStr() == "123");
+ sess.setItem(leaf, "123");
+ REQUIRE(sess.getPendingChanges().value().findPath(leaf)->asTerm().valueStr() == "123");
DOCTEST_SUBCASE("apply")
{
@@ -328,7 +329,7 @@ TEST_CASE("session")
sess.switchDatastore(sysrepo::Datastore::FactoryDefault);
auto data = sess.getData("/*");
REQUIRE(*data->printStr(libyang::DataFormat::JSON, libyang::PrintFlags::WithSiblings) == "{\n\n}\n");
- REQUIRE_THROWS_AS(sess.setItem("/test_module:leafInt32", "123"), sysrepo::ErrorWithCode);
+ REQUIRE_THROWS_AS(sess.setItem(leaf, "123"), sysrepo::ErrorWithCode);
}
DOCTEST_SUBCASE("session IDs")
@@ -391,9 +392,9 @@ TEST_CASE("session")
DOCTEST_SUBCASE("replace config")
{
- REQUIRE(!sess.getData("/test_module:leafInt32"));
+ REQUIRE(!sess.getData(leaf));
// some "reasonable data" for two modules
- sess.setItem("/test_module:leafInt32", "666");
+ sess.setItem(leaf, "666");
sess.setItem("/ietf-netconf-acm:nacm/groups/group[name='ahoj']/user-name[.='foo']", "");
sess.applyChanges();
@@ -401,16 +402,16 @@ TEST_CASE("session")
REQUIRE(!!conf);
// override a single leaf
- REQUIRE(sess.getOneNode("/test_module:leafInt32").asTerm().valueStr() == "666");
- sess.setItem("/test_module:leafInt32", "123");
+ REQUIRE(sess.getOneNode(leaf).asTerm().valueStr() == "666");
+ sess.setItem(leaf, "123");
sess.setItem("/ietf-netconf-acm:nacm/groups/group[name='ahoj']/user-name[.='bar']", "");
sess.applyChanges();
- REQUIRE(sess.getOneNode("/test_module:leafInt32").asTerm().valueStr() == "123");
+ REQUIRE(sess.getOneNode(leaf).asTerm().valueStr() == "123");
DOCTEST_SUBCASE("this module empty config")
{
sess.replaceConfig(std::nullopt, "test_module");
- REQUIRE(!sess.getData("/test_module:leafInt32"));
+ REQUIRE(!sess.getData(leaf));
REQUIRE(sess.getOneNode("/ietf-netconf-acm:nacm/groups/group[name='ahoj']/user-name[.='foo']").asTerm().valueStr() == "foo");
REQUIRE(sess.getOneNode("/ietf-netconf-acm:nacm/groups/group[name='ahoj']/user-name[.='bar']").asTerm().valueStr() == "bar");
}
@@ -418,7 +419,7 @@ TEST_CASE("session")
DOCTEST_SUBCASE("this module")
{
sess.replaceConfig(conf, "test_module");
- REQUIRE(sess.getOneNode("/test_module:leafInt32").asTerm().valueStr() == "666");
+ REQUIRE(sess.getOneNode(leaf).asTerm().valueStr() == "666");
REQUIRE(sess.getOneNode("/ietf-netconf-acm:nacm/groups/group[name='ahoj']/user-name[.='foo']").asTerm().valueStr() == "foo");
REQUIRE(sess.getOneNode("/ietf-netconf-acm:nacm/groups/group[name='ahoj']/user-name[.='bar']").asTerm().valueStr() == "bar");
}
@@ -426,7 +427,7 @@ TEST_CASE("session")
DOCTEST_SUBCASE("other module")
{
sess.replaceConfig(std::nullopt, "ietf-netconf-acm");
- REQUIRE(sess.getOneNode("/test_module:leafInt32").asTerm().valueStr() == "123");
+ REQUIRE(sess.getOneNode(leaf).asTerm().valueStr() == "123");
REQUIRE(!sess.getData("/ietf-netconf-acm:nacm/groups/group[name='ahoj']/user-name[.='foo']"));
REQUIRE(!sess.getData("/ietf-netconf-acm:nacm/groups/group[name='ahoj']/user-name[.='bar']"));
}
@@ -434,7 +435,7 @@ TEST_CASE("session")
DOCTEST_SUBCASE("entire datastore empty config")
{
sess.replaceConfig(std::nullopt);
- REQUIRE(!sess.getData("/test_module:leafInt32"));
+ REQUIRE(!sess.getData(leaf));
REQUIRE(!sess.getData("/ietf-netconf-acm:nacm/groups/group[name='ahoj']/user-name[.='foo']"));
REQUIRE(!sess.getData("/ietf-netconf-acm:nacm/groups/group[name='ahoj']/user-name[.='bar']"));
}
@@ -442,7 +443,7 @@ TEST_CASE("session")
DOCTEST_SUBCASE("entire datastore")
{
sess.replaceConfig(conf);
- REQUIRE(sess.getOneNode("/test_module:leafInt32").asTerm().valueStr() == "666");
+ REQUIRE(sess.getOneNode(leaf).asTerm().valueStr() == "666");
REQUIRE(sess.getOneNode("/ietf-netconf-acm:nacm/groups/group[name='ahoj']/user-name[.='foo']").asTerm().valueStr() == "foo");
REQUIRE(!sess.getData("/ietf-netconf-acm:nacm/groups/group[name='ahoj']/user-name[.='bar']"));
}
--
2.43.0
@@ -1,42 +0,0 @@
From 80e9659b41ea1de2e20d62f94319fe5af26adcee Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Wed, 18 Dec 2024 17:57:36 +0100
Subject: [PATCH 06/20] build: a single place to define package version
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Don't ask me how I found out that I need this :).
Change-Id: I2cd7397895ed4852f852e99b97543dde76eaff8f
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
CMakeLists.txt | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 119bdd7..68ade53 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -20,7 +20,8 @@ add_custom_target(sysrepo-cpp-version-cmake
cmake/ProjectGitVersionRunner.cmake
)
include(cmake/ProjectGitVersion.cmake)
-prepare_git_version(SYSREPO_CPP_VERSION "3")
+set(SYSREPO_CPP_PKG_VERSION "3")
+prepare_git_version(SYSREPO_CPP_VERSION ${SYSREPO_CPP_PKG_VERSION})
find_package(Doxygen)
option(WITH_DOCS "Create and install internal documentation (needs Doxygen)" ${DOXYGEN_FOUND})
@@ -29,7 +30,6 @@ option(WITH_EXAMPLES "Build examples" ON)
find_package(PkgConfig)
pkg_check_modules(LIBYANG_CPP REQUIRED libyang-cpp>=3 IMPORTED_TARGET)
pkg_check_modules(SYSREPO REQUIRED sysrepo>=2.12.0 sysrepo<3 IMPORTED_TARGET)
-set(SYSREPO_CPP_PKG_VERSION "3")
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/include)
--
2.43.0
@@ -1,357 +0,0 @@
From 32bdd8bcad04f890fe724fb6cc2fc74f9b982576 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Wed, 18 Dec 2024 17:16:55 +0100
Subject: [PATCH 07/20] API/ABI break: Change how pushed ops data work
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Upstream has changed the way how pushed operational data are stored and
managed. From now on, each session has a separate edit, these edits are
applied on top of each other using a given priority (priority setting is
not implemented in the C++ wrapper at this time, patches welcome). Each
session can retrieve and manipulate the stored edit.
The key difference is that these edits are now per-session. This has a
wide-ranging impact, and the way how previously set nodes are "undone"
has changed. Unfortunately, upstream refused to rename the relevant
functions due to "API stability concerns", so we're taking care of
avoiding the possible confusion at the C++ layer:
- `Session::dropForeignOperationalContent` (aka `sr_discard_items`),
which is now used to ensure that matching content from "previous
sources" is discarded. These "previous sources" are either content of
`running`, or stuff that was stored/pushed by other sessions with
lower priority. It *cannot* be used to remove stuff that was
previously pushed by the current session.
- `Session::discardOperationalChanges` (aka `sr_discard_oper_changes`)
discards previously pushed content from *this session*.
- It is now possible to fully manage the edit (which incrementally
builds the `operational` DS) of the current session. Use
`Session::operationalChanges()` to retrieve a full libyang::DataNode
forest, modify it in whichever ways are needed, and store it back via
`Session::editBatch(..., sysrepo::DefaultOperation::Replace)` followed
by `Session::applyChanges()`.
Change-Id: Iba05a88411fd4c47c03d8c2b48cb7aadfd5dcd2a
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
.zuul.yaml | 4 +-
CMakeLists.txt | 4 +-
README.md | 1 -
include/sysrepo-cpp/Session.hpp | 6 ++-
src/Session.cpp | 96 ++++++++++++++++++++++++---------
tests/session.cpp | 59 ++++++++++++++++----
6 files changed, 128 insertions(+), 42 deletions(-)
diff --git a/.zuul.yaml b/.zuul.yaml
index c3aea22..70c5e49 100644
--- a/.zuul.yaml
+++ b/.zuul.yaml
@@ -6,7 +6,7 @@
- name: github/CESNET/libyang
override-checkout: devel
- name: github/sysrepo/sysrepo
- override-checkout: cesnet/2024-10-before-oper-changes
+ override-checkout: cesnet/2025-01-30--00
- name: github/onqtam/doctest
override-checkout: v2.4.8
- name: github/rollbear/trompeloeil
@@ -17,7 +17,7 @@
- name: github/CESNET/libyang
override-checkout: devel
- name: github/sysrepo/sysrepo
- override-checkout: cesnet/2024-10-before-oper-changes
+ override-checkout: cesnet/2025-01-30--00
- name: github/onqtam/doctest
override-checkout: v2.4.11
- name: github/rollbear/trompeloeil
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 68ade53..9e09d3d 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -20,7 +20,7 @@ add_custom_target(sysrepo-cpp-version-cmake
cmake/ProjectGitVersionRunner.cmake
)
include(cmake/ProjectGitVersion.cmake)
-set(SYSREPO_CPP_PKG_VERSION "3")
+set(SYSREPO_CPP_PKG_VERSION "4")
prepare_git_version(SYSREPO_CPP_VERSION ${SYSREPO_CPP_PKG_VERSION})
find_package(Doxygen)
@@ -29,7 +29,7 @@ option(WITH_EXAMPLES "Build examples" ON)
find_package(PkgConfig)
pkg_check_modules(LIBYANG_CPP REQUIRED libyang-cpp>=3 IMPORTED_TARGET)
-pkg_check_modules(SYSREPO REQUIRED sysrepo>=2.12.0 sysrepo<3 IMPORTED_TARGET)
+pkg_check_modules(SYSREPO REQUIRED sysrepo>=3.4.7 IMPORTED_TARGET)
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/include)
diff --git a/README.md b/README.md
index e14b772..27dc748 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,6 @@ It uses RAII for automatic memory management.
## Dependencies
- [sysrepo](https://github.com/sysrepo/sysrepo) - the `devel` branch (even for the `master` branch of *sysrepo-cpp*)
- - we temporarily require pre-v3 sysrepo which introduced backward-incompatible changes to operational data handling
- [libyang-cpp](https://github.com/CESNET/libyang-cpp) - C++ bindings for *libyang*
- C++20 compiler (e.g., GCC 10.x+, clang 10+)
- CMake 3.19+
diff --git a/include/sysrepo-cpp/Session.hpp b/include/sysrepo-cpp/Session.hpp
index 477e604..238f480 100644
--- a/include/sysrepo-cpp/Session.hpp
+++ b/include/sysrepo-cpp/Session.hpp
@@ -85,13 +85,15 @@ public:
void setItem(const std::string& path, const std::optional<std::string>& value, const EditOptions opts = sysrepo::EditOptions::Default);
void editBatch(libyang::DataNode edit, const DefaultOperation op);
void deleteItem(const std::string& path, const EditOptions opts = sysrepo::EditOptions::Default);
- void discardItems(const std::optional<std::string>& xpath);
void moveItem(const std::string& path, const MovePosition move, const std::optional<std::string>& keys_or_value, const std::optional<std::string>& origin = std::nullopt, const EditOptions opts = sysrepo::EditOptions::Default);
std::optional<libyang::DataNode> getData(const std::string& path, int maxDepth = 0, const GetOptions opts = sysrepo::GetOptions::Default, std::chrono::milliseconds timeout = std::chrono::milliseconds{0}) const;
libyang::DataNode getOneNode(const std::string& path, std::chrono::milliseconds timeout = std::chrono::milliseconds{0}) const;
std::optional<const libyang::DataNode> getPendingChanges() const;
void applyChanges(std::chrono::milliseconds timeout = std::chrono::milliseconds{0});
- void discardChanges();
+ void discardChanges(const std::optional<std::string>& xpath = std::nullopt);
+ std::optional<libyang::DataNode> operationalChanges(const std::optional<std::string>& moduleName = std::nullopt) const;
+ void discardOperationalChanges(const std::optional<std::string>& moduleName = std::nullopt, std::chrono::milliseconds timeout = std::chrono::milliseconds{0});
+ void dropForeignOperationalContent(const std::optional<std::string>& xpath);
void copyConfig(const Datastore source, const std::optional<std::string>& moduleName = std::nullopt, std::chrono::milliseconds timeout = std::chrono::milliseconds{0});
libyang::DataNode sendRPC(libyang::DataNode input, std::chrono::milliseconds timeout = std::chrono::milliseconds{0});
void sendNotification(libyang::DataNode notification, const Wait wait, std::chrono::milliseconds timeout = std::chrono::milliseconds{0});
diff --git a/src/Session.cpp b/src/Session.cpp
index 94a0fad..634e450 100644
--- a/src/Session.cpp
+++ b/src/Session.cpp
@@ -23,6 +23,26 @@ extern "C" {
using namespace std::string_literals;
namespace sysrepo {
+
+namespace {
+libyang::DataNode wrapSrData(std::shared_ptr<sr_session_ctx_s> sess, sr_data_t* data)
+{
+ // Since the lyd_node came from sysrepo and it is wrapped in a sr_data_t, we have to postpone calling the
+ // sr_release_data() until after we're "done" with the libyang::DataNode.
+ //
+ // Normally, sr_release_data() would free the lyd_data as well. However, it is possible that the user wants to
+ // manipulate the data tree (think unlink()) in a way which might have needed to overwrite the tree->data pointer.
+ // Just delegate all the freeing to the C++ wrapper around lyd_data. The sysrepo library doesn't care about this.
+ auto tree = std::exchange(data->tree, nullptr);
+
+ // Use wrapRawNode, not wrapUnmanagedRawNode because we want to let the C++ wrapper manage memory.
+ // Note: We're capturing the session inside the lambda.
+ return libyang::wrapRawNode(tree, std::shared_ptr<sr_data_t>(data, [extend_session_lifetime = sess] (sr_data_t* data) {
+ sr_release_data(data);
+ }));
+}
+}
+
/**
* Wraps a pointer to sr_session_ctx_s and manages the lifetime of it. Also extends the lifetime of the connection
* specified by the `conn` argument.
@@ -119,20 +139,61 @@ void Session::deleteItem(const std::string& path, const EditOptions opts)
}
/**
- * Prepare to discard nodes matching the specified xpath (or all if not set) previously set by the session connection.
- * Usable only for sysrepo::Datastore::Operational. The changes are applied only after calling Session::applyChanges.
+ * Prepare to drop "earlier content" from other sources in the operational DS for nodes matching the specified XPath
+ *
+ * The "earlier content" might come from the `running` datastore, or be pushed into the `operational` DS from
+ * another session, with a lower priority. This function prepares a special node into the current session's
+ * stored edit which effectively discards any matching content from previous, lower-priority sources.
+ *
+ * This function cannot be used to remove an edit which was pushed via the current session. To do that,
+ * use discardChanges(), or retrieve the stored edit and manipulate its libyang data tree.
+ *
+ * The changes are applied only after calling Session::applyChanges.
*
* Wraps `sr_discard_items`.
*
* @param xpath Expression filtering the nodes to discard, nullopt for all nodes.
*/
-void Session::discardItems(const std::optional<std::string>& xpath)
+void Session::dropForeignOperationalContent(const std::optional<std::string>& xpath)
{
auto res = sr_discard_items(m_sess.get(), xpath ? xpath->c_str() : nullptr);
throwIfError(res, "Session::discardItems: Can't discard "s + (xpath ? "'"s + *xpath + "'" : "all nodes"s), m_sess.get());
}
+/** @short Get a copy of the stored push-operational data for this session
+ *
+ * To modify the stored push operational data, modify this tree in-place and pass it to editBatch()
+ * with the "replace" operation.
+ *
+ * Wraps `sr_get_oper_changes`.
+ */
+std::optional<libyang::DataNode> Session::operationalChanges(const std::optional<std::string>& moduleName) const
+{
+ sr_data_t* data;
+ auto res = sr_get_oper_changes(m_sess.get(), moduleName ? moduleName->c_str() : nullptr, &data);
+
+ throwIfError(res, "Session::operationalChanges: Couldn't retrieve data'"s + (moduleName ? " for \"" + *moduleName + '"' : ""s), m_sess.get());
+
+ if (!data) {
+ return std::nullopt;
+ }
+
+ return wrapSrData(m_sess, data);
+
+}
+
+/** @short Discard push operational changes of a module for this session
+ *
+ * Wraps `sr_discard_oper_changes`.
+ *
+ * */
+void Session::discardOperationalChanges(const std::optional<std::string>& moduleName, std::chrono::milliseconds timeout)
+{
+ auto res = sr_discard_oper_changes(nullptr, m_sess.get(), moduleName ? nullptr : moduleName->c_str(), timeout.count());
+ throwIfError(res, "Session::discardOoperationalChanges: Couldn't discard "s + (moduleName ? "for module \"" + *moduleName + "\"" : "globally"s), m_sess.get());
+}
+
/**
* Moves item (a list or a leaf-list) specified by `path`.
* @param path Node to move.
@@ -155,25 +216,6 @@ void Session::moveItem(const std::string& path, const MovePosition move, const s
throwIfError(res, "Session::moveItem: Can't move '"s + path + "'", m_sess.get());
}
-namespace {
-libyang::DataNode wrapSrData(std::shared_ptr<sr_session_ctx_s> sess, sr_data_t* data)
-{
- // Since the lyd_node came from sysrepo and it is wrapped in a sr_data_t, we have to postpone calling the
- // sr_release_data() until after we're "done" with the libyang::DataNode.
- //
- // Normally, sr_release_data() would free the lyd_data as well. However, it is possible that the user wants to
- // manipulate the data tree (think unlink()) in a way which might have needed to overwrite the tree->data pointer.
- // Just delegate all the freeing to the C++ wrapper around lyd_data. The sysrepo library doesn't care about this.
- auto tree = std::exchange(data->tree, nullptr);
-
- // Use wrapRawNode, not wrapUnmanagedRawNode because we want to let the C++ wrapper manage memory.
- // Note: We're capturing the session inside the lambda.
- return libyang::wrapRawNode(tree, std::shared_ptr<sr_data_t>(data, [extend_session_lifetime = sess] (sr_data_t* data) {
- sr_release_data(data);
- }));
-}
-}
-
/**
* @brief Returns a tree which contains all nodes that match the provided XPath.
*
@@ -263,13 +305,15 @@ void Session::applyChanges(std::chrono::milliseconds timeout)
}
/**
- * Discards changes made in this Session.
+ * Discards changes made earlier in this Session, optionally only below a given XPath
+ *
+ * The changes are applied only after calling Session::applyChanges.
*
- * Wraps `sr_discard_changes`.
+ * Wraps `sr_discard_changes_xpath`.
*/
-void Session::discardChanges()
+void Session::discardChanges(const std::optional<std::string>& xpath)
{
- auto res = sr_discard_changes(m_sess.get());
+ auto res = sr_discard_changes_xpath(m_sess.get(), xpath ? xpath->c_str() : nullptr);
throwIfError(res, "Session::discardChanges: Couldn't discard changes", m_sess.get());
}
diff --git a/tests/session.cpp b/tests/session.cpp
index f762f10..2637f67 100644
--- a/tests/session.cpp
+++ b/tests/session.cpp
@@ -211,7 +211,7 @@ TEST_CASE("session")
}
}
- DOCTEST_SUBCASE("Session::deleteOperItem")
+ DOCTEST_SUBCASE("push operational data and deleting stuff")
{
// Set some arbitrary leaf.
sess.setItem(leaf, "123");
@@ -230,15 +230,51 @@ TEST_CASE("session")
sess.switchDatastore(sysrepo::Datastore::Operational);
REQUIRE(sess.getData(leaf)->asTerm().valueStr() == "123");
- // After using deleteItem, the leaf is no longer accesible from the operational datastore.
- sess.deleteItem(leaf);
- sess.applyChanges();
- REQUIRE(!sess.getData(leaf));
+ DOCTEST_SUBCASE("discardOperationalChanges")
+ {
+ // apply a change which makes the leaf disappear
+ sess.dropForeignOperationalContent(leaf);
+ REQUIRE(!!sess.getData(leaf));
+ sess.applyChanges();
+ REQUIRE(!sess.getData(leaf));
- // Using discardItems makes the leaf visible again (in the operational datastore).
- sess.discardItems(leaf);
- sess.applyChanges();
- REQUIRE(sess.getData(leaf)->asTerm().valueStr() == "123");
+ // Using discardOperationalChanges makes the leaf visible again (in the operational datastore).
+ // Also, no need to applyChanges().
+ sess.discardOperationalChanges("test_module");
+ REQUIRE(sess.getData(leaf)->asTerm().valueStr() == "123");
+ }
+
+ DOCTEST_SUBCASE("direct edit of a libyang::DataNode")
+ {
+ // at first, set the leaf to some random value
+ sess.setItem(leaf, "456");
+ sess.applyChanges();
+ REQUIRE(sess.getData(leaf)->asTerm().valueStr() == "456");
+
+ // change the edit in-place
+ auto pushed = sess.operationalChanges();
+ REQUIRE(pushed->path() == leaf);
+ pushed->asTerm().changeValue("666");
+ sess.editBatch(*pushed, sysrepo::DefaultOperation::Replace);
+ sess.applyChanges();
+ REQUIRE(sess.getData(leaf)->asTerm().valueStr() == "666");
+
+ // Remove that previous edit in-place. Since the new edit cannot be empty, set some other leaf.
+ pushed = sess.operationalChanges();
+ auto another = "/test_module:popelnice/s"s;
+ pushed->newPath(another, "xxx");
+ pushed = *pushed->findPath(another);
+ pushed->findPath(leaf)->unlink();
+ // "the edit" for sysrepo must refer to a top-level node
+ while (pushed->parent()) {
+ pushed = *pushed->parent();
+ }
+ REQUIRE(!pushed->findPath(leaf));
+ REQUIRE(!!pushed->findPath(another));
+ sess.editBatch(*pushed, sysrepo::DefaultOperation::Replace);
+ sess.applyChanges();
+ REQUIRE(sess.getData(leaf)->asTerm().valueStr() == "123");
+ }
}
DOCTEST_SUBCASE("edit batch")
@@ -321,6 +357,11 @@ TEST_CASE("session")
sess.discardChanges();
}
+ DOCTEST_SUBCASE("discard XPath")
+ {
+ sess.discardChanges(leaf);
+ }
+
REQUIRE(sess.getPendingChanges() == std::nullopt);
}
--
2.43.0
@@ -1,131 +0,0 @@
From 27dc076cbdff3c9edf00abb05bdce59d1944988b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Fri, 31 Jan 2025 17:54:06 +0100
Subject: [PATCH 08/20] upstream break: sr_rpc_send_tree's `output` might be
NULL now
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
As of upstream commit 927fd5221bd28ff50cc1ee7c2cb5c5f30b34c2c9, sysrepo
can now return a nullptr when the resulting tree is empty. This appears
to have been done in response to our bugreport where we pointed out that
`netopeer2-server` started crashing on empty RPCs. However, it's unclear
why this necessitated a change in sysrepo, given that this feature has
worked well for years.
Anyway, let's propagate this through the C++ layer; what else can I do,
after all.
Bug: https://github.com/CESNET/netopeer2/issues/1695
Change-Id: I646d19f7448d98f0d21e9eec006f28fbb5e479fc
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
.zuul.yaml | 4 ++--
CMakeLists.txt | 4 ++--
include/sysrepo-cpp/Session.hpp | 2 +-
src/Session.cpp | 6 ++++--
tests/subscriptions.cpp | 7 ++++---
5 files changed, 13 insertions(+), 10 deletions(-)
diff --git a/.zuul.yaml b/.zuul.yaml
index 70c5e49..dc87c6f 100644
--- a/.zuul.yaml
+++ b/.zuul.yaml
@@ -6,7 +6,7 @@
- name: github/CESNET/libyang
override-checkout: devel
- name: github/sysrepo/sysrepo
- override-checkout: cesnet/2025-01-30--00
+ override-checkout: devel
- name: github/onqtam/doctest
override-checkout: v2.4.8
- name: github/rollbear/trompeloeil
@@ -17,7 +17,7 @@
- name: github/CESNET/libyang
override-checkout: devel
- name: github/sysrepo/sysrepo
- override-checkout: cesnet/2025-01-30--00
+ override-checkout: devel
- name: github/onqtam/doctest
override-checkout: v2.4.11
- name: github/rollbear/trompeloeil
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 9e09d3d..85ff84c 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -20,7 +20,7 @@ add_custom_target(sysrepo-cpp-version-cmake
cmake/ProjectGitVersionRunner.cmake
)
include(cmake/ProjectGitVersion.cmake)
-set(SYSREPO_CPP_PKG_VERSION "4")
+set(SYSREPO_CPP_PKG_VERSION "5")
prepare_git_version(SYSREPO_CPP_VERSION ${SYSREPO_CPP_PKG_VERSION})
find_package(Doxygen)
@@ -29,7 +29,7 @@ option(WITH_EXAMPLES "Build examples" ON)
find_package(PkgConfig)
pkg_check_modules(LIBYANG_CPP REQUIRED libyang-cpp>=3 IMPORTED_TARGET)
-pkg_check_modules(SYSREPO REQUIRED sysrepo>=3.4.7 IMPORTED_TARGET)
+pkg_check_modules(SYSREPO REQUIRED sysrepo>=3.4.8 IMPORTED_TARGET)
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/include)
diff --git a/include/sysrepo-cpp/Session.hpp b/include/sysrepo-cpp/Session.hpp
index 238f480..6e31fa2 100644
--- a/include/sysrepo-cpp/Session.hpp
+++ b/include/sysrepo-cpp/Session.hpp
@@ -95,7 +95,7 @@ public:
void discardOperationalChanges(const std::optional<std::string>& moduleName = std::nullopt, std::chrono::milliseconds timeout = std::chrono::milliseconds{0});
void dropForeignOperationalContent(const std::optional<std::string>& xpath);
void copyConfig(const Datastore source, const std::optional<std::string>& moduleName = std::nullopt, std::chrono::milliseconds timeout = std::chrono::milliseconds{0});
- libyang::DataNode sendRPC(libyang::DataNode input, std::chrono::milliseconds timeout = std::chrono::milliseconds{0});
+ std::optional<libyang::DataNode> sendRPC(libyang::DataNode input, std::chrono::milliseconds timeout = std::chrono::milliseconds{0});
void sendNotification(libyang::DataNode notification, const Wait wait, std::chrono::milliseconds timeout = std::chrono::milliseconds{0});
void replaceConfig(std::optional<libyang::DataNode> config, const std::optional<std::string>& moduleName = std::nullopt, std::chrono::milliseconds timeout = std::chrono::milliseconds{0});
diff --git a/src/Session.cpp b/src/Session.cpp
index 634e450..5df04bc 100644
--- a/src/Session.cpp
+++ b/src/Session.cpp
@@ -343,13 +343,15 @@ void Session::copyConfig(const Datastore source, const std::optional<std::string
* @param input Libyang tree representing the RPC/action.
* @param timeout Optional timeout.
*/
-libyang::DataNode Session::sendRPC(libyang::DataNode input, std::chrono::milliseconds timeout)
+std::optional<libyang::DataNode> Session::sendRPC(libyang::DataNode input, std::chrono::milliseconds timeout)
{
sr_data_t* output;
auto res = sr_rpc_send_tree(m_sess.get(), libyang::getRawNode(input), timeout.count(), &output);
throwIfError(res, "Couldn't send RPC", m_sess.get());
- assert(output); // TODO: sysrepo always gives the RPC node? (even when it has not output or output nodes?)
+ if (!output) {
+ return std::nullopt;
+ }
return wrapSrData(m_sess, output);
}
diff --git a/tests/subscriptions.cpp b/tests/subscriptions.cpp
index 36fd61c..6fc9cbb 100644
--- a/tests/subscriptions.cpp
+++ b/tests/subscriptions.cpp
@@ -420,9 +420,10 @@ TEST_CASE("subscriptions")
if (ret == sysrepo::ErrorCode::Ok) {
auto output = sess.sendRPC(sess.getContext().newPath(rpcPath));
if (setFunction) {
- REQUIRE(output.findPath("/test_module:shutdown/success", libyang::InputOutputNodes::Output));
- } else {
- REQUIRE(!output.findPath("/test_module:shutdown/success", libyang::InputOutputNodes::Output).has_value());
+ REQUIRE(!!output);
+ REQUIRE(output->findPath("/test_module:shutdown/success", libyang::InputOutputNodes::Output));
+ } else if (output) {
+ REQUIRE(!output->findPath("/test_module:shutdown/success", libyang::InputOutputNodes::Output).has_value());
}
} else {
REQUIRE_THROWS(sess.sendRPC(sess.getContext().newPath(rpcPath)));
--
2.43.0
@@ -1,83 +0,0 @@
From dc8facb5befbd17f2848098bbc9267c9d9f58e2d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Mon, 10 Mar 2025 12:08:47 +0100
Subject: [PATCH 09/20] tests: adapt to upstream breaking change in sr_get_data
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
It seems that since upstream commit ba8897ce3 [1] the max_depth
parameter started behaving differently. It now behaves as the
documentation [2] states, i.e:
Maximum depth of the selected subtrees. 0 is unlimited, 1 will not
return any descendant nodes. If a list should be returned, its keys
are always returned as well.
The previous behaviour was probably wrong. When reported via slack,
another fix was issued [3], so let's depend on that as well.
[1] https://github.com/sysrepo/sysrepo/commit/ba8897ce3da0bc8203d5b964851fd60fb164760d
[2] https://netopeer.liberouter.org/doc/sysrepo/devel/html/group__get__data__api.html
[3] https://github.com/sysrepo/sysrepo/commit/309b34d28cc09771cf488d04478161f684f4b8a7
Change-Id: I337dc1f1f77b9501dda29931846116050123bcad
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
CMakeLists.txt | 2 +-
tests/session.cpp | 11 +++++++++--
2 files changed, 10 insertions(+), 3 deletions(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 85ff84c..fb132a2 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -29,7 +29,7 @@ option(WITH_EXAMPLES "Build examples" ON)
find_package(PkgConfig)
pkg_check_modules(LIBYANG_CPP REQUIRED libyang-cpp>=3 IMPORTED_TARGET)
-pkg_check_modules(SYSREPO REQUIRED sysrepo>=3.4.8 IMPORTED_TARGET)
+pkg_check_modules(SYSREPO REQUIRED sysrepo>=3.6.5 IMPORTED_TARGET)
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/include)
diff --git a/tests/session.cpp b/tests/session.cpp
index 2637f67..5be0d79 100644
--- a/tests/session.cpp
+++ b/tests/session.cpp
@@ -127,6 +127,13 @@ TEST_CASE("session")
data = sess.getData("/test_module:popelnice", 1);
REQUIRE(data);
REQUIRE(*data->printStr(libyang::DataFormat::JSON, libyang::PrintFlags::KeepEmptyCont) == R"({
+ "test_module:popelnice": {}
+}
+)");
+
+ data = sess.getData("/test_module:popelnice", 2);
+ REQUIRE(data);
+ REQUIRE(*data->printStr(libyang::DataFormat::JSON, libyang::PrintFlags::KeepEmptyCont) == R"({
"test_module:popelnice": {
"content": {}
}
@@ -134,7 +141,7 @@ TEST_CASE("session")
)");
// If a list should be returned, its keys are always returned as well.
- data = sess.getData("/test_module:popelnice", 2);
+ data = sess.getData("/test_module:popelnice", 3);
REQUIRE(data);
REQUIRE(*data->printStr(libyang::DataFormat::JSON, libyang::PrintFlags::KeepEmptyCont) == R"({
"test_module:popelnice": {
@@ -152,7 +159,7 @@ TEST_CASE("session")
}
)");
- data = sess.getData("/test_module:popelnice", 3);
+ data = sess.getData("/test_module:popelnice", 4);
REQUIRE(data);
REQUIRE(*data->printStr(libyang::DataFormat::JSON, libyang::PrintFlags::KeepEmptyCont) == R"({
"test_module:popelnice": {
--
2.43.0
@@ -1,222 +0,0 @@
From 6ed5442db482a9a37128add02778aebd096f9207 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Fri, 14 Mar 2025 16:20:09 +0100
Subject: [PATCH 10/20] Utilities for working with existing
sysrepo:discard-items nodes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
findMatchingDiscard() and findMatchingDiscardPrefixes() are useful for
figuring out which nodes need to be prune when adding/modifying a
discarding node.
unlinkFromForest() is useful for removing "a node" from a forest of data
node trees. Sometimes the node to be deleted *could* be a root node
which is also the first sibling among its collection of siblings, and in
that case some extra care is needed to ensure that we still have a
reference to something after we've done with the unlinking.
In the real world this doesn't happen that often because there usually
is something like a top-level container node below which we're
processing these discards. Still, let's put this utility function into a
single place.
Depends-on: https://gerrit.cesnet.cz/c/CzechLight/libyang-cpp/+/8436
Change-Id: Ib346be6887f75220a8a6435864c74aef8d5925bb
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
include/sysrepo-cpp/utils/utils.hpp | 3 ++
src/Session.cpp | 3 ++
src/utils/utils.cpp | 57 ++++++++++++++++++++++
tests/session.cpp | 74 +++++++++++++++++++++++++++--
4 files changed, 134 insertions(+), 3 deletions(-)
diff --git a/include/sysrepo-cpp/utils/utils.hpp b/include/sysrepo-cpp/utils/utils.hpp
index 22d7d66..64b1c2e 100644
--- a/include/sysrepo-cpp/utils/utils.hpp
+++ b/include/sysrepo-cpp/utils/utils.hpp
@@ -12,4 +12,7 @@
namespace sysrepo {
Session wrapUnmanagedSession(sr_session_ctx_s* session);
void setLogLevelStderr(const LogLevel);
+std::optional<libyang::DataNodeOpaque> findMatchingDiscard(libyang::DataNode root, const std::string& xpath);
+std::vector<libyang::DataNodeOpaque> findMatchingDiscardPrefixes(libyang::DataNode root, const std::string& xpathPrefix);
+void unlinkFromForest(std::optional<libyang::DataNode>& root, libyang::DataNode node);
}
diff --git a/src/Session.cpp b/src/Session.cpp
index 5df04bc..3c21e84 100644
--- a/src/Session.cpp
+++ b/src/Session.cpp
@@ -153,6 +153,7 @@ void Session::deleteItem(const std::string& path, const EditOptions opts)
* Wraps `sr_discard_items`.
*
* @param xpath Expression filtering the nodes to discard, nullopt for all nodes.
+ * @see findMatchingDiscard()
*/
void Session::dropForeignOperationalContent(const std::optional<std::string>& xpath)
{
@@ -167,6 +168,8 @@ void Session::dropForeignOperationalContent(const std::optional<std::string>& xp
* with the "replace" operation.
*
* Wraps `sr_get_oper_changes`.
+ *
+ * @see findMatchingDiscard()
*/
std::optional<libyang::DataNode> Session::operationalChanges(const std::optional<std::string>& moduleName) const
{
diff --git a/src/utils/utils.cpp b/src/utils/utils.cpp
index 972a4fa..e339e88 100644
--- a/src/utils/utils.cpp
+++ b/src/utils/utils.cpp
@@ -72,4 +72,61 @@ void checkNoThreadFlag(const SubscribeOptions opts, const std::optional<FDHandli
}
}
+/**
+ * @short If there's a sysrepo:discard-items node which matches the given XPath, return it
+ *
+ * @see Session::operationalChanges()
+ * @see Session::dropForeignOperationalContent()
+ */
+std::optional<libyang::DataNode> findMatchingDiscard(libyang::DataNode root, const std::string& xpath)
+{
+ auto discard = root.firstOpaqueSibling();
+ while (discard) {
+ if (discard->name().matches("sysrepo", "discard-items") && discard->value() == xpath) {
+ return discard;
+ }
+ if (auto next = discard->nextSibling()) {
+ discard = next->asOpaque();
+ } else {
+ break;
+ }
+ }
+ return std::nullopt;
+}
+
+/**
+ * @short Find all sysrepo:discard-items nodes which match the given XPath or the descendats of this XPath
+ */
+std::vector<libyang::DataNode> findMatchingDiscardPrefixes(libyang::DataNode root, const std::string& xpathPrefix)
+{
+ auto withSlash = (xpathPrefix.empty() || xpathPrefix[xpathPrefix.size() - 1] == '/') ? xpathPrefix : xpathPrefix + '/';
+ auto withBracket = (xpathPrefix.empty() || xpathPrefix[xpathPrefix.size() - 1] == '[') ? xpathPrefix : xpathPrefix + '[';
+ std::vector<libyang::DataNode> res;
+ auto discard = root.firstOpaqueSibling();
+ while (discard) {
+ if (discard->name().matches("sysrepo", "discard-items")) {
+ if (auto text = discard->value(); text == xpathPrefix || text.starts_with(withSlash) || text.starts_with(withBracket)) {
+ res.emplace_back(*discard);
+ }
+ }
+ if (auto next = discard->nextSibling()) {
+ discard = next->asOpaque();
+ } else {
+ break;
+ }
+ }
+ return res;
+}
+
+/**
+ * @short Remove a node from a forest of tree nodes while modifying the root in-place
+ */
+void unlinkFromForest(std::optional<libyang::DataNode>& root, libyang::DataNode node)
+{
+ if (node == root) {
+ root = node.nextSibling();
+ }
+ node.unlink();
+}
+
}
diff --git a/tests/session.cpp b/tests/session.cpp
index 5be0d79..9a419a1 100644
--- a/tests/session.cpp
+++ b/tests/session.cpp
@@ -245,9 +245,77 @@ TEST_CASE("session")
sess.applyChanges();
REQUIRE(!sess.getData(leaf));
- // Using discardOperationalChanges makes the leaf visible again (in the operational datastore).
- // Also, no need to applyChanges().
- sess.discardOperationalChanges("test_module");
+ // check that a magic sysrepo:discard-items node is in place
+ REQUIRE(!!sess.operationalChanges());
+ auto matchingDiscard = sysrepo::findMatchingDiscard(*sess.operationalChanges(), leaf);
+ REQUIRE(!!matchingDiscard);
+ REQUIRE(matchingDiscard->value() == leaf);
+ REQUIRE(matchingDiscard->name().moduleOrNamespace == "sysrepo");
+ REQUIRE(matchingDiscard->name().name == "discard-items");
+ REQUIRE(!sysrepo::findMatchingDiscard(*sess.operationalChanges(), "something else"));
+
+ DOCTEST_SUBCASE("forget changes via discardOperationalChanges(module)")
+ {
+ // Using discardOperationalChanges makes the leaf visible again (in the operational datastore).
+ // Also, no need to applyChanges().
+ sess.discardOperationalChanges("test_module");
+ }
+
+ DOCTEST_SUBCASE("forget changes via a selective edit")
+ {
+ // this edit only has a single node, which means that we cannot really call unlink() and hope for a sane result
+ REQUIRE(matchingDiscard->firstSibling() == *matchingDiscard);
+
+ // so, we add a dummy node instead...
+ auto root = matchingDiscard->newPath("/test_module:popelnice/s", "foo");
+ // ...and only then we nuke the eixtsing discard-items node
+ matchingDiscard->unlink();
+ sess.editBatch(*root, sysrepo::DefaultOperation::Replace);
+ sess.applyChanges();
+ }
+
+ DOCTEST_SUBCASE("multiple sysrepo:discard-items nodes")
+ {
+ sess.dropForeignOperationalContent("/test_module:popelnice");
+ sess.dropForeignOperationalContent("/test_module:popelnice/s");
+ sess.dropForeignOperationalContent("/test_module:values");
+ sess.dropForeignOperationalContent("/test_module:popelnice/content");
+ sess.dropForeignOperationalContent("/test_module:denyAllLeaf");
+ sess.dropForeignOperationalContent(leaf); // yup, once more, in addition to the one at the very beginning
+ sess.applyChanges();
+
+ auto forPopelnice = sysrepo::findMatchingDiscard(*sess.operationalChanges(), "/test_module:popelnice");
+ REQUIRE(!!forPopelnice);
+ REQUIRE(forPopelnice->value() == "/test_module:popelnice");
+ auto oneMatch = sysrepo::findMatchingDiscard(*sess.operationalChanges(), "/test_module:values");
+ REQUIRE(!!oneMatch);
+ REQUIRE(oneMatch->value() == "/test_module:values");
+
+ auto atOrBelowPopelnice = sysrepo::findMatchingDiscardPrefixes(*sess.operationalChanges(), "/test_module:popelnice");
+ REQUIRE(atOrBelowPopelnice.size() == 3);
+ // yup, these are apparently backwards compared to how I put them in. Never mind.
+ REQUIRE(atOrBelowPopelnice[2].value() == "/test_module:popelnice");
+ REQUIRE(atOrBelowPopelnice[1].value() == "/test_module:popelnice/s");
+ REQUIRE(atOrBelowPopelnice[0].value() == "/test_module:popelnice/content");
+
+ auto belowPopelnice = sysrepo::findMatchingDiscardPrefixes(*sess.operationalChanges(), "/test_module:popelnice/");
+ REQUIRE(belowPopelnice.size() == 2);
+ // again, the order is reversed
+ REQUIRE(belowPopelnice[1].value() == "/test_module:popelnice/s");
+ REQUIRE(belowPopelnice[0].value() == "/test_module:popelnice/content");
+
+ auto newEdit = sess.operationalChanges();
+ auto forLeaf = sysrepo::findMatchingDiscardPrefixes(*newEdit, leaf);
+ REQUIRE(forLeaf.size() == 2);
+ REQUIRE(forLeaf[0].value() == leaf);
+ REQUIRE(forLeaf[1].value() == leaf);
+ for (auto node : forLeaf) {
+ sysrepo::unlinkFromForest(newEdit, node);
+ }
+ sess.editBatch(*newEdit, sysrepo::DefaultOperation::Replace);
+ sess.applyChanges();
+ }
+
REQUIRE(sess.getData(leaf)->asTerm().valueStr() == "123");
}
--
2.43.0
-36
View File
@@ -1,36 +0,0 @@
From aa78815b05090df63bef9ac1b6473f32aac5363d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= <jan.kundrat@cesnet.cz>
Date: Tue, 18 Mar 2025 11:25:05 +0100
Subject: [PATCH 11/20] Fix a typo
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
...because I don't want to go through the CI again, hence a separate
commit.
Fixes: 6ed5442 Utilities for working with existing sysrepo:discard-items nodes
Reported-by: Tomáš Pecka <tomas.pecka@cesnet.cz>
Change-Id: I31f3efc1006fe222b7853b24a116b3d8cc04dc8d
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/utils/utils.cpp | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/utils/utils.cpp b/src/utils/utils.cpp
index e339e88..ff4a32c 100644
--- a/src/utils/utils.cpp
+++ b/src/utils/utils.cpp
@@ -95,7 +95,7 @@ std::optional<libyang::DataNode> findMatchingDiscard(libyang::DataNode root, con
}
/**
- * @short Find all sysrepo:discard-items nodes which match the given XPath or the descendats of this XPath
+ * @short Find all sysrepo:discard-items nodes which match the given XPath or the descendants of this XPath
*/
std::vector<libyang::DataNode> findMatchingDiscardPrefixes(libyang::DataNode root, const std::string& xpathPrefix)
{
--
2.43.0
File diff suppressed because it is too large Load Diff
@@ -1,414 +0,0 @@
From 9d812203b3c0550995be8d60e7b11761bdbac04d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Tue, 1 Apr 2025 21:22:04 +0200
Subject: [PATCH 13/20] add subtree filtering to notifications subscription
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Change-Id: Ie547b5f478cc8e3b09ea2f324b62854576787e1b
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
CMakeLists.txt | 2 +-
include/sysrepo-cpp/Session.hpp | 6 +-
src/Session.cpp | 58 +++++++++--
tests/subscriptions-dynamic.cpp | 173 ++++++++++++++++++++++++++++++--
4 files changed, 213 insertions(+), 26 deletions(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 01ec967..47472d2 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -82,7 +82,7 @@ if(BUILD_TESTING)
--install ${CMAKE_CURRENT_SOURCE_DIR}/tests/yang/ietf-interfaces@2018-02-20.yang
--install ${CMAKE_CURRENT_SOURCE_DIR}/tests/yang/ietf-ip@2018-02-22.yang
--install ${CMAKE_CURRENT_SOURCE_DIR}/tests/yang/ietf-network-instance@2019-01-21.yang
- --install ${CMAKE_CURRENT_SOURCE_DIR}/tests/yang/ietf-subscribed-notifications@2019-09-09.yang -e replay
+ --install ${CMAKE_CURRENT_SOURCE_DIR}/tests/yang/ietf-subscribed-notifications@2019-09-09.yang -e replay -e subtree
--install ${CMAKE_CURRENT_SOURCE_DIR}/tests/yang/ietf-yang-push@2019-09-09.yang -e on-change
)
diff --git a/include/sysrepo-cpp/Session.hpp b/include/sysrepo-cpp/Session.hpp
index 255f861..0781890 100644
--- a/include/sysrepo-cpp/Session.hpp
+++ b/include/sysrepo-cpp/Session.hpp
@@ -137,17 +137,17 @@ public:
const std::optional<FDHandling>& callbacks = std::nullopt);
[[nodiscard]] DynamicSubscription yangPushPeriodic(
- const std::optional<std::string>& xpathFilter,
+ const std::optional<std::variant<std::string, libyang::DataNodeAny>>& filter,
std::chrono::milliseconds periodTime,
const std::optional<NotificationTimeStamp>& anchorTime = std::nullopt,
const std::optional<NotificationTimeStamp>& stopTime = std::nullopt);
[[nodiscard]] DynamicSubscription yangPushOnChange(
- const std::optional<std::string>& xpathFilter,
+ const std::optional<std::variant<std::string, libyang::DataNodeAny>>& filter,
const std::optional<std::chrono::milliseconds>& dampeningPeriod = std::nullopt,
SyncOnStart syncOnStart = SyncOnStart::No,
const std::optional<NotificationTimeStamp>& stopTime = std::nullopt);
[[nodiscard]] DynamicSubscription subscribeNotifications(
- const std::optional<std::string>& xpathFilter,
+ const std::optional<std::variant<std::string, libyang::DataNodeAny>>& filter,
const std::optional<std::string>& stream = std::nullopt,
const std::optional<NotificationTimeStamp>& stopTime = std::nullopt,
const std::optional<NotificationTimeStamp>& startTime = std::nullopt);
diff --git a/src/Session.cpp b/src/Session.cpp
index 2c27c92..1d65d70 100644
--- a/src/Session.cpp
+++ b/src/Session.cpp
@@ -42,6 +42,37 @@ libyang::DataNode wrapSrData(std::shared_ptr<sr_session_ctx_s> sess, sr_data_t*
sr_release_data(data);
}));
}
+
+std::optional<std::string> constructXPathFilter(const std::optional<std::variant<std::string, libyang::DataNodeAny>>& filter)
+{
+ if (!filter) {
+ return std::nullopt;
+ }
+
+ if (std::holds_alternative<std::string>(*filter)) {
+ return std::get<std::string>(*filter);
+ }
+
+ auto node = std::get<libyang::DataNodeAny>(*filter);
+ auto value = node.releaseValue();
+
+ if (!value) {
+ return "/"; // select nothing, RFC 6241, 6.4.2
+ }
+
+ if (std::holds_alternative<libyang::DataNode>(*value)) {
+ char* str;
+
+ auto filterTree = std::get<libyang::DataNode>(*value);
+ auto res = srsn_filter_subtree2xpath(libyang::getRawNode(filterTree), nullptr, &str);
+ std::unique_ptr<char, decltype([](auto* p) constexpr { std::free(p); })> strDeleter(str); // pass ownership of c-string to the deleter
+
+ throwIfError(res, "Unable to convert subtree filter to xpath");
+ return str;
+ }
+
+ throw Error("Subtree filter anydata node must contain (possibly empty) libyang tree");
+}
}
/**
@@ -526,9 +557,9 @@ Subscription Session::onNotification(
/**
* Subscribe for receiving notifications according to 'ietf-yang-push' YANG periodic subscriptions.
*
- * Wraps `srsn_yang_push_periodic`.
+ * Wraps `srsn_subscribe` and `srsn_filter_subtree2xpath` for subtree filters.
*
- * @param xpathFilter Optional XPath that filters received notification.
+ * @param filter Optional filter for received notification, xpath filter for string type, subtree filter for libyang::DataNodeAny
* @param periodTime Notification period.
* @param anchorTime Optional anchor time for the period. Anchor time acts as a reference point for the period.
* @param stopTime Optional stop time ending the notification subscription.
@@ -536,7 +567,7 @@ Subscription Session::onNotification(
* @return A YangPushSubscription handle.
*/
DynamicSubscription Session::yangPushPeriodic(
- const std::optional<std::string>& xpathFilter,
+ const std::optional<std::variant<std::string, libyang::DataNodeAny>>& filter,
std::chrono::milliseconds periodTime,
const std::optional<NotificationTimeStamp>& anchorTime,
const std::optional<NotificationTimeStamp>& stopTime)
@@ -545,6 +576,8 @@ DynamicSubscription Session::yangPushPeriodic(
uint32_t subId;
auto stopSpec = stopTime ? std::optional{toTimespec(*stopTime)} : std::nullopt;
auto anchorSpec = anchorTime ? std::optional{toTimespec(*anchorTime)} : std::nullopt;
+ auto xpathFilter = constructXPathFilter(filter);
+
auto res = srsn_yang_push_periodic(m_sess.get(),
toDatastore(activeDatastore()),
xpathFilter ? xpathFilter->c_str() : nullptr,
@@ -561,9 +594,9 @@ DynamicSubscription Session::yangPushPeriodic(
/**
* Subscribe for receiving notifications according to 'ietf-yang-push' YANG on-change subscriptions.
*
- * Wraps `srsn_yang_push_on_change`.
+ * Wraps `srsn_subscribe` and `srsn_filter_subtree2xpath` for subtree filters.
*
- * @param xpathFilter Optional XPath that filters received notification.
+ * @param filter Optional filter for received notification, xpath filter for string type, subtree filter for libyang::DataNodeAny
* @param dampeningPeriod Optional dampening period.
* @param syncOnStart Whether to start with a notification of the current state.
* @param stopTime Optional stop time ending the notification subscription.
@@ -571,7 +604,7 @@ DynamicSubscription Session::yangPushPeriodic(
* @return A YangPushSubscription handle.
*/
DynamicSubscription Session::yangPushOnChange(
- const std::optional<std::string>& xpathFilter,
+ const std::optional<std::variant<std::string, libyang::DataNodeAny>>& filter,
const std::optional<std::chrono::milliseconds>& dampeningPeriod,
SyncOnStart syncOnStart,
const std::optional<NotificationTimeStamp>& stopTime)
@@ -579,6 +612,8 @@ DynamicSubscription Session::yangPushOnChange(
int fd;
uint32_t subId;
auto stopSpec = stopTime ? std::optional{toTimespec(*stopTime)} : std::nullopt;
+ auto xpathFilter = constructXPathFilter(filter);
+
auto res = srsn_yang_push_on_change(m_sess.get(),
toDatastore(activeDatastore()),
xpathFilter ? xpathFilter->c_str() : nullptr,
@@ -596,11 +631,11 @@ DynamicSubscription Session::yangPushOnChange(
}
/**
- * Subscribe for receiving notifications according to 'ietf-subscribed-notifications'
+ * Subscribe for receiving notifications according to 'ietf-subscribed-notifications'.
*
- * Wraps `srsn_subscribe.
+ * Wraps `srsn_subscribe` and `srsn_filter_subtree2xpath` for subtree filters.
*
- * @param xpathFilter Optional XPath that filters received notification.
+ * @param filter Optional filter for received notification, xpath filter for string type, subtree filter for libyang::DataNodeAny
* @param stream Optional stream to subscribe to.
* @param stopTime Optional stop time ending the subscription.
* @param startTime Optional start time of the subscription, used for replaying stored notifications.
@@ -608,7 +643,7 @@ DynamicSubscription Session::yangPushOnChange(
* @return A YangPushSubscription handle.
*/
DynamicSubscription Session::subscribeNotifications(
- const std::optional<std::string>& xpathFilter,
+ const std::optional<std::variant<std::string, libyang::DataNodeAny>>& filter,
const std::optional<std::string>& stream,
const std::optional<NotificationTimeStamp>& stopTime,
const std::optional<NotificationTimeStamp>& startTime)
@@ -618,10 +653,11 @@ DynamicSubscription Session::subscribeNotifications(
auto stopSpec = stopTime ? std::optional{toTimespec(*stopTime)} : std::nullopt;
auto startSpec = startTime ? std::optional{toTimespec(*startTime)} : std::nullopt;
struct timespec replayStartSpec;
+ auto xpathFilter = constructXPathFilter(filter);
auto res = srsn_subscribe(m_sess.get(),
stream ? stream->c_str() : nullptr,
- xpathFilter ? xpathFilter->c_str() : nullptr,
+ xpathFilter ? xpathFilter->data() : nullptr,
stopSpec ? &stopSpec.value() : nullptr,
startSpec ? &startSpec.value() : nullptr,
false,
diff --git a/tests/subscriptions-dynamic.cpp b/tests/subscriptions-dynamic.cpp
index a44bfba..84a0880 100644
--- a/tests/subscriptions-dynamic.cpp
+++ b/tests/subscriptions-dynamic.cpp
@@ -28,6 +28,9 @@
#define REQUIRE_NOTIFICATION(SUBSCRIPTION, NOTIFICATION) \
TROMPELOEIL_REQUIRE_CALL(rec, recordNotification(NOTIFICATION)).IN_SEQUENCE(seq);
+#define REQUIRE_NAMED_NOTIFICATION(SUBSCRIPTION, NOTIFICATION) \
+ expectations.emplace_back(TROMPELOEIL_NAMED_REQUIRE_CALL(rec, recordNotification(NOTIFICATION)).IN_SEQUENCE(seq));
+
#define READ_NOTIFICATION(SUBSCRIPTION) \
REQUIRE(pipeStatus((SUBSCRIPTION).fd()) == PipeStatus::DataReady); \
(SUBSCRIPTION).processEvent(cbNotif);
@@ -276,6 +279,89 @@ TEST_CASE("Dynamic subscriptions")
const auto excMessage = "Couldn't terminate yang-push subscription with id " + std::to_string(sub->subscriptionId()) + ": SR_ERR_NOT_FOUND";
REQUIRE_THROWS_WITH_AS(sub->terminate(), excMessage.c_str(), sysrepo::ErrorWithCode);
}
+
+ DOCTEST_SUBCASE("Filtering")
+ {
+ std::optional<sysrepo::DynamicSubscription> sub;
+ std::vector<std::unique_ptr<trompeloeil::expectation>> expectations;
+
+ DOCTEST_SUBCASE("xpath filter")
+ {
+ sub = sess.subscribeNotifications("/test_module:ping");
+
+ REQUIRE_NAMED_NOTIFICATION(sub, notifications[0]);
+ }
+
+ DOCTEST_SUBCASE("subtree filter")
+ {
+ libyang::CreatedNodes createdNodes;
+
+ DOCTEST_SUBCASE("filter a node")
+ {
+ DOCTEST_SUBCASE("XML")
+ {
+ createdNodes = sess.getContext().newPath2(
+ "/ietf-subscribed-notifications:establish-subscription/stream-subtree-filter",
+ libyang::XML{"<ping xmlns='urn:ietf:params:xml:ns:yang:test_module' />"});
+ }
+
+ DOCTEST_SUBCASE("JSON")
+ {
+ createdNodes = sess.getContext().newPath2(
+ "/ietf-subscribed-notifications:establish-subscription/stream-subtree-filter",
+ libyang::JSON{R"({"test_module:ping": {}})"});
+ }
+
+ REQUIRE_NAMED_NOTIFICATION(sub, notifications[0]);
+ }
+
+ DOCTEST_SUBCASE("filter more top level nodes")
+ {
+ DOCTEST_SUBCASE("XML")
+ {
+ createdNodes = sess.getContext().newPath2(
+ "/ietf-subscribed-notifications:establish-subscription/stream-subtree-filter",
+ libyang::XML{"<ping xmlns='urn:ietf:params:xml:ns:yang:test_module' />"
+ "<silent-ping xmlns='urn:ietf:params:xml:ns:yang:test_module' />"});
+ }
+
+ DOCTEST_SUBCASE("JSON")
+ {
+ createdNodes = sess.getContext().newPath2(
+ "/ietf-subscribed-notifications:establish-subscription/stream-subtree-filter",
+ libyang::JSON{R"({
+ "test_module:ping": {},
+ "test_module:silent-ping": {}
+ })"});
+ }
+
+ REQUIRE_NAMED_NOTIFICATION(sub, notifications[0]);
+ REQUIRE_NAMED_NOTIFICATION(sub, notifications[1]);
+ }
+
+ DOCTEST_SUBCASE("empty filter selects nothing")
+ {
+ createdNodes = sess.getContext().newPath2(
+ "/ietf-subscribed-notifications:establish-subscription/stream-subtree-filter",
+ std::nullopt);
+ }
+
+ sub = sess.subscribeNotifications(createdNodes.createdNode->asAny());
+ }
+
+ CLIENT_SEND_NOTIFICATION(notifications[0]);
+ CLIENT_SEND_NOTIFICATION(notifications[1]);
+
+ // read as many notifications as we expect
+ for (size_t i = 0; i < expectations.size(); ++i) {
+ READ_NOTIFICATION_BLOCKING(*sub);
+ }
+
+ sub->terminate();
+
+ // ensure no more notifications were sent
+ REQUIRE_PIPE_HANGUP(*sub);
+ }
}
DOCTEST_SUBCASE("YANG Push on change")
@@ -285,9 +371,73 @@ TEST_CASE("Dynamic subscriptions")
* between writing to sysrepo and reading the notifications.
*/
- auto sub = sess.yangPushOnChange(std::nullopt, std::nullopt, sysrepo::SyncOnStart::Yes);
+ DOCTEST_SUBCASE("Filters")
+ {
+ std::optional<sysrepo::DynamicSubscription> sub;
+
+ DOCTEST_SUBCASE("XPath filter")
+ {
+ sub = sess.yangPushOnChange("/test_module:leafInt32 | /test_module:popelnice/content/trash[name='asd']");
+ }
+
+ DOCTEST_SUBCASE("Subtree filter")
+ {
+ auto createdNodes = sess.getContext().newPath2(
+ "/ietf-subscribed-notifications:establish-subscription/ietf-yang-push:datastore-subtree-filter",
+ libyang::XML{"<leafInt32 xmlns='http://example.com/' />"
+ "<popelnice xmlns='http://example.com/'><content><trash><name>asd</name></trash></content></popelnice>"});
+ sub = sess.yangPushOnChange(createdNodes.createdNode->asAny());
+ }
+
+ client.setItem("/test_module:leafInt32", "42");
+ client.setItem("/test_module:popelnice/s", "asd");
+ client.setItem("/test_module:popelnice/content/trash[name='asd']", std::nullopt);
+ client.applyChanges();
- REQUIRE_YANG_PUSH_UPDATE(sub, R"({
+ client.deleteItem("/test_module:popelnice/s");
+ client.applyChanges();
+
+ REQUIRE_YANG_PUSH_UPDATE(*sub, R"({
+ "ietf-yang-push:push-change-update": {
+ "datastore-changes": {
+ "yang-patch": {
+ "patch-id": "patch-1",
+ "edit": [
+ {
+ "edit-id": "edit-1",
+ "operation": "create",
+ "target": "/test_module:leafInt32",
+ "value": {
+ "test_module:leafInt32": 42
+ }
+ },
+ {
+ "edit-id": "edit-2",
+ "operation": "create",
+ "target": "/test_module:popelnice/content/trash[name='asd']",
+ "value": {
+ "test_module:trash": {
+ "name": "asd"
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+}
+)");
+ READ_YANG_PUSH_UPDATE(*sub);
+
+ sub->terminate();
+ REQUIRE_PIPE_HANGUP(*sub);
+ }
+
+ DOCTEST_SUBCASE("Sync on start")
+ {
+ auto sub = sess.yangPushOnChange(std::nullopt, std::nullopt, sysrepo::SyncOnStart::Yes);
+
+ REQUIRE_YANG_PUSH_UPDATE(sub, R"({
"ietf-yang-push:push-update": {
"datastore-contents": {
"test_module:values": [
@@ -298,14 +448,14 @@ TEST_CASE("Dynamic subscriptions")
}
}
)");
- READ_YANG_PUSH_UPDATE(sub);
+ READ_YANG_PUSH_UPDATE(sub);
- client.setItem("/test_module:leafInt32", "123");
- client.setItem("/test_module:values[.='5']", std::nullopt);
- client.deleteItem("/test_module:values[.='3']");
- client.applyChanges();
+ client.setItem("/test_module:leafInt32", "123");
+ client.setItem("/test_module:values[.='5']", std::nullopt);
+ client.deleteItem("/test_module:values[.='3']");
+ client.applyChanges();
- REQUIRE_YANG_PUSH_UPDATE(sub, R"({
+ REQUIRE_YANG_PUSH_UPDATE(sub, R"({
"ietf-yang-push:push-change-update": {
"datastore-changes": {
"yang-patch": {
@@ -342,10 +492,11 @@ TEST_CASE("Dynamic subscriptions")
}
}
)");
- READ_YANG_PUSH_UPDATE(sub);
+ READ_YANG_PUSH_UPDATE(sub);
- sub.terminate();
- REQUIRE_PIPE_HANGUP(sub);
+ sub.terminate();
+ REQUIRE_PIPE_HANGUP(sub);
+ }
}
DOCTEST_SUBCASE("YANG Push periodic")
--
2.43.0
@@ -1,187 +0,0 @@
From b960058e37c471084538bb5db8b3b5e9e992c46c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Wed, 2 Apr 2025 21:47:21 +0200
Subject: [PATCH 14/20] add excluded changes to YANG push on-change wrapper
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires
Change-Id: Idb7bb48a3c60ff70286441cd4d0a5c1c28acee15
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
include/sysrepo-cpp/Enum.hpp | 12 +++++++
include/sysrepo-cpp/Session.hpp | 1 +
src/Session.cpp | 12 ++++++-
src/utils/enum.hpp | 8 +++++
tests/subscriptions-dynamic.cpp | 60 +++++++++++++++++++++++++++++++++
5 files changed, 92 insertions(+), 1 deletion(-)
diff --git a/include/sysrepo-cpp/Enum.hpp b/include/sysrepo-cpp/Enum.hpp
index cac1f2e..48467c6 100644
--- a/include/sysrepo-cpp/Enum.hpp
+++ b/include/sysrepo-cpp/Enum.hpp
@@ -209,6 +209,18 @@ constexpr GetOptions operator|(const GetOptions a, const GetOptions b)
return implEnumBitOr(a, b);
}
+/**
+ * @brief Wraps `srsn_yang_push_change_t`
+ */
+enum class YangPushChange : uint32_t {
+ Create = 0, /**< SRSN_YP_CHANGE_CREATE */
+ Delete = 1, /**< SRSN_YP_CHANGE_DELETE */
+ Insert = 2, /**< SRSN_YP_CHANGE_INSERT */
+ Move = 3, /**< SRSN_YP_CHANGE_MOVE */
+ Replace = 4, /**< SRSN_YP_CHANGE_REPLACE */
+ EnumCount = 5, /**< SRSN_COUNT_YP_CHANGE */
+};
+
std::ostream& operator<<(std::ostream& os, const NotificationType& type);
std::ostream& operator<<(std::ostream& os, const Event& event);
std::ostream& operator<<(std::ostream& os, const ChangeOperation& changeOp);
diff --git a/include/sysrepo-cpp/Session.hpp b/include/sysrepo-cpp/Session.hpp
index 0781890..b7cad72 100644
--- a/include/sysrepo-cpp/Session.hpp
+++ b/include/sysrepo-cpp/Session.hpp
@@ -145,6 +145,7 @@ public:
const std::optional<std::variant<std::string, libyang::DataNodeAny>>& filter,
const std::optional<std::chrono::milliseconds>& dampeningPeriod = std::nullopt,
SyncOnStart syncOnStart = SyncOnStart::No,
+ const std::set<YangPushChange>& excludedChanges = {},
const std::optional<NotificationTimeStamp>& stopTime = std::nullopt);
[[nodiscard]] DynamicSubscription subscribeNotifications(
const std::optional<std::variant<std::string, libyang::DataNodeAny>>& filter,
diff --git a/src/Session.cpp b/src/Session.cpp
index 1d65d70..40c3df3 100644
--- a/src/Session.cpp
+++ b/src/Session.cpp
@@ -607,6 +607,7 @@ DynamicSubscription Session::yangPushOnChange(
const std::optional<std::variant<std::string, libyang::DataNodeAny>>& filter,
const std::optional<std::chrono::milliseconds>& dampeningPeriod,
SyncOnStart syncOnStart,
+ const std::set<YangPushChange>& excludedChanges,
const std::optional<NotificationTimeStamp>& stopTime)
{
int fd;
@@ -614,12 +615,21 @@ DynamicSubscription Session::yangPushOnChange(
auto stopSpec = stopTime ? std::optional{toTimespec(*stopTime)} : std::nullopt;
auto xpathFilter = constructXPathFilter(filter);
+ /* The enum values are not used as the other enum flags in sysrepo-cpp.
+ * srsn_yang_push_on_change expects an integer array of size EnumCount with 0 or 1 values.
+ */
+ using YangPushChangeUnderlying = std::underlying_type_t<YangPushChange>;
+ std::array<int, static_cast<YangPushChangeUnderlying>(YangPushChange::EnumCount)> excludedChangesArray{};
+ for (const auto& change: excludedChanges) {
+ excludedChangesArray[static_cast<YangPushChangeUnderlying>(change)] = 1;
+ }
+
auto res = srsn_yang_push_on_change(m_sess.get(),
toDatastore(activeDatastore()),
xpathFilter ? xpathFilter->c_str() : nullptr,
dampeningPeriod ? dampeningPeriod->count() : 0,
syncOnStart == SyncOnStart::Yes,
- nullptr,
+ excludedChangesArray.data(),
stopSpec ? &stopSpec.value() : nullptr,
0,
nullptr,
diff --git a/src/utils/enum.hpp b/src/utils/enum.hpp
index a2dcf7a..2d3a298 100644
--- a/src/utils/enum.hpp
+++ b/src/utils/enum.hpp
@@ -9,6 +9,7 @@
#include <type_traits>
extern "C" {
#include <sysrepo.h>
+#include <sysrepo/subscribed_notifications.h>
}
#include <sysrepo-cpp/Enum.hpp>
@@ -171,4 +172,11 @@ constexpr sr_get_options_t toGetOptions(const GetOptions opts)
{
return static_cast<sr_get_options_t>(opts);
}
+
+static_assert(static_cast<YangPushChange>(SRSN_YP_CHANGE_CREATE) == YangPushChange::Create);
+static_assert(static_cast<YangPushChange>(SRSN_YP_CHANGE_DELETE) == YangPushChange::Delete);
+static_assert(static_cast<YangPushChange>(SRSN_YP_CHANGE_INSERT) == YangPushChange::Insert);
+static_assert(static_cast<YangPushChange>(SRSN_YP_CHANGE_MOVE) == YangPushChange::Move);
+static_assert(static_cast<YangPushChange>(SRSN_YP_CHANGE_REPLACE) == YangPushChange::Replace);
+static_assert(static_cast<YangPushChange>(SRSN_COUNT_YP_CHANGE) == YangPushChange::EnumCount);
}
diff --git a/tests/subscriptions-dynamic.cpp b/tests/subscriptions-dynamic.cpp
index 84a0880..5ed173b 100644
--- a/tests/subscriptions-dynamic.cpp
+++ b/tests/subscriptions-dynamic.cpp
@@ -497,6 +497,66 @@ TEST_CASE("Dynamic subscriptions")
sub.terminate();
REQUIRE_PIPE_HANGUP(sub);
}
+
+ DOCTEST_SUBCASE("Excluded changes")
+ {
+ auto sub = sess.yangPushOnChange(std::nullopt, std::nullopt, sysrepo::SyncOnStart::No, {sysrepo::YangPushChange::Create});
+
+ client.setItem("/test_module:leafInt32", "123");
+ client.applyChanges(); // excluded (create)
+ client.setItem("/test_module:leafInt32", "124");
+ client.applyChanges();
+ client.setItem("/test_module:leafInt32", "125");
+ client.applyChanges();
+
+ REQUIRE_YANG_PUSH_UPDATE(sub, R"({
+ "ietf-yang-push:push-change-update": {
+ "datastore-changes": {
+ "yang-patch": {
+ "patch-id": "patch-1",
+ "edit": [
+ {
+ "edit-id": "edit-1",
+ "operation": "replace",
+ "target": "/test_module:leafInt32",
+ "value": {
+ "test_module:leafInt32": 124
+ }
+ }
+ ]
+ }
+ }
+ }
+}
+)");
+
+ READ_YANG_PUSH_UPDATE(sub);
+
+ REQUIRE_YANG_PUSH_UPDATE(sub, R"({
+ "ietf-yang-push:push-change-update": {
+ "datastore-changes": {
+ "yang-patch": {
+ "patch-id": "patch-2",
+ "edit": [
+ {
+ "edit-id": "edit-1",
+ "operation": "replace",
+ "target": "/test_module:leafInt32",
+ "value": {
+ "test_module:leafInt32": 125
+ }
+ }
+ ]
+ }
+ }
+ }
+}
+)");
+ READ_YANG_PUSH_UPDATE(sub);
+
+ sub.terminate();
+ REQUIRE_PIPE_HANGUP(sub);
+ }
}
DOCTEST_SUBCASE("YANG Push periodic")
--
2.43.0

Some files were not shown because too many files have changed in this diff Show More