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; } |