OXYGENHUB
# 35fea4237cc9 oxygenhub / crates / hub / src / backups.rs
40.6 KiB · Rust · last changed feat: keyless GCS backups, and TEST (closes #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
/// A Cloud Storage bucket, reached keylessly (docs/backups.md §3, §6):
180
/// the hub mints a short token as `backup:OWNER`, Google's STS exchanges
181
/// it for an access token through the owner's workload identity
182
/// provider, and that writes the bucket. No key exists anywhere; the
183
/// access token lasts an hour and is fetched again before it runs out.
184
pub struct Gcs {
185
    bucket: String,
186
    /// Keys go under this, when set.
187
    prefix: String,
188
    storage: String,
189
    sts: String,
190
    /// The provider's resource name, `//iam.googleapis.com/projects/…`.
191
    provider: String,
192
    subject: String,
193
    app: Shared,
194
    client: reqwest::Client,
195
    rt: tokio::runtime::Handle,
196
    token: std::sync::Mutex<Option<(String, i64)>>,
197
}
198
199
impl Gcs {
200
    pub fn new(app: &Shared, s: &Setting) -> Gcs {
201
        Gcs {
202
            bucket: s.target.clone(),
203
            prefix: s.prefix.trim_matches('/').to_string(),
204
            storage: app.config.backups.storage_url.trim_end_matches('/').to_string(),
205
            sts: app.config.backups.sts_url.trim_end_matches('/').to_string(),
206
            provider: s.provider.clone(),
207
            subject: format!("backup:{}", s.owner),
208
            app: app.clone(),
209
            client: reqwest::Client::builder().timeout(std::time::Duration::from_secs(300)).build().unwrap_or_default(),
210
            rt: tokio::runtime::Handle::current(),
211
            token: std::sync::Mutex::new(None),
212
        }
213
    }
214
215
    fn name(&self, key: &str) -> String {
216
        if self.prefix.is_empty() {
217
            key.to_string()
218
        } else {
219
            format!("{}/{key}", self.prefix)
220
        }
221
    }
222
223
    /// An access token, exchanged for a fresh one of the hub's when the
224
    /// last is within five minutes of running out.
225
    fn access(&self) -> Result<String> {
226
        let now = db::now_ms();
227
        if let Some((t, until)) = self.token.lock().unwrap().as_ref() {
228
            if *until - now > 300_000 {
229
                return Ok(t.clone());
230
            }
231
        }
232
        let (token, expires_in) = self.rt.block_on(async {
233
            let issuer = self.app.oidc().await?;
234
            // Google's default audience for a provider: its name over https.
235
            let audience = format!("https:{}", self.provider);
236
            let jwt = issuer.mint_for(self.app.config.base_url(), &self.subject, &audience, db::now_ms(), 600);
237
            let res = self
238
                .client
239
                .post(format!("{}/v1/token", self.sts))
240
                .json(&serde_json::json!({
241
                    "grantType": "urn:ietf:params:oauth:grant-type:token-exchange",
242
                    "audience": self.provider,
243
                    "scope": "https://www.googleapis.com/auth/devstorage.read_write",
244
                    "requestedTokenType": "urn:ietf:params:oauth:token-type:access_token",
245
                    "subjectToken": jwt,
246
                    "subjectTokenType": "urn:ietf:params:oauth:token-type:jwt",
247
                }))
248
                .send()
249
                .await?;
250
            let status = res.status();
251
            let body: serde_json::Value = res.json().await.unwrap_or_default();
252
            if !status.is_success() {
253
                bail!(
254
                    "Google's token exchange refused {}: {} (check the provider's issuer is this hub's address and its attribute condition admits backup:)",
255
                    self.subject,
256
                    body["error_description"].as_str().or(body["error"].as_str()).unwrap_or("no reason given")
257
                );
258
            }
259
            let token = body["access_token"].as_str().context("the token exchange gave no access token")?.to_string();
260
            anyhow::Ok((token, body["expires_in"].as_i64().unwrap_or(3600)))
261
        })?;
262
        *self.token.lock().unwrap() = Some((token.clone(), now + expires_in * 1000));
263
        Ok(token)
264
    }
265
266
    fn object_url(&self, key: &str) -> Result<reqwest::Url> {
267
        let mut u = reqwest::Url::parse(&format!("{}/storage/v1/b/{}/o", self.storage, self.bucket))?;
268
        u.path_segments_mut().map_err(|_| anyhow::anyhow!("bad storage URL"))?.push(&self.name(key));
269
        Ok(u)
270
    }
271
272
    fn refused(&self, what: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Error {
273
        let hint = if status == reqwest::StatusCode::FORBIDDEN || status == reqwest::StatusCode::UNAUTHORIZED {
274
            format!(" (grant roles/storage.objectAdmin on gs://{} to the principal …/subject/{})", self.bucket, self.subject)
275
        } else {
276
            String::new()
277
        };
278
        anyhow::anyhow!("GCS {what}: {status}{hint}: {}", body.chars().take(200).collect::<String>())
279
    }
280
281
    /// Every object name under `full` (no delimiter), for removal.
282
    fn all_under(&self, full: &str) -> Result<Vec<String>> {
283
        let mut out = Vec::new();
284
        let mut page: Option<String> = None;
285
        loop {
286
            let token = self.access()?;
287
            let mut u = reqwest::Url::parse(&format!("{}/storage/v1/b/{}/o", self.storage, self.bucket))?;
288
            u.query_pairs_mut().append_pair("prefix", full).append_pair("fields", "items(name),nextPageToken");
289
            if let Some(p) = &page {
290
                u.query_pairs_mut().append_pair("pageToken", p);
291
            }
292
            let body: serde_json::Value = self.rt.block_on(async {
293
                let res = self.client.get(u).bearer_auth(&token).send().await?;
294
                let status = res.status();
295
                let text = res.text().await?;
296
                if !status.is_success() {
297
                    return Err(self.refused("list", status, &text));
298
                }
299
                anyhow::Ok(serde_json::from_str(&text)?)
300
            })?;
301
            out.extend(body["items"].as_array().into_iter().flatten().filter_map(|i| i["name"].as_str().map(String::from)));
302
            match body["nextPageToken"].as_str() {
303
                Some(p) => page = Some(p.to_string()),
304
                None => return Ok(out),
305
            }
306
        }
307
    }
308
}
309
310
impl Destination for Gcs {
311
    fn exists(&self, key: &str) -> Result<bool> {
312
        let token = self.access()?;
313
        let mut u = self.object_url(key)?;
314
        u.query_pairs_mut().append_pair("fields", "name");
315
        self.rt.block_on(async {
316
            let res = self.client.get(u).bearer_auth(&token).send().await?;
317
            match res.status() {
318
                s if s.is_success() => Ok(true),
319
                reqwest::StatusCode::NOT_FOUND => Ok(false),
320
                s => Err(self.refused("read", s, &res.text().await.unwrap_or_default())),
321
            }
322
        })
323
    }
324
    fn put(&self, key: &str, bytes: &[u8]) -> Result<()> {
325
        let token = self.access()?;
326
        let mut u = reqwest::Url::parse(&format!("{}/upload/storage/v1/b/{}/o", self.storage, self.bucket))?;
327
        u.query_pairs_mut().append_pair("uploadType", "media").append_pair("name", &self.name(key));
328
        let body = bytes.to_vec();
329
        self.rt.block_on(async {
330
            let res = self.client.post(u).bearer_auth(&token).header("content-type", "application/octet-stream").body(body).send().await?;
331
            let status = res.status();
332
            if !status.is_success() {
333
                return Err(self.refused("write", status, &res.text().await.unwrap_or_default()));
334
            }
335
            Ok(())
336
        })
337
    }
338
    fn get(&self, key: &str) -> Result<Vec<u8>> {
339
        let token = self.access()?;
340
        let mut u = self.object_url(key)?;
341
        u.query_pairs_mut().append_pair("alt", "media");
342
        self.rt.block_on(async {
343
            let res = self.client.get(u).bearer_auth(&token).send().await?;
344
            let status = res.status();
345
            if !status.is_success() {
346
                return Err(self.refused(&format!("read {key}"), status, &res.text().await.unwrap_or_default()));
347
            }
348
            Ok(res.bytes().await?.to_vec())
349
        })
350
    }
351
    fn list(&self, prefix: &str) -> Result<Vec<String>> {
352
        let full = format!("{}/", self.name(prefix.trim_end_matches('/')));
353
        let mut out = std::collections::BTreeSet::new();
354
        let mut page: Option<String> = None;
355
        loop {
356
            let token = self.access()?;
357
            let mut u = reqwest::Url::parse(&format!("{}/storage/v1/b/{}/o", self.storage, self.bucket))?;
358
            u.query_pairs_mut()
359
                .append_pair("prefix", &full)
360
                .append_pair("delimiter", "/")
361
                .append_pair("fields", "items(name),prefixes,nextPageToken");
362
            if let Some(p) = &page {
363
                u.query_pairs_mut().append_pair("pageToken", p);
364
            }
365
            let body: serde_json::Value = self.rt.block_on(async {
366
                let res = self.client.get(u).bearer_auth(&token).send().await?;
367
                let status = res.status();
368
                let text = res.text().await?;
369
                if !status.is_success() {
370
                    return Err(self.refused("list", status, &text));
371
                }
372
                anyhow::Ok(serde_json::from_str(&text)?)
373
            })?;
374
            for n in body["items"].as_array().into_iter().flatten().filter_map(|i| i["name"].as_str()) {
375
                out.insert(n.trim_start_matches(&full).to_string());
376
            }
377
            for p in body["prefixes"].as_array().into_iter().flatten().filter_map(|p| p.as_str()) {
378
                out.insert(p.trim_start_matches(&full).trim_end_matches('/').to_string());
379
            }
380
            match body["nextPageToken"].as_str() {
381
                Some(p) => page = Some(p.to_string()),
382
                None => return Ok(out.into_iter().filter(|n| !n.is_empty()).collect()),
383
            }
384
        }
385
    }
386
    fn remove(&self, key: &str) -> Result<()> {
387
        let mut names = self.all_under(&format!("{}/", self.name(key)))?;
388
        names.push(self.name(key));
389
        for n in names {
390
            let token = self.access()?;
391
            let mut u = reqwest::Url::parse(&format!("{}/storage/v1/b/{}/o", self.storage, self.bucket))?;
392
            u.path_segments_mut().map_err(|_| anyhow::anyhow!("bad storage URL"))?.push(&n);
393
            self.rt.block_on(async {
394
                let res = self.client.delete(u).bearer_auth(&token).send().await?;
395
                match res.status() {
396
                    s if s.is_success() || s == reqwest::StatusCode::NOT_FOUND => Ok(()),
397
                    s => Err(self.refused("delete", s, &res.text().await.unwrap_or_default())),
398
                }
399
            })?;
400
        }
401
        Ok(())
402
    }
403
}
404
405
/// Check a destination end to end: write a probe, read it back, remove it.
406
pub fn probe(dest: &dyn Destination) -> Result<()> {
407
    let marker = format!("probe {}", db::now_ms());
408
    dest.put(".oxyhub-probe", marker.as_bytes())?;
409
    if dest.get(".oxyhub-probe")? != marker.as_bytes() {
410
        bail!("the probe read back differently");
411
    }
412
    dest.remove(".oxyhub-probe")
413
}
414
415
/// The owner's destination, from its setting and the site's rules.
416
pub fn destination(app: &Shared, s: &Setting) -> Result<Box<dyn Destination>> {
417
    match s.destination.as_str() {
418
        "dir" => {
419
            let allowed = &app.config.backups.directories;
420
            let target = PathBuf::from(&s.target);
421
            if !allowed.contains(&target) {
422
                bail!("{} is not a backup directory this hub allows (hub.toml [backups] directories)", s.target);
423
            }
424
            let mut root = target.join(&s.owner);
425
            if !s.prefix.is_empty() {
426
                root = root.join(s.prefix.trim_matches('/'));
427
            }
428
            Ok(Box::new(Directory { root }))
429
        }
430
        "gcs" => {
431
            if s.target.is_empty() || s.provider.is_empty() {
432
                bail!("a GCS destination needs its bucket and its workload identity provider");
433
            }
434
            Ok(Box::new(Gcs::new(app, s)))
435
        }
436
        other => bail!("unknown destination {other:?}"),
437
    }
438
}
439
440
// ---------------------------------------------------------------- what
441
442
/// One file of a repository's store, in a manifest.
443
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
444
pub struct File {
445
    pub path: String,
446
    pub size: u64,
447
    /// Its modification time in nanoseconds: unchanged size and time mean
448
    /// unchanged content, so it is not hashed again.
449
    pub mtime: i128,
450
    pub hash: String,
451
}
452
453
/// A repository at one run.
454
#[derive(Clone, Debug, Serialize, Deserialize)]
455
pub struct Manifest {
456
    pub owner: String,
457
    pub name: String,
458
    /// `repos` or `wikis`.
459
    pub kind: String,
460
    pub files: Vec<File>,
461
}
462
463
fn blob_key(hash: &str) -> String {
464
    format!("blobs/{}/{hash}", &hash[..2])
465
}
466
467
/// The large files that never change once written come first.
468
fn immutable(path: &str) -> bool {
469
    path.starts_with(".oxy/objects/") && (path.contains("/pack-") || path.contains("/loose/"))
470
}
471
472
#[derive(Default)]
473
struct Sent {
474
    files: i64,
475
    sent: i64,
476
    bytes: i64,
477
}
478
479
/// Walk `dir`, sending each file the destination lacks; `known` says which
480
/// paths are unchanged since the last run (and their hashes).
481
fn copy_dir(dest: &dyn Destination, dir: &Path, known: &HashMap<String, File>, only_immutable: bool, sent: &mut Sent) -> Result<Vec<File>> {
482
    let mut out = Vec::new();
483
    let mut stack = vec![dir.to_path_buf()];
484
    while let Some(d) = stack.pop() {
485
        let entries = match std::fs::read_dir(&d) {
486
            Ok(e) => e,
487
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
488
            Err(e) => return Err(e.into()),
489
        };
490
        for e in entries.flatten() {
491
            let p = e.path();
492
            let ft = e.file_type()?;
493
            if ft.is_dir() {
494
                stack.push(p);
495
                continue;
496
            }
497
            if !ft.is_file() {
498
                continue;
499
            }
500
            let rel = p.strip_prefix(dir).unwrap_or(&p).to_string_lossy().replace('\\', "/");
501
            if only_immutable && !immutable(&rel) {
502
                continue;
503
            }
504
            // A file packed away while the walk ran is simply gone.
505
            let Ok(md) = std::fs::metadata(&p) else { continue };
506
            let mtime =
507
                md.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_nanos() as i128).unwrap_or(0);
508
            let size = md.len();
509
            sent.files += 1;
510
            if let Some(k) = known.get(&rel).filter(|k| k.size == size && k.mtime == mtime) {
511
                out.push(k.clone());
512
                continue;
513
            }
514
            let Ok(bytes) = std::fs::read(&p) else { continue };
515
            let hash = blake3::hash(&bytes).to_hex().to_string();
516
            let key = blob_key(&hash);
517
            if !dest.exists(&key)? {
518
                dest.put(&key, &bytes)?;
519
                sent.sent += 1;
520
                sent.bytes += bytes.len() as i64;
521
            }
522
            out.push(File { path: rel, size, mtime, hash });
523
        }
524
    }
525
    out.sort_by(|a, b| a.path.cmp(&b.path));
526
    Ok(out)
527
}
528
529
// ---------------------------------------------------------------- records
530
531
/// Tables and columns never written: secrets, credentials, sessions.
532
fn private_table(t: &str) -> bool {
533
    ["secret", "token", "session", "password", "totp", "recovery", "sqlx"].iter().any(|w| t.contains(w))
534
}
535
536
fn private_column(c: &str) -> bool {
537
    ["secret", "token", "password", "salt", "totp", "sealed", "hash_key"].iter().any(|w| c.contains(w))
538
}
539
540
async fn tables(pool: &Pool) -> Result<Vec<String>> {
541
    let mut t: Vec<String> =
542
        sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").fetch_all(pool).await?;
543
    t.retain(|t| !private_table(t));
544
    t.sort();
545
    Ok(t)
546
}
547
548
async fn columns(pool: &Pool, table: &str) -> Result<Vec<String>> {
549
    Ok(sqlx::query_scalar(&format!("SELECT name FROM pragma_table_info('{table}')")).fetch_all(pool).await?)
550
}
551
552
/// The rows of `table` matching `cond`, as a JSON array, private columns
553
/// left out and blobs as hex.
554
async fn rows(pool: &Pool, table: &str, cond: &str) -> Result<serde_json::Value> {
555
    let cols = columns(pool, table).await?;
556
    let fields: Vec<String> = cols
557
        .iter()
558
        .filter(|c| !private_column(c))
559
        .map(|c| format!("'{c}', CASE WHEN typeof(\"{c}\") = 'blob' THEN hex(\"{c}\") ELSE \"{c}\" END"))
560
        .collect();
561
    if fields.is_empty() {
562
        return Ok(serde_json::json!([]));
563
    }
564
    let sql = format!("SELECT json_group_array(json_object({})) FROM \"{table}\" WHERE {cond}", fields.join(", "));
565
    let text: String = sqlx::query_scalar(&sql).fetch_one(pool).await?;
566
    Ok(serde_json::from_str(&text)?)
567
}
568
569
/// Every row about something: the tables with column `key` equal to `id`,
570
/// then, three levels deep, the tables whose foreign keys point at rows
571
/// already taken (an issue's comments, a proposal's reviews and their
572
/// threads).
573
async fn records(pool: &Pool, key: &str, id: i64) -> Result<serde_json::Value> {
574
    let all = tables(pool).await?;
575
    let mut conds: Vec<(String, String)> = Vec::new();
576
    for t in &all {
577
        if columns(pool, t).await?.iter().any(|c| c == key) {
578
            conds.push((t.clone(), format!("\"{key}\" = {id}")));
579
        }
580
    }
581
    for _ in 0..3 {
582
        let mut more = Vec::new();
583
        for t in &all {
584
            if conds.iter().any(|(c, _)| c == t) || more.iter().any(|(c, _): &(String, String)| c == t) {
585
                continue;
586
            }
587
            let fks: Vec<(String, String, String)> =
588
                sqlx::query_as(&format!("SELECT \"table\", \"from\", \"to\" FROM pragma_foreign_key_list('{t}')")).fetch_all(pool).await?;
589
            if let Some((parent, from, to, pcond)) = fks
590
                .iter()
591
                .find_map(|(p, f, to)| conds.iter().find(|(c, _)| c == p).map(|(_, pc)| (p.clone(), f.clone(), to.clone(), pc.clone())))
592
            {
593
                more.push((t.clone(), format!("\"{from}\" IN (SELECT \"{to}\" FROM \"{parent}\" WHERE {pcond})")));
594
            }
595
        }
596
        if more.is_empty() {
597
            break;
598
        }
599
        conds.extend(more);
600
    }
601
    let mut out = serde_json::Map::new();
602
    for (t, cond) in conds {
603
        let r = rows(pool, &t, &cond).await?;
604
        if r.as_array().is_some_and(|a| !a.is_empty()) {
605
            out.insert(t, r);
606
        }
607
    }
608
    Ok(serde_json::Value::Object(out))
609
}
610
611
// ---------------------------------------------------------------- a run
612
613
fn run_name(ms: i64) -> String {
614
    let t = time::OffsetDateTime::from_unix_timestamp_nanos(ms as i128 * 1_000_000).unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
615
    format!("{:04}{:02}{:02}T{:02}{:02}{:02}Z", t.year(), u8::from(t.month()), t.day(), t.hour(), t.minute(), t.second())
616
}
617
618
/// The last whole run's manifests, by `kind/name`.
619
fn last_manifests(dest: &dyn Destination) -> HashMap<String, HashMap<String, File>> {
620
    let mut out = HashMap::new();
621
    let Ok(at) = dest.get("LATEST").map(|b| String::from_utf8_lossy(&b).trim().to_string()) else { return out };
622
    for name in dest.list(&format!("runs/{at}/repos")).unwrap_or_default() {
623
        if let Ok(m) = dest.get(&format!("runs/{at}/repos/{name}")).and_then(|b| Ok(serde_json::from_slice::<Manifest>(&b)?)) {
624
            out.insert(format!("{}/{}", m.kind, m.name), m.files.into_iter().map(|f| (f.path.clone(), f)).collect());
625
        }
626
    }
627
    out
628
}
629
630
/// Back up `owner` now, as its setting says. The run is recorded whatever
631
/// happens; a failure notifies the owner once until a run succeeds.
632
pub async fn run(app: &Shared, owner: &str, trigger: &str) -> Result<Run> {
633
    let s = setting(&app.db, owner).await?.with_context(|| format!("{owner} has no backup set up"))?;
634
    let started = db::now_ms();
635
    let at = run_name(started);
636
    let id: i64 =
637
        sqlx::query_scalar("INSERT INTO backup_runs (owner, started_ms, status, at, trigger) VALUES (?, ?, 'running', ?, ?) RETURNING id")
638
            .bind(owner)
639
            .bind(started)
640
            .bind(&at)
641
            .bind(trigger)
642
            .fetch_one(&app.db)
643
            .await?;
644
    let outcome = run_into(app, &s, &at).await;
645
    let (status, detail, sent) = match outcome {
646
        Ok((sent, problems)) if problems.is_empty() => ("ok", String::new(), sent),
647
        Ok((sent, problems)) => ("partial", problems.join("; "), sent),
648
        Err(e) => ("failed", format!("{e:#}"), (0, Sent::default())),
649
    };
650
    sqlx::query("UPDATE backup_runs SET finished_ms = ?, status = ?, detail = ?, repos = ?, files = ?, sent = ?, bytes = ? WHERE id = ?")
651
        .bind(db::now_ms())
652
        .bind(status)
653
        .bind(&detail)
654
        .bind(sent.0)
655
        .bind(sent.1.files)
656
        .bind(sent.1.sent)
657
        .bind(sent.1.bytes)
658
        .bind(id)
659
        .execute(&app.db)
660
        .await?;
661
    if status != "ok" {
662
        notify_failure(app, &s, status, &detail).await;
663
    }
664
    let r: Run = sqlx::query_as("SELECT * FROM backup_runs WHERE id = ?").bind(id).fetch_one(&app.db).await?;
665
    Ok(r)
666
}
667
668
async fn run_into(app: &Shared, s: &Setting, at: &str) -> Result<((i64, Sent), Vec<String>)> {
669
    let dest: std::sync::Arc<dyn Destination> = destination(app, s)?.into();
670
    let known = {
671
        let d = dest.clone();
672
        tokio::task::spawn_blocking(move || last_manifests(d.as_ref())).await?
673
    };
674
    let mine: Vec<db::repos::RepoRow> = db::repos::list(&app.db).await?.into_iter().filter(|r| r.owner == s.owner).collect();
675
    let mut problems = Vec::new();
676
    let mut sent = Sent::default();
677
    let mut repos = 0i64;
678
    for row in &mine {
679
        let Some(name) = RepoName::new(&row.owner, &row.name) else { continue };
680
        for (kind, store) in [("repos", &app.repos), ("wikis", &app.wikis)] {
681
            if !store.exists(&name) {
682
                continue;
683
            }
684
            let dir = store.dir(&name);
685
            let k = known.get(&format!("{kind}/{}", row.name)).cloned().unwrap_or_default();
686
            // The large, unchanging files without the lock, then the whole
687
            // walk again under the read lock: pushes wait for that moment.
688
            let (d1, dir1, k1) = (dest.clone(), dir.clone(), k.clone());
689
            let first = tokio::task::spawn_blocking(move || {
690
                let mut s = Sent::default();
691
                copy_dir(d1.as_ref(), &dir1, &k1, true, &mut s).map(|f| (f, s))
692
            })
693
            .await?;
694
            let mut k2 = k;
695
            if let Ok((files, _)) = &first {
696
                for f in files {
697
                    k2.insert(f.path.clone(), f.clone());
698
                }
699
            }
700
            let guard = store.read_guard(&name).await;
701
            let (d2, dir2) = (dest.clone(), dir.clone());
702
            let second = tokio::task::spawn_blocking(move || {
703
                let _guard = guard;
704
                let mut s = Sent::default();
705
                copy_dir(d2.as_ref(), &dir2, &k2, false, &mut s).map(|f| (f, s))
706
            })
707
            .await?;
708
            match second {
709
                Ok((files, s2)) => {
710
                    let s1 = first.map(|(_, s)| s).unwrap_or_default();
711
                    sent.files += s2.files;
712
                    sent.sent += s1.sent + s2.sent;
713
                    sent.bytes += s1.bytes + s2.bytes;
714
                    let m = Manifest { owner: row.owner.clone(), name: row.name.clone(), kind: kind.into(), files };
715
                    let key = format!("runs/{at}/repos/{kind}-{}.json", row.name);
716
                    tokio::task::block_in_place(|| dest.put(&key, &serde_json::to_vec(&m)?))?;
717
                    if kind == "repos" {
718
                        repos += 1;
719
                    }
720
                }
721
                Err(e) => problems.push(format!("{}/{} ({kind}): {e:#}", row.owner, row.name)),
722
            }
723
        }
724
        let rec = async {
725
            let mut r = records(&app.db, "repo_id", row.id).await?;
726
            // The repository's own row, which restore recreates first.
727
            let own = rows(&app.db, "repositories", &format!("id = {}", row.id)).await?;
728
            if let Some(m) = r.as_object_mut() {
729
                m.insert("repositories".into(), own);
730
            }
731
            anyhow::Ok(r)
732
        };
733
        match rec.await {
734
            Ok(r) => tokio::task::block_in_place(|| dest.put(&format!("runs/{at}/records/{}.json", row.name), &serde_json::to_vec(&r)?))?,
735
            Err(e) => problems.push(format!("{}/{} records: {e:#}", row.owner, row.name)),
736
        }
737
    }
738
    // The owner itself.
739
    let owner_rows = if s.kind == "org" {
740
        match db::accounts::org_by_name(&app.db, &s.owner).await? {
741
            Some(o) => records(&app.db, "org_id", o.id).await?,
742
            None => serde_json::json!({}),
743
        }
744
    } else {
745
        match db::accounts::account_by_name(&app.db, &s.owner).await? {
746
            Some(a) => records(&app.db, "account_id", a.id).await?,
747
            None => serde_json::json!({}),
748
        }
749
    };
750
    // The blocking calls of a destination (GCS drives its client on this
751
    // runtime) run where blocking is allowed.
752
    tokio::task::block_in_place(|| -> Result<()> {
753
        dest.put(&format!("runs/{at}/owner.json"), &serde_json::to_vec(&owner_rows)?)?;
754
        dest.put(&format!("runs/{at}/DONE"), at.as_bytes())?;
755
        dest.put("LATEST", at.as_bytes())?;
756
        prune(dest.as_ref(), s.keep_days)
757
    })?;
758
    Ok(((repos, sent), problems))
759
}
760
761
/// Drop whole runs older than `keep_days`, and the blobs no kept run
762
/// names. The newest whole run is always kept.
763
pub fn prune(dest: &dyn Destination, keep_days: i64) -> Result<()> {
764
    let cutoff = run_name(db::now_ms() - keep_days.max(1) * 86_400_000);
765
    let runs = dest.list("runs")?;
766
    let whole: Vec<&String> = runs.iter().filter(|r| dest.exists(&format!("runs/{r}/DONE")).unwrap_or(false)).collect();
767
    let newest = whole.last().map(|s| s.to_string());
768
    let mut removed = false;
769
    for r in &runs {
770
        if r.as_str() < cutoff.as_str() && Some(r.to_string()) != newest {
771
            dest.remove(&format!("runs/{r}"))?;
772
            removed = true;
773
        }
774
    }
775
    if !removed {
776
        return Ok(());
777
    }
778
    let mut wanted = std::collections::HashSet::new();
779
    for r in dest.list("runs")? {
780
        for m in dest.list(&format!("runs/{r}/repos"))? {
781
            if let Ok(m) = dest.get(&format!("runs/{r}/repos/{m}")).and_then(|b| Ok(serde_json::from_slice::<Manifest>(&b)?)) {
782
                wanted.extend(m.files.into_iter().map(|f| f.hash));
783
            }
784
        }
785
    }
786
    for shard in dest.list("blobs")? {
787
        for b in dest.list(&format!("blobs/{shard}"))? {
788
            if !wanted.contains(&b) {
789
                dest.remove(&format!("blobs/{shard}/{b}"))?;
790
            }
791
        }
792
    }
793
    Ok(())
794
}
795
796
async fn notify_failure(app: &Shared, s: &Setting, status: &str, detail: &str) {
797
    // Once per spell of failures: not when the run before failed too.
798
    let before: Option<String> =
799
        sqlx::query_scalar("SELECT status FROM backup_runs WHERE owner = ? AND finished_ms IS NOT NULL ORDER BY id DESC LIMIT 1 OFFSET 1")
800
            .bind(&s.owner)
801
            .fetch_optional(&app.db)
802
            .await
803
            .ok()
804
            .flatten();
805
    if before.as_deref().is_some_and(|b| b != "ok") {
806
        return;
807
    }
808
    let (who, url): (Vec<i64>, String) = if s.kind == "org" {
809
        match db::accounts::org_by_name(&app.db, &s.owner).await.ok().flatten() {
810
            Some(o) => (
811
                db::accounts::org_members(&app.db, o.id)
812
                    .await
813
                    .unwrap_or_default()
814
                    .into_iter()
815
                    .filter(|(_, r)| r == "owner")
816
                    .map(|(a, _)| a.id)
817
                    .collect(),
818
                format!("/orgs/{}/settings#backups", s.owner),
819
            ),
820
            None => return,
821
        }
822
    } else {
823
        match db::accounts::account_by_name(&app.db, &s.owner).await.ok().flatten() {
824
            Some(a) => (vec![a.id], "/settings/backups".into()),
825
            None => return,
826
        }
827
    };
828
    let title = format!("Backup of {} {status}: {}", s.owner, detail.chars().take(160).collect::<String>());
829
    for id in who {
830
        let _ = db::notify::add(
831
            &app.db,
832
            db::notify::New {
833
                account_id: id,
834
                repo_id: None,
835
                reason: db::notify::Reason::Participating,
836
                kind: "backup",
837
                title: &title,
838
                url: &url,
839
                actor: "",
840
            },
841
        )
842
        .await;
843
    }
844
}
845
846
/// Run each owner's backup when it is due, one at a time. A run left
847
/// "running" by a restart is marked failed first.
848
pub async fn scheduler(app: Shared) {
849
    let _ = sqlx::query(
850
        "UPDATE backup_runs SET status = 'failed', detail = 'the hub restarted during the run', finished_ms = ? WHERE status = 'running'",
851
    )
852
    .bind(db::now_ms())
853
    .execute(&app.db)
854
    .await;
855
    loop {
856
        tokio::time::sleep(std::time::Duration::from_secs(60)).await;
857
        for s in due(&app.db).await.unwrap_or_default() {
858
            if let Err(e) = run(&app, &s.owner, "schedule").await {
859
                tracing::warn!("backup of {}: {e:#}", s.owner);
860
            }
861
        }
862
    }
863
}
864
865
/// The owners whose next run is due now.
866
pub async fn due(pool: &Pool) -> sqlx::Result<Vec<Setting>> {
867
    let all: Vec<Setting> = sqlx::query_as("SELECT * FROM backup_settings WHERE every != 'off'").fetch_all(pool).await?;
868
    let now = db::now_ms();
869
    let mut out = Vec::new();
870
    for s in all {
871
        let Some(period) = s.period_s() else { continue };
872
        let last: Option<i64> =
873
            sqlx::query_scalar("SELECT MAX(started_ms) FROM backup_runs WHERE owner = ?").bind(&s.owner).fetch_one(pool).await?;
874
        if last.is_none_or(|l| now - l >= period * 1000) {
875
            out.push(s);
876
        }
877
    }
878
    Ok(out)
879
}
880
881
/// Restore every repository of the whole run `at` (the latest when none)
882
/// from `dest` into `repos_root/OWNER/NAME` and `wikis_root/…`, refusing to
883
/// overwrite one that exists. Each file is checked against its hash.
884
pub fn restore(dest: &dyn Destination, at: Option<&str>, repos_root: &Path, wikis_root: &Path) -> Result<Vec<String>> {
885
    let at = match at {
886
        Some(a) => a.to_string(),
887
        None => String::from_utf8_lossy(&dest.get("LATEST").context("no whole run at this destination")?).trim().to_string(),
888
    };
889
    if !dest.exists(&format!("runs/{at}/DONE"))? {
890
        bail!("run {at} is not whole (no DONE): pick another with --run");
891
    }
892
    let mut done = Vec::new();
893
    for name in dest.list(&format!("runs/{at}/repos"))? {
894
        let m: Manifest = serde_json::from_slice(&dest.get(&format!("runs/{at}/repos/{name}"))?)?;
895
        let root = if m.kind == "wikis" { wikis_root } else { repos_root };
896
        let dir = root.join(&m.owner).join(&m.name);
897
        if dir.exists() {
898
            bail!("{} exists: restore into an empty hub, or move it aside", dir.display());
899
        }
900
        for f in &m.files {
901
            let bytes = dest.get(&blob_key(&f.hash))?;
902
            if blake3::hash(&bytes).to_hex().as_str() != f.hash {
903
                bail!("{}/{} {}: the copy does not match its hash", m.owner, m.name, f.path);
904
            }
905
            let p = dir.join(&f.path);
906
            std::fs::create_dir_all(p.parent().context("no parent")?)?;
907
            std::fs::write(&p, &bytes)?;
908
        }
909
        done.push(format!("{}/{} ({})", m.owner, m.name, m.kind));
910
    }
911
    Ok(done)
912
}
913
914
/// Restore the whole run `at` (the latest when none) into this hub: each
915
/// repository's and wiki's store byte for byte, then its row, from the
916
/// run's records, so it shows on the hub as it was. Issues, proposals and
917
/// the rest stay in the run's records for now (docs/backups.md §7).
918
pub async fn restore_into_hub(app: &Shared, dest: std::sync::Arc<dyn Destination>, at: Option<String>) -> Result<Vec<String>> {
919
    let (repos_root, wikis_root) = (app.repos.root().to_path_buf(), app.wikis.root().to_path_buf());
920
    let (d, a) = (dest.clone(), at.clone());
921
    let done = tokio::task::spawn_blocking(move || restore(d.as_ref(), a.as_deref(), &repos_root, &wikis_root)).await??;
922
    let at = match at {
923
        Some(a) => a,
924
        None => String::from_utf8_lossy(&tokio::task::block_in_place(|| dest.get("LATEST"))?).trim().to_string(),
925
    };
926
    for name in tokio::task::block_in_place(|| dest.list(&format!("runs/{at}/records")))? {
927
        let rec: serde_json::Value =
928
            serde_json::from_slice(&tokio::task::block_in_place(|| dest.get(&format!("runs/{at}/records/{name}")))?)?;
929
        let Some(row) = rec["repositories"].as_array().and_then(|a| a.first()) else { continue };
930
        let (owner, repo) = (row["owner"].as_str().unwrap_or_default(), row["name"].as_str().unwrap_or_default());
931
        if owner.is_empty() || db::repos::get(&app.db, owner, repo).await?.is_some() {
932
            continue;
933
        }
934
        let visibility = match row["visibility"].as_str() {
935
            Some("private") => hub_api::Visibility::Private,
936
            Some("internal") => hub_api::Visibility::Internal,
937
            _ => hub_api::Visibility::Public,
938
        };
939
        let made = db::repos::create(&app.db, owner, repo, row["description"].as_str().unwrap_or_default(), visibility).await?;
940
        if let Some(b) = row["default_bookmark"].as_str().filter(|b| !b.is_empty()) {
941
            db::repos::set_default_bookmark(&app.db, made.id, b).await?;
942
        }
943
    }
944
    Ok(done)
945
}
946
947
#[cfg(test)]
948
mod tests {
949
    use super::*;
950
951
    #[test]
952
    fn a_directory_destination_keeps_keys_inside_its_root() {
953
        let d = tempfile::tempdir().unwrap();
954
        let dest = Directory { root: d.path().to_path_buf() };
955
        dest.put("runs/a/DONE", b"a").unwrap();
956
        assert_eq!(dest.get("runs/a/DONE").unwrap(), b"a");
957
        assert_eq!(dest.list("runs").unwrap(), vec!["a"]);
958
        assert!(dest.put("../escape", b"x").is_err() && dest.put("/abs", b"x").is_err());
959
        dest.remove("runs").unwrap();
960
        assert!(dest.list("runs").unwrap().is_empty());
961
    }
962
963
    #[test]
964
    fn files_are_sent_once_and_unchanged_ones_not_read_again() {
965
        let src = tempfile::tempdir().unwrap();
966
        let out = tempfile::tempdir().unwrap();
967
        let dest = Directory { root: out.path().to_path_buf() };
968
        std::fs::create_dir_all(src.path().join(".oxy/objects/loose")).unwrap();
969
        std::fs::write(src.path().join(".oxy/objects/loose/aa"), b"object").unwrap();
970
        std::fs::write(src.path().join(".oxy/ops-head"), b"ref one").unwrap();
971
        let mut s = Sent::default();
972
        let files = copy_dir(&dest, src.path(), &HashMap::new(), false, &mut s).unwrap();
973
        assert_eq!((files.len(), s.sent), (2, 2));
974
        let known: HashMap<String, File> = files.iter().map(|f| (f.path.clone(), f.clone())).collect();
975
        let mut s = Sent::default();
976
        let again = copy_dir(&dest, src.path(), &known, false, &mut s).unwrap();
977
        assert_eq!((again, s.sent), (files.clone(), 0), "nothing new travels");
978
        // The first pass takes only the files that never change.
979
        let mut s = Sent::default();
980
        let first = copy_dir(&dest, src.path(), &HashMap::new(), true, &mut s).unwrap();
981
        assert_eq!(first.iter().map(|f| f.path.as_str()).collect::<Vec<_>>(), vec![".oxy/objects/loose/aa"]);
982
    }
983
984
    #[test]
985
    fn run_names_sort_by_time() {
986
        assert_eq!(run_name(0), "19700101T000000Z");
987
        assert!(run_name(1_791_900_000_000) < run_name(1_791_903_600_000));
988
    }
989
}