OXYGENHUB
# e8fa53694f68 oxygenhub / crates / hub / src / backups.rs
29.5 KiB · Rust · last changed feat: backups for users and organisations, to a directory, with restore (refs #169) 22 days ago source blame history raw
1
//! Backups (docs/backups.md, oxygenhub#169): each user and organisation
2
//! copies its repositories, on a schedule, to a destination it controls.
3
//!
4
//! A repository is its store directory (`.oxy/`): packs and loose objects
5
//! that never change once written, and small files that do (refs, the
6
//! operation log, policies, signature indexes). A run copies each file
7
//! once, named by its BLAKE3 (`blobs/`), and writes a manifest of path to
8
//! hash per repository (`runs/<at>/repos/`), so only what changed travels
9
//! and any kept run can be restored byte for byte, signatures and all.
10
//! The large files are copied first; the walk is then repeated under the
11
//! repository's read lock, which pushes wait on, so each manifest is one
12
//! consistent moment. Beside each manifest go the hub's records for the
13
//! repository as JSON (never a secret value), the owner's records, and
14
//! last a `DONE` marker: a run without one was cut short and is ignored.
15
16
use crate::db::{self, Pool};
17
use crate::repos::RepoName;
18
use crate::Shared;
19
use anyhow::{bail, Context, Result};
20
use serde::{Deserialize, Serialize};
21
use std::collections::HashMap;
22
use std::path::{Path, PathBuf};
23
24
/// How often, as the settings offer it.
25
pub const EVERY: &[(&str, &str)] = &[("off", "Off"), ("hourly", "Every hour"), ("daily", "Every day")];
26
/// How long runs are kept, in days.
27
pub const KEEP: &[i64] = &[7, 30, 90];
28
29
/// What an owner backs up, where and how often.
30
#[derive(Clone, Debug, sqlx::FromRow, Serialize)]
31
pub struct Setting {
32
    pub owner: String,
33
    pub kind: String,
34
    pub every: String,
35
    pub destination: String,
36
    pub target: String,
37
    pub prefix: String,
38
    pub provider: String,
39
    pub keep_days: i64,
40
    pub updated_ms: i64,
41
}
42
43
impl Setting {
44
    pub fn off(owner: &str, kind: &str) -> Setting {
45
        Setting {
46
            owner: owner.into(),
47
            kind: kind.into(),
48
            every: "off".into(),
49
            destination: "dir".into(),
50
            target: String::new(),
51
            prefix: String::new(),
52
            provider: String::new(),
53
            keep_days: 30,
54
            updated_ms: 0,
55
        }
56
    }
57
58
    /// Seconds between runs; none when off.
59
    pub fn period_s(&self) -> Option<i64> {
60
        match self.every.as_str() {
61
            "hourly" => Some(3600),
62
            "daily" => Some(86_400),
63
            _ => None,
64
        }
65
    }
66
}
67
68
/// One run, as the settings page and the API show it.
69
#[derive(Clone, Debug, sqlx::FromRow, Serialize)]
70
pub struct Run {
71
    pub id: i64,
72
    pub owner: String,
73
    pub started_ms: i64,
74
    pub finished_ms: Option<i64>,
75
    pub status: String,
76
    pub at: String,
77
    pub repos: i64,
78
    pub files: i64,
79
    pub sent: i64,
80
    pub bytes: i64,
81
    pub detail: String,
82
    pub trigger: String,
83
}
84
85
pub async fn setting(pool: &Pool, owner: &str) -> sqlx::Result<Option<Setting>> {
86
    sqlx::query_as("SELECT * FROM backup_settings WHERE owner = ?").bind(owner).fetch_optional(pool).await
87
}
88
89
pub async fn save(pool: &Pool, s: &Setting) -> sqlx::Result<()> {
90
    sqlx::query(
91
        "INSERT INTO backup_settings (owner, kind, every, destination, target, prefix, provider, keep_days, updated_ms) \
92
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(owner) DO UPDATE SET kind = excluded.kind, every = excluded.every, \
93
         destination = excluded.destination, target = excluded.target, prefix = excluded.prefix, provider = excluded.provider, \
94
         keep_days = excluded.keep_days, updated_ms = excluded.updated_ms",
95
    )
96
    .bind(&s.owner)
97
    .bind(&s.kind)
98
    .bind(&s.every)
99
    .bind(&s.destination)
100
    .bind(&s.target)
101
    .bind(&s.prefix)
102
    .bind(&s.provider)
103
    .bind(s.keep_days)
104
    .bind(db::now_ms())
105
    .execute(pool)
106
    .await?;
107
    Ok(())
108
}
109
110
pub async fn runs(pool: &Pool, owner: &str, limit: i64) -> sqlx::Result<Vec<Run>> {
111
    sqlx::query_as("SELECT * FROM backup_runs WHERE owner = ? ORDER BY id DESC LIMIT ?").bind(owner).bind(limit).fetch_all(pool).await
112
}
113
114
// ---------------------------------------------------------------- where
115
116
/// Where backups are written: keys are `/`-separated paths.
117
pub trait Destination: Send + Sync {
118
    fn exists(&self, key: &str) -> Result<bool>;
119
    /// Write `bytes` at `key`, whole or not at all.
120
    fn put(&self, key: &str, bytes: &[u8]) -> Result<()>;
121
    fn get(&self, key: &str) -> Result<Vec<u8>>;
122
    /// The names directly under `prefix` (files and "directories").
123
    fn list(&self, prefix: &str) -> Result<Vec<String>>;
124
    /// Remove `key` and everything under it.
125
    fn remove(&self, key: &str) -> Result<()>;
126
}
127
128
/// A directory on the hub's host.
129
pub struct Directory {
130
    pub root: PathBuf,
131
}
132
133
impl Directory {
134
    fn path(&self, key: &str) -> Result<PathBuf> {
135
        if key.split('/').any(|p| p == ".." || p == ".") || key.starts_with('/') {
136
            bail!("not a clean key: {key}");
137
        }
138
        Ok(self.root.join(key))
139
    }
140
}
141
142
impl Destination for Directory {
143
    fn exists(&self, key: &str) -> Result<bool> {
144
        Ok(self.path(key)?.exists())
145
    }
146
    fn put(&self, key: &str, bytes: &[u8]) -> Result<()> {
147
        let p = self.path(key)?;
148
        std::fs::create_dir_all(p.parent().context("no parent")?)?;
149
        let tmp = p.with_extension(format!("part-{}", std::process::id()));
150
        std::fs::write(&tmp, bytes)?;
151
        std::fs::rename(&tmp, &p)?;
152
        Ok(())
153
    }
154
    fn get(&self, key: &str) -> Result<Vec<u8>> {
155
        let p = self.path(key)?;
156
        std::fs::read(&p).with_context(|| format!("read {}", p.display()))
157
    }
158
    fn list(&self, prefix: &str) -> Result<Vec<String>> {
159
        let p = self.path(prefix)?;
160
        let mut out: Vec<String> = match std::fs::read_dir(&p) {
161
            Ok(d) => d.flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect(),
162
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
163
            Err(e) => return Err(e.into()),
164
        };
165
        out.sort();
166
        Ok(out)
167
    }
168
    fn remove(&self, key: &str) -> Result<()> {
169
        let p = self.path(key)?;
170
        if p.is_dir() {
171
            std::fs::remove_dir_all(p)?;
172
        } else if p.exists() {
173
            std::fs::remove_file(p)?;
174
        }
175
        Ok(())
176
    }
177
}
178
179
/// The owner's destination, from its setting and the site's rules.
180
pub fn destination(app: &Shared, s: &Setting) -> Result<Box<dyn Destination>> {
181
    match s.destination.as_str() {
182
        "dir" => {
183
            let allowed = &app.config.backups.directories;
184
            let target = PathBuf::from(&s.target);
185
            if !allowed.contains(&target) {
186
                bail!("{} is not a backup directory this hub allows (hub.toml [backups] directories)", s.target);
187
            }
188
            let mut root = target.join(&s.owner);
189
            if !s.prefix.is_empty() {
190
                root = root.join(s.prefix.trim_matches('/'));
191
            }
192
            Ok(Box::new(Directory { root }))
193
        }
194
        "gcs" => bail!("GCS destinations come with oxygenhub#169's next step"),
195
        other => bail!("unknown destination {other:?}"),
196
    }
197
}
198
199
// ---------------------------------------------------------------- what
200
201
/// One file of a repository's store, in a manifest.
202
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
203
pub struct File {
204
    pub path: String,
205
    pub size: u64,
206
    /// Its modification time in nanoseconds: unchanged size and time mean
207
    /// unchanged content, so it is not hashed again.
208
    pub mtime: i128,
209
    pub hash: String,
210
}
211
212
/// A repository at one run.
213
#[derive(Clone, Debug, Serialize, Deserialize)]
214
pub struct Manifest {
215
    pub owner: String,
216
    pub name: String,
217
    /// `repos` or `wikis`.
218
    pub kind: String,
219
    pub files: Vec<File>,
220
}
221
222
fn blob_key(hash: &str) -> String {
223
    format!("blobs/{}/{hash}", &hash[..2])
224
}
225
226
/// The large files that never change once written come first.
227
fn immutable(path: &str) -> bool {
228
    path.starts_with(".oxy/objects/") && (path.contains("/pack-") || path.contains("/loose/"))
229
}
230
231
#[derive(Default)]
232
struct Sent {
233
    files: i64,
234
    sent: i64,
235
    bytes: i64,
236
}
237
238
/// Walk `dir`, sending each file the destination lacks; `known` says which
239
/// paths are unchanged since the last run (and their hashes).
240
fn copy_dir(dest: &dyn Destination, dir: &Path, known: &HashMap<String, File>, only_immutable: bool, sent: &mut Sent) -> Result<Vec<File>> {
241
    let mut out = Vec::new();
242
    let mut stack = vec![dir.to_path_buf()];
243
    while let Some(d) = stack.pop() {
244
        let entries = match std::fs::read_dir(&d) {
245
            Ok(e) => e,
246
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
247
            Err(e) => return Err(e.into()),
248
        };
249
        for e in entries.flatten() {
250
            let p = e.path();
251
            let ft = e.file_type()?;
252
            if ft.is_dir() {
253
                stack.push(p);
254
                continue;
255
            }
256
            if !ft.is_file() {
257
                continue;
258
            }
259
            let rel = p.strip_prefix(dir).unwrap_or(&p).to_string_lossy().replace('\\', "/");
260
            if only_immutable && !immutable(&rel) {
261
                continue;
262
            }
263
            // A file packed away while the walk ran is simply gone.
264
            let Ok(md) = std::fs::metadata(&p) else { continue };
265
            let mtime =
266
                md.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_nanos() as i128).unwrap_or(0);
267
            let size = md.len();
268
            sent.files += 1;
269
            if let Some(k) = known.get(&rel).filter(|k| k.size == size && k.mtime == mtime) {
270
                out.push(k.clone());
271
                continue;
272
            }
273
            let Ok(bytes) = std::fs::read(&p) else { continue };
274
            let hash = blake3::hash(&bytes).to_hex().to_string();
275
            let key = blob_key(&hash);
276
            if !dest.exists(&key)? {
277
                dest.put(&key, &bytes)?;
278
                sent.sent += 1;
279
                sent.bytes += bytes.len() as i64;
280
            }
281
            out.push(File { path: rel, size, mtime, hash });
282
        }
283
    }
284
    out.sort_by(|a, b| a.path.cmp(&b.path));
285
    Ok(out)
286
}
287
288
// ---------------------------------------------------------------- records
289
290
/// Tables and columns never written: secrets, credentials, sessions.
291
fn private_table(t: &str) -> bool {
292
    ["secret", "token", "session", "password", "totp", "recovery", "sqlx"].iter().any(|w| t.contains(w))
293
}
294
295
fn private_column(c: &str) -> bool {
296
    ["secret", "token", "password", "salt", "totp", "sealed", "hash_key"].iter().any(|w| c.contains(w))
297
}
298
299
async fn tables(pool: &Pool) -> Result<Vec<String>> {
300
    let mut t: Vec<String> =
301
        sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").fetch_all(pool).await?;
302
    t.retain(|t| !private_table(t));
303
    t.sort();
304
    Ok(t)
305
}
306
307
async fn columns(pool: &Pool, table: &str) -> Result<Vec<String>> {
308
    Ok(sqlx::query_scalar(&format!("SELECT name FROM pragma_table_info('{table}')")).fetch_all(pool).await?)
309
}
310
311
/// The rows of `table` matching `cond`, as a JSON array, private columns
312
/// left out and blobs as hex.
313
async fn rows(pool: &Pool, table: &str, cond: &str) -> Result<serde_json::Value> {
314
    let cols = columns(pool, table).await?;
315
    let fields: Vec<String> = cols
316
        .iter()
317
        .filter(|c| !private_column(c))
318
        .map(|c| format!("'{c}', CASE WHEN typeof(\"{c}\") = 'blob' THEN hex(\"{c}\") ELSE \"{c}\" END"))
319
        .collect();
320
    if fields.is_empty() {
321
        return Ok(serde_json::json!([]));
322
    }
323
    let sql = format!("SELECT json_group_array(json_object({})) FROM \"{table}\" WHERE {cond}", fields.join(", "));
324
    let text: String = sqlx::query_scalar(&sql).fetch_one(pool).await?;
325
    Ok(serde_json::from_str(&text)?)
326
}
327
328
/// Every row about something: the tables with column `key` equal to `id`,
329
/// then, three levels deep, the tables whose foreign keys point at rows
330
/// already taken (an issue's comments, a proposal's reviews and their
331
/// threads).
332
async fn records(pool: &Pool, key: &str, id: i64) -> Result<serde_json::Value> {
333
    let all = tables(pool).await?;
334
    let mut conds: Vec<(String, String)> = Vec::new();
335
    for t in &all {
336
        if columns(pool, t).await?.iter().any(|c| c == key) {
337
            conds.push((t.clone(), format!("\"{key}\" = {id}")));
338
        }
339
    }
340
    for _ in 0..3 {
341
        let mut more = Vec::new();
342
        for t in &all {
343
            if conds.iter().any(|(c, _)| c == t) || more.iter().any(|(c, _): &(String, String)| c == t) {
344
                continue;
345
            }
346
            let fks: Vec<(String, String, String)> =
347
                sqlx::query_as(&format!("SELECT \"table\", \"from\", \"to\" FROM pragma_foreign_key_list('{t}')")).fetch_all(pool).await?;
348
            if let Some((parent, from, to, pcond)) = fks
349
                .iter()
350
                .find_map(|(p, f, to)| conds.iter().find(|(c, _)| c == p).map(|(_, pc)| (p.clone(), f.clone(), to.clone(), pc.clone())))
351
            {
352
                more.push((t.clone(), format!("\"{from}\" IN (SELECT \"{to}\" FROM \"{parent}\" WHERE {pcond})")));
353
            }
354
        }
355
        if more.is_empty() {
356
            break;
357
        }
358
        conds.extend(more);
359
    }
360
    let mut out = serde_json::Map::new();
361
    for (t, cond) in conds {
362
        let r = rows(pool, &t, &cond).await?;
363
        if r.as_array().is_some_and(|a| !a.is_empty()) {
364
            out.insert(t, r);
365
        }
366
    }
367
    Ok(serde_json::Value::Object(out))
368
}
369
370
// ---------------------------------------------------------------- a run
371
372
fn run_name(ms: i64) -> String {
373
    let t = time::OffsetDateTime::from_unix_timestamp_nanos(ms as i128 * 1_000_000).unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
374
    format!("{:04}{:02}{:02}T{:02}{:02}{:02}Z", t.year(), u8::from(t.month()), t.day(), t.hour(), t.minute(), t.second())
375
}
376
377
/// The last whole run's manifests, by `kind/name`.
378
fn last_manifests(dest: &dyn Destination) -> HashMap<String, HashMap<String, File>> {
379
    let mut out = HashMap::new();
380
    let Ok(at) = dest.get("LATEST").map(|b| String::from_utf8_lossy(&b).trim().to_string()) else { return out };
381
    for name in dest.list(&format!("runs/{at}/repos")).unwrap_or_default() {
382
        if let Ok(m) = dest.get(&format!("runs/{at}/repos/{name}")).and_then(|b| Ok(serde_json::from_slice::<Manifest>(&b)?)) {
383
            out.insert(format!("{}/{}", m.kind, m.name), m.files.into_iter().map(|f| (f.path.clone(), f)).collect());
384
        }
385
    }
386
    out
387
}
388
389
/// Back up `owner` now, as its setting says. The run is recorded whatever
390
/// happens; a failure notifies the owner once until a run succeeds.
391
pub async fn run(app: &Shared, owner: &str, trigger: &str) -> Result<Run> {
392
    let s = setting(&app.db, owner).await?.with_context(|| format!("{owner} has no backup set up"))?;
393
    let started = db::now_ms();
394
    let at = run_name(started);
395
    let id: i64 =
396
        sqlx::query_scalar("INSERT INTO backup_runs (owner, started_ms, status, at, trigger) VALUES (?, ?, 'running', ?, ?) RETURNING id")
397
            .bind(owner)
398
            .bind(started)
399
            .bind(&at)
400
            .bind(trigger)
401
            .fetch_one(&app.db)
402
            .await?;
403
    let outcome = run_into(app, &s, &at).await;
404
    let (status, detail, sent) = match outcome {
405
        Ok((sent, problems)) if problems.is_empty() => ("ok", String::new(), sent),
406
        Ok((sent, problems)) => ("partial", problems.join("; "), sent),
407
        Err(e) => ("failed", format!("{e:#}"), (0, Sent::default())),
408
    };
409
    sqlx::query("UPDATE backup_runs SET finished_ms = ?, status = ?, detail = ?, repos = ?, files = ?, sent = ?, bytes = ? WHERE id = ?")
410
        .bind(db::now_ms())
411
        .bind(status)
412
        .bind(&detail)
413
        .bind(sent.0)
414
        .bind(sent.1.files)
415
        .bind(sent.1.sent)
416
        .bind(sent.1.bytes)
417
        .bind(id)
418
        .execute(&app.db)
419
        .await?;
420
    if status != "ok" {
421
        notify_failure(app, &s, status, &detail).await;
422
    }
423
    let r: Run = sqlx::query_as("SELECT * FROM backup_runs WHERE id = ?").bind(id).fetch_one(&app.db).await?;
424
    Ok(r)
425
}
426
427
async fn run_into(app: &Shared, s: &Setting, at: &str) -> Result<((i64, Sent), Vec<String>)> {
428
    let dest: std::sync::Arc<dyn Destination> = destination(app, s)?.into();
429
    let known = {
430
        let d = dest.clone();
431
        tokio::task::spawn_blocking(move || last_manifests(d.as_ref())).await?
432
    };
433
    let mine: Vec<db::repos::RepoRow> = db::repos::list(&app.db).await?.into_iter().filter(|r| r.owner == s.owner).collect();
434
    let mut problems = Vec::new();
435
    let mut sent = Sent::default();
436
    let mut repos = 0i64;
437
    for row in &mine {
438
        let Some(name) = RepoName::new(&row.owner, &row.name) else { continue };
439
        for (kind, store) in [("repos", &app.repos), ("wikis", &app.wikis)] {
440
            if !store.exists(&name) {
441
                continue;
442
            }
443
            let dir = store.dir(&name);
444
            let k = known.get(&format!("{kind}/{}", row.name)).cloned().unwrap_or_default();
445
            // The large, unchanging files without the lock, then the whole
446
            // walk again under the read lock: pushes wait for that moment.
447
            let (d1, dir1, k1) = (dest.clone(), dir.clone(), k.clone());
448
            let first = tokio::task::spawn_blocking(move || {
449
                let mut s = Sent::default();
450
                copy_dir(d1.as_ref(), &dir1, &k1, true, &mut s).map(|f| (f, s))
451
            })
452
            .await?;
453
            let mut k2 = k;
454
            if let Ok((files, _)) = &first {
455
                for f in files {
456
                    k2.insert(f.path.clone(), f.clone());
457
                }
458
            }
459
            let guard = store.read_guard(&name).await;
460
            let (d2, dir2) = (dest.clone(), dir.clone());
461
            let second = tokio::task::spawn_blocking(move || {
462
                let _guard = guard;
463
                let mut s = Sent::default();
464
                copy_dir(d2.as_ref(), &dir2, &k2, false, &mut s).map(|f| (f, s))
465
            })
466
            .await?;
467
            match second {
468
                Ok((files, s2)) => {
469
                    let s1 = first.map(|(_, s)| s).unwrap_or_default();
470
                    sent.files += s2.files;
471
                    sent.sent += s1.sent + s2.sent;
472
                    sent.bytes += s1.bytes + s2.bytes;
473
                    let m = Manifest { owner: row.owner.clone(), name: row.name.clone(), kind: kind.into(), files };
474
                    let key = format!("runs/{at}/repos/{kind}-{}.json", row.name);
475
                    dest.put(&key, &serde_json::to_vec(&m)?)?;
476
                    if kind == "repos" {
477
                        repos += 1;
478
                    }
479
                }
480
                Err(e) => problems.push(format!("{}/{} ({kind}): {e:#}", row.owner, row.name)),
481
            }
482
        }
483
        let rec = async {
484
            let mut r = records(&app.db, "repo_id", row.id).await?;
485
            // The repository's own row, which restore recreates first.
486
            let own = rows(&app.db, "repositories", &format!("id = {}", row.id)).await?;
487
            if let Some(m) = r.as_object_mut() {
488
                m.insert("repositories".into(), own);
489
            }
490
            anyhow::Ok(r)
491
        };
492
        match rec.await {
493
            Ok(r) => dest.put(&format!("runs/{at}/records/{}.json", row.name), &serde_json::to_vec(&r)?)?,
494
            Err(e) => problems.push(format!("{}/{} records: {e:#}", row.owner, row.name)),
495
        }
496
    }
497
    // The owner itself.
498
    let owner_rows = if s.kind == "org" {
499
        match db::accounts::org_by_name(&app.db, &s.owner).await? {
500
            Some(o) => records(&app.db, "org_id", o.id).await?,
501
            None => serde_json::json!({}),
502
        }
503
    } else {
504
        match db::accounts::account_by_name(&app.db, &s.owner).await? {
505
            Some(a) => records(&app.db, "account_id", a.id).await?,
506
            None => serde_json::json!({}),
507
        }
508
    };
509
    dest.put(&format!("runs/{at}/owner.json"), &serde_json::to_vec(&owner_rows)?)?;
510
    dest.put(&format!("runs/{at}/DONE"), at.as_bytes())?;
511
    dest.put("LATEST", at.as_bytes())?;
512
    prune(dest.as_ref(), s.keep_days)?;
513
    Ok(((repos, sent), problems))
514
}
515
516
/// Drop whole runs older than `keep_days`, and the blobs no kept run
517
/// names. The newest whole run is always kept.
518
pub fn prune(dest: &dyn Destination, keep_days: i64) -> Result<()> {
519
    let cutoff = run_name(db::now_ms() - keep_days.max(1) * 86_400_000);
520
    let runs = dest.list("runs")?;
521
    let whole: Vec<&String> = runs.iter().filter(|r| dest.exists(&format!("runs/{r}/DONE")).unwrap_or(false)).collect();
522
    let newest = whole.last().map(|s| s.to_string());
523
    let mut removed = false;
524
    for r in &runs {
525
        if r.as_str() < cutoff.as_str() && Some(r.to_string()) != newest {
526
            dest.remove(&format!("runs/{r}"))?;
527
            removed = true;
528
        }
529
    }
530
    if !removed {
531
        return Ok(());
532
    }
533
    let mut wanted = std::collections::HashSet::new();
534
    for r in dest.list("runs")? {
535
        for m in dest.list(&format!("runs/{r}/repos"))? {
536
            if let Ok(m) = dest.get(&format!("runs/{r}/repos/{m}")).and_then(|b| Ok(serde_json::from_slice::<Manifest>(&b)?)) {
537
                wanted.extend(m.files.into_iter().map(|f| f.hash));
538
            }
539
        }
540
    }
541
    for shard in dest.list("blobs")? {
542
        for b in dest.list(&format!("blobs/{shard}"))? {
543
            if !wanted.contains(&b) {
544
                dest.remove(&format!("blobs/{shard}/{b}"))?;
545
            }
546
        }
547
    }
548
    Ok(())
549
}
550
551
async fn notify_failure(app: &Shared, s: &Setting, status: &str, detail: &str) {
552
    // Once per spell of failures: not when the run before failed too.
553
    let before: Option<String> =
554
        sqlx::query_scalar("SELECT status FROM backup_runs WHERE owner = ? AND finished_ms IS NOT NULL ORDER BY id DESC LIMIT 1 OFFSET 1")
555
            .bind(&s.owner)
556
            .fetch_optional(&app.db)
557
            .await
558
            .ok()
559
            .flatten();
560
    if before.as_deref().is_some_and(|b| b != "ok") {
561
        return;
562
    }
563
    let (who, url): (Vec<i64>, String) = if s.kind == "org" {
564
        match db::accounts::org_by_name(&app.db, &s.owner).await.ok().flatten() {
565
            Some(o) => (
566
                db::accounts::org_members(&app.db, o.id)
567
                    .await
568
                    .unwrap_or_default()
569
                    .into_iter()
570
                    .filter(|(_, r)| r == "owner")
571
                    .map(|(a, _)| a.id)
572
                    .collect(),
573
                format!("/orgs/{}/settings#backups", s.owner),
574
            ),
575
            None => return,
576
        }
577
    } else {
578
        match db::accounts::account_by_name(&app.db, &s.owner).await.ok().flatten() {
579
            Some(a) => (vec![a.id], "/settings/backups".into()),
580
            None => return,
581
        }
582
    };
583
    let title = format!("Backup of {} {status}: {}", s.owner, detail.chars().take(160).collect::<String>());
584
    for id in who {
585
        let _ = db::notify::add(
586
            &app.db,
587
            db::notify::New {
588
                account_id: id,
589
                repo_id: None,
590
                reason: db::notify::Reason::Participating,
591
                kind: "backup",
592
                title: &title,
593
                url: &url,
594
                actor: "",
595
            },
596
        )
597
        .await;
598
    }
599
}
600
601
/// Run each owner's backup when it is due, one at a time. A run left
602
/// "running" by a restart is marked failed first.
603
pub async fn scheduler(app: Shared) {
604
    let _ = sqlx::query(
605
        "UPDATE backup_runs SET status = 'failed', detail = 'the hub restarted during the run', finished_ms = ? WHERE status = 'running'",
606
    )
607
    .bind(db::now_ms())
608
    .execute(&app.db)
609
    .await;
610
    loop {
611
        tokio::time::sleep(std::time::Duration::from_secs(60)).await;
612
        for s in due(&app.db).await.unwrap_or_default() {
613
            if let Err(e) = run(&app, &s.owner, "schedule").await {
614
                tracing::warn!("backup of {}: {e:#}", s.owner);
615
            }
616
        }
617
    }
618
}
619
620
/// The owners whose next run is due now.
621
pub async fn due(pool: &Pool) -> sqlx::Result<Vec<Setting>> {
622
    let all: Vec<Setting> = sqlx::query_as("SELECT * FROM backup_settings WHERE every != 'off'").fetch_all(pool).await?;
623
    let now = db::now_ms();
624
    let mut out = Vec::new();
625
    for s in all {
626
        let Some(period) = s.period_s() else { continue };
627
        let last: Option<i64> =
628
            sqlx::query_scalar("SELECT MAX(started_ms) FROM backup_runs WHERE owner = ?").bind(&s.owner).fetch_one(pool).await?;
629
        if last.is_none_or(|l| now - l >= period * 1000) {
630
            out.push(s);
631
        }
632
    }
633
    Ok(out)
634
}
635
636
/// Restore every repository of the whole run `at` (the latest when none)
637
/// from `dest` into `repos_root/OWNER/NAME` and `wikis_root/…`, refusing to
638
/// overwrite one that exists. Each file is checked against its hash.
639
pub fn restore(dest: &dyn Destination, at: Option<&str>, repos_root: &Path, wikis_root: &Path) -> Result<Vec<String>> {
640
    let at = match at {
641
        Some(a) => a.to_string(),
642
        None => String::from_utf8_lossy(&dest.get("LATEST").context("no whole run at this destination")?).trim().to_string(),
643
    };
644
    if !dest.exists(&format!("runs/{at}/DONE"))? {
645
        bail!("run {at} is not whole (no DONE): pick another with --run");
646
    }
647
    let mut done = Vec::new();
648
    for name in dest.list(&format!("runs/{at}/repos"))? {
649
        let m: Manifest = serde_json::from_slice(&dest.get(&format!("runs/{at}/repos/{name}"))?)?;
650
        let root = if m.kind == "wikis" { wikis_root } else { repos_root };
651
        let dir = root.join(&m.owner).join(&m.name);
652
        if dir.exists() {
653
            bail!("{} exists: restore into an empty hub, or move it aside", dir.display());
654
        }
655
        for f in &m.files {
656
            let bytes = dest.get(&blob_key(&f.hash))?;
657
            if blake3::hash(&bytes).to_hex().as_str() != f.hash {
658
                bail!("{}/{} {}: the copy does not match its hash", m.owner, m.name, f.path);
659
            }
660
            let p = dir.join(&f.path);
661
            std::fs::create_dir_all(p.parent().context("no parent")?)?;
662
            std::fs::write(&p, &bytes)?;
663
        }
664
        done.push(format!("{}/{} ({})", m.owner, m.name, m.kind));
665
    }
666
    Ok(done)
667
}
668
669
/// Restore the whole run `at` (the latest when none) into this hub: each
670
/// repository's and wiki's store byte for byte, then its row, from the
671
/// run's records, so it shows on the hub as it was. Issues, proposals and
672
/// the rest stay in the run's records for now (docs/backups.md §7).
673
pub async fn restore_into_hub(app: &Shared, dest: std::sync::Arc<dyn Destination>, at: Option<String>) -> Result<Vec<String>> {
674
    let (repos_root, wikis_root) = (app.repos.root().to_path_buf(), app.wikis.root().to_path_buf());
675
    let (d, a) = (dest.clone(), at.clone());
676
    let done = tokio::task::spawn_blocking(move || restore(d.as_ref(), a.as_deref(), &repos_root, &wikis_root)).await??;
677
    let at = match at {
678
        Some(a) => a,
679
        None => String::from_utf8_lossy(&dest.get("LATEST")?).trim().to_string(),
680
    };
681
    for name in dest.list(&format!("runs/{at}/records"))? {
682
        let rec: serde_json::Value = serde_json::from_slice(&dest.get(&format!("runs/{at}/records/{name}"))?)?;
683
        let Some(row) = rec["repositories"].as_array().and_then(|a| a.first()) else { continue };
684
        let (owner, repo) = (row["owner"].as_str().unwrap_or_default(), row["name"].as_str().unwrap_or_default());
685
        if owner.is_empty() || db::repos::get(&app.db, owner, repo).await?.is_some() {
686
            continue;
687
        }
688
        let visibility = match row["visibility"].as_str() {
689
            Some("private") => hub_api::Visibility::Private,
690
            Some("internal") => hub_api::Visibility::Internal,
691
            _ => hub_api::Visibility::Public,
692
        };
693
        let made = db::repos::create(&app.db, owner, repo, row["description"].as_str().unwrap_or_default(), visibility).await?;
694
        if let Some(b) = row["default_bookmark"].as_str().filter(|b| !b.is_empty()) {
695
            db::repos::set_default_bookmark(&app.db, made.id, b).await?;
696
        }
697
    }
698
    Ok(done)
699
}
700
701
#[cfg(test)]
702
mod tests {
703
    use super::*;
704
705
    #[test]
706
    fn a_directory_destination_keeps_keys_inside_its_root() {
707
        let d = tempfile::tempdir().unwrap();
708
        let dest = Directory { root: d.path().to_path_buf() };
709
        dest.put("runs/a/DONE", b"a").unwrap();
710
        assert_eq!(dest.get("runs/a/DONE").unwrap(), b"a");
711
        assert_eq!(dest.list("runs").unwrap(), vec!["a"]);
712
        assert!(dest.put("../escape", b"x").is_err() && dest.put("/abs", b"x").is_err());
713
        dest.remove("runs").unwrap();
714
        assert!(dest.list("runs").unwrap().is_empty());
715
    }
716
717
    #[test]
718
    fn files_are_sent_once_and_unchanged_ones_not_read_again() {
719
        let src = tempfile::tempdir().unwrap();
720
        let out = tempfile::tempdir().unwrap();
721
        let dest = Directory { root: out.path().to_path_buf() };
722
        std::fs::create_dir_all(src.path().join(".oxy/objects/loose")).unwrap();
723
        std::fs::write(src.path().join(".oxy/objects/loose/aa"), b"object").unwrap();
724
        std::fs::write(src.path().join(".oxy/ops-head"), b"ref one").unwrap();
725
        let mut s = Sent::default();
726
        let files = copy_dir(&dest, src.path(), &HashMap::new(), false, &mut s).unwrap();
727
        assert_eq!((files.len(), s.sent), (2, 2));
728
        let known: HashMap<String, File> = files.iter().map(|f| (f.path.clone(), f.clone())).collect();
729
        let mut s = Sent::default();
730
        let again = copy_dir(&dest, src.path(), &known, false, &mut s).unwrap();
731
        assert_eq!((again, s.sent), (files.clone(), 0), "nothing new travels");
732
        // The first pass takes only the files that never change.
733
        let mut s = Sent::default();
734
        let first = copy_dir(&dest, src.path(), &HashMap::new(), true, &mut s).unwrap();
735
        assert_eq!(first.iter().map(|f| f.path.as_str()).collect::<Vec<_>>(), vec![".oxy/objects/loose/aa"]);
736
    }
737
738
    #[test]
739
    fn run_names_sort_by_time() {
740
        assert_eq!(run_name(0), "19700101T000000Z");
741
        assert!(run_name(1_791_900_000_000) < run_name(1_791_903_600_000));
742
    }
743
}