Skip to main content

incus_client/resources/
storage.rs

1//! Storage pool and volume CRUD. Volumes are scoped under a pool
2//! (`/1.0/storage-pools/{pool}/volumes`) - Incus has no global cross-pool
3//! volumes endpoint, so "list all volumes across all pools" is inherently a
4//! list-pools-then-list-volumes-per-pool fan-out on the caller's part, not a
5//! gap in this crate.
6
7use serde::Deserialize;
8
9use crate::error::{Error, Result};
10use crate::operations::{optional_operation_from_envelope, Operation};
11use crate::transport::{
12    resource_error_or, sync_metadata, Client, Method, RecursionQuery, WithEtag,
13};
14
15#[derive(Debug, Clone, Deserialize)]
16pub struct StoragePool {
17    pub name: String,
18    pub driver: String,
19    pub status: String,
20    #[serde(default)]
21    pub config: serde_json::Value,
22}
23
24#[derive(Debug, Clone, Deserialize)]
25pub struct StorageVolume {
26    pub name: String,
27    #[serde(rename = "type")]
28    pub volume_type: String,
29    pub content_type: String,
30    #[serde(default)]
31    pub config: serde_json::Value,
32}
33
34impl Client {
35    pub async fn list_storage_pools(&self, recursion: bool) -> Result<Vec<serde_json::Value>> {
36        let recursion_query = RecursionQuery::new(recursion);
37        let envelope = self
38            .request(
39                Method::Get,
40                "/1.0/storage-pools",
41                &recursion_query.as_query(),
42                None,
43                None,
44            )
45            .await?;
46        Ok(serde_json::from_value(sync_metadata(envelope, "list")?)?)
47    }
48
49    /// Fetches one storage pool by name, along with its ETag for use as a
50    /// later `If-Match` precondition.
51    pub async fn get_storage_pool(&self, name: &str) -> Result<WithEtag<StoragePool>> {
52        let path = format!("/1.0/storage-pools/{name}");
53        let envelope = self
54            .request(Method::Get, &path, &[], None, None)
55            .await
56            .map_err(|err| resource_error_or(err, name))?;
57        match envelope {
58            crate::transport::IncusEnvelope::Sync { metadata, etag } => Ok(WithEtag {
59                value: serde_json::from_value(metadata)?,
60                etag,
61            }),
62            other => Err(Error::InvalidResponse(format!(
63                "expected a sync storage pool response, got {other:?}"
64            ))),
65        }
66    }
67
68    /// Creates a storage pool. Synchronous: the pool exists by the time
69    /// this returns, with no operation to wait on. Verified against
70    /// `cmd/incusd/storage_pools.go`'s `storagePoolsPost` on the
71    /// `lxc/incus` `main` branch, which always returns
72    /// `response.SyncResponseLocation`.
73    pub async fn create_storage_pool(&self, params: &serde_json::Value) -> Result<()> {
74        self.request(Method::Post, "/1.0/storage-pools", &[], Some(params), None)
75            .await?;
76        Ok(())
77    }
78
79    /// Full replacement update (PUT). `etag`, if provided, is sent as
80    /// `If-Match` for optimistic concurrency; a stale ETag surfaces as
81    /// `Error::PreconditionFailed`, not the generic `Error::Api`.
82    ///
83    /// Synchronous, like [`Client::create_storage_pool`] - verified against
84    /// `cmd/incusd/storage_pools.go`'s `doStoragePoolUpdate`, which always
85    /// returns `response.EmptySyncResponse`.
86    pub async fn update_storage_pool(
87        &self,
88        name: &str,
89        new_definition: &serde_json::Value,
90        etag: Option<&str>,
91    ) -> Result<()> {
92        let path = format!("/1.0/storage-pools/{name}");
93        self.request(Method::Put, &path, &[], Some(new_definition), etag)
94            .await
95            .map_err(|err| resource_error_or(err, name))?;
96        Ok(())
97    }
98
99    /// Same as [`Client::update_storage_pool`], but takes the `WithEtag`
100    /// from a prior [`Client::get_storage_pool`] call directly instead of a
101    /// bare `etag: Option<&str>` - see
102    /// `instances::Client::update_instance_guarded`'s doc comment for why
103    /// this exists alongside the raw-`etag` version.
104    pub async fn update_storage_pool_guarded(
105        &self,
106        fetched: &WithEtag<StoragePool>,
107        new_definition: &serde_json::Value,
108    ) -> Result<()> {
109        self.update_storage_pool(&fetched.value().name, new_definition, fetched.etag())
110            .await
111    }
112
113    /// Synchronous - verified against `cmd/incusd/storage_pools.go`'s
114    /// `storagePoolDelete`, which always returns `response.EmptySyncResponse`.
115    pub async fn delete_storage_pool(&self, name: &str) -> Result<()> {
116        let path = format!("/1.0/storage-pools/{name}");
117        self.request(Method::Delete, &path, &[], None, None)
118            .await
119            .map_err(|err| resource_error_or(err, name))?;
120        Ok(())
121    }
122
123    /// Lists volumes within one pool. See the module doc comment for why
124    /// there's no "list all volumes across all pools" convenience method.
125    ///
126    /// Per the Incus REST API, `recursion = false` returns an array of bare
127    /// URL strings (not typed volume objects), so - like every other
128    /// `list_*` method in this crate - this returns untyped
129    /// `serde_json::Value`s rather than `Vec<StorageVolume>`. Use
130    /// `recursion = true` to get full volume objects in one call, or
131    /// `get_storage_volume` to fetch one volume's full object by name.
132    pub async fn list_storage_volumes(
133        &self,
134        pool_name: &str,
135        recursion: bool,
136    ) -> Result<Vec<serde_json::Value>> {
137        let recursion_query = RecursionQuery::new(recursion);
138        let path = format!("/1.0/storage-pools/{pool_name}/volumes");
139        let envelope = self
140            .request(Method::Get, &path, &recursion_query.as_query(), None, None)
141            .await?;
142        Ok(serde_json::from_value(sync_metadata(envelope, "list")?)?)
143    }
144
145    /// Creates a volume. Unlike every other create/update/delete method in
146    /// this crate, volume creation is genuinely conditional on the request
147    /// payload: creating a blank volume (`params` has no `source.name`) is
148    /// synchronous, but creating one by copying another volume (`params`
149    /// has a `source.name`) is asynchronous. Verified against
150    /// `cmd/incusd/storage_volumes.go`'s `doVolumeCreateOrCopy` on the
151    /// `lxc/incus` `main` branch: it returns `response.EmptySyncResponse`
152    /// when `req.Source.Name == ""`, and `operations.OperationResponse(op)`
153    /// otherwise. Returns `None` for the synchronous case (nothing to wait
154    /// for) and `Some(operation)` for the asynchronous one.
155    pub async fn create_storage_volume(
156        &self,
157        pool_name: &str,
158        params: &serde_json::Value,
159    ) -> Result<Option<Operation>> {
160        let path = format!("/1.0/storage-pools/{pool_name}/volumes");
161        let envelope = self
162            .request(Method::Post, &path, &[], Some(params), None)
163            .await?;
164        optional_operation_from_envelope(envelope)
165    }
166
167    /// Fetches one volume's full object by pool, type, and name, along with
168    /// its ETag for use as a later `If-Match` precondition.
169    pub async fn get_storage_volume(
170        &self,
171        pool_name: &str,
172        volume_type: &str,
173        volume_name: &str,
174    ) -> Result<WithEtag<StorageVolume>> {
175        let path = format!("/1.0/storage-pools/{pool_name}/volumes/{volume_type}/{volume_name}");
176        let envelope = self
177            .request(Method::Get, &path, &[], None, None)
178            .await
179            .map_err(|err| resource_error_or(err, volume_name))?;
180        match envelope {
181            crate::transport::IncusEnvelope::Sync { metadata, etag } => Ok(WithEtag {
182                value: serde_json::from_value(metadata)?,
183                etag,
184            }),
185            other => Err(Error::InvalidResponse(format!(
186                "expected a sync storage volume response, got {other:?}"
187            ))),
188        }
189    }
190
191    /// Full replacement update (PUT). `etag`, if provided, is sent as
192    /// `If-Match` for optimistic concurrency; a stale ETag surfaces as
193    /// `Error::PreconditionFailed`, not the generic `Error::Api`.
194    ///
195    /// Synchronous - verified against `cmd/incusd/storage_volumes.go`'s
196    /// `storagePoolVolumePut`, which always returns
197    /// `response.EmptySyncResponse`.
198    pub async fn update_storage_volume(
199        &self,
200        pool_name: &str,
201        volume_type: &str,
202        volume_name: &str,
203        new_definition: &serde_json::Value,
204        etag: Option<&str>,
205    ) -> Result<()> {
206        let path = format!("/1.0/storage-pools/{pool_name}/volumes/{volume_type}/{volume_name}");
207        self.request(Method::Put, &path, &[], Some(new_definition), etag)
208            .await
209            .map_err(|err| resource_error_or(err, volume_name))?;
210        Ok(())
211    }
212
213    /// Same as [`Client::update_storage_volume`], but takes the `WithEtag`
214    /// from a prior [`Client::get_storage_volume`] call directly instead of
215    /// a bare `etag: Option<&str>` - `volume_type` and the volume's own
216    /// `name` are derived from the fetched value; `pool_name` is still
217    /// required explicitly since a volume's pool isn't part of its own
218    /// returned object. See `instances::Client::update_instance_guarded`'s
219    /// doc comment for why this exists alongside the raw-`etag` version.
220    pub async fn update_storage_volume_guarded(
221        &self,
222        pool_name: &str,
223        fetched: &WithEtag<StorageVolume>,
224        new_definition: &serde_json::Value,
225    ) -> Result<()> {
226        self.update_storage_volume(
227            pool_name,
228            &fetched.value().volume_type,
229            &fetched.value().name,
230            new_definition,
231            fetched.etag(),
232        )
233        .await
234    }
235
236    /// Synchronous - verified against `cmd/incusd/storage_volumes.go`'s
237    /// `storagePoolVolumeDelete`, which always returns
238    /// `response.EmptySyncResponse`.
239    pub async fn delete_storage_volume(
240        &self,
241        pool_name: &str,
242        volume_type: &str,
243        volume_name: &str,
244    ) -> Result<()> {
245        let path = format!("/1.0/storage-pools/{pool_name}/volumes/{volume_type}/{volume_name}");
246        self.request(Method::Delete, &path, &[], None, None)
247            .await
248            .map_err(|err| resource_error_or(err, volume_name))?;
249        Ok(())
250    }
251}
252
253#[cfg(test)]
254#[path = "storage_tests.rs"]
255mod tests;