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;