OXYGENHUB

feat: backups for users and organisations, to a directory, with restore (refs #169) d7d182467382

Settings › Backups and an organisation's Settings › Backups choose how
often (off, hourly, daily), where (a directory hub.toml allows, or a GCS
bucket with its workload identity provider, which comes next), a prefix
and how long runs are kept. The hub's scheduler runs each due backup:
every repository's and wiki's store is copied file by file, content-
addressed so only what changed travels, the final pass under the
repository's read lock so each manifest is one consistent moment; the
hub's records for each repository and the owner go beside it as JSON
(never a secret, token, session or password); a DONE marker closes a
whole run; old runs and unreferenced blobs are pruned; a failure notifies
the owner once per spell. oxygenhub admin backup run and restore; restore
brings the stores back byte for byte and recreates the repositories' rows.
Tested end to end: back up, a second run sending nothing, restore into a
fresh hub, identical files and bookmarks, another owner never included.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-13 21:50 UTC
Signature
✔ signed by Andy McCall
Hash
d7d182467382d2aa9551464dae4e326c45a0c0abae89114d9a24067691da852b
Depends on
101740454617 18e381ea88c2 19bb31ff1f5c 38833ed78707 42fa95540468 43e8ca33bc5d 61ecbd299f22 64caed18236a 6dbd999ad794 756fe44a0f4d 8a3334b7a364 8ddebaa7e856 93be9ce4653e a7e6e8e6094c b1e92a6233ee b5668108487f c9aacc0e7dc1 d1727c573f30 d23464c0ac60 e71583501b1d f677d6270298
Depended on by
f2d2776b2a14 9041db4e4d92 166f6e9bf4f1 cb7b3cd45dc0 33d98147511b e57765dee61a 86a69b53aff0 7c06f5b28ade 4f40eed0588f c8684d44d549 4e2cad00a484 3b1bc27fe805 5d90e0e045e2 9a777fec4ed9 ffb67a81188c bff9106edcba 0cd118748131 2e9188ca4f98 7b9f4570daf5 d3b048b27277 1e8a6e8bb91b 5ef6cf72cd19 da8f7826a645 35fea4237cc9 6ae3fa0af4f6
In bookmarks
api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
29 atoms, 5 tree operations, 18 files

Browse the tree at this change

added crates/hub/migrations/0060_backups.sql +37 −0
1 +
-- Backups (docs/backups.md, oxygenhub#169): what each user and organisation
2 +
-- backs up, where and how often, and how each run went.
3 +
CREATE TABLE backup_settings (
4 +
    owner       TEXT PRIMARY KEY,
5 +
    -- 'user' or 'org'
6 +
    kind        TEXT NOT NULL,
7 +
    -- 'off', 'hourly' or 'daily'
8 +
    every       TEXT NOT NULL DEFAULT 'off',
9 +
    -- 'dir' (a directory hub.toml allows) or 'gcs'
10 +
    destination TEXT NOT NULL DEFAULT 'dir',
11 +
    -- the directory, or the bucket
12 +
    target      TEXT NOT NULL DEFAULT '',
13 +
    prefix      TEXT NOT NULL DEFAULT '',
14 +
    -- for 'gcs': the workload identity provider's resource name
15 +
    provider    TEXT NOT NULL DEFAULT '',
16 +
    keep_days   INTEGER NOT NULL DEFAULT 30,
17 +
    updated_ms  INTEGER NOT NULL
18 +
);
19 +
20 +
CREATE TABLE backup_runs (
21 +
    id          INTEGER PRIMARY KEY,
22 +
    owner       TEXT NOT NULL,
23 +
    started_ms  INTEGER NOT NULL,
24 +
    finished_ms INTEGER,
25 +
    -- 'running', 'ok', 'partial' or 'failed'
26 +
    status      TEXT NOT NULL,
27 +
    -- the run's name at its destination (runs/<at>/)
28 +
    at          TEXT NOT NULL DEFAULT '',
29 +
    repos       INTEGER NOT NULL DEFAULT 0,
30 +
    files       INTEGER NOT NULL DEFAULT 0,
31 +
    sent        INTEGER NOT NULL DEFAULT 0,
32 +
    bytes       INTEGER NOT NULL DEFAULT 0,
33 +
    detail      TEXT NOT NULL DEFAULT '',
34 +
    -- 'schedule' or 'now'
35 +
    trigger     TEXT NOT NULL DEFAULT 'schedule'
36 +
);
37 +
CREATE INDEX backup_runs_by_owner ON backup_runs (owner, id DESC);
added crates/hub/src/backups.rs +743 −0
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 +
}
added crates/hub/src/web/backups.rs +204 −0
1 +
//! Backups' pages (docs/backups.md §5, oxygenhub#169): a user's under
2 +
//! Settings › Backups, an organisation's under its settings, each with
3 +
//! where its repositories go, how often, how long runs are kept, "back up
4 +
//! now" and the recent runs.
5 +
6 +
use super::settings::{csrf, require};
7 +
use super::{filters, Error, Page, Shell};
8 +
use crate::auth::Viewer;
9 +
use crate::backups::{self, Run, Setting};
10 +
use crate::Shared;
11 +
use askama::Template;
12 +
use axum::extract::{Path, State};
13 +
use axum::Form;
14 +
use serde::Deserialize;
15 +
16 +
#[derive(Template)]
17 +
#[template(path = "backups.html")]
18 +
pub struct BackupsPage {
19 +
    shell: Shell,
20 +
    /// `user` or `org`.
21 +
    kind: &'static str,
22 +
    owner: String,
23 +
    /// Where the form posts.
24 +
    base: String,
25 +
    setting: Setting,
26 +
    runs: Vec<Run>,
27 +
    /// The directories this hub allows.
28 +
    dirs: Vec<String>,
29 +
    notice: Option<String>,
30 +
    error: Option<String>,
31 +
}
32 +
33 +
impl BackupsPage {
34 +
    fn every(&self) -> &'static [(&'static str, &'static str)] {
35 +
        backups::EVERY
36 +
    }
37 +
    fn keeps(&self) -> &'static [i64] {
38 +
        backups::KEEP
39 +
    }
40 +
}
41 +
42 +
async fn page(
43 +
    app: &Shared,
44 +
    shell: Shell,
45 +
    kind: &'static str,
46 +
    owner: &str,
47 +
    notice: Option<String>,
48 +
    error: Option<String>,
49 +
) -> Result<Page<BackupsPage>, Error> {
50 +
    let e = |e| Error::from_db(shell.clone(), e);
51 +
    let setting = backups::setting(&app.db, owner).await.map_err(e)?.unwrap_or_else(|| Setting::off(owner, kind));
52 +
    let runs = backups::runs(&app.db, owner, 50).await.map_err(e)?;
53 +
    let base = if kind == "org" { format!("/orgs/{owner}/settings/backups") } else { "/settings/backups".into() };
54 +
    let dirs = app.config.backups.directories.iter().map(|d| d.display().to_string()).collect();
55 +
    Ok(Page(BackupsPage { shell, kind, owner: owner.into(), base, setting, runs, dirs, notice, error }))
56 +
}
57 +
58 +
#[derive(Deserialize)]
59 +
pub struct BackupForm {
60 +
    #[serde(rename = "_csrf")]
61 +
    csrf_token: String,
62 +
    every: String,
63 +
    #[serde(default)]
64 +
    destination: String,
65 +
    #[serde(default)]
66 +
    dir: String,
67 +
    #[serde(default)]
68 +
    bucket: String,
69 +
    #[serde(default)]
70 +
    provider: String,
71 +
    #[serde(default)]
72 +
    prefix: String,
73 +
    #[serde(default)]
74 +
    keep_days: String,
75 +
}
76 +
77 +
/// The form as a setting, or why it cannot be one.
78 +
fn setting_of(app: &Shared, owner: &str, kind: &str, f: &BackupForm) -> Result<Setting, String> {
79 +
    if !backups::EVERY.iter().any(|(v, _)| *v == f.every) {
80 +
        return Err("Pick how often: off, hourly or daily.".into());
81 +
    }
82 +
    let keep_days: i64 = f.keep_days.parse().unwrap_or(30);
83 +
    if !backups::KEEP.contains(&keep_days) {
84 +
        return Err("Keep runs for 7, 30 or 90 days.".into());
85 +
    }
86 +
    let prefix = f.prefix.trim().trim_matches('/').to_string();
87 +
    if prefix.split('/').any(|p| p == ".." || p == ".") {
88 +
        return Err("The prefix is a plain path, without . or .. parts.".into());
89 +
    }
90 +
    let (target, provider) = match f.destination.as_str() {
91 +
        "dir" => {
92 +
            if !app.config.backups.directories.iter().any(|d| d.display().to_string() == f.dir) {
93 +
                return Err("Pick one of the directories this hub allows.".into());
94 +
            }
95 +
            (f.dir.clone(), String::new())
96 +
        }
97 +
        "gcs" => {
98 +
            let bucket = f.bucket.trim().trim_start_matches("gs://").trim_end_matches('/').to_string();
99 +
            if bucket.is_empty() || !bucket.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "-_.".contains(c)) {
100 +
                return Err("Give the bucket's name, such as andymccall-oxygen-backup.".into());
101 +
            }
102 +
            let provider = f.provider.trim().to_string();
103 +
            if !provider.starts_with("//iam.googleapis.com/projects/") || !provider.contains("/providers/") {
104 +
                return Err(
105 +
                    "Give the workload identity provider's full name, //iam.googleapis.com/projects/…/providers/… (docs/backups.md §6)."
106 +
                        .into(),
107 +
                );
108 +
            }
109 +
            (bucket, provider)
110 +
        }
111 +
        _ => return Err("Pick where backups go.".into()),
112 +
    };
113 +
    Ok(Setting {
114 +
        owner: owner.into(),
115 +
        kind: kind.into(),
116 +
        every: f.every.clone(),
117 +
        destination: f.destination.clone(),
118 +
        target,
119 +
        prefix,
120 +
        provider,
121 +
        keep_days,
122 +
        updated_ms: 0,
123 +
    })
124 +
}
125 +
126 +
async fn save(app: &Shared, shell: Shell, kind: &'static str, owner: &str, f: &BackupForm) -> Result<Page<BackupsPage>, Error> {
127 +
    match setting_of(app, owner, kind, f) {
128 +
        Ok(s) => {
129 +
            backups::save(&app.db, &s).await.map_err(|e| Error::from_db(shell.clone(), e))?;
130 +
            page(app, shell, kind, owner, Some("Saved.".into()), None).await
131 +
        }
132 +
        Err(why) => page(app, shell, kind, owner, None, Some(why)).await,
133 +
    }
134 +
}
135 +
136 +
async fn run_now(app: &Shared, shell: Shell, kind: &'static str, owner: &str) -> Result<Page<BackupsPage>, Error> {
137 +
    let set = backups::setting(&app.db, owner).await.map_err(|e| Error::from_db(shell.clone(), e))?;
138 +
    if set.is_none() {
139 +
        return page(app, shell, kind, owner, None, Some("Save where backups go first.".into())).await;
140 +
    }
141 +
    let (app2, owner2) = (app.clone(), owner.to_string());
142 +
    tokio::spawn(async move {
143 +
        if let Err(e) = backups::run(&app2, &owner2, "now").await {
144 +
            tracing::warn!("backup of {owner2}: {e:#}");
145 +
        }
146 +
    });
147 +
    page(app, shell, kind, owner, Some("Backing up now: the run shows below when it finishes.".into()), None).await
148 +
}
149 +
150 +
#[derive(Deserialize)]
151 +
pub struct Csrf {
152 +
    #[serde(rename = "_csrf")]
153 +
    csrf_token: String,
154 +
}
155 +
156 +
pub async fn user(State(app): State<Shared>, viewer: Viewer) -> Result<Page<BackupsPage>, Error> {
157 +
    let shell = Shell::new(&app, "/settings/backups", &viewer);
158 +
    let me = require(&viewer, &shell)?;
159 +
    page(&app, shell, "user", &me.account.username, None, None).await
160 +
}
161 +
162 +
pub async fn user_save(State(app): State<Shared>, viewer: Viewer, Form(f): Form<BackupForm>) -> Result<Page<BackupsPage>, Error> {
163 +
    let shell = Shell::new(&app, "/settings/backups", &viewer);
164 +
    let me = require(&viewer, &shell)?;
165 +
    csrf(&viewer, &f.csrf_token, &shell)?;
166 +
    save(&app, shell, "user", &me.account.username, &f).await
167 +
}
168 +
169 +
pub async fn user_run(State(app): State<Shared>, viewer: Viewer, Form(f): Form<Csrf>) -> Result<Page<BackupsPage>, Error> {
170 +
    let shell = Shell::new(&app, "/settings/backups", &viewer);
171 +
    let me = require(&viewer, &shell)?;
172 +
    csrf(&viewer, &f.csrf_token, &shell)?;
173 +
    run_now(&app, shell, "user", &me.account.username).await
174 +
}
175 +
176 +
pub async fn org(State(app): State<Shared>, viewer: Viewer, Path(name): Path<String>) -> Result<Page<BackupsPage>, Error> {
177 +
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings/backups"), &viewer);
178 +
    let (_, org) = super::orgs::owned_org(&app, &shell, &viewer, &name).await?;
179 +
    page(&app, shell, "org", &org.name, None, None).await
180 +
}
181 +
182 +
pub async fn org_save(
183 +
    State(app): State<Shared>,
184 +
    viewer: Viewer,
185 +
    Path(name): Path<String>,
186 +
    Form(f): Form<BackupForm>,
187 +
) -> Result<Page<BackupsPage>, Error> {
188 +
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings/backups"), &viewer);
189 +
    let (_, org) = super::orgs::owned_org(&app, &shell, &viewer, &name).await?;
190 +
    csrf(&viewer, &f.csrf_token, &shell)?;
191 +
    save(&app, shell, "org", &org.name, &f).await
192 +
}
193 +
194 +
pub async fn org_run(
195 +
    State(app): State<Shared>,
196 +
    viewer: Viewer,
197 +
    Path(name): Path<String>,
198 +
    Form(f): Form<Csrf>,
199 +
) -> Result<Page<BackupsPage>, Error> {
200 +
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings/backups"), &viewer);
201 +
    let (_, org) = super::orgs::owned_org(&app, &shell, &viewer, &name).await?;
202 +
    csrf(&viewer, &f.csrf_token, &shell)?;
203 +
    run_now(&app, shell, "org", &org.name).await
204 +
}
added crates/hub/tests/backups.rs +165 −0
1 +
//! Backups (docs/backups.md, oxygenhub#169), end to end: a real `oxy` push,
2 +
//! a backup to a directory, a second run that sends nothing new, and a
3 +
//! restore into a fresh hub that gives back the same repository, byte for
4 +
//! byte; and an owner's backup never holding another's repositories.
5 +
6 +
use oxy_core::repo::Repo;
7 +
use oxygenhub::backups::{self, Destination, Directory, Setting};
8 +
use oxygenhub::config::Config;
9 +
use oxygenhub::repos::RepoName;
10 +
use oxygenhub::{db, Shared};
11 +
use std::path::Path;
12 +
use std::sync::Arc;
13 +
14 +
fn hermetic() {
15 +
    static ONCE: std::sync::Once = std::sync::Once::new();
16 +
    static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
17 +
    ONCE.call_once(|| {
18 +
        let d = tempfile::tempdir().unwrap();
19 +
        std::env::set_var("OXY_HOME", d.path());
20 +
        DIR.set(d).ok();
21 +
    });
22 +
}
23 +
24 +
struct Hub {
25 +
    app: Shared,
26 +
    base: String,
27 +
    _dir: tempfile::TempDir,
28 +
}
29 +
30 +
async fn hub(backups: &Path) -> Hub {
31 +
    hermetic();
32 +
    let dir = tempfile::tempdir().unwrap();
33 +
    let mut config = Config::load(dir.path()).unwrap();
34 +
    config.auth.push_tokens = vec!["site".into()];
35 +
    config.backups.directories = vec![backups.to_path_buf()];
36 +
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
37 +
    let port = listener.local_addr().unwrap().port();
38 +
    config.server.base_url = format!("http://127.0.0.1:{port}");
39 +
    let app = oxygenhub::build(config).await.unwrap();
40 +
    let router = oxygenhub::router(app.clone());
41 +
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
42 +
    Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir }
43 +
}
44 +
45 +
fn write(root: &Path, rel: &str, content: &str) {
46 +
    let p = root.join(rel);
47 +
    std::fs::create_dir_all(p.parent().unwrap()).unwrap();
48 +
    std::fs::write(p, content).unwrap();
49 +
}
50 +
51 +
async fn create(app: &Shared, owner: &str, name: &str, visibility: hub_api::Visibility) {
52 +
    app.repos.create(&RepoName::parse(&format!("{owner}/{name}")).unwrap(), None).unwrap();
53 +
    db::repos::create(&app.db, owner, name, "a demo", visibility).await.unwrap();
54 +
}
55 +
56 +
fn push(url: &str, work: &Path, files: &[(&str, &str)]) {
57 +
    let mut c = Repo::init(work).unwrap();
58 +
    c.config.set("remote.origin.url", url).unwrap();
59 +
    c.config.set("remote.origin.token", "site").unwrap();
60 +
    c.save_config().unwrap();
61 +
    for (f, t) in files {
62 +
        write(work, f, t);
63 +
    }
64 +
    c.snapshot("t").unwrap();
65 +
    c.commit("Some work", &[]).unwrap();
66 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
67 +
}
68 +
69 +
/// Every file under `dir`, with its bytes.
70 +
fn files(dir: &Path) -> Vec<(String, Vec<u8>)> {
71 +
    let mut out = Vec::new();
72 +
    let mut stack = vec![dir.to_path_buf()];
73 +
    while let Some(d) = stack.pop() {
74 +
        for e in std::fs::read_dir(&d).unwrap().flatten() {
75 +
            let p = e.path();
76 +
            if p.is_dir() {
77 +
                stack.push(p);
78 +
            } else {
79 +
                out.push((p.strip_prefix(dir).unwrap().to_string_lossy().into_owned(), std::fs::read(&p).unwrap()));
80 +
            }
81 +
        }
82 +
    }
83 +
    out.sort();
84 +
    out
85 +
}
86 +
87 +
#[tokio::test(flavor = "multi_thread")]
88 +
async fn a_backup_restores_the_same_repository_and_sends_only_what_changed() {
89 +
    let store = tempfile::tempdir().unwrap();
90 +
    let h = hub(store.path()).await;
91 +
    db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", false).await.unwrap();
92 +
    db::accounts::create_account(&h.app.db, "bob", "[email protected]", "x", "salt", false).await.unwrap();
93 +
    create(&h.app, "andy", "demo", hub_api::Visibility::Private).await;
94 +
    create(&h.app, "bob", "other", hub_api::Visibility::Public).await;
95 +
    let work = tempfile::tempdir().unwrap();
96 +
    push(&format!("{}/andy/demo", h.base), work.path(), &[("README.md", "hello\n"), ("src/main.rs", "fn main() {}\n")]);
97 +
    let other = tempfile::tempdir().unwrap();
98 +
    push(&format!("{}/bob/other", h.base), other.path(), &[("x.txt", "bob's\n")]);
99 +
100 +
    // Andy backs up to the directory the hub allows.
101 +
    let target = store.path().display().to_string();
102 +
    let mut s = Setting::off("andy", "user");
103 +
    s.every = "hourly".into();
104 +
    s.target = target.clone();
105 +
    backups::save(&h.app.db, &s).await.unwrap();
106 +
    let first = backups::run(&h.app, "andy", "now").await.unwrap();
107 +
    assert_eq!((first.status.as_str(), first.repos), ("ok", 1), "{}", first.detail);
108 +
    assert!(first.sent > 0 && first.bytes > 0);
109 +
    let dest = Directory { root: store.path().join("andy") };
110 +
    assert!(dest.exists(&format!("runs/{}/DONE", first.at)).unwrap());
111 +
    let records: serde_json::Value = serde_json::from_slice(&dest.get(&format!("runs/{}/records/demo.json", first.at)).unwrap()).unwrap();
112 +
    assert_eq!(records["repositories"][0]["visibility"], "private");
113 +
    let text = String::from_utf8_lossy(&dest.get(&format!("runs/{}/records/demo.json", first.at)).unwrap()).into_owned();
114 +
    assert!(!text.contains("bob/other") && !text.contains("\"other\""), "nothing of another owner");
115 +
    assert!(!store.path().join("bob").exists(), "bob set up no backup");
116 +
117 +
    // Nothing changed: the next run sends nothing new.
118 +
    tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
119 +
    let second = backups::run(&h.app, "andy", "now").await.unwrap();
120 +
    assert_eq!((second.status.as_str(), second.sent), ("ok", 0), "{}", second.detail);
121 +
122 +
    // A fresh hub, restored from the backup, has the same repository.
123 +
    let empty = tempfile::tempdir().unwrap();
124 +
    let fresh = hub(empty.path()).await;
125 +
    let dest: Arc<dyn Destination> = Arc::new(Directory { root: store.path().join("andy") });
126 +
    let done = backups::restore_into_hub(&fresh.app, dest, None).await.unwrap();
127 +
    assert_eq!(done, vec!["andy/demo (repos)"]);
128 +
    let name = RepoName::parse("andy/demo").unwrap();
129 +
    assert_eq!(files(&fresh.app.repos.dir(&name)), files(&h.app.repos.dir(&name)), "byte for byte");
130 +
    let row = db::repos::get(&fresh.app.db, "andy", "demo").await.unwrap().expect("listed on the hub");
131 +
    assert_eq!((row.visibility.as_str(), row.description.as_str()), ("private", "a demo"));
132 +
    let (a, b) = (h.app.repos.open(&name).unwrap(), fresh.app.repos.open(&name).unwrap());
133 +
    assert_eq!(
134 +
        a.bookmarks().unwrap().iter().map(|b| b.heads.clone()).collect::<Vec<_>>(),
135 +
        b.bookmarks().unwrap().iter().map(|b| b.heads.clone()).collect::<Vec<_>>()
136 +
    );
137 +
138 +
    // A directory the hub does not allow is refused.
139 +
    let mut bad = Setting::off("andy", "user");
140 +
    bad.every = "hourly".into();
141 +
    bad.target = "/tmp/somewhere-else".into();
142 +
    backups::save(&h.app.db, &bad).await.unwrap();
143 +
    let refused = backups::run(&h.app, "andy", "now").await.unwrap();
144 +
    assert_eq!(refused.status, "failed");
145 +
    assert!(refused.detail.contains("not a backup directory this hub allows"), "{}", refused.detail);
146 +
}
147 +
148 +
#[tokio::test(flavor = "multi_thread")]
149 +
async fn backups_fall_due_on_their_schedule() {
150 +
    let store = tempfile::tempdir().unwrap();
151 +
    let h = hub(store.path()).await;
152 +
    let mut s = Setting::off("andy", "user");
153 +
    s.target = store.path().display().to_string();
154 +
    backups::save(&h.app.db, &s).await.unwrap();
155 +
    assert!(backups::due(&h.app.db).await.unwrap().is_empty(), "off is never due");
156 +
    s.every = "hourly".into();
157 +
    backups::save(&h.app.db, &s).await.unwrap();
158 +
    assert_eq!(backups::due(&h.app.db).await.unwrap().len(), 1, "never run: due");
159 +
    sqlx::query("INSERT INTO backup_runs (owner, started_ms, status) VALUES ('andy', ?, 'ok')")
160 +
        .bind(db::now_ms())
161 +
        .execute(&h.app.db)
162 +
        .await
163 +
        .unwrap();
164 +
    assert!(backups::due(&h.app.db).await.unwrap().is_empty(), "ran just now");
165 +
}
added templates/backups.html +61 −0
1 +
{% extends "base.html" %}
2 +
{% import "settings_tabs.html" as t %}
3 +
{% block title %}Backups{% if kind == "org" %} · {{ owner }}{% endif %}{% endblock %}
4 +
{% block tabs %}{% if kind == "user" %}{% call t::tabs("backups") %}{% endif %}{% endblock %}
5 +
{% block content %}
6 +
<div class="head"><h1>Backups <small>{% if kind == "org" %}<a href="/orgs/{{ owner }}/settings">{{ owner }}</a>'s repositories{% else %}your repositories{% endif %}, copied to a place you control</small></h1></div>
7 +
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
8 +
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
9 +
<p class="small mute backup-what">Each run copies every repository {% if kind == "org" %}the organisation{% else %}you{% endif %} own, with its wiki, byte for byte (signatures and all), its issues, proposals, releases and settings (never a secret value){% if kind == "org" %}, and the organisation's members and teams{% endif %}. Only what changed since the last run travels, and any kept run can be restored with <code>oxygenhub admin restore</code>.</p>
10 +
11 +
<form class="card wide backup-form" method="post" action="{{ base }}">
12 +
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
13 +
  <h2>Where and how often</h2>
14 +
  <fieldset class="choices">
15 +
    <legend class="small mute">How often</legend>
16 +
    {% for (v, label) in every() %}<label class="choice"><input type="radio" name="every" value="{{ v }}"{% if setting.every == *v %} checked{% endif %}> {{ label }}</label>{% endfor %}
17 +
  </fieldset>
18 +
  <fieldset class="choices">
19 +
    <legend class="small mute">Where</legend>
20 +
    {% if !dirs.is_empty() %}<label class="choice"><input type="radio" name="destination" value="dir"{% if setting.destination == "dir" %} checked{% endif %}> A directory on this hub's host</label>{% endif %}
21 +
    <label class="choice"><input type="radio" name="destination" value="gcs"{% if setting.destination == "gcs" || dirs.is_empty() %} checked{% endif %}> A Google Cloud Storage bucket, with no key</label>
22 +
  </fieldset>
23 +
  {% if !dirs.is_empty() %}
24 +
  <label for="dir">Directory</label>
25 +
  <select id="dir" name="dir">{% for d in dirs %}<option value="{{ d }}"{% if setting.destination == "dir" && setting.target == *d %} selected{% endif %}>{{ d }}/{{ owner }}</option>{% endfor %}</select>
26 +
  {% endif %}
27 +
  <label for="bucket">GCS bucket</label>
28 +
  <input type="text" id="bucket" name="bucket" placeholder="{{ owner }}-oxygen-backup" value="{% if setting.destination == "gcs" %}{{ setting.target }}{% endif %}">
29 +
  <label for="provider">Workload identity provider</label>
30 +
  <input type="text" id="provider" name="provider" placeholder="//iam.googleapis.com/projects/NUMBER/locations/global/workloadIdentityPools/oxygenhub/providers/oxygenhub-io" value="{{ setting.provider }}">
31 +
  <p class="small mute">The hub signs in as <code>backup:{{ owner }}</code> with a token it issues itself; Google exchanges it for a few minutes' access to this bucket. No key file exists. One-time setup: <a href="/help#backups">Help › Backups</a> (docs/backups.md §6).</p>
32 +
  <label for="prefix">Prefix (optional)</label>
33 +
  <input type="text" id="prefix" name="prefix" value="{{ setting.prefix }}" placeholder="oxygenhub">
34 +
  <label for="keep_days">Keep runs for</label>
35 +
  <select id="keep_days" name="keep_days">{% for k in keeps() %}<option value="{{ k }}"{% if setting.keep_days == *k %} selected{% endif %}>{{ k }} days</option>{% endfor %}</select>
36 +
  <button type="submit">SAVE</button>
37 +
</form>
38 +
39 +
<section class="card-box" id="runs">
40 +
  <div class="card-head"><h2>Runs</h2><span class="count-chip">{{ runs.len() }}</span>
41 +
    <form class="inline more" method="post" action="{{ base }}/run"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="small primary-btn"{% if setting.updated_ms == 0 %} disabled title="Save where backups go first"{% endif %}>Back up now</button></form>
42 +
  </div>
43 +
  {% if runs.is_empty() %}<p class="empty small">No runs yet.{% if setting.every == "off" %} Pick how often and where, then save.{% endif %}</p>
44 +
  {% else %}
45 +
  <div class="table"><table class="backup-runs">
46 +
    <tr><th>Started</th><th>Took</th><th>Outcome</th><th>Repositories</th><th>Files sent</th><th>Size sent</th><th></th></tr>
47 +
    {% for r in runs %}
48 +
    <tr>
49 +
      <td><time title="{{ r.started_ms|when_i }}">{{ r.started_ms|ago_i }}</time>{% if r.trigger == "now" %} <span class="mute small">by hand</span>{% endif %}</td>
50 +
      <td>{% if let Some(f) = r.finished_ms %}{{ ((f - r.started_ms) / 1000) }} s{% else %}<span class="mute">running</span>{% endif %}</td>
51 +
      <td><span class="backup-status st-{{ r.status }}">{{ r.status }}</span></td>
52 +
      <td>{{ r.repos }}</td>
53 +
      <td>{{ r.sent }} <span class="mute small">of {{ r.files }}</span></td>
54 +
      <td>{{ (r.bytes / 1024) }} KiB</td>
55 +
      <td class="small mute">{{ r.detail }}</td>
56 +
    </tr>
57 +
    {% endfor %}
58 +
  </table></div>
59 +
  {% endif %}
60 +
</section>
61 +
{% endblock %}
modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Backups (#169, docs/backups.md):** a user (Settings › Backups) or an organisation's owners copy every repository they own, with its wiki, issues, proposals, releases and settings (never a secret value), hourly or daily, to a directory the hub allows. Only what changed travels, runs are kept 7, 30 or 90 days, a failure lands in the owner's inbox, and `oxygenhub admin backup restore` brings repositories back byte for byte. GCS, with no key, is next.
7 8
- **Diffusion (#148, docs/diffusion.md).** When a repository that others pin as a subrepo moves on, each consumer that follows it gets a proposal moving the pin, signed by the hub, tested by the consumer's own pipeline, with the upstream's changes listed. One proposal per consumer and subrepo, kept up to date; never landed by itself. Turned on per repository (Settings › Diffusion, `PUT /repos/{owner}/{name}/diffusion`, or `follow` in `.oxymodules`), off by default; `green` waits for the upstream's run to pass. A repository's page shows who uses it and how far behind each is (Diffusion › Diffuses to, `GET /repos/{owner}/{name}/consumers`). Consumers are bumped on the bookmark their work lands on, learned from where their proposals land.
8 9
- **Diffusion in the API (#153):** `GET /repos/{owner}/{name}/diffusion` gives what a repository takes from and what it diffuses to, each with its pill, for the VS Code extension.
9 10
- **The card layout everywhere (#159–#164):** the About card's counts in its heading and its buttons centred; wider repository cards with each Diffusion row on one line; the Code button and + New project in the accent blue; proposal and issue sidebars, profile and organisation pages, search results and the wiki's sidebar as cards; every panel's and form card's heading a shaded bar.
…
169 unchanged lines
modified crates/hub/src/config.rs +10 −0
…
21 unchanged lines
22 22
    pub flow: Flow,
23 23
    pub apt: Apt,
24 24
    pub diffusion: Diffusion,
25 +
    pub backups: Backups,
25 26
}
26 27
28 +
/// Backups (docs/backups.md, oxygenhub#169): the directories on this host
29 +
/// an owner may back up to. Empty, only GCS destinations can be chosen.
30 +
#[derive(Clone, Debug, Default, Deserialize)]
31 +
#[serde(default, deny_unknown_fields)]
32 +
pub struct Backups {
33 +
    pub directories: Vec<PathBuf>,
34 +
}
35 +
27 36
/// Diffusion (#148, docs/diffusion.md §4.2): the site's default for
28 37
/// repositories that say nothing.
29 38
#[derive(Clone, Debug, Deserialize)]
…
232 unchanged lines
262 271
            flow: Flow::default(),
263 272
            apt: Apt::default(),
264 273
            diffusion: Diffusion::default(),
274 +
            backups: Backups::default(),
265 275
        }
266 276
    }
267 277
}
…
123 unchanged lines
modified crates/hub/src/lib.rs +2 −0
…
6 unchanged lines
7 7
pub mod apt;
8 8
pub mod assistants;
9 9
pub mod auth;
10 +
pub mod backups;
10 11
pub mod config;
11 12
pub mod coverage;
12 13
pub mod db;
…
122 unchanged lines
135 136
    tokio::spawn(flow::scheduler(app.clone()));
136 137
    tokio::spawn(flow::placement(app.clone()));
137 138
    tokio::spawn(maintenance::nightly(app.clone()));
139 +
    tokio::spawn(backups::scheduler(app.clone()));
138 140
    // The reverse pin index for repositories pushed before it existed (#148).
139 141
    tokio::spawn({
140 142
        let app = app.clone();
…
83 unchanged lines
modified crates/hub/src/main.rs +51 −0
…
52 unchanged lines
53 53
        #[command(subcommand)]
54 54
        cmd: UserCmd,
55 55
    },
56 +
    /// Backups (docs/backups.md)
57 +
    Backup {
58 +
        #[command(subcommand)]
59 +
        cmd: BackupCmd,
60 +
    },
61 +
}
62 +
63 +
#[derive(Subcommand)]
64 +
enum BackupCmd {
65 +
    /// Back up a user's or organisation's repositories now, as its settings say
66 +
    Run { owner: String },
67 +
    /// Restore every repository of a whole run from a backup directory
68 +
    /// (the one holding LATEST, runs/ and blobs/) into this hub
69 +
    Restore {
70 +
        #[arg(long)]
71 +
        from: std::path::PathBuf,
72 +
        /// The run (runs/<RUN>); the latest whole one when not given
73 +
        #[arg(long)]
74 +
        run: Option<String>,
75 +
    },
56 76
}
57 77
58 78
#[derive(Subcommand)]
…
60 unchanged lines
119 139
            let config = Config::load(&cli.data)?;
120 140
            let app = oxygenhub::build(config).await?;
121 141
            repo_admin(&app, cmd).await
142 +
        }
143 +
        Cmd::Admin { cmd: AdminCmd::Backup { cmd } } => {
144 +
            let config = Config::load(&cli.data)?;
145 +
            let app = oxygenhub::build(config).await?;
146 +
            match cmd {
147 +
                BackupCmd::Run { owner } => {
148 +
                    let r = oxygenhub::backups::run(&app, &owner, "now").await?;
149 +
                    println!(
150 +
                        "{} {}: {} repositories, {} of {} files sent ({} KiB){}",
151 +
                        r.status,
152 +
                        r.at,
153 +
                        r.repos,
154 +
                        r.sent,
155 +
                        r.files,
156 +
                        r.bytes / 1024,
157 +
                        if r.detail.is_empty() { String::new() } else { format!(": {}", r.detail) }
158 +
                    );
159 +
                    if r.status == "failed" {
160 +
                        std::process::exit(1);
161 +
                    }
162 +
                    Ok(())
163 +
                }
164 +
                BackupCmd::Restore { from, run } => {
165 +
                    let dest: std::sync::Arc<dyn oxygenhub::backups::Destination> =
166 +
                        std::sync::Arc::new(oxygenhub::backups::Directory { root: from });
167 +
                    for d in oxygenhub::backups::restore_into_hub(&app, dest, run).await? {
168 +
                        println!("restored {d}");
169 +
                    }
170 +
                    Ok(())
171 +
                }
172 +
            }
122 173
        }
123 174
        Cmd::Admin { cmd: AdminCmd::User { cmd } } => {
124 175
            let config = Config::load(&cli.data)?;
…
173 unchanged lines
modified crates/hub/src/web/mod.rs +5 −0
…
5 unchanged lines
6 6
mod admin;
7 7
pub(crate) mod assistants;
8 8
mod avatar;
9 +
mod backups;
9 10
pub mod chain;
10 11
pub mod coverage;
11 12
mod device;
…
76 unchanged lines
88 89
        .route("/settings/keys/verify", post(settings::keys_verify))
89 90
        .route("/settings/keys/unlink", post(settings::keys_unlink))
90 91
        .route("/settings/tokens", get(settings::tokens).post(settings::tokens_create))
92 +
        .route("/settings/backups", get(backups::user).post(backups::user_save))
93 +
        .route("/settings/backups/run", post(backups::user_run))
94 +
        .route("/orgs/{name}/settings/backups", get(backups::org).post(backups::org_save))
95 +
        .route("/orgs/{name}/settings/backups/run", post(backups::org_run))
91 96
        .route("/settings/tokens/{id}/delete", post(settings::tokens_delete))
92 97
        .route("/new", get(repo_settings::new_form).post(repo_settings::create))
93 98
        .route("/orgs/new", get(orgs::new_form).post(orgs::create))
…
652 unchanged lines
modified crates/hub/src/web/orgs.rs +6 −1
…
125 unchanged lines
126 126
    }))
127 127
}
128 128
129 -
async fn owned_org(app: &Shared, shell: &Shell, viewer: &Viewer, name: &str) -> Result<(accounts::Account, accounts::Organisation), Error> {
129 +
pub(crate) async fn owned_org(
130 +
    app: &Shared,
131 +
    shell: &Shell,
132 +
    viewer: &Viewer,
133 +
    name: &str,
134 +
) -> Result<(accounts::Account, accounts::Organisation), Error> {
130 135
    let me = require(viewer, shell)?;
131 136
    let org = accounts::org_by_name(&app.db, name)
132 137
        .await
…
358 unchanged lines
modified crates/hub/tests/issues.rs +45 −0
…
962 unchanged lines
963 963
    assert_eq!(fresh.status, StatusCode::OK);
964 964
    assert!(fresh.body.contains("Fresh"));
965 965
}
966 +
967 +
/// Backups' settings (#169): a user's page, its checks, and an
968 +
/// organisation's page for its owners only.
969 +
#[tokio::test(flavor = "multi_thread")]
970 +
async fn backups_are_set_up_in_settings() {
971 +
    let h = hub().await;
972 +
    let app = &h.app;
973 +
    let andy = register(app, "andy").await;
974 +
    let page = get(app, "/settings/backups", Some(&andy)).await;
975 +
    assert_eq!(page.status, StatusCode::OK);
976 +
    assert!(page.body.contains("href=\"/settings/backups\" class=\"on\"") && page.body.contains("No runs yet."), "{}", page.body);
977 +
    let csrf = csrf_of(&page.body);
978 +
    // This hub allows no directory: only a bucket, and a real provider name.
979 +
    let bad = post(
980 +
        app,
981 +
        "/settings/backups",
982 +
        Some(&andy),
983 +
        &format!("_csrf={csrf}&every=hourly&destination=gcs&bucket=andy-backup&provider=nope&keep_days=30"),
984 +
    )
985 +
    .await;
986 +
    assert!(bad.body.contains("workload identity provider"), "{}", bad.body);
987 +
    let dir =
988 +
        post(app, "/settings/backups", Some(&andy), &format!("_csrf={csrf}&every=hourly&destination=dir&dir=%2Ftmp&keep_days=30")).await;
989 +
    assert!(dir.body.contains("Pick one of the directories this hub allows."));
990 +
    let provider =
991 +
        "%2F%2Fiam.googleapis.com%2Fprojects%2F1%2Flocations%2Fglobal%2FworkloadIdentityPools%2Foxygenhub%2Fproviders%2Foxygenhub-io";
992 +
    let ok = post(
993 +
        app,
994 +
        "/settings/backups",
995 +
        Some(&andy),
996 +
        &format!("_csrf={csrf}&every=hourly&destination=gcs&bucket=gs%3A%2F%2Fandy-backup&provider={provider}&keep_days=7"),
997 +
    )
998 +
    .await;
999 +
    assert!(ok.body.contains("Saved."), "{}", ok.body);
1000 +
    let s = oxygenhub::backups::setting(&app.db, "andy").await.unwrap().unwrap();
1001 +
    assert_eq!((s.target.as_str(), s.keep_days, s.every.as_str()), ("andy-backup", 7, "hourly"));
1002 +
    // An organisation's backups are its owners' business only.
1003 +
    let bob = register(app, "bob").await;
1004 +
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
1005 +
    db::accounts::create_org(&app.db, "club", "Club", andy_id).await.unwrap();
1006 +
    assert_eq!(get(app, "/orgs/club/settings/backups", Some(&bob)).await.status, StatusCode::NOT_FOUND);
1007 +
    let club = get(app, "/orgs/club/settings/backups", Some(&andy)).await;
1008 +
    assert!(club.body.contains("club</a>'s repositories"), "{}", club.body);
1009 +
    assert!(get(app, "/orgs/club/settings", Some(&andy)).await.body.contains("/orgs/club/settings/backups"));
1010 +
}
modified docs/backups.md +6 −1
1 1
# Backups: per site, organisation, user and repository
2 2
3 -
Status: design, 2026-10-13 (oxygenhub#169). Hourly backups configured by a
3 +
Status: BK2 built 2026-10-13 (oxygenhub#169): user and organisation
4 +
settings, the hourly and daily schedule with "back up now", directory
5 +
destinations, every repository's and wiki's store byte for byte, the
6 +
records as JSON, retention, failure notices and restore of the stores and
7 +
repository rows (`oxygenhub admin backup run|restore`). Next: the GCS
8 +
destination (BK3), then restoring issues and proposals from the records. Hourly backups configured by a
4 9
user and by an organisation come first; finer-grained ones (per
5 10
repository, more often than hourly) later. The site's own backup
6 11
(everything that runs a hub, #168) is an operations script today
…
161 unchanged lines
modified docs/spec.md +2 −2
…
579 unchanged lines
580 580
- **Masking.** Log intake replaces any token this hub signed (recognised by its `kid`) with `***`, as it does secret values.
581 581
- **Trust, for example.** AWS: an IAM OIDC provider with the hub's URL and audience `sts.amazonaws.com`, a role whose trust policy matches `sub` `repo:andymccall/metalgear:bookmark:main`; the job then calls `aws sts assume-role-with-web-identity --web-identity-token file:///run/secrets/aws-token`. Google: a workload identity pool provider with the same issuer, mapping `assertion.sub`. HashiCorp Vault: the JWT auth method with `bound_audiences` and `bound_claims` on `repository`.
582 582
583 -
### 13.5.1 Backups (designed 2026-10-13, oxygenhub#169)
583 +
### 13.5.1 Backups (oxygenhub#169: directory destinations built 2026-10-13, GCS next)
584 584
585 -
Each user and organisation backs up its repositories hourly to a place it controls: a GCS bucket reached keylessly (the hub mints a short-lived token with `sub = backup:OWNER`, which GCP's workload identity federation exchanges for an access token, so no key exists), or a directory the site allows. A run sends only the content-addressed objects the destination lacks, the refs at that moment and the hub's records (issues, proposals, releases, wiki, settings; never secret values), and is whole once its `DONE` marker is written; `oxygenhub admin restore` brings repositories back from any whole run. The design, the layout at a destination, setup and restore are in `docs/backups.md`.
585 +
Each user and organisation backs up its repositories hourly to a place it controls: a GCS bucket reached keylessly (the hub mints a short-lived token with `sub = backup:OWNER`, which GCP's workload identity federation exchanges for an access token, so no key exists), or a directory the site allows. A run sends only the content-addressed objects the destination lacks, the refs at that moment and the hub's records (issues, proposals, releases, wiki, settings; never secret values), and is whole once its `DONE` marker is written; `oxygenhub admin restore` brings repositories back from any whole run. Built: Settings › Backups and an organisation's Settings › Backups (how often: off, hourly or daily; a directory from `hub.toml` `[backups] directories` or a GCS bucket with its workload identity provider; a prefix; runs kept 7, 30 or 90 days; "back up now"; the last 50 runs with their outcome), migration 0060 (`backup_settings`, `backup_runs`), the hub's scheduler (one run at a time; a run left running by a restart is marked failed), each repository's and wiki's store copied file by file under the repository's read lock for the final pass, the records as JSON (every table keyed by the repository, and three levels of rows pointing at them; tables and columns holding secrets, tokens, sessions or passwords never), pruning of old runs and unreferenced blobs, an inbox notice to the owner (an organisation's owners) once per spell of failures, and `oxygenhub admin backup run OWNER` and `oxygenhub admin backup restore --from DIR [--run AT]`, which restores the stores and the repositories' rows. The design, the layout at a destination, setup and restore are in `docs/backups.md`.
586 586
587 587
### 13.6 Coverage reports (oxygenhub#113, done 2026-10-06)
588 588
…
168 unchanged lines
modified templates/help.html +7 −0
…
11 unchanged lines
12 12
  <a href="#delegate">Letting the hub act for you</a>
13 13
  <a href="#flow">Pipelines</a>
14 14
  <a href="#keys">Keyboard</a>
15 +
  <a href="#backups">Backups</a>
15 16
  <a href="#glossary">Coming from GitHub</a>
16 17
</nav>
17 18
<div class="sections">
…
90 unchanged lines
108 109
  </table>
109 110
</section>
110 111
112 +
<section class="panel" id="backups">
113 +
  <h2>Backups</h2>
114 +
  <p>Under Settings › Backups (or an organisation's settings) you choose where your repositories are copied and how often. Each run copies every repository you own, its wiki, issues, proposals, releases and settings, but never a secret value. Only what changed travels, and every kept run can be restored with <code>oxygenhub admin backup restore</code>.</p>
115 +
  <p>A Google Cloud Storage bucket needs no key: the hub signs in as <code>backup:YOU</code> with a token it issues itself, which Google trusts through workload identity federation. Once per project, create a pool and an OIDC provider whose issuer is this hub's address; once per bucket, grant <code>roles/storage.objectAdmin</code> to the principal <code>…/subject/backup:YOU</code>. The exact commands are in the hub's <code>docs/backups.md</code> §6.</p>
116 +
</section>
117 +
111 118
<section class="panel" id="glossary">
112 119
  <h2>Coming from GitHub</h2>
113 120
  <table class="list glossary">
…
23 unchanged lines
modified templates/org_settings.html +2 −0
…
16 unchanged lines
17 17
  <a href="#assistants">AI assistants</a>
18 18
  <a href="#teams">Teams</a>
19 19
  <a href="#storage">Storage</a>
20 +
  <a href="#backups">Backups</a>
20 21
</nav>
21 22
<div class="sections">
22 23
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
23 24
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
25 +
<section class="panel" id="backups"><h2>Backups</h2><p class="small">Copy every repository {{ org.name }} owns, with its issues, proposals and wiki, hourly to a bucket or directory you control. <a class="go small" href="/orgs/{{ org.name }}/settings/backups">Set up backups</a></p></section>
24 26
<form class="card" id="general" method="post" action="/orgs/{{ org.name }}/settings">
25 27
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
26 28
  <label for="display_name">Display name</label><input type="text" id="display_name" name="display_name" value="{{ org.display_name }}">
…
72 unchanged lines
modified templates/settings_tabs.html +1 −0
…
4 unchanged lines
5 5
  <a href="/settings/security"{% if on == "security" %} class="on" aria-current="page"{% endif %}>Security</a>
6 6
  <a href="/settings/keys"{% if on == "keys" %} class="on" aria-current="page"{% endif %}>Keys</a>
7 7
  <a href="/settings/tokens"{% if on == "tokens" %} class="on" aria-current="page"{% endif %}>Tokens</a>
8 +
  <a href="/settings/backups"{% if on == "backups" %} class="on" aria-current="page"{% endif %}>Backups</a>
8 9
  <a href="/orgs/new">New organisation</a>
9 10
</div></nav>
10 11
{% endmacro %}
modified templates/style.css +8 −0
…
1160 unchanged lines
1161 1161
.side-sections .side-sec:not(:has(> .side-head)) { padding-top: 10px; }
1162 1162
.card-box > ul.card-list { margin: 0; padding: 10px 16px; }
1163 1163
.who-card .side-card { margin-top: 14px; }
1164 +
1165 +
/* Backups (#169): each run's outcome. */
1166 +
.backup-status { display: inline-block; padding: 0 8px; border-radius: 10px; font-size: 12px; font-weight: 600; border: 1px solid; }
1167 +
.backup-status.st-ok { color: var(--ok); }
1168 +
.backup-status.st-partial { color: var(--warn); }
1169 +
.backup-status.st-failed { color: var(--bad); }
1170 +
.backup-status.st-running { color: var(--mute); }
1171 +
.backup-what { max-width: 760px; }