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;