Skip to main content

isb_apps/volume_backup/
model.rs

1//! The pure parts: a volume's settings, and the names isb gives snapshots,
2//! staged restores and backup objects (and reads back for retention).
3
4use serde::{Deserialize, Serialize};
5use std::time::Duration;
6
7use crate::backup::Compression;
8use crate::cron::Schedule;
9use crate::error::{Error, Result};
10
11/// Where an instance's pre-snapshot hook lives.
12pub const HOOK_PATH: &str = "/etc/isb/pre-snapshot";
13/// How long the hook may run unless the settings say otherwise.
14pub const DEFAULT_HOOK_TIMEOUT: Duration = Duration::from_secs(300);
15/// The longest a hook may be given.
16pub const MAX_HOOK_TIMEOUT: Duration = Duration::from_secs(3600);
17/// Scheduled snapshots: `auto-<stamp>`; only these are pruned.
18pub const AUTO: &str = "auto-";
19/// Snapshot now, unnamed: `manual-<stamp>`, kept until deleted.
20pub const MANUAL: &str = "manual-";
21/// The snapshot a backup exports from, deleted after it.
22pub const BACKUP_SNAPSHOT: &str = "isb-backup-";
23/// Where a staged restore is mounted inside the instance.
24pub const RESTORE_ROOT: &str = "/restore";
25
26/// incus volume config keys on a staged restore.
27pub const KEY_RESTORE_OF: &str = "user.isb.restore-of";
28pub const KEY_RESTORE_FROM: &str = "user.isb.restore-from";
29pub const KEY_RESTORE_STAMP: &str = "user.isb.restore-stamp";
30pub const KEY_RESTORE_BY: &str = "user.isb.restore-by";
31pub const KEY_RESTORE_INSTANCE: &str = "user.isb.restore-instance";
32/// A volume isb made for a moment (a backup's export) and deletes.
33pub const KEY_TEMPORARY: &str = "user.isb.temporary";
34
35fn default_keep() -> u32 {
36    7
37}
38
39fn yes() -> bool {
40    true
41}
42
43/// What a user sets on a volume: its snapshot schedule and its hook.
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct VolumeSettings {
47    /// Cron for scheduled snapshots; none: no schedule.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub schedule: Option<String>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub timezone: Option<String>,
52    /// Scheduled snapshots kept (older `auto-` ones are deleted).
53    #[serde(default = "default_keep")]
54    pub keep: u32,
55    #[serde(default = "yes")]
56    pub enabled: bool,
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub missed_grace: Option<String>,
59    /// How long `/etc/isb/pre-snapshot` may run (default 5m).
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub hook_timeout: Option<String>,
62    /// A failing hook stops the snapshot (default: reported, snapshot taken).
63    #[serde(default)]
64    pub hook_required: bool,
65}
66
67impl Default for VolumeSettings {
68    fn default() -> Self {
69        VolumeSettings {
70            schedule: None,
71            timezone: None,
72            keep: default_keep(),
73            enabled: true,
74            missed_grace: None,
75            hook_timeout: None,
76            hook_required: false,
77        }
78    }
79}
80
81impl VolumeSettings {
82    pub fn schedule(&self) -> Result<Option<Schedule>> {
83        let Some(s) = self.schedule.as_deref().filter(|s| !s.trim().is_empty()) else {
84            return Ok(None);
85        };
86        let off = crate::cron::parse_offset(self.timezone.as_deref().unwrap_or("UTC"))?;
87        Schedule::parse_in(s, off).map(Some)
88    }
89
90    pub fn hook_timeout(&self) -> Result<Duration> {
91        match &self.hook_timeout {
92            None => Ok(DEFAULT_HOOK_TIMEOUT),
93            Some(t) => {
94                let d = crate::flex::parse_duration(t)
95                    .map_err(|e| Error::invalid(format!("hook_timeout: {e}")))?;
96                if d.is_zero() || d > MAX_HOOK_TIMEOUT {
97                    return Err(Error::invalid("hook_timeout: more than 0s, at most 1h"));
98                }
99                Ok(d)
100            }
101        }
102    }
103
104    pub fn validate(&self) -> Result<()> {
105        self.schedule()?;
106        self.hook_timeout()?;
107        crate::jobs::parse_grace(&self.missed_grace)?;
108        if self.keep == 0 || self.keep > 1000 {
109            return Err(Error::invalid("keep: 1 to 1000 snapshots"));
110        }
111        Ok(())
112    }
113}
114
115/// A volume's stored settings with the scheduler's anchor.
116#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
117pub struct VolumeRecord {
118    pub settings: VolumeSettings,
119    pub anchor: i64,
120    pub created_at: u64,
121    pub updated_at: u64,
122}
123
124/// A custom volume's name as isb accepts it: incus' rules, and safe as a
125/// directory name.
126pub fn validate_volume_name(s: &str) -> Result<()> {
127    let ok = !s.is_empty()
128        && s.len() <= 128
129        && !s.starts_with(['.', '-'])
130        && s.chars()
131            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'));
132    if ok {
133        Ok(())
134    } else {
135        Err(Error::invalid(format!(
136            "volume {s:?}: up to 128 characters of letters, digits, _, - and ., not starting with . or -"
137        )))
138    }
139}
140
141/// A snapshot name a user gives.
142pub fn validate_snapshot_name(s: &str) -> Result<()> {
143    let ok = !s.is_empty()
144        && s.len() <= 63
145        && s.starts_with(|c: char| c.is_ascii_alphanumeric())
146        && s.chars()
147            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'));
148    if !ok {
149        return Err(Error::invalid(format!(
150            "snapshot {s:?}: up to 63 characters of letters, digits, _, - and ., starting with a letter or digit"
151        )));
152    }
153    if s.starts_with(AUTO) || s.starts_with(BACKUP_SNAPSHOT) {
154        return Err(Error::invalid(format!(
155            "snapshot {s:?}: {AUTO}* and {BACKUP_SNAPSHOT}* names are isb's own"
156        )));
157    }
158    Ok(())
159}
160
161/// A time as isb stamps names with: `20261003T090912Z`.
162pub fn stamp(t: i64) -> String {
163    crate::cron::compact_utc(t)
164}
165
166/// The time in an `auto-<stamp>` snapshot's name.
167pub fn auto_time(snapshot: &str) -> Option<i64> {
168    crate::cron::parse_compact_utc(snapshot.strip_prefix(AUTO)?)
169}
170
171/// Which scheduled snapshots retention deletes: `auto-` ones beyond the
172/// newest `keep`. Manual and other snapshots are never touched.
173pub fn select_snapshot_prune(names: &[String], keep: usize) -> Vec<String> {
174    let mut auto: Vec<(i64, &String)> = names
175        .iter()
176        .filter_map(|n| auto_time(n).map(|t| (t, n)))
177        .collect();
178    auto.sort_by(|a, b| b.cmp(a));
179    auto.into_iter()
180        .skip(keep)
181        .map(|(_, n)| n.clone())
182        .collect()
183}
184
185/// A staged restore of `volume` made at `stamp`: the new volume, the disk
186/// device on the instance and where it is mounted.
187#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
188pub struct Staged {
189    pub volume: String,
190    pub device: String,
191    pub path: String,
192}
193
194pub fn staged(volume: &str, stamp: &str) -> Staged {
195    Staged {
196        volume: format!("{volume}-restore-{stamp}"),
197        device: format!("isb-restore-{stamp}"),
198        path: format!("{RESTORE_ROOT}/{stamp}"),
199    }
200}
201
202/// The temporary volume a backup exports from.
203pub fn export_volume(volume: &str, stamp: &str) -> String {
204    format!("{volume}-backup-{stamp}")
205}
206
207/// A volume backup's object key.
208pub fn volume_key(prefix: &str, volume: &str, t: i64, c: Compression) -> String {
209    format!("{prefix}{volume}-{}.volume.tar{}", stamp(t), c.extension())
210}
211
212/// A key [`volume_key`] wrote: its volume, time and compression.
213pub fn parse_volume_key(prefix: &str, key: &str) -> Option<(String, i64, Compression)> {
214    let file = key.strip_prefix(prefix)?;
215    if file.contains('/') {
216        return None;
217    }
218    let (stem, rest) = file.split_once(".volume.tar")?;
219    let c = match rest {
220        "" => Compression::None,
221        ".gz" => Compression::Gzip,
222        ".zst" => Compression::Zstd,
223        _ => return None,
224    };
225    let (vol, ts) = stem.split_at(stem.len().checked_sub(16)?);
226    let vol = vol.strip_suffix('-').filter(|v| !v.is_empty())?;
227    Some((vol.to_string(), crate::cron::parse_compact_utc(ts)?, c))
228}
229
230/// Which volume backup objects retention deletes: those [`volume_key`]
231/// wrote under `prefix` beyond the newest `keep`.
232pub fn select_volume_prune(prefix: &str, keys: &[crate::s3::Object], keep: usize) -> Vec<String> {
233    let mut ours: Vec<(i64, &str)> = keys
234        .iter()
235        .filter_map(|o| parse_volume_key(prefix, &o.key).map(|(_, t, _)| (t, o.key.as_str())))
236        .collect();
237    ours.sort_by(|a, b| b.cmp(a));
238    ours.into_iter()
239        .skip(keep)
240        .map(|(_, k)| k.to_string())
241        .collect()
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    fn t(s: &str) -> i64 {
249        crate::cron::parse_compact_utc(s).unwrap()
250    }
251
252    #[test]
253    fn snapshot_retention_prunes_only_scheduled_ones() {
254        let names: Vec<String> = [
255            "auto-20261001T000000Z",
256            "auto-20261003T000000Z",
257            "manual-20260101T000000Z",
258            "auto-20261002T000000Z",
259            "before-upgrade",
260            "auto-garbage",
261            "isb-backup-20200101T000000Z",
262        ]
263        .iter()
264        .map(|s| s.to_string())
265        .collect();
266        assert_eq!(
267            select_snapshot_prune(&names, 1),
268            ["auto-20261002T000000Z", "auto-20261001T000000Z"]
269        );
270        assert!(select_snapshot_prune(&names, 3).is_empty());
271        assert_eq!(
272            auto_time("auto-20261003T000000Z"),
273            Some(t("20261003T000000Z"))
274        );
275    }
276
277    #[test]
278    fn staged_restore_names() {
279        let s = staged("acme_home", &stamp(t("20261003T090912Z")));
280        assert_eq!(s.volume, "acme_home-restore-20261003T090912Z");
281        assert_eq!(s.device, "isb-restore-20261003T090912Z");
282        assert_eq!(s.path, "/restore/20261003T090912Z");
283        validate_volume_name(&s.volume).unwrap();
284        assert_eq!(
285            export_volume("acme_home", "20261003T090912Z"),
286            "acme_home-backup-20261003T090912Z"
287        );
288    }
289
290    #[test]
291    fn volume_keys_round_trip() {
292        let p = "isb/acme/home/";
293        let at = t("20261003T040506Z");
294        for c in [Compression::Gzip, Compression::Zstd, Compression::None] {
295            let k = volume_key(p, "web_data", at, c);
296            assert_eq!(parse_volume_key(p, &k), Some(("web_data".into(), at, c)));
297        }
298        assert_eq!(
299            volume_key(p, "a-b", at, Compression::Gzip),
300            "isb/acme/home/a-b-20261003T040506Z.volume.tar.gz"
301        );
302        for other in [
303            "isb/acme/home/db-20261003T040506Z.postgres.gz",
304            "isb/acme/home/x-20261003T040506Z.volume.tar.bz2",
305            "isb/acme/home/-20261003T040506Z.volume.tar",
306            "isb/acme/home/sub/x-20261003T040506Z.volume.tar",
307            "isb/acme/home/x-2026.volume.tar",
308        ] {
309            assert_eq!(parse_volume_key(p, other), None, "{other}");
310        }
311        let obj = |k: &str| crate::s3::Object {
312            key: k.into(),
313            size: 1,
314            last_modified: String::new(),
315        };
316        let keys = [
317            obj("isb/acme/home/v-20261001T000000Z.volume.tar.gz"),
318            obj("isb/acme/home/v-20261003T000000Z.volume.tar.gz"),
319            obj("isb/acme/home/v-20261002T000000Z.volume.tar.zst"),
320            obj("isb/acme/home/notes.txt"),
321        ];
322        assert_eq!(
323            select_volume_prune(p, &keys, 2),
324            ["isb/acme/home/v-20261001T000000Z.volume.tar.gz"]
325        );
326    }
327
328    #[test]
329    fn names_and_settings_are_checked() {
330        for ok in [
331            "web_data",
332            "acme_workspace_home",
333            "shop-production_pg_data",
334            "v.1",
335        ] {
336            validate_volume_name(ok).unwrap();
337        }
338        for bad in ["", "../x", "a/b", ".hidden", "-x", "a b"] {
339            assert!(validate_volume_name(bad).is_err(), "{bad}");
340        }
341        validate_snapshot_name("before-upgrade").unwrap();
342        for bad in ["auto-1", "isb-backup-x", "-x", "a/b", ""] {
343            assert!(validate_snapshot_name(bad).is_err(), "{bad}");
344        }
345        let mut s = VolumeSettings::default();
346        s.validate().unwrap();
347        assert!(s.schedule().unwrap().is_none());
348        assert_eq!(s.hook_timeout().unwrap(), DEFAULT_HOOK_TIMEOUT);
349        s.schedule = Some("@hourly".into());
350        s.hook_timeout = Some("30s".into());
351        s.validate().unwrap();
352        assert_eq!(s.hook_timeout().unwrap(), Duration::from_secs(30));
353        s.hook_timeout = Some("2h".into());
354        assert!(s.validate().is_err());
355        s.hook_timeout = None;
356        s.keep = 0;
357        assert!(s.validate().is_err());
358        s.keep = 3;
359        s.schedule = Some("* *".into());
360        assert!(s.validate().is_err());
361    }
362}