Skip to main content

incus_client/
transport.rs

1//! The one place resource/operation code reaches the Incus daemon through:
2//! `Client::request`. Everything below this module is HTTP framing
3//! (`transport::unix`); everything above it (operations, resources) works
4//! only with `IncusEnvelope`, never raw bytes.
5
6pub(crate) mod unix;
7
8use std::path::PathBuf;
9use std::sync::Arc;
10use std::time::Duration;
11
12use crate::config::ClientConfig;
13use crate::error::{Error, Result};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub(crate) enum Method {
17    Get,
18    Post,
19    Put,
20    Patch,
21    Delete,
22}
23
24impl Method {
25    pub(crate) fn as_str(self) -> &'static str {
26        match self {
27            Method::Get => "GET",
28            Method::Post => "POST",
29            Method::Put => "PUT",
30            Method::Patch => "PATCH",
31            Method::Delete => "DELETE",
32        }
33    }
34}
35
36/// A parsed Incus response envelope - see
37/// <https://linuxcontainers.org/incus/docs/main/rest-api/>. Error responses
38/// (HTTP 4xx/5xx, or a `{"type":"error",...}` body) are turned into
39/// `Err(Error::Api { .. })` by [`Client::request`] rather than represented
40/// here, so callers never need to check for an `Error` envelope variant
41/// themselves.
42#[derive(Debug, Clone)]
43pub(crate) enum IncusEnvelope {
44    Sync {
45        metadata: serde_json::Value,
46        etag: Option<String>,
47    },
48    Async {
49        /// The raw operation JSON object - `crate::operations` deserializes
50        /// this into a typed `Operation`. Kept untyped here so this module
51        /// has no dependency on `crate::operations`.
52        metadata: serde_json::Value,
53    },
54}
55
56/// A value paired with the ETag it was fetched with, for later use as an
57/// `If-Match` precondition on an update.
58///
59/// Fields are `pub(crate)`, not `pub`: every `WithEtag` a caller can observe
60/// came from an actual `get_*` call in this crate, which is what makes the
61/// ETag trustworthy as "this really was fetched, not typed in by hand." A
62/// `pub` struct literal would let a caller construct
63/// `WithEtag { value, etag: Some("whatever") }` directly, defeating that
64/// guarantee. Use [`WithEtag::value`], [`WithEtag::etag`], or
65/// [`WithEtag::into_parts`] to get the data back out.
66#[derive(Debug, Clone)]
67pub struct WithEtag<T> {
68    pub(crate) value: T,
69    pub(crate) etag: Option<String>,
70}
71
72impl<T> WithEtag<T> {
73    /// The fetched value.
74    pub fn value(&self) -> &T {
75        &self.value
76    }
77
78    /// The ETag it was fetched with, if the response carried one. Pass this
79    /// straight to the matching `update_*`/`patch_*` call's `etag`
80    /// parameter (as `Some(etag.as_deref())`) for optimistic-concurrency
81    /// protection.
82    pub fn etag(&self) -> Option<&str> {
83        self.etag.as_deref()
84    }
85
86    /// Consumes `self`, returning the value and ETag by ownership - useful
87    /// when you need to move `value` out (e.g. to mutate it in place
88    /// before sending it back as a `new_definition`) without cloning.
89    pub fn into_parts(self) -> (T, Option<String>) {
90        (self.value, self.etag)
91    }
92}
93
94/// Maps the two HTTP statuses that mean something specific about a
95/// caller-identified resource into their matching typed `Error` variant -
96/// 404 into `Error::NotFound { resource }`, and 412 (a stale `If-Match`
97/// ETag) into `Error::PreconditionFailed { resource }`. Every other error
98/// passes through unchanged. Shared by every resource module's `get_*`,
99/// `update_*`/`patch_*`, and `delete_*` methods, so both status-to-variant
100/// mappings live in exactly one place rather than once per resource type.
101pub(crate) fn resource_error_or(err: Error, resource: &str) -> Error {
102    match err {
103        Error::Api {
104            status_code: 404, ..
105        } => Error::NotFound {
106            resource: resource.to_owned(),
107        },
108        Error::Api {
109            status_code: 412, ..
110        } => Error::PreconditionFailed {
111            resource: resource.to_owned(),
112        },
113        other => other,
114    }
115}
116
117/// Unwraps an [`IncusEnvelope::Sync`]'s metadata, or a distinguishing
118/// `Error::InvalidResponse` if the envelope was some other shape. Shared by
119/// every resource module's `list_*` method and the bare (non-ETag) `get_*`
120/// methods (`get_storage_pool`, `get_storage_volume`) - the ETag-returning
121/// `get_*` methods (`get_instance`, `get_image`, `get_network`,
122/// `get_project`) match on `IncusEnvelope::Sync` directly instead, since they
123/// also need the `etag` field to build a [`WithEtag`].
124///
125/// `what` names the expected response for the error message (e.g. `"list"`,
126/// `"storage pool"`).
127pub(crate) fn sync_metadata(envelope: IncusEnvelope, what: &str) -> Result<serde_json::Value> {
128    match envelope {
129        IncusEnvelope::Sync { metadata, .. } => Ok(metadata),
130        other => Err(Error::InvalidResponse(format!(
131            "expected a sync {what} response, got {other:?}"
132        ))),
133    }
134}
135
136/// Owns the string form of a `recursion` bool so a `[("recursion", &str)]`
137/// query slice can be built without a dangling borrow - every `list_*`
138/// method in this crate takes an explicit `recursion` bool and sends it as
139/// this same query param.
140pub(crate) struct RecursionQuery {
141    value: String,
142}
143
144impl RecursionQuery {
145    pub(crate) fn new(recursion: bool) -> Self {
146        Self {
147            value: recursion.to_string(),
148        }
149    }
150
151    pub(crate) fn as_query(&self) -> [(&str, &str); 1] {
152        [("recursion", self.value.as_str())]
153    }
154}
155
156#[derive(Debug, Clone)]
157struct ClientInner {
158    socket_path: PathBuf,
159    request_timeout: Option<Duration>,
160}
161
162/// The Incus API client. Cheap to clone (`Arc`-backed) - share one instance
163/// across tasks rather than constructing a new one per call.
164#[derive(Debug, Clone)]
165pub struct Client(Arc<ClientInner>);
166
167impl Client {
168    /// Builds a client from `config`. No I/O happens here - connection
169    /// attempts happen lazily, once per request, when a method is called.
170    #[must_use]
171    pub fn new(config: ClientConfig) -> Self {
172        Self(Arc::new(ClientInner {
173            socket_path: config.socket_path,
174            request_timeout: config.request_timeout,
175        }))
176    }
177
178    /// Executes one Incus API request and returns its parsed envelope,
179    /// bounded by the client's configured [`ClientConfig::with_request_timeout`].
180    /// Every resource method in this crate is built on top of this.
181    pub(crate) async fn request(
182        &self,
183        method: Method,
184        path: &str,
185        query: &[(&str, &str)],
186        body: Option<&serde_json::Value>,
187        if_match: Option<&str>,
188    ) -> Result<IncusEnvelope> {
189        self.request_with_timeout(method, path, query, body, if_match, self.0.request_timeout)
190            .await
191    }
192
193    /// Like [`Client::request`], but lets the caller override the client's
194    /// configured default timeout for this one call - used by
195    /// [`Client::wait_for_operation`]'s long-poll, which already has its own
196    /// server-side bound (Incus's `.../wait?timeout=<seconds>` query param,
197    /// or a genuinely unbounded long-poll when no `timeout` is given) and
198    /// must not *also* be subject to the client-wide default meant for
199    /// ordinary, fast-returning requests - otherwise any operation that
200    /// legitimately takes longer than that default to complete would fail
201    /// with `Error::Timeout` even though nothing actually went wrong.
202    pub(crate) async fn request_with_timeout(
203        &self,
204        method: Method,
205        path: &str,
206        query: &[(&str, &str)],
207        body: Option<&serde_json::Value>,
208        if_match: Option<&str>,
209        timeout: Option<Duration>,
210    ) -> Result<IncusEnvelope> {
211        let body_bytes = body.map(serde_json::to_vec).transpose()?;
212        let raw = unix::execute(
213            &self.0.socket_path,
214            unix::RequestSpec {
215                method,
216                path,
217                query,
218                body: body_bytes.as_deref(),
219                if_match,
220            },
221            timeout,
222        )
223        .await?;
224
225        if raw.status >= 400 {
226            // Preserve the daemon's actual words in every fallback path
227            // instead of discarding the raw body behind a generic
228            // "unknown error"/"unparseable error body" string - a
229            // mismatch between the assumed `{"error": "..."}` shape and
230            // what a given Incus version/proxy actually sends shouldn't
231            // erase the only diagnostic information available.
232            let raw_body_lossy = || String::from_utf8_lossy(&raw.body).into_owned();
233            let message = match serde_json::from_slice::<serde_json::Value>(&raw.body) {
234                Ok(error_body) => match error_body.get("error").and_then(serde_json::Value::as_str)
235                {
236                    Some(text) => text.to_owned(),
237                    None => format!(
238                        "HTTP {}: response body did not match the expected {{\"error\": ...}} \
239                         shape: {}",
240                        raw.status,
241                        raw_body_lossy()
242                    ),
243                },
244                Err(_) => format!("HTTP {}: {}", raw.status, raw_body_lossy()),
245            };
246            return Err(Error::Api {
247                status_code: raw.status,
248                message,
249            });
250        }
251
252        let mut parsed: serde_json::Value = serde_json::from_slice(&raw.body)?;
253        // Capture the envelope type as an owned String (one small clone) so
254        // the immutable borrow of `parsed` ends here, freeing us to take
255        // `metadata` out of `parsed` by value below via `.remove(...)`
256        // instead of `.cloned()`-ing the whole (potentially large) subtree.
257        let envelope_type = parsed
258            .get("type")
259            .and_then(serde_json::Value::as_str)
260            .ok_or_else(|| {
261                Error::InvalidResponse(format!("response body had no \"type\" field: {parsed}"))
262            })?
263            .to_owned();
264
265        match envelope_type.as_str() {
266            "sync" => {
267                let metadata = parsed
268                    .as_object_mut()
269                    .and_then(|obj| obj.remove("metadata"))
270                    .unwrap_or(serde_json::Value::Null);
271                let etag = raw.header("etag").map(str::to_owned);
272                Ok(IncusEnvelope::Sync { metadata, etag })
273            }
274            "async" => {
275                // Validate envelope-shape strictness against the documented
276                // Incus response even though the URL itself isn't stored -
277                // `operation_from_envelope` derives the operation ID from
278                // `metadata.id` instead, so this crate never needs to parse
279                // it.
280                if parsed
281                    .get("operation")
282                    .and_then(serde_json::Value::as_str)
283                    .is_none()
284                {
285                    return Err(Error::InvalidResponse(
286                        "async response had no \"operation\" field".to_owned(),
287                    ));
288                }
289                let metadata = parsed
290                    .as_object_mut()
291                    .and_then(|obj| obj.remove("metadata"))
292                    .ok_or_else(|| {
293                        Error::InvalidResponse(
294                            "async response had no \"metadata\" field".to_owned(),
295                        )
296                    })?;
297                Ok(IncusEnvelope::Async { metadata })
298            }
299            other => Err(Error::InvalidResponse(format!(
300                "unknown envelope type {other:?}"
301            ))),
302        }
303    }
304
305    /// Only called from `events::subscribe_events`, which is gated behind
306    /// the `events` feature - suppress the dead-code lint under default
307    /// features rather than widen this method's visibility or duplicate the
308    /// socket path lookup in `events.rs`.
309    #[cfg_attr(not(feature = "events"), allow(dead_code))]
310    pub(crate) fn socket_path(&self) -> &std::path::Path {
311        &self.0.socket_path
312    }
313}
314
315#[cfg(test)]
316#[path = "transport_tests.rs"]
317mod tests;