all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Gabriel Goller <g.goller@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [pve-devel] [PATCH ve-rs 1/4] frr: add templates and structs to represent the frr config
Date: Fri, 19 Sep 2025 11:41:13 +0200	[thread overview]
Message-ID: <20250919094122.73373-2-g.goller@proxmox.com> (raw)
In-Reply-To: <20250919094122.73373-1-g.goller@proxmox.com>

Add jinja templates and the minijinja configuration + structs to
represent the frr config in rust. The rust config can then be rendered
using minijinja and the jinja template files.

Serializing the config using templates provides better ergonomics and is
cleaner than writing an overly complex serde serializer for the
convoluted frr config format. It's also better than the current model,
where we have a custom trait similar to Display that serializes structs.
The current approach has a few problem with the most prominent being the
difference between Display and the custom serializer trait -- so we
some times serialize using Display and some using FrrDumper.

Signed-off-by: Gabriel Goller <g.goller@proxmox.com>
---
 proxmox-frr/Cargo.toml                  |   4 +
 proxmox-frr/debian/control              |  14 ++
 proxmox-frr/examples/route_map.rs       |  70 +++++++
 proxmox-frr/src/lib.rs                  | 166 +++++----------
 proxmox-frr/src/openfabric.rs           |  15 +-
 proxmox-frr/src/ospf.rs                 |  37 +---
 proxmox-frr/src/route_map.rs            | 108 +++-------
 proxmox-frr/src/serializer.rs           | 256 +++++++-----------------
 proxmox-frr/templates/access_list.jinja |   6 +
 proxmox-frr/templates/fabricd.jinja     |  36 ++++
 proxmox-frr/templates/interface.jinja   |   4 +
 proxmox-frr/templates/ospfd.jinja       |  27 +++
 proxmox-frr/templates/route_map.jinja   |  11 +
 13 files changed, 331 insertions(+), 423 deletions(-)
 create mode 100644 proxmox-frr/examples/route_map.rs
 create mode 100644 proxmox-frr/templates/access_list.jinja
 create mode 100644 proxmox-frr/templates/fabricd.jinja
 create mode 100644 proxmox-frr/templates/interface.jinja
 create mode 100644 proxmox-frr/templates/ospfd.jinja
 create mode 100644 proxmox-frr/templates/route_map.jinja

diff --git a/proxmox-frr/Cargo.toml b/proxmox-frr/Cargo.toml
index 8b01fa4bf806..a14eab20a462 100644
--- a/proxmox-frr/Cargo.toml
+++ b/proxmox-frr/Cargo.toml
@@ -13,6 +13,10 @@ rust-version.workspace = true
 thiserror = { workspace = true }
 anyhow = "1"
 tracing = "0.1"
+serde = { workspace = true, features = [ "derive" ] }
+minijinja = { version = "2.5", features = [ "multi_template", "loader" ] }
+bon = "3.7"
 
 proxmox-network-types = { workspace = true }
 proxmox-sdn-types = { workspace = true }
+proxmox-serde = { workspace = true }
diff --git a/proxmox-frr/debian/control b/proxmox-frr/debian/control
index 999661978f90..2c31b52d0408 100644
--- a/proxmox-frr/debian/control
+++ b/proxmox-frr/debian/control
@@ -7,8 +7,15 @@ Build-Depends-Arch: cargo:native <!nocheck>,
  rustc:native (>= 1.82) <!nocheck>,
  libstd-rust-dev <!nocheck>,
  librust-anyhow-1+default-dev <!nocheck>,
+ librust-bon-3+default-dev (>= 3.7-~~) <!nocheck>,
+ librust-minijinja-2+default-dev (>= 2.5-~~) <!nocheck>,
+ librust-minijinja-2+loader-dev (>= 2.5-~~) <!nocheck>,
+ librust-minijinja-2+multi-template-dev (>= 2.5-~~) <!nocheck>,
  librust-proxmox-network-types-0.1+default-dev (>= 0.1.1-~~) <!nocheck>,
  librust-proxmox-sdn-types-0.1+default-dev <!nocheck>,
+ librust-proxmox-serde-1+default-dev <!nocheck>,
+ librust-serde-1+default-dev <!nocheck>,
+ librust-serde-1+derive-dev <!nocheck>,
  librust-thiserror-2+default-dev <!nocheck>,
  librust-tracing-0.1+default-dev <!nocheck>
 Maintainer: Proxmox Support Team <support@proxmox.com>
@@ -25,8 +32,15 @@ Multi-Arch: same
 Depends:
  ${misc:Depends},
  librust-anyhow-1+default-dev,
+ librust-bon-3+default-dev (>= 3.7-~~),
+ librust-minijinja-2+default-dev (>= 2.5-~~),
+ librust-minijinja-2+loader-dev (>= 2.5-~~),
+ librust-minijinja-2+multi-template-dev (>= 2.5-~~),
  librust-proxmox-network-types-0.1+default-dev (>= 0.1.1-~~),
  librust-proxmox-sdn-types-0.1+default-dev,
+ librust-proxmox-serde-1+default-dev,
+ librust-serde-1+default-dev,
+ librust-serde-1+derive-dev,
  librust-thiserror-2+default-dev,
  librust-tracing-0.1+default-dev
 Provides:
diff --git a/proxmox-frr/examples/route_map.rs b/proxmox-frr/examples/route_map.rs
new file mode 100644
index 000000000000..9dc641caaf89
--- /dev/null
+++ b/proxmox-frr/examples/route_map.rs
@@ -0,0 +1,70 @@
+use std::collections::{BTreeMap, BTreeSet};
+
+use proxmox_frr::{
+    openfabric::{OpenfabricInterface, OpenfabricRouterName},
+    route_map::{
+        AccessAction, AccessListName, RouteMap, RouteMapMatch, RouteMapMatchInner, RouteMapName,
+        RouteMapSet,
+    },
+    serializer::to_raw_config,
+    FrrConfig, Interface, InterfaceName, OpenfabricFrrConfig,
+};
+
+fn main() {
+    let mut interfaces = BTreeMap::new();
+    interfaces.insert(
+        InterfaceName::new("ens18").unwrap(),
+        Interface {
+            addresses: vec![
+                "6.6.6.4/32".parse().unwrap(),
+                "10.10.10.0/24".parse().unwrap(),
+            ],
+            properties: OpenfabricInterface::builder()
+                .fabric_id(OpenfabricRouterName::new("cool".parse().unwrap()))
+                .passive(true)
+                .is_ipv4(true)
+                .is_ipv6(false)
+                .build(),
+        },
+    );
+    interfaces.insert(
+        InterfaceName::new("ens19").unwrap(),
+        Interface {
+            addresses: vec!["6.6.6.5/32".parse().unwrap()],
+            properties: OpenfabricInterface::builder()
+                .fabric_id(OpenfabricRouterName::new("cool".parse().unwrap()))
+                .is_ipv4(false)
+                .is_ipv6(false)
+                .build(),
+        },
+    );
+    let config = OpenfabricFrrConfig {
+        router: BTreeMap::new(),
+        interfaces,
+        access_lists: Vec::new(),
+        protocol_routemaps: BTreeSet::new(),
+        routemaps: vec![RouteMap {
+            name: RouteMapName::new("SOMETHING".to_string()),
+            seq: 10,
+            action: AccessAction::Permit,
+            matches: vec![
+                RouteMapMatch::V4(RouteMapMatchInner::Address(AccessListName::new(
+                    "coolList".to_string(),
+                ))),
+                RouteMapMatch::V4(RouteMapMatchInner::NextHop("127.0.0.1".to_string())),
+                RouteMapMatch::V6(RouteMapMatchInner::NextHop("127.0.0.1".to_string())),
+            ],
+            sets: vec![
+                RouteMapSet::Src("8.8.8.8".parse().unwrap()),
+                RouteMapSet::Community("coolCommunity".to_string()),
+            ],
+        }],
+    };
+
+    let full_config = FrrConfig {
+        openfabric: config,
+        ospf: proxmox_frr::OspfFrrConfig::default(),
+    };
+    let rendered = to_raw_config(&full_config).expect("error rendering frr config");
+    println!("{}", rendered.join("\n"));
+}
diff --git a/proxmox-frr/src/lib.rs b/proxmox-frr/src/lib.rs
index 86101182fafd..d255b81e1f40 100644
--- a/proxmox-frr/src/lib.rs
+++ b/proxmox-frr/src/lib.rs
@@ -4,11 +4,14 @@ pub mod route_map;
 pub mod serializer;
 
 use std::collections::{BTreeMap, BTreeSet};
-use std::fmt::Display;
 use std::str::FromStr;
 
 use crate::route_map::{AccessList, ProtocolRouteMap, RouteMap};
 
+use openfabric::{OpenfabricInterface, OpenfabricRouter, OpenfabricRouterName};
+use ospf::{OspfInterface, OspfRouter, OspfRouterName};
+use proxmox_network_types::ip_address::Cidr;
+use serde::Serialize;
 use thiserror::Error;
 
 /// Generic FRR router.
@@ -34,73 +37,6 @@ impl From<openfabric::OpenfabricRouter> for Router {
     }
 }
 
-/// Generic FRR routername.
-///
-/// The variants represent different protocols. Some have `router <protocol> <name>`, others have
-/// `router <protocol> <process-id>`, some only have `router <protocol>`.
-#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
-pub enum RouterName {
-    Openfabric(openfabric::OpenfabricRouterName),
-    Ospf(ospf::OspfRouterName),
-}
-
-impl From<openfabric::OpenfabricRouterName> for RouterName {
-    fn from(value: openfabric::OpenfabricRouterName) -> Self {
-        Self::Openfabric(value)
-    }
-}
-
-impl Display for RouterName {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        match self {
-            Self::Openfabric(r) => r.fmt(f),
-            Self::Ospf(r) => r.fmt(f),
-        }
-    }
-}
-
-/// The interface name is the same on ospf and openfabric, but it is an enum so that we can have
-/// two different entries in the btreemap. This allows us to have an interface in a ospf and
-/// openfabric fabric.
-#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
-pub enum InterfaceName {
-    Openfabric(CommonInterfaceName),
-    Ospf(CommonInterfaceName),
-}
-
-impl Display for InterfaceName {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        match self {
-            InterfaceName::Openfabric(frr_word) => frr_word.fmt(f),
-            InterfaceName::Ospf(frr_word) => frr_word.fmt(f),
-        }
-    }
-}
-
-/// Generic FRR Interface.
-///
-/// In FRR config it looks like this:
-/// ```text
-/// interface <name>
-/// ! ...
-#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
-pub enum Interface {
-    Openfabric(openfabric::OpenfabricInterface),
-    Ospf(ospf::OspfInterface),
-}
-
-impl From<openfabric::OpenfabricInterface> for Interface {
-    fn from(value: openfabric::OpenfabricInterface) -> Self {
-        Self::Openfabric(value)
-    }
-}
-
-impl From<ospf::OspfInterface> for Interface {
-    fn from(value: ospf::OspfInterface) -> Self {
-        Self::Ospf(value)
-    }
-}
-
 #[derive(Error, Debug)]
 pub enum FrrWordError {
     #[error("word is empty")]
@@ -113,7 +49,7 @@ pub enum FrrWordError {
 ///
 /// Every string argument or value in FRR is an FrrWord. FrrWords must only contain ascii
 /// characters and must not have a whitespace.
-#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
 pub struct FrrWord(String);
 
 impl FrrWord {
@@ -144,12 +80,6 @@ impl FromStr for FrrWord {
     }
 }
 
-impl Display for FrrWord {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        self.0.fmt(f)
-    }
-}
-
 impl AsRef<str> for FrrWord {
     fn as_ref(&self) -> &str {
         &self.0
@@ -157,7 +87,7 @@ impl AsRef<str> for FrrWord {
 }
 
 #[derive(Error, Debug)]
-pub enum CommonInterfaceNameError {
+pub enum InterfaceNameError {
     #[error("interface name too long")]
     TooLong,
 }
@@ -166,38 +96,58 @@ pub enum CommonInterfaceNameError {
 ///
 /// FRR itself doesn't enforce any limits, but the kernel does. Linux only allows interface names
 /// to be a maximum of 16 bytes. This is enforced by this struct.
-#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
-pub struct CommonInterfaceName(String);
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
+pub struct InterfaceName(String);
 
-impl TryFrom<&str> for CommonInterfaceName {
-    type Error = CommonInterfaceNameError;
+impl TryFrom<&str> for InterfaceName {
+    type Error = InterfaceNameError;
 
     fn try_from(value: &str) -> Result<Self, Self::Error> {
         Self::new(value)
     }
 }
 
-impl TryFrom<String> for CommonInterfaceName {
-    type Error = CommonInterfaceNameError;
+impl TryFrom<String> for InterfaceName {
+    type Error = InterfaceNameError;
 
     fn try_from(value: String) -> Result<Self, Self::Error> {
         Self::new(value)
     }
 }
 
-impl CommonInterfaceName {
-    pub fn new<T: AsRef<str> + Into<String>>(s: T) -> Result<Self, CommonInterfaceNameError> {
+impl InterfaceName {
+    pub fn new<T: AsRef<str> + Into<String>>(s: T) -> Result<Self, InterfaceNameError> {
         if s.as_ref().len() <= 15 {
             Ok(Self(s.into()))
         } else {
-            Err(CommonInterfaceNameError::TooLong)
+            Err(InterfaceNameError::TooLong)
+        }
+    }
+}
+
+#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
+pub struct Interface<T> {
+    pub addresses: Vec<Cidr>,
+
+    #[serde(flatten)]
+    pub properties: T,
+}
+
+impl From<OpenfabricInterface> for Interface<OpenfabricInterface> {
+    fn from(value: OpenfabricInterface) -> Self {
+        Interface {
+            addresses: Vec::new(),
+            properties: value,
         }
     }
 }
 
-impl Display for CommonInterfaceName {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        self.0.fmt(f)
+impl From<OspfInterface> for Interface<OspfInterface> {
+    fn from(value: OspfInterface) -> Self {
+        Interface {
+            addresses: Vec::new(),
+            properties: value,
+        }
     }
 }
 
@@ -208,34 +158,24 @@ impl Display for CommonInterfaceName {
 /// gets generated using the `FrrConfigBuilder` in `proxmox-ve-config`.
 #[derive(Clone, Debug, PartialEq, Eq, Default)]
 pub struct FrrConfig {
-    pub router: BTreeMap<RouterName, Router>,
-    pub interfaces: BTreeMap<InterfaceName, Interface>,
+    pub openfabric: OpenfabricFrrConfig,
+    pub ospf: OspfFrrConfig,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize)]
+pub struct OpenfabricFrrConfig {
+    pub router: BTreeMap<OpenfabricRouterName, OpenfabricRouter>,
+    pub interfaces: BTreeMap<InterfaceName, Interface<OpenfabricInterface>>,
     pub access_lists: Vec<AccessList>,
     pub routemaps: Vec<RouteMap>,
     pub protocol_routemaps: BTreeSet<ProtocolRouteMap>,
 }
 
-impl FrrConfig {
-    pub fn new() -> Self {
-        Self::default()
-    }
-
-    pub fn router(&self) -> impl Iterator<Item = (&RouterName, &Router)> + '_ {
-        self.router.iter()
-    }
-
-    pub fn interfaces(&self) -> impl Iterator<Item = (&InterfaceName, &Interface)> + '_ {
-        self.interfaces.iter()
-    }
-
-    pub fn access_lists(&self) -> impl Iterator<Item = &AccessList> + '_ {
-        self.access_lists.iter()
-    }
-    pub fn routemaps(&self) -> impl Iterator<Item = &RouteMap> + '_ {
-        self.routemaps.iter()
-    }
-
-    pub fn protocol_routemaps(&self) -> impl Iterator<Item = &ProtocolRouteMap> + '_ {
-        self.protocol_routemaps.iter()
-    }
+#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize)]
+pub struct OspfFrrConfig {
+    pub router: BTreeMap<OspfRouterName, OspfRouter>,
+    pub interfaces: BTreeMap<InterfaceName, Interface<OspfInterface>>,
+    pub access_lists: Vec<AccessList>,
+    pub routemaps: Vec<RouteMap>,
+    pub protocol_routemaps: BTreeSet<ProtocolRouteMap>,
 }
diff --git a/proxmox-frr/src/openfabric.rs b/proxmox-frr/src/openfabric.rs
index 6e2a7200ab37..8f3386416137 100644
--- a/proxmox-frr/src/openfabric.rs
+++ b/proxmox-frr/src/openfabric.rs
@@ -1,15 +1,16 @@
 use std::fmt::Debug;
-use std::fmt::Display;
 
+use bon::Builder;
 use proxmox_sdn_types::net::Net;
 
+use serde::Serialize;
 use thiserror::Error;
 
 use crate::FrrWord;
 use crate::FrrWordError;
 
 /// The name of a OpenFabric router. Is an FrrWord.
-#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
 pub struct OpenfabricRouterName(FrrWord);
 
 impl From<FrrWord> for OpenfabricRouterName {
@@ -24,16 +25,10 @@ impl OpenfabricRouterName {
     }
 }
 
-impl Display for OpenfabricRouterName {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        write!(f, "openfabric {}", self.0)
-    }
-}
-
 /// All the properties a OpenFabric router can hold.
 ///
 /// These can serialized with a " " space prefix as they are in the `router openfabric` block.
-#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
 pub struct OpenfabricRouter {
     /// The NET address
     pub net: Net,
@@ -67,7 +62,7 @@ impl OpenfabricRouter {
 ///
 /// The is_ipv4 and is_ipv6 properties decide if we need to add `ip router openfabric`, `ipv6
 /// router openfabric`, or both. An interface can only be part of a single fabric.
-#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Builder)]
 pub struct OpenfabricInterface {
     // Note: an interface can only be a part of a single fabric (so no vec needed here)
     pub fabric_id: OpenfabricRouterName,
diff --git a/proxmox-frr/src/ospf.rs b/proxmox-frr/src/ospf.rs
index d0e098e099d2..ce67b9158047 100644
--- a/proxmox-frr/src/ospf.rs
+++ b/proxmox-frr/src/ospf.rs
@@ -1,7 +1,8 @@
 use std::fmt::Debug;
-use std::fmt::Display;
 use std::net::Ipv4Addr;
 
+use bon::Builder;
+use serde::Serialize;
 use thiserror::Error;
 
 use crate::{FrrWord, FrrWordError};
@@ -18,15 +19,9 @@ use crate::{FrrWord, FrrWordError};
 /// router ospf
 /// !...
 /// ```
-#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
 pub struct OspfRouterName;
 
-impl Display for OspfRouterName {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        write!(f, "ospf")
-    }
-}
-
 #[derive(Error, Debug)]
 pub enum AreaParsingError {
     #[error("Invalid area idenitifier. Area must be a number or an ipv4 address.")]
@@ -44,7 +39,7 @@ pub enum AreaParsingError {
 /// or "0" as an area, which then gets translated to "0.0.0.5" and "0.0.0.0" by FRR. We allow both
 /// a number or an ip-address. Note that the area "0" (or "0.0.0.0") is a special area - it creates
 /// a OSPF "backbone" area.
-#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
 pub struct Area(FrrWord);
 
 impl TryFrom<FrrWord> for Area {
@@ -65,12 +60,6 @@ impl Area {
     }
 }
 
-impl Display for Area {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        write!(f, "area {}", self.0)
-    }
-}
-
 /// The OSPF router properties.
 ///
 /// Currently the only property of a OSPF router is the router_id. The router_id is used to
@@ -84,7 +73,7 @@ impl Display for Area {
 /// router ospf
 ///  router-id <ipv4-address>
 /// ```
-#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
 pub struct OspfRouter {
     pub router_id: Ipv4Addr,
 }
@@ -119,7 +108,8 @@ pub enum OspfInterfaceError {
 /// ! or
 /// ip ospf network broadcast
 /// ```
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
+#[serde(rename_all = "kebab-case")]
 pub enum NetworkType {
     Broadcast,
     NonBroadcast,
@@ -132,17 +122,6 @@ pub enum NetworkType {
     PointToMultipoint,
 }
 
-impl Display for NetworkType {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        match self {
-            NetworkType::Broadcast => write!(f, "broadcast"),
-            NetworkType::NonBroadcast => write!(f, "non-broadcast"),
-            NetworkType::PointToPoint => write!(f, "point-to-point"),
-            NetworkType::PointToMultipoint => write!(f, "point-to-multicast"),
-        }
-    }
-}
-
 /// The OSPF interface properties.
 ///
 /// The interface gets tied to its fabric by the area property and the FRR `ip ospf area <area>`
@@ -156,7 +135,7 @@ impl Display for NetworkType {
 ///  ip ospf passive <value>
 ///  ip ospf network <value>
 /// ```
-#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Builder)]
 pub struct OspfInterface {
     // Note: an interface can only be a part of a single area(so no vec needed here)
     pub area: Area,
diff --git a/proxmox-frr/src/route_map.rs b/proxmox-frr/src/route_map.rs
index 0918a3cead14..2ef9d75618a5 100644
--- a/proxmox-frr/src/route_map.rs
+++ b/proxmox-frr/src/route_map.rs
@@ -1,29 +1,19 @@
-use std::{
-    fmt::{self, Display},
-    net::IpAddr,
-};
+use std::net::IpAddr;
 
 use proxmox_network_types::ip_address::Cidr;
+use serde::Serialize;
 
 /// The action for a [`AccessListRule`].
 ///
 /// The default is Permit. Deny can be used to create a NOT match (e.g. match all routes that are
 /// NOT in 10.10.10.0/24 using `ip access-list TEST deny 10.10.10.0/24`).
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "kebab-case")]
 pub enum AccessAction {
     Permit,
     Deny,
 }
 
-impl fmt::Display for AccessAction {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        match self {
-            AccessAction::Permit => write!(f, "permit"),
-            AccessAction::Deny => write!(f, "deny"),
-        }
-    }
-}
-
 /// A single [`AccessList`] rule.
 ///
 /// Every rule in a [`AccessList`] is its own command and gets written into a new line (with the
@@ -40,15 +30,16 @@ impl fmt::Display for AccessAction {
 /// ! or
 /// ipv6 access-list filter permit 2001:db8::/64
 /// ```
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
 pub struct AccessListRule {
     pub action: AccessAction,
     pub network: Cidr,
     pub seq: Option<u32>,
+    pub is_ipv6: bool,
 }
 
 /// The name of an [`AccessList`].
-#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
 pub struct AccessListName(String);
 
 impl AccessListName {
@@ -57,12 +48,6 @@ impl AccessListName {
     }
 }
 
-impl Display for AccessListName {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        self.0.fmt(f)
-    }
-}
-
 /// A FRR access-list.
 ///
 /// Holds a vec of rules. Each rule will get its own line, FRR will collect all the rules with the
@@ -75,7 +60,7 @@ impl Display for AccessListName {
 /// ip access-list pve_test permit 12.1.1.0/24
 /// ip access-list pve_test deny 8.8.8.8/32
 /// ```
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
 pub struct AccessList {
     pub name: AccessListName,
     pub rules: Vec<AccessListRule>,
@@ -98,66 +83,38 @@ pub struct AccessList {
 /// ! or
 ///  match ipv6 next-hop <ip-address>
 /// ```
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
+#[serde(tag = "protocol_type", rename_all = "lowercase")]
 pub enum RouteMapMatch {
+    #[serde(rename = "ip")]
     V4(RouteMapMatchInner),
+    #[serde(rename = "ipv6")]
     V6(RouteMapMatchInner),
 }
 
-impl Display for RouteMapMatch {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        match self {
-            RouteMapMatch::V4(route_map_match_v4) => match route_map_match_v4 {
-                RouteMapMatchInner::IpAddress(access_list_name) => {
-                    write!(f, "match ip address {access_list_name}")
-                }
-                RouteMapMatchInner::IpNextHop(next_hop) => {
-                    write!(f, "match ip next-hop {next_hop}")
-                }
-            },
-            RouteMapMatch::V6(route_map_match_v6) => match route_map_match_v6 {
-                RouteMapMatchInner::IpAddress(access_list_name) => {
-                    write!(f, "match ipv6 address {access_list_name}")
-                }
-                RouteMapMatchInner::IpNextHop(next_hop) => {
-                    write!(f, "match ipv6 next-hop {next_hop}")
-                }
-            },
-        }
-    }
-}
-
 /// A route-map match statement generic on the IP-version.
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
+#[serde(tag = "match_type", content = "value", rename_all = "kebab-case")]
 pub enum RouteMapMatchInner {
-    IpAddress(AccessListName),
-    IpNextHop(String),
+    Address(AccessListName),
+    NextHop(String),
 }
 
 /// Defines the Action a route-map takes when it matches on a route.
 ///
 /// If the route matches the [`RouteMapMatch`], then a [`RouteMapSet`] action will be executed.
 /// We currently only use the IpSrc command which changes the source address of the route.
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
+#[serde(tag = "set_type", content = "value", rename_all = "kebab-case")]
 pub enum RouteMapSet {
     LocalPreference(u32),
-    IpSrc(IpAddr),
+    Src(IpAddr),
     Metric(u32),
     Community(String),
 }
 
-impl Display for RouteMapSet {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        match self {
-            RouteMapSet::LocalPreference(pref) => write!(f, "set local-preference {}", pref),
-            RouteMapSet::IpSrc(addr) => write!(f, "set src {}", addr),
-            RouteMapSet::Metric(metric) => write!(f, "set metric {}", metric),
-            RouteMapSet::Community(community) => write!(f, "set community {}", community),
-        }
-    }
-}
-
-#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+/// Name of the route-map
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
 pub struct RouteMapName(String);
 
 impl RouteMapName {
@@ -166,12 +123,6 @@ impl RouteMapName {
     }
 }
 
-impl Display for RouteMapName {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        self.0.fmt(f)
-    }
-}
-
 /// A FRR route-map.
 ///
 /// In FRR route-maps are used to manipulate routes learned by protocols. We can match on specific
@@ -186,7 +137,7 @@ impl Display for RouteMapName {
 ///  set src <ip-address>
 /// exit
 /// ```
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
 pub struct RouteMap {
     pub name: RouteMapName,
     pub seq: u32,
@@ -198,21 +149,13 @@ pub struct RouteMap {
 /// The ProtocolType used in the [`ProtocolRouteMap`].
 ///
 /// Specifies to which protocols we can attach route-maps.
-#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
+#[serde(rename_all = "kebab-case")]
 pub enum ProtocolType {
     Openfabric,
     Ospf,
 }
 
-impl Display for ProtocolType {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        match self {
-            ProtocolType::Openfabric => write!(f, "openfabric"),
-            ProtocolType::Ospf => write!(f, "ospf"),
-        }
-    }
-}
-
 /// ProtocolRouteMap statement.
 ///
 /// This statement attaches the route-map to the protocol, so that all the routes learned through
@@ -225,9 +168,8 @@ impl Display for ProtocolType {
 /// ! or
 /// ipv6 protocol <protocol> route-map <route-map-name>
 /// ```
-#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
 pub struct ProtocolRouteMap {
     pub is_ipv6: bool,
-    pub protocol: ProtocolType,
     pub routemap_name: RouteMapName,
 }
diff --git a/proxmox-frr/src/serializer.rs b/proxmox-frr/src/serializer.rs
index f8a3c7238d94..ade4a2aae7ca 100644
--- a/proxmox-frr/src/serializer.rs
+++ b/proxmox-frr/src/serializer.rs
@@ -1,203 +1,83 @@
-use std::fmt::{self, Write};
-
-use crate::{
-    openfabric::{OpenfabricInterface, OpenfabricRouter},
-    ospf::{OspfInterface, OspfRouter},
-    route_map::{AccessList, AccessListName, ProtocolRouteMap, RouteMap},
-    FrrConfig, Interface, InterfaceName, Router, RouterName,
-};
-
-pub struct FrrConfigBlob<'a> {
-    buf: &'a mut (dyn Write + 'a),
-}
+use std::{fs, path::PathBuf};
+
+use minijinja::Environment;
+
+use crate::{FrrConfig, OpenfabricFrrConfig, OspfFrrConfig};
+
+fn create_env<'a>() -> Environment<'a> {
+    let mut env = Environment::new();
+
+    // avoid unnecessary additional newlines
+    env.set_trim_blocks(true);
+    env.set_lstrip_blocks(true);
+
+    env.set_loader(move |name| {
+        let override_path = PathBuf::from(format!("/etc/pve/sdn/templates/{name}"));
+        // first read the override template:
+        match fs::read_to_string(override_path) {
+            Ok(template_content) => Ok(Some(template_content)),
+            // if that fails, read the vendored template:
+            Err(_) => match name {
+                "fabricd.jinja" => Ok(Some(include_str!("../templates/fabricd.jinja").to_owned())),
+                "ospfd.jinja" => Ok(Some(include_str!("../templates/ospfd.jinja").to_owned())),
+                "route_map.jinja" => Ok(Some(
+                    include_str!("../templates/route_map.jinja").to_owned(),
+                )),
+                "interface.jinja" => Ok(Some(
+                    include_str!("../templates/interface.jinja").to_owned(),
+                )),
+                "access_list.jinja" => Ok(Some(
+                    include_str!("../templates/access_list.jinja").to_owned(),
+                )),
+                _ => Ok(None),
+            },
+        }
+    });
 
-impl Write for FrrConfigBlob<'_> {
-    fn write_str(&mut self, s: &str) -> Result<(), fmt::Error> {
-        self.buf.write_str(s)
-    }
+    env
 }
 
-pub trait FrrSerializer {
-    fn serialize(&self, f: &mut FrrConfigBlob<'_>) -> fmt::Result;
+fn render_openfabric_config(
+    env: &mut Environment<'_>,
+    config: &OpenfabricFrrConfig,
+) -> Result<String, anyhow::Error> {
+    let template = env.get_template("fabricd.jinja")?;
+    let rendered = template.render(config)?;
+    Ok(rendered)
 }
 
-pub fn to_raw_config(frr_config: &FrrConfig) -> Result<Vec<String>, anyhow::Error> {
-    let mut out = String::new();
-    let mut blob = FrrConfigBlob { buf: &mut out };
-    frr_config.serialize(&mut blob)?;
-
-    Ok(out.as_str().lines().map(String::from).collect())
+fn render_ospf_config(
+    env: &mut Environment<'_>,
+    config: &OspfFrrConfig,
+) -> Result<String, anyhow::Error> {
+    let template = env.get_template("ospfd.jinja")?;
+    let rendered = template.render(config)?;
+    Ok(rendered)
 }
 
+/// Render the passed [`FrrConfig`] into a single string containing the whole config.
 pub fn dump(config: &FrrConfig) -> Result<String, anyhow::Error> {
-    let mut out = String::new();
-    let mut blob = FrrConfigBlob { buf: &mut out };
-    config.serialize(&mut blob)?;
-    Ok(out)
-}
-
-impl FrrSerializer for FrrConfig {
-    fn serialize(&self, f: &mut FrrConfigBlob<'_>) -> fmt::Result {
-        self.router().try_for_each(|router| router.serialize(f))?;
-        self.interfaces()
-            .try_for_each(|interface| interface.serialize(f))?;
-        self.access_lists().try_for_each(|list| list.serialize(f))?;
-        self.routemaps().try_for_each(|map| map.serialize(f))?;
-        self.protocol_routemaps()
-            .try_for_each(|pm| pm.serialize(f))?;
-        Ok(())
-    }
-}
-
-impl FrrSerializer for (&RouterName, &Router) {
-    fn serialize(&self, f: &mut FrrConfigBlob<'_>) -> fmt::Result {
-        let router_name = self.0;
-        let router = self.1;
-        writeln!(f, "router {router_name}")?;
-        router.serialize(f)?;
-        writeln!(f, "exit")?;
-        writeln!(f, "!")?;
-        Ok(())
-    }
-}
+    let mut result = String::new();
+    let mut env = create_env();
 
-impl FrrSerializer for (&InterfaceName, &Interface) {
-    fn serialize(&self, f: &mut FrrConfigBlob<'_>) -> fmt::Result {
-        let interface_name = self.0;
-        let interface = self.1;
-        writeln!(f, "interface {interface_name}")?;
-        interface.serialize(f)?;
-        writeln!(f, "exit")?;
-        writeln!(f, "!")?;
-        Ok(())
-    }
-}
+    result.push_str(&render_openfabric_config(&mut env, &config.openfabric)?);
+    result.push_str(&render_ospf_config(&mut env, &config.ospf)?);
 
-impl FrrSerializer for (&AccessListName, &AccessList) {
-    fn serialize(&self, f: &mut FrrConfigBlob<'_>) -> fmt::Result {
-        self.1.serialize(f)?;
-        writeln!(f, "!")
-    }
+    Ok(result)
 }
 
-impl FrrSerializer for Interface {
-    fn serialize(&self, f: &mut FrrConfigBlob<'_>) -> fmt::Result {
-        match self {
-            Interface::Openfabric(openfabric_interface) => openfabric_interface.serialize(f)?,
-            Interface::Ospf(ospf_interface) => ospf_interface.serialize(f)?,
-        }
-        Ok(())
-    }
-}
+/// Render the passed [`FrrConfig`] into the literal Frr config.
+///
+/// The Frr config is returned as lines stored in a Vec.
+pub fn to_raw_config(config: &FrrConfig) -> Result<Vec<String>, anyhow::Error> {
+    let mut result = String::new();
+    let mut env = create_env();
 
-impl FrrSerializer for OpenfabricInterface {
-    fn serialize(&self, f: &mut FrrConfigBlob<'_>) -> fmt::Result {
-        if self.is_ipv6 {
-            writeln!(f, " ipv6 router {}", self.fabric_id)?;
-        }
-        if self.is_ipv4 {
-            writeln!(f, " ip router {}", self.fabric_id)?;
-        }
-        if self.passive == Some(true) {
-            writeln!(f, " openfabric passive")?;
-        }
-        if let Some(interval) = self.hello_interval {
-            writeln!(f, " openfabric hello-interval {interval}",)?;
-        }
-        if let Some(multiplier) = self.hello_multiplier {
-            writeln!(f, " openfabric hello-multiplier {multiplier}",)?;
-        }
-        if let Some(interval) = self.csnp_interval {
-            writeln!(f, " openfabric csnp-interval {interval}",)?;
-        }
-        Ok(())
-    }
-}
+    result.push_str(&render_openfabric_config(&mut env, &config.openfabric)?);
+    result.push_str(&render_ospf_config(&mut env, &config.ospf)?);
 
-impl FrrSerializer for OspfInterface {
-    fn serialize(&self, f: &mut FrrConfigBlob<'_>) -> fmt::Result {
-        writeln!(f, " ip ospf {}", self.area)?;
-        if self.passive == Some(true) {
-            writeln!(f, " ip ospf passive")?;
-        }
-        if let Some(network_type) = &self.network_type {
-            writeln!(f, " ip ospf network {network_type}")?;
-        }
-        Ok(())
-    }
-}
-
-impl FrrSerializer for Router {
-    fn serialize(&self, f: &mut FrrConfigBlob<'_>) -> fmt::Result {
-        match self {
-            Router::Openfabric(open_fabric_router) => open_fabric_router.serialize(f),
-            Router::Ospf(ospf_router) => ospf_router.serialize(f),
-        }
-    }
-}
-
-impl FrrSerializer for OpenfabricRouter {
-    fn serialize(&self, f: &mut FrrConfigBlob<'_>) -> fmt::Result {
-        writeln!(f, " net {}", self.net())?;
-        Ok(())
-    }
-}
-
-impl FrrSerializer for OspfRouter {
-    fn serialize(&self, f: &mut FrrConfigBlob<'_>) -> fmt::Result {
-        writeln!(f, " ospf router-id {}", self.router_id())?;
-        Ok(())
-    }
-}
-
-impl FrrSerializer for AccessList {
-    fn serialize(&self, f: &mut FrrConfigBlob<'_>) -> fmt::Result {
-        for i in &self.rules {
-            if i.network.is_ipv6() {
-                write!(f, "ipv6 ")?;
-            }
-            write!(f, "access-list {} ", self.name)?;
-            if let Some(seq) = i.seq {
-                write!(f, "seq {seq} ")?;
-            }
-            write!(f, "{} ", i.action)?;
-            writeln!(f, "{}", i.network)?;
-        }
-        writeln!(f, "!")?;
-        Ok(())
-    }
-}
-
-impl FrrSerializer for RouteMap {
-    fn serialize(&self, f: &mut FrrConfigBlob<'_>) -> fmt::Result {
-        writeln!(f, "route-map {} {} {}", self.name, self.action, self.seq)?;
-        for i in &self.matches {
-            writeln!(f, " {}", i)?;
-        }
-        for i in &self.sets {
-            writeln!(f, " {}", i)?;
-        }
-        writeln!(f, "exit")?;
-        writeln!(f, "!")
-    }
-}
-
-impl FrrSerializer for ProtocolRouteMap {
-    fn serialize(&self, f: &mut FrrConfigBlob<'_>) -> fmt::Result {
-        if self.is_ipv6 {
-            writeln!(
-                f,
-                "ipv6 protocol {} route-map {}",
-                self.protocol, self.routemap_name
-            )?;
-        } else {
-            writeln!(
-                f,
-                "ip protocol {} route-map {}",
-                self.protocol, self.routemap_name
-            )?;
-        }
-        writeln!(f, "!")?;
-        Ok(())
-    }
+    Ok(result
+        .lines()
+        .map(|line| line.to_owned())
+        .collect::<Vec<String>>())
 }
diff --git a/proxmox-frr/templates/access_list.jinja b/proxmox-frr/templates/access_list.jinja
new file mode 100644
index 000000000000..2faf467ff166
--- /dev/null
+++ b/proxmox-frr/templates/access_list.jinja
@@ -0,0 +1,6 @@
+{% for access_list in access_lists %}
+!
+{%  for rule in access_list.rules %}
+{{ "ipv6 " if rule.is_ipv6 }}access-list {{ access_list.name }} {{ ("seq " ~ rules.seq ~ " ") if rule.seq }}{{ rule.action }} {{ rule.network }}
+{%  endfor%}
+{% endfor %}
diff --git a/proxmox-frr/templates/fabricd.jinja b/proxmox-frr/templates/fabricd.jinja
new file mode 100644
index 000000000000..e7fe511da190
--- /dev/null
+++ b/proxmox-frr/templates/fabricd.jinja
@@ -0,0 +1,36 @@
+{% for router_name, router_config in router|items %}
+!
+router openfabric {{ router_name }}
+ net {{ router_config.net }}
+exit
+{% endfor %}
+{% for interface_name, interface_config in interfaces|items %}
+!
+{% include 'interface.jinja' %}
+{% if interface_config.fabric_id and interface_config.is_ipv4 %}
+ ip router openfabric {{ interface_config.fabric_id }}
+{% endif %}
+{% if interface_config.fabric_id and interface_config.is_ipv6 %}
+ ipv6 router openfabric {{ interface_config.fabric_id }}
+{% endif %}
+{% if interface_config.passive %}
+ openfabric passive
+{% endif %}
+{% if interface_config.hello_interval %}
+ openfabric hello-interval {{ interface_config.hello_interval}}
+{% endif %}
+{% if interface_config.hello_multiplier %}
+ openfabric hello-multiplier {{ interface_config.hello_multiplier}}
+{% endif %}
+{% if interface_config.csnp_interval %}
+ openfabric csnp-interval {{ interface_config.csnp_interval}}
+{% endif %}
+exit
+{% endfor %}
+{% include 'access_list.jinja' %}
+{% include 'route_map.jinja' %}
+{% for protocol_routemap in protocol_routemaps %}
+!
+{{ "ipv6" if protocol_routemap.is_ipv6 else "ip"}} protocol openfabric route-map {{ protocol_routemap.routemap_name }}
+{% endfor %}
+
diff --git a/proxmox-frr/templates/interface.jinja b/proxmox-frr/templates/interface.jinja
new file mode 100644
index 000000000000..f4a87e24fc41
--- /dev/null
+++ b/proxmox-frr/templates/interface.jinja
@@ -0,0 +1,4 @@
+interface {{ interface_name }}
+{% for address in interface_config.addresses %}
+ ip address {{address}}
+{% endfor %}
diff --git a/proxmox-frr/templates/ospfd.jinja b/proxmox-frr/templates/ospfd.jinja
new file mode 100644
index 000000000000..de9aceb4fc6a
--- /dev/null
+++ b/proxmox-frr/templates/ospfd.jinja
@@ -0,0 +1,27 @@
+{% for router_name, router_config in router|items %}
+{%  if loop.first is true %}
+!
+router ospf
+ ospf router-id {{ router_config.router_id }}
+exit
+{%  endif %}
+{% endfor %}
+{% for interface_name, interface_config in interfaces|items %}
+!
+{% include 'interface.jinja' %}
+ ip ospf area {{ interface_config.area }}
+{% if interface_config.passive %}
+ ip ospf passive
+{% endif %}
+{% if interface_config.network_type %}
+ ip ospf network {{ interface_config.network_type }}
+{% endif %}
+exit
+{% endfor %}
+{% include 'access_list.jinja' %}
+{% include 'route_map.jinja' %}
+{% for protocol_routemap in protocol_routemaps %}
+!
+{{ "ipv6" if protocol_routemap.is_ipv6 else "ip"}} protocol ospf route-map {{ protocol_routemap.routemap_name }}
+{% endfor %}
+
diff --git a/proxmox-frr/templates/route_map.jinja b/proxmox-frr/templates/route_map.jinja
new file mode 100644
index 000000000000..1fd8c7197003
--- /dev/null
+++ b/proxmox-frr/templates/route_map.jinja
@@ -0,0 +1,11 @@
+{% for routemap in routemaps %}
+!
+route-map {{ routemap.name }} {{ routemap.action }} {{ routemap.seq }}
+{%  for match in routemap.matches %}
+ match {{ match.protocol_type }} {{ match.match_type }} {{ match.value }}
+{%  endfor %}
+{%  for set in routemap.sets %}
+ set {{ set.set_type }} {{ set.value }}
+{%  endfor %}
+exit
+{% endfor %}
-- 
2.47.3



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


  reply	other threads:[~2025-09-19  9:42 UTC|newest]

Thread overview: 9+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2025-09-19  9:41 [pve-devel] [RFC network/ve-rs 0/8] Template-based FRR config generation Gabriel Goller
2025-09-19  9:41 ` Gabriel Goller [this message]
2025-09-19  9:41 ` [pve-devel] [PATCH ve-rs 2/4] sdn-types: forward serialize to display for NET Gabriel Goller
2025-09-19  9:41 ` [pve-devel] [PATCH ve-rs 3/4] ve-config: fabrics: use new proxmox-frr structs to generate frr config Gabriel Goller
2025-09-19  9:41 ` [pve-devel] [PATCH ve-rs 4/4] tests: always prepend the frr delimiter/comment "!" to the block Gabriel Goller
2025-09-19  9:41 ` [pve-devel] [PATCH network 1/4] sdn: remove duplicate comment line '!' in frr config Gabriel Goller
2025-09-19  9:41 ` [pve-devel] [PATCH network 2/4] sdn: add trailing newline " Gabriel Goller
2025-09-19  9:41 ` [pve-devel] [PATCH network 3/4] sdn: tests: add missing comment '!' " Gabriel Goller
2025-09-19  9:41 ` [pve-devel] [PATCH network 4/4] tests: use Test::Differences to make test assertions Gabriel Goller

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20250919094122.73373-2-g.goller@proxmox.com \
    --to=g.goller@proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal