1use std::io::{Read, Write};
12
13use serde_json::{Value, json};
14
15use super::{incus, model, org_pool, pre_snapshot};
16use crate::backup::{BackupFile, BackupSpec, Backups, Compression, Destination};
17use crate::client::Client;
18use crate::error::{Error, Result};
19use crate::jobs::RunLog;
20use crate::org::OrgId;
21use crate::volume::VolumeInfo;
22
23const EXPORT: &str = "isb";
25
26pub fn backup_once(
28 bk: &Backups,
29 org: &OrgId,
30 spec: &BackupSpec,
31 volume: &str,
32 log: &mut RunLog,
33) -> Result<Value> {
34 let d = bk.destination_get(org, &spec.destination)?;
35 let c = bk.client(org, &d)?;
36 let (oc, pool) = org_pool(bk.apps().client(), org)?;
37 let info = incus::volume(&oc, &pool, volume)?;
38 let settings = super::load_settings(bk.state_dir(), org, volume);
39 let prefix = crate::backup::backup_prefix(&d, org, &spec.name);
40 let t = crate::stack::now_secs() as i64;
41 let key = model::volume_key(&prefix, volume, t, spec.compression);
42 let started = std::time::Instant::now();
43 let st = model::stamp(t);
44 let snap = format!("{}{st}", model::BACKUP_SNAPSHOT);
45 super::allow_snapshots(bk.apps().client(), org)?;
46 let hooks = pre_snapshot(&oc, &info, &settings, ("backup", &snap), log)?;
47 log.line(&format!(
48 "isb: snapshotting {volume} as {snap}, exporting it to s3://{}/{key}",
49 d.bucket
50 ));
51 incus::snapshot_create(&oc, &pool, volume, &snap, "isb backup")?;
52 let res = export_snapshot(
53 &oc,
54 &pool,
55 (volume, &snap, &model::export_volume(volume, &st)),
56 (&c, &key, spec.compression),
57 log,
58 );
59 if let Err(e) = incus::snapshot_delete(&oc, &pool, volume, &snap) {
60 log.line(&format!("isb: deleting snapshot {snap}: {e}"));
61 }
62 let (raw, size) = res?;
63 log.line(&format!(
64 "isb: uploaded {size} bytes ({raw} before compression) in {:.1}s",
65 started.elapsed().as_secs_f64()
66 ));
67 match c.head(&key)? {
68 Some(n) if n == size => log.line(&format!("isb: verified: {key} is {n} bytes")),
69 other => {
70 return Err(Error::invalid(format!(
71 "verify {key}: uploaded {size} bytes, HEAD says {other:?}"
72 )));
73 }
74 }
75 let pruned = prune(&c, &prefix, spec.keep as usize, log)?;
76 Ok(json!({
77 "key": key, "size": size, "dump_bytes": raw, "volume": volume,
78 "destination": d.name, "pruned": pruned, "hooks": hooks,
79 }))
80}
81
82fn prune(
83 c: &crate::s3::Client,
84 prefix: &str,
85 keep: usize,
86 log: &mut RunLog,
87) -> Result<Vec<String>> {
88 let mut pruned = Vec::new();
89 for k in model::select_volume_prune(prefix, &c.list(prefix)?, keep) {
90 match c.delete(&k) {
91 Ok(()) => {
92 log.line(&format!("isb: pruned {k}"));
93 pruned.push(k);
94 }
95 Err(e) => log.line(&format!("isb: prune {k}: {e}")),
96 }
97 }
98 Ok(pruned)
99}
100
101fn export_snapshot(
103 oc: &Client,
104 pool: &str,
105 (volume, snap, tmp): (&str, &str, &str),
106 to: (&crate::s3::Client, &str, Compression),
107 log: &mut RunLog,
108) -> Result<(u64, u64)> {
109 incus::copy_from_snapshot(
110 oc,
111 pool,
112 (volume, snap),
113 tmp,
114 &[(model::KEY_TEMPORARY, "true".into())],
115 )?;
116 let res = upload(oc, pool, tmp, to);
117 if let Err(e) = incus::backup_delete(oc, pool, tmp, EXPORT) {
118 if !e.is_not_found() {
119 log.line(&format!("isb: deleting the export of {tmp}: {e}"));
120 }
121 }
122 if let Err(e) = incus::delete_volume(oc, pool, tmp) {
123 log.line(&format!("isb: deleting {tmp}: {e}"));
124 }
125 res
126}
127
128fn upload(
131 oc: &Client,
132 pool: &str,
133 tmp: &str,
134 (c, key, compression): (&crate::s3::Client, &str, Compression),
135) -> Result<(u64, u64)> {
136 let mut r = incus::export(oc, pool, tmp, EXPORT)?;
137 let mut w = crate::backup::compressor(compression, c.upload(key, compression.content_type()));
138 let mut buf = vec![0u8; 256 << 10];
139 let mut raw = 0u64;
140 loop {
141 let n = match r.read(&mut buf) {
142 Ok(0) => break,
143 Ok(n) => n,
144 Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
145 Err(e) => return Err(Error::invalid(format!("reading the export: {e}"))),
146 };
147 raw += n as u64;
148 w.write_all(&buf[..n])
149 .map_err(|e| Error::invalid(format!("upload: {e}")))?;
150 }
151 if raw == 0 {
152 return Err(Error::invalid("the export was empty"));
153 }
154 let upload = w
155 .finish()
156 .map_err(|e| Error::invalid(format!("compress: {e}")))?;
157 Ok((raw, upload.finish()?))
158}
159
160pub fn files(bk: &Backups, org: &OrgId, d: &Destination, prefix: &str) -> Result<Vec<BackupFile>> {
162 let c = bk.client(org, d)?;
163 let mut out: Vec<(i64, BackupFile)> = c
164 .list(prefix)?
165 .into_iter()
166 .filter_map(|o| {
167 let (volume, t, compression) = model::parse_volume_key(prefix, &o.key)?;
168 Some((
169 t,
170 BackupFile {
171 key: o.key,
172 size: o.size,
173 taken_at: crate::cron::rfc3339(t),
174 engine: None,
175 volume: Some(volume),
176 compression,
177 },
178 ))
179 })
180 .collect();
181 out.sort_by_key(|a| std::cmp::Reverse(a.0));
182 Ok(out.into_iter().map(|(_, f)| f).collect())
183}
184
185pub fn check_source(bk: &Backups, org: &OrgId, volume: &str) -> Result<VolumeInfo> {
187 model::validate_volume_name(volume)?;
188 let (oc, pool) = org_pool(bk.apps().client(), org)?;
189 incus::volume(&oc, &pool, volume)
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn upload_and_retention_against_a_fake_s3() {
198 let (ep, _seen) = crate::s3::tests::fake_s3("sk");
199 let c = crate::s3::tests::client(&ep, "sk");
200 let p = "isb/acme/home/";
201 for k in [
202 "isb/acme/home/ws_home-20261001T000000Z.volume.tar.gz",
203 "isb/acme/home/ws_home-20261002T000000Z.volume.tar.zst",
204 "isb/acme/home/ws_home-20261003T000000Z.volume.tar.gz",
205 "isb/acme/home/notes.txt",
206 "isb/acme/home/db-20261001T000000Z.postgres.gz",
207 ] {
208 c.put(k, b"x").unwrap();
209 }
210 let key = model::volume_key(p, "ws_home", 1_791_158_400, Compression::Zstd);
212 let mut w =
213 crate::backup::compressor(Compression::Zstd, c.upload(&key, "application/zstd"));
214 w.write_all(&[7u8; 100_000]).unwrap();
215 let size = w.finish().unwrap().finish().unwrap();
216 assert_eq!(c.head(&key).unwrap(), Some(size));
217 let mut log = RunLog::sink();
218 let gone = prune(&c, p, 2, &mut log).unwrap();
219 assert_eq!(gone.len(), 2, "{gone:?}");
220 let left: Vec<String> = c.list(p).unwrap().into_iter().map(|o| o.key).collect();
221 for k in [
222 key.as_str(),
223 "isb/acme/home/ws_home-20261003T000000Z.volume.tar.gz",
224 "isb/acme/home/notes.txt",
225 "isb/acme/home/db-20261001T000000Z.postgres.gz",
226 ] {
227 assert!(left.iter().any(|l| l == k), "{k} kept: {left:?}");
228 }
229 let (_, body) = c.get(&key).unwrap();
231 let mut back = Vec::new();
232 crate::backup::decompressor(Compression::Zstd, body)
233 .unwrap()
234 .read_to_end(&mut back)
235 .unwrap();
236 assert_eq!(back, vec![7u8; 100_000]);
237 }
238}