public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Hannes Laimer <h.laimer@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH pve-cluster 03/12] rust: notify: add the wire protocol
Date: Thu, 22 Oct 2026 16:21:56 +0200	[thread overview]
Message-ID: <20261022142205.930536-4-h.laimer@proxmox.com> (raw)
In-Reply-To: <20261022142205.930536-1-h.laimer@proxmox.com>

Requests, replies and events are all JSON objects, one per line. So a
client needs a single parser, and the stream stays readable as text.
A request is a command with its arguments, and a reply carries either
a result or an error. An event carries a mutation's type, the path,
the rename target and the captured values per subscription name.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 src/rust/pmxcfs-notify/src/lib.rs      |   8 ++
 src/rust/pmxcfs-notify/src/protocol.rs | 185 +++++++++++++++++++++++++
 2 files changed, 193 insertions(+)
 create mode 100644 src/rust/pmxcfs-notify/src/protocol.rs

diff --git a/src/rust/pmxcfs-notify/src/lib.rs b/src/rust/pmxcfs-notify/src/lib.rs
index 889fe50..d35d3c5 100644
--- a/src/rust/pmxcfs-notify/src/lib.rs
+++ b/src/rust/pmxcfs-notify/src/lib.rs
@@ -1,5 +1,12 @@
 //! Change notification socket for pmxcfs.
 //!
+//! Clients connect to a Unix stream socket and exchange newline delimited JSON objects. A request
+//! is `{"command": "...", "args": {...}}` and is answered with `{"ok": <json>}` or
+//! `{"error": "..."}`. Once a client has sent `subscribe` with named path templates, every
+//! matching memdb mutation is pushed as `{"event": {...}}` with the event type, the path, the
+//! rename target, and per subscription name the parameter sets captured from the path and the
+//! rename target.
+//!
 //! A template is a memdb path whose components may each hold one `{name}` placeholder for all or
 //! part of that component. The last component may instead be `{name...}` for the rest of the path.
 //! `nodes/{node}/qemu-server/{vmid}.conf` captures node and vmid, and `{path...}` matches every
@@ -7,6 +14,7 @@
 
 #![allow(dead_code)]
 
+mod protocol;
 mod template;
 
 /// Events and templates name memdb paths without a leading slash.
diff --git a/src/rust/pmxcfs-notify/src/protocol.rs b/src/rust/pmxcfs-notify/src/protocol.rs
new file mode 100644
index 0000000..d28c9dc
--- /dev/null
+++ b/src/rust/pmxcfs-notify/src/protocol.rs
@@ -0,0 +1,185 @@
+use std::collections::BTreeMap;
+
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+use crate::template::{Captures, Template};
+
+pub(crate) const PROTOCOL_VERSION: u32 = 1;
+
+/// Keyed by subscription name, with one capture set per side of the event that matched.
+pub(crate) type Params<'a> = BTreeMap<&'a str, Vec<Captures<'a>>>;
+
+#[non_exhaustive]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum EventKind {
+    Create,
+    Write,
+    Mtime,
+    Rename,
+    Delete,
+    Mkdir,
+    Resync,
+}
+
+#[derive(Serialize)]
+pub(crate) struct Event<'a> {
+    #[serde(rename = "type")]
+    pub kind: EventKind,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub path: Option<&'a str>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub to: Option<&'a str>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub params: Option<Params<'a>>,
+}
+
+/// Everything the server sends. The variant name is the single key of the JSON object on the wire.
+#[derive(Serialize)]
+#[serde(rename_all = "lowercase")]
+pub(crate) enum Message<'a> {
+    Ok(Value),
+    Error(String),
+    Event(Event<'a>),
+}
+
+impl Message<'static> {
+    pub(crate) fn resync() -> Self {
+        Message::Event(Event {
+            kind: EventKind::Resync,
+            path: None,
+            to: None,
+            params: None,
+        })
+    }
+}
+
+impl Message<'_> {
+    pub(crate) fn line(&self) -> String {
+        let mut line = serde_json::to_string(self).expect("message serialization cannot fail");
+        line.push('\n');
+        line
+    }
+}
+
+#[derive(Debug, PartialEq, Eq, Deserialize)]
+#[serde(tag = "command", content = "args", rename_all = "lowercase")]
+pub(crate) enum Command {
+    Hello,
+    Subscribe {
+        #[serde(default)]
+        patterns: BTreeMap<String, Template>,
+    },
+    Unsubscribe,
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn parse(line: &str) -> Result<Command, serde_json::Error> {
+        serde_json::from_str(line)
+    }
+
+    fn template(text: &str) -> Template {
+        text.parse().unwrap()
+    }
+
+    #[test]
+    fn parses_commands() {
+        assert_eq!(parse(r#"{"command":"hello"}"#).unwrap(), Command::Hello);
+        assert_eq!(
+            parse(
+                r#"{"command":"subscribe","args":{"patterns":{"guest":"nodes/{node}/qemu-server/{vmid}.conf","dc":"/datacenter.cfg"}}}"#
+            )
+            .unwrap(),
+            Command::Subscribe {
+                patterns: BTreeMap::from([
+                    ("dc".to_owned(), template("datacenter.cfg")),
+                    (
+                        "guest".to_owned(),
+                        template("nodes/{node}/qemu-server/{vmid}.conf")
+                    ),
+                ]),
+            }
+        );
+        assert_eq!(
+            parse(r#"{"command":"subscribe","args":{}}"#).unwrap(),
+            Command::Subscribe {
+                patterns: BTreeMap::new(),
+            }
+        );
+        assert_eq!(
+            parse(r#"{"command":"unsubscribe"}"#).unwrap(),
+            Command::Unsubscribe
+        );
+    }
+
+    #[test]
+    fn rejects_bad_requests() {
+        assert!(parse("not json").is_err());
+        assert!(parse(r#"{"args":{}}"#).is_err());
+        assert!(parse(r#"{"command":"subscribe"}"#).is_err());
+        assert!(parse(r#"{"command":"subscribe","args":{"patterns":["a.cfg"]}}"#).is_err());
+        assert!(parse(r#"{"command":"subscribe","args":{"patterns":{"x":1}}}"#).is_err());
+        let err =
+            parse(r#"{"command":"subscribe","args":{"patterns":{"x":"{a}{b}"}}}"#).unwrap_err();
+        assert!(
+            err.to_string()
+                .starts_with("only one placeholder per component in '{a}{b}'"),
+            "{err}"
+        );
+        let err = parse(r#"{"command":"bogus"}"#).unwrap_err();
+        assert!(
+            err.to_string().starts_with("unknown variant `bogus`"),
+            "{err}"
+        );
+    }
+
+    #[test]
+    fn formats_events() {
+        let params = Params::from([(
+            "guest",
+            vec![Captures::from([("node", "n1"), ("vmid", "100")])],
+        )]);
+        let write = Message::Event(Event {
+            kind: EventKind::Write,
+            path: Some("nodes/n1/qemu-server/100.conf"),
+            to: None,
+            params: Some(params),
+        });
+        assert_eq!(
+            write.line(),
+            "{\"event\":{\"type\":\"write\",\"path\":\"nodes/n1/qemu-server/100.conf\",\"params\":{\"guest\":[{\"node\":\"n1\",\"vmid\":\"100\"}]}}}\n"
+        );
+        let params = Params::from([("dc", vec![Captures::new()])]);
+        let rename = Message::Event(Event {
+            kind: EventKind::Rename,
+            path: Some("a"),
+            to: Some("datacenter.cfg"),
+            params: Some(params),
+        });
+        assert_eq!(
+            rename.line(),
+            "{\"event\":{\"type\":\"rename\",\"path\":\"a\",\"to\":\"datacenter.cfg\",\"params\":{\"dc\":[{}]}}}\n"
+        );
+        assert_eq!(
+            Message::resync().line(),
+            "{\"event\":{\"type\":\"resync\"}}\n"
+        );
+    }
+
+    #[test]
+    fn formats_replies() {
+        assert_eq!(Message::Ok(Value::Null).line(), "{\"ok\":null}\n");
+        assert_eq!(
+            Message::Ok(serde_json::json!({"protocol": 1})).line(),
+            "{\"ok\":{\"protocol\":1}}\n"
+        );
+        assert_eq!(
+            Message::Error("multi\nline".to_owned()).line(),
+            "{\"error\":\"multi\\nline\"}\n"
+        );
+    }
+}
-- 
2.47.3





  parent reply	other threads:[~2026-10-22 14:22 UTC|newest]

Thread overview: 13+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-10-22 14:21 [PATCH cluster 00/12] pmxcfs: add a change notification socket Hannes Laimer
2026-10-22 14:21 ` [PATCH pve-cluster 01/12] buildsys: add rust workspace under src/rust Hannes Laimer
2026-10-22 14:21 ` [PATCH pve-cluster 02/12] rust: notify: add path templates with placeholder capture Hannes Laimer
2026-10-22 14:21 ` Hannes Laimer [this message]
2026-10-22 14:21 ` [PATCH pve-cluster 04/12] rust: notify: add subscription matching Hannes Laimer
2026-10-22 14:21 ` [PATCH pve-cluster 05/12] rust: notify: add the socket server Hannes Laimer
2026-10-22 14:21 ` [PATCH pve-cluster 06/12] rust: ffi: add C ABI staticlib for pmxcfs Hannes Laimer
2026-10-22 14:22 ` [PATCH pve-cluster 07/12] pmxcfs: memdb: add change notification hook Hannes Laimer
2026-10-22 14:22 ` [PATCH pve-cluster 08/12] buildsys: link pmxcfs against the rust notify staticlib Hannes Laimer
2026-10-22 14:22 ` [PATCH pve-cluster 09/12] pmxcfs: notify: emit change events over the notification socket Hannes Laimer
2026-10-22 14:22 ` [PATCH pve-cluster 10/12] cfs: add perl client for the change " Hannes Laimer
2026-10-22 14:22 ` [PATCH pve-cluster 11/12] cfs: add hook registry for change notification consumers Hannes Laimer
2026-10-22 14:22 ` [PATCH pve-cluster 12/12] cfs: add runner executing cluster change hooks in children Hannes Laimer

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=20261022142205.930536-4-h.laimer@proxmox.com \
    --to=h.laimer@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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal