Skip to main content

incus_client/
events.rs

1//! WebSocket subscription to Incus's `/1.0/events` push-notification
2//! stream. This is an *enhancement* over [`Client::wait_for_operation`],
3//! not a replacement - that method works without this `events` feature at
4//! all.
5//!
6//! `subscribe_events` directly re-exposes the underlying WebSocket stream
7//! rather than buffering through an intermediate channel, so a slow
8//! consumer simply leaves frames unread in the transport's own receive
9//! buffer (natural backpressure) instead of risking unbounded in-process
10//! buffering.
11
12use futures::{Stream, StreamExt};
13use serde::Deserialize;
14use tokio::net::UnixStream;
15use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
16use tokio_tungstenite::tungstenite::Message;
17use tokio_tungstenite::WebSocketStream;
18
19use crate::error::{Error, Result};
20use crate::operations::Operation;
21use crate::transport::Client;
22
23/// Which event types to subscribe to. All `true` by default (subscribe to
24/// everything Incus emits).
25///
26/// Setting every field to `false` sends `?type=` (an empty value) to Incus
27/// rather than omitting the parameter - whether a real daemon then treats
28/// that as "no filter, send everything" or "filter to nothing" is not
29/// verified by this crate. [`EventFilter::default`] covers the common case;
30/// if you deliberately construct an all-`false` filter, confirm the
31/// behavior against a real daemon first.
32#[derive(Debug, Clone, Copy)]
33pub struct EventFilter {
34    pub operations: bool,
35    pub lifecycle: bool,
36    pub logging: bool,
37}
38
39impl Default for EventFilter {
40    fn default() -> Self {
41        Self {
42            operations: true,
43            lifecycle: true,
44            logging: true,
45        }
46    }
47}
48
49impl EventFilter {
50    fn query_value(self) -> String {
51        let mut types = Vec::new();
52        if self.operations {
53            types.push("operation");
54        }
55        if self.lifecycle {
56            types.push("lifecycle");
57        }
58        if self.logging {
59            types.push("logging");
60        }
61        types.join(",")
62    }
63}
64
65/// One event from the `/1.0/events` stream. `Lifecycle` and `Logging`
66/// payloads stay untyped (`serde_json::Value`) for v1 - only `Operation`
67/// events are fully typed, since that's what operation-completion tracking
68/// needs.
69#[derive(Debug, Clone)]
70pub enum Event {
71    Operation(Operation),
72    Lifecycle(serde_json::Value),
73    Logging(serde_json::Value),
74}
75
76#[derive(Debug, Deserialize)]
77struct RawFrame {
78    #[serde(rename = "type")]
79    kind: String,
80    metadata: serde_json::Value,
81}
82
83fn parse_event(text: &str) -> Result<Event> {
84    let frame: RawFrame = serde_json::from_str(text)?;
85    match frame.kind.as_str() {
86        "operation" => Ok(Event::Operation(serde_json::from_value(frame.metadata)?)),
87        "lifecycle" => Ok(Event::Lifecycle(frame.metadata)),
88        "logging" => Ok(Event::Logging(frame.metadata)),
89        other => Err(Error::InvalidResponse(format!(
90            "unknown event frame type {other:?}"
91        ))),
92    }
93}
94
95/// The event stream returned by [`Client::subscribe_events`]. Yields
96/// `Result<Event>` - a malformed frame surfaces as one `Err` item rather
97/// than terminating the whole stream, since one bad frame from a busy
98/// daemon shouldn't take down an otherwise-healthy subscription.
99///
100/// This crate has no TLS transport in this epic (Incus's events endpoint is
101/// reached over the same Unix socket as every other request), so the inner
102/// stream is `WebSocketStream<UnixStream>` directly rather than wrapped in
103/// `tokio_tungstenite::MaybeTlsStream`.
104///
105/// The hand-rolled `poll_next` below (rather than a `futures` combinator
106/// chain like `filter_map`/`scan`) is a deliberate choice, not an
107/// oversight: this stream needs three behaviors that don't compose cleanly
108/// through a single combinator - skip-and-continue-polling (Ping/Pong),
109/// emit-and-keep-going (a parsed event, or a recoverable per-frame error),
110/// and emit-one-final-item-then-permanently-stop (an abnormal close).
111/// `filter_map` alone can express the first two but conflates "skip" with
112/// "stop", which is exactly the bug the `done` flag below fixes; layering
113/// `scan` on top to add termination just moves the same state machine into
114/// nested combinator closures without simplifying it.
115pub struct EventStream {
116    inner: WebSocketStream<UnixStream>,
117    // Set once the connection has genuinely ended (a close frame was seen,
118    // or the underlying stream itself ended). tokio-tungstenite doesn't
119    // guarantee a `Close`/`None` observation is the *last* thing a poll
120    // ever yields - without this, a caller could see the same
121    // abnormal-close `Err` (or `None`) repeat indefinitely instead of the
122    // stream actually terminating.
123    done: bool,
124}
125
126impl Stream for EventStream {
127    type Item = Result<Event>;
128
129    fn poll_next(
130        mut self: std::pin::Pin<&mut Self>,
131        cx: &mut std::task::Context<'_>,
132    ) -> std::task::Poll<Option<Self::Item>> {
133        if self.done {
134            return std::task::Poll::Ready(None);
135        }
136        loop {
137            match self.inner.poll_next_unpin(cx) {
138                std::task::Poll::Ready(Some(Ok(Message::Text(text)))) => {
139                    return std::task::Poll::Ready(Some(parse_event(text.as_str())));
140                }
141                std::task::Poll::Ready(Some(Ok(Message::Ping(_) | Message::Pong(_)))) => {
142                    // tokio-tungstenite answers pings automatically; nothing
143                    // for the caller to see here - poll again.
144                    continue;
145                }
146                // A close frame carrying a non-`Normal` code means the
147                // daemon ended the subscription abnormally (restart,
148                // internal error, protocol violation) rather than the
149                // subscription simply running its course - surface that as
150                // one `Err` item so a caller waiting on a specific event
151                // can't mistake "the daemon dropped us" for "nothing more
152                // to send."
153                std::task::Poll::Ready(Some(Ok(Message::Close(Some(frame)))))
154                    if frame.code != CloseCode::Normal =>
155                {
156                    self.done = true;
157                    return std::task::Poll::Ready(Some(Err(Error::InvalidResponse(format!(
158                        "/1.0/events subscription closed abnormally: {:?} ({})",
159                        frame.code, frame.reason
160                    )))));
161                }
162                std::task::Poll::Ready(Some(Ok(Message::Close(_))))
163                | std::task::Poll::Ready(None) => {
164                    self.done = true;
165                    return std::task::Poll::Ready(None);
166                }
167                std::task::Poll::Ready(Some(Ok(Message::Binary(_) | Message::Frame(_)))) => {
168                    return std::task::Poll::Ready(Some(Err(Error::InvalidResponse(
169                        "unexpected binary websocket frame from /1.0/events".to_owned(),
170                    ))));
171                }
172                // Distinguish a genuine socket I/O failure (`Transport`,
173                // consistent with every other transport error in this
174                // crate) from a WebSocket-layer protocol violation
175                // (oversized frame, malformed handshake data, capacity
176                // limit, ...) that isn't really an I/O problem at all - a
177                // caller shouldn't have to parse the error message to tell
178                // them apart.
179                std::task::Poll::Ready(Some(Err(tokio_tungstenite::tungstenite::Error::Io(
180                    io_err,
181                )))) => {
182                    return std::task::Poll::Ready(Some(Err(Error::Transport(io_err))));
183                }
184                std::task::Poll::Ready(Some(Err(err))) => {
185                    return std::task::Poll::Ready(Some(Err(Error::WebSocketProtocol(
186                        err.to_string(),
187                    ))));
188                }
189                std::task::Poll::Pending => return std::task::Poll::Pending,
190            }
191        }
192    }
193}
194
195impl Client {
196    /// Subscribes to Incus's `/1.0/events` WebSocket stream, filtered per
197    /// `filter`. The connection is made over the same Unix socket as every
198    /// other request - see the module doc comment for why the resulting
199    /// stream is exposed directly rather than through a buffering channel.
200    pub async fn subscribe_events(&self, filter: EventFilter) -> Result<EventStream> {
201        let socket_path = self.socket_path();
202        let stream = UnixStream::connect(&socket_path)
203            .await
204            .map_err(Error::Transport)?;
205        let request = format!("ws://localhost/1.0/events?type={}", filter.query_value());
206        let (ws_stream, _response) = tokio_tungstenite::client_async(request, stream)
207            .await
208            .map_err(|err| Error::Transport(std::io::Error::other(err.to_string())))?;
209        Ok(EventStream {
210            inner: ws_stream,
211            done: false,
212        })
213    }
214}
215
216#[cfg(test)]
217#[path = "events_tests.rs"]
218mod tests;