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;