Skip to main content

isb_apps/volume_backup/
incus.rs

1//! The incus calls volume snapshots, exports and staged restores need, on
2//! a client already scoped to the org's project.
3
4use std::io::Read;
5use std::time::Duration;
6
7use serde::Serialize;
8use serde_json::{Value, json};
9
10use crate::client::{Client, encode_segment};
11use crate::error::{Error, Result};
12use crate::volume::VolumeInfo;
13
14/// Deadline for a snapshot, a copy or a backup: a `dir` pool copies every
15/// file, so these can take a while on a big volume.
16pub const SLOW: Duration = Duration::from_secs(3600);
17/// Idle limit on an export or import stream.
18pub const IDLE: Duration = Duration::from_secs(600);
19
20fn vol(pool: &str, name: &str) -> String {
21    format!(
22        "/1.0/storage-pools/{}/volumes/custom/{}",
23        encode_segment(pool),
24        encode_segment(name)
25    )
26}
27
28/// One snapshot of a custom volume.
29#[derive(Debug, Clone, Serialize)]
30pub struct Snapshot {
31    pub name: String,
32    pub created_at: String,
33    #[serde(skip_serializing_if = "String::is_empty")]
34    pub description: String,
35    /// `auto` (scheduled, pruned), `manual`, or `other` (not made by isb's
36    /// schedule: kept until deleted).
37    pub kind: &'static str,
38}
39
40/// The instances a volume is attached to (names in the org's project).
41pub fn instances_using(v: &VolumeInfo) -> Vec<String> {
42    v.used_by
43        .iter()
44        .filter_map(|u| {
45            let rest = u.strip_prefix("/1.0/instances/")?;
46            let name = rest.split(['?', '/']).next()?;
47            (!name.is_empty()).then(|| name.to_string())
48        })
49        .collect()
50}
51
52pub fn is_running(c: &Client, instance: &str) -> Result<bool> {
53    let v = c.get(&format!("/1.0/instances/{}", encode_segment(instance)))?;
54    Ok(v.get("status").and_then(Value::as_str) == Some("Running"))
55}
56
57/// The volume, or a not-found naming it.
58pub fn volume(c: &Client, pool: &str, name: &str) -> Result<VolumeInfo> {
59    crate::volume::get(c, pool, name)?
60        .ok_or_else(|| Error::NotFound(format!("volume {name} in pool {pool}")))
61}
62
63/// Snapshots, oldest first (incus' order).
64pub fn snapshots(c: &Client, pool: &str, name: &str) -> Result<Vec<Snapshot>> {
65    let v = c.get(&format!("{}/snapshots?recursion=1", vol(pool, name)))?;
66    let mut out: Vec<Snapshot> = v
67        .as_array()
68        .into_iter()
69        .flatten()
70        .filter_map(|s| {
71            let full = s.get("name")?.as_str()?;
72            let name = full.rsplit('/').next().unwrap_or(full).to_string();
73            let kind = if super::model::auto_time(&name).is_some() {
74                "auto"
75            } else if name.starts_with(super::model::MANUAL) {
76                "manual"
77            } else {
78                "other"
79            };
80            Some(Snapshot {
81                created_at: s
82                    .get("created_at")
83                    .and_then(Value::as_str)
84                    .unwrap_or("")
85                    .into(),
86                description: s
87                    .get("description")
88                    .and_then(Value::as_str)
89                    .unwrap_or("")
90                    .into(),
91                name,
92                kind,
93            })
94        })
95        .collect();
96    out.sort_by(|a, b| a.created_at.cmp(&b.created_at));
97    Ok(out)
98}
99
100pub fn snapshot_create(c: &Client, pool: &str, name: &str, snap: &str, desc: &str) -> Result<()> {
101    c.mutate(
102        "POST",
103        &format!("{}/snapshots", vol(pool, name)),
104        Some(&json!({"name": snap, "description": desc})),
105        &format!("snapshot {name}/{snap}"),
106        SLOW,
107    )
108    .map(|_| ())
109}
110
111pub fn snapshot_delete(c: &Client, pool: &str, name: &str, snap: &str) -> Result<()> {
112    c.mutate(
113        "DELETE",
114        &format!("{}/snapshots/{}", vol(pool, name), encode_segment(snap)),
115        None,
116        &format!("delete snapshot {name}/{snap}"),
117        SLOW,
118    )
119    .map(|_| ())
120}
121
122/// A new volume `new` holding snapshot `snap` of `name`, with `config` set.
123pub fn copy_from_snapshot(
124    c: &Client,
125    pool: &str,
126    (name, snap): (&str, &str),
127    new: &str,
128    config: &[(&str, String)],
129) -> Result<()> {
130    let body = json!({
131        "name": new, "type": "custom", "content_type": "filesystem",
132        "source": {"type": "copy", "pool": pool, "name": format!("{name}/{snap}"), "volume_only": true},
133    });
134    c.mutate(
135        "POST",
136        &format!("/1.0/storage-pools/{}/volumes/custom", encode_segment(pool)),
137        Some(&body),
138        &format!("copy {name}/{snap} to {new}"),
139        SLOW,
140    )?;
141    set_config(c, pool, new, config)
142}
143
144/// Add `user.*` keys to a volume's config.
145pub fn set_config(c: &Client, pool: &str, name: &str, config: &[(&str, String)]) -> Result<()> {
146    if config.is_empty() {
147        return Ok(());
148    }
149    let m: serde_json::Map<String, Value> = config
150        .iter()
151        .map(|(k, v)| ((*k).to_string(), json!(v)))
152        .collect();
153    c.mutate(
154        "PATCH",
155        &vol(pool, name),
156        Some(&json!({"config": m})),
157        &format!("label volume {name}"),
158        c.get_timeouts().other,
159    )
160    .map(|_| ())
161}
162
163/// Delete a volume, waiting out a few seconds of "in use" after a detach.
164pub fn delete_volume(c: &Client, pool: &str, name: &str) -> Result<()> {
165    let started = std::time::Instant::now();
166    loop {
167        match crate::volume::remove(c, pool, name) {
168            Err(e) if !e.is_not_found() && started.elapsed() < Duration::from_secs(30) => {
169                std::thread::sleep(Duration::from_secs(1));
170            }
171            Err(e) if e.is_not_found() => return Ok(()),
172            r => return r,
173        }
174    }
175}
176
177/// An incus backup (tarball) of `name` alone, streamed out. The caller
178/// deletes it with [`backup_delete`] once read.
179pub fn export(c: &Client, pool: &str, name: &str, backup: &str) -> Result<impl Read + use<>> {
180    let expires = chrono_free_expiry();
181    c.mutate(
182        "POST",
183        &format!("{}/backups", vol(pool, name)),
184        Some(&json!({
185            "name": backup, "volume_only": true, "optimized_storage": false,
186            "compression_algorithm": "none", "expires_at": expires,
187        })),
188        &format!("back up {name}"),
189        SLOW,
190    )?;
191    c.get_stream(
192        &format!(
193            "{}/backups/{}/export",
194            vol(pool, name),
195            encode_segment(backup)
196        ),
197        IDLE,
198    )
199}
200
201/// One day from now, RFC 3339: incus deletes a forgotten export itself.
202fn chrono_free_expiry() -> String {
203    let now = std::time::SystemTime::now()
204        .duration_since(std::time::UNIX_EPOCH)
205        .map(|d| d.as_secs() as i64)
206        .unwrap_or(0);
207    crate::cron::rfc3339(now + 86_400)
208}
209
210pub fn backup_delete(c: &Client, pool: &str, name: &str, backup: &str) -> Result<()> {
211    c.mutate(
212        "DELETE",
213        &format!("{}/backups/{}", vol(pool, name), encode_segment(backup)),
214        None,
215        &format!("delete the export of {name}"),
216        c.get_timeouts().other,
217    )
218    .map(|_| ())
219}
220
221/// A new volume `new` from an incus backup tarball read from `body`.
222pub fn import(c: &Client, pool: &str, new: &str, body: &mut dyn Read) -> Result<()> {
223    let reply = c.post_stream(
224        &format!("/1.0/storage-pools/{}/volumes/custom", encode_segment(pool)),
225        &[
226            ("Content-Type", "application/octet-stream".into()),
227            ("X-Incus-name", new.into()),
228        ],
229        body,
230        IDLE,
231    )?;
232    match reply {
233        crate::client::Reply::Sync(_) => Ok(()),
234        crate::client::Reply::Async { operation, .. } => c
235            .wait_operation(&operation, &format!("import {new}"), SLOW)
236            .map(|_| ()),
237    }
238}
239
240/// Attach volume `source` at `path` in `instance` as `device`.
241pub fn attach(
242    c: &Client,
243    instance: &str,
244    device: &str,
245    (pool, source): (&str, &str),
246    path: &str,
247) -> Result<()> {
248    let props = json!({"type": "disk", "pool": pool, "source": source, "path": path});
249    crate::sandbox::update_instance(
250        c,
251        instance,
252        &format!("attach {source} to {instance}"),
253        &mut |_, devices| {
254            devices.insert(device.to_string(), props.clone());
255            Ok(())
256        },
257    )
258}
259
260/// Detach every disk of `instance` whose source is `source`.
261pub fn detach(c: &Client, instance: &str, source: &str) -> Result<()> {
262    crate::sandbox::update_instance(
263        c,
264        instance,
265        &format!("detach {source} from {instance}"),
266        &mut |_, devices| {
267            devices.retain(|_, d| {
268                !(d.get("type").and_then(Value::as_str) == Some("disk")
269                    && d.get("source").and_then(Value::as_str) == Some(source))
270            });
271            Ok(())
272        },
273    )
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn instances_from_used_by() {
282        let v = VolumeInfo {
283            name: "v".into(),
284            pool: "p".into(),
285            content_type: "filesystem".into(),
286            config: Default::default(),
287            used_by: vec![
288                "/1.0/instances/web-1?project=isb-acme".into(),
289                "/1.0/instances/ws".into(),
290                "/1.0/profiles/default".into(),
291            ],
292        };
293        assert_eq!(instances_using(&v), ["web-1", "ws"]);
294    }
295}