1use serde::{Deserialize, Serialize};
5use std::time::Duration;
6
7use crate::backup::Compression;
8use crate::cron::Schedule;
9use crate::error::{Error, Result};
10
11pub const HOOK_PATH: &str = "/etc/isb/pre-snapshot";
13pub const DEFAULT_HOOK_TIMEOUT: Duration = Duration::from_secs(300);
15pub const MAX_HOOK_TIMEOUT: Duration = Duration::from_secs(3600);
17pub const AUTO: &str = "auto-";
19pub const MANUAL: &str = "manual-";
21pub const BACKUP_SNAPSHOT: &str = "isb-backup-";
23pub const RESTORE_ROOT: &str = "/restore";
25
26pub 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";
32pub const KEY_TEMPORARY: &str = "user.isb.temporary";
34
35fn default_keep() -> u32 {
36 7
37}
38
39fn yes() -> bool {
40 true
41}
42
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct VolumeSettings {
47 #[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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub hook_timeout: Option<String>,
62 #[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#[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
124pub 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
141pub 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
161pub fn stamp(t: i64) -> String {
163 crate::cron::compact_utc(t)
164}
165
166pub fn auto_time(snapshot: &str) -> Option<i64> {
168 crate::cron::parse_compact_utc(snapshot.strip_prefix(AUTO)?)
169}
170
171pub 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#[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
202pub fn export_volume(volume: &str, stamp: &str) -> String {
204 format!("{volume}-backup-{stamp}")
205}
206
207pub fn volume_key(prefix: &str, volume: &str, t: i64, c: Compression) -> String {
209 format!("{prefix}{volume}-{}.volume.tar{}", stamp(t), c.extension())
210}
211
212pub 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
230pub 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}