Backups per repository: leave one out, or give it its own schedule d3b048b27277
docs/backups.md §5. A repository's Settings › Backup (and PUT
/repos/{owner}/{name}/backup, by its owners and admins; migration 0062):
with its owner's backup, left out, or at least hourly or daily.
Left out, it is in no new run. With its own schedule, its owner's backup
runs at least that often, and runs even when the owner's own schedule is
off, then taking only the repositories that asked. A run still sends only
what changed and keeps every included repository, so each run stays
whole and restores everything. A run asked for by hand takes every
repository not left out. Admin › Backups counts what a scheduled run
covers.
Tested: one left out is in no run; an hourly repository makes a daily
owner due; with the owner off, only it is backed up; by hand, all but the
one left out; the settings card saves and refuses an unknown choice.
closes #177
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-14 03:27 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
d3b048b27277329d343d78bf508f6ac4c04766e8ef9ae64c13cdce55571a0397- Depends on
- 18e381ea88c2 19bb31ff1f5c 1e8a6e8bb91b 1f78655a0466 3f4b0ea6eabf 5ef6cf72cd19 6dbd999ad794 7edaff01d506 9008bf6c2b8c b64e2537d702 d0fbea2eb46e d23464c0ac60 d7d182467382 e71583501b1d eb2a3502601d f4d6ef418a96
- Depended on by
- 9041db4e4d92 c7067c7bda56 cb7b3cd45dc0 0ee5c3fb32eb a9f1c0bf403a 4a68f1064b08 16ee5d19d220 8dcaeeb848c6 107eaba52c9d 5d90e0e045e2 2e9188ca4f98 7b9f4570daf5
- 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
- 27 atoms, 1 tree operation, 10 files
Browse the tree at this change
added crates/hub/migrations/0062_repo_backup.sql +4 −0
| 1 | + | -- Per-repository backups (docs/backups.md §5, oxygenhub#177): empty goes |
|
| 2 | + | -- with its owner's backup; `off` leaves it out; `hourly` or `daily` backs |
|
| 3 | + | -- it up at least that often, whatever its owner's schedule. |
|
| 4 | + | ALTER TABLE repositories ADD COLUMN backup TEXT NOT NULL DEFAULT ''; |
modified crates/hub/src/api/mod.rs +27 −0
| … | 85 unchanged lines |
||
| 86 | 86 | .route("/repos/{owner}/{name}/tags", get(tags)) |
|
| 87 | 87 | .route("/repos/{owner}/{name}/consumers", get(consumers)) |
|
| 88 | 88 | .route("/repos/{owner}/{name}/diffusion", get(diffusion).put(set_diffusion)) |
|
| 89 | + | .route("/repos/{owner}/{name}/backup", axum::routing::put(set_backup)) |
|
| 89 | 90 | .route("/repos/{owner}/{name}/history/{r}", get(history)) |
|
| 90 | 91 | .route("/repos/{owner}/{name}/changes/{hash}", get(change)) |
|
| 91 | 92 | .route("/repos/{owner}/{name}/tree/{r}", get(tree_root)) |
|
| … | 478 unchanged lines |
||
| 570 | 571 | } |
|
| 571 | 572 | ||
| 572 | 573 | #[derive(serde::Deserialize)] |
|
| 574 | + | struct BackupSetting { |
|
| 575 | + | /// `owner` (with its owner's), `hourly`, `daily` or `off`. |
|
| 576 | + | backup: String, |
|
| 577 | + | } |
|
| 578 | + | ||
| 579 | + | /// `PUT /repos/{owner}/{name}/backup {"backup": ...}`: this repository's |
|
| 580 | + | /// backup (docs/backups.md §5). Owners and admins. |
|
| 581 | + | async fn set_backup( |
|
| 582 | + | State(app): State<Shared>, |
|
| 583 | + | headers: HeaderMap, |
|
| 584 | + | Path((owner, name)): Path<(String, String)>, |
|
| 585 | + | Json(b): Json<BackupSetting>, |
|
| 586 | + | ) -> Result<Json<serde_json::Value>, ApiError> { |
|
| 587 | + | let c = caller(&app, &headers).await?; |
|
| 588 | + | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Maintain).await?; |
|
| 589 | + | let setting = match b.backup.as_str() { |
|
| 590 | + | "owner" => "", |
|
| 591 | + | "hourly" | "daily" | "off" => b.backup.as_str(), |
|
| 592 | + | _ => return Err(ApiError(StatusCode::BAD_REQUEST, "backup is owner, hourly, daily or off".into())), |
|
| 593 | + | }; |
|
| 594 | + | db::repos::set_backup(&app.db, row.id, setting).await?; |
|
| 595 | + | db::accounts::audit(&app.db, c.account.as_ref(), "repo.backup", &format!("{owner}/{name}"), &b.backup).await.ok(); |
|
| 596 | + | Ok(Json(serde_json::json!({ "backup": b.backup }))) |
|
| 597 | + | } |
|
| 598 | + | ||
| 599 | + | #[derive(serde::Deserialize)] |
|
| 573 | 600 | struct DiffusionSetting { |
|
| 574 | 601 | /// `off`, `green`, `any`, or `default` (the site's). |
|
| 575 | 602 | follow: String, |
|
| … | 1946 unchanged lines |
modified crates/hub/src/backups.rs +42 −9
| … | 110 unchanged lines |
||
| 111 | 111 | sqlx::query_as("SELECT * FROM backup_runs WHERE owner = ? ORDER BY id DESC LIMIT ?").bind(owner).bind(limit).fetch_all(pool).await |
|
| 112 | 112 | } |
|
| 113 | 113 | ||
| 114 | + | /// What a repository may choose about its backup (§5): with its owner's, |
|
| 115 | + | /// left out, or at least hourly or daily whatever its owner's schedule. |
|
| 116 | + | pub const REPO_CHOICES: &[(&str, &str)] = |
|
| 117 | + | &[("", "With its owner's backup"), ("hourly", "At least every hour"), ("daily", "At least every day"), ("off", "Left out")]; |
|
| 118 | + | ||
| 119 | + | /// Whether `row` is in its owner's run: never when it is left out, always |
|
| 120 | + | /// when it has its own schedule, else as its owner's schedule says (a run |
|
| 121 | + | /// asked for by hand takes every repository not left out). |
|
| 122 | + | pub fn included(s: &Setting, row: &db::repos::RepoRow, by_hand: bool) -> bool { |
|
| 123 | + | match row.backup.as_str() { |
|
| 124 | + | "off" => false, |
|
| 125 | + | "hourly" | "daily" => true, |
|
| 126 | + | _ => by_hand || s.every != "off", |
|
| 127 | + | } |
|
| 128 | + | } |
|
| 129 | + | ||
| 130 | + | /// Seconds between an owner's runs: the shortest of its own schedule and |
|
| 131 | + | /// its repositories' own; none when nothing is scheduled. |
|
| 132 | + | pub fn period_with(s: &Setting, repos: &[db::repos::RepoRow]) -> Option<i64> { |
|
| 133 | + | let own = repos.iter().filter_map(|r| match r.backup.as_str() { |
|
| 134 | + | "hourly" => Some(3600), |
|
| 135 | + | "daily" => Some(86_400), |
|
| 136 | + | _ => None, |
|
| 137 | + | }); |
|
| 138 | + | s.period_s().into_iter().chain(own).min() |
|
| 139 | + | } |
|
| 140 | + | ||
| 114 | 141 | /// One owner's backup as Admin › Backups shows it (§5): its setting, its |
|
| 115 | 142 | /// newest run, and how many runs failed in the last day. An administrator |
|
| 116 | 143 | /// sees that it ran, never what it wrote. |
|
| … | 34 unchanged lines |
||
| 151 | 178 | .bind(day_ago) |
|
| 152 | 179 | .fetch_one(pool) |
|
| 153 | 180 | .await?; |
|
| 154 | - | let repos = counts.iter().find(|(o, _)| *o == s.owner).map_or(0, |c| c.1); |
|
| 181 | + | let repos = db::repos::list(pool).await?.iter().filter(|r| r.owner == s.owner && included(&s, r, false)).count() as i64; |
|
| 155 | 182 | out.push(Overview { setting: s, last, failed_day, repos }); |
|
| 156 | 183 | } |
|
| 157 | - | let uncovered = |
|
| 158 | - | counts.into_iter().filter(|(owner, _)| !out.iter().any(|o| o.setting.owner == *owner && o.setting.every != "off")).collect(); |
|
| 184 | + | let uncovered = counts.into_iter().filter(|(owner, _)| !out.iter().any(|o| o.setting.owner == *owner && o.repos > 0)).collect(); |
|
| 159 | 185 | Ok((out, uncovered)) |
|
| 160 | 186 | } |
|
| 161 | 187 | ||
| … | 782 unchanged lines |
||
| 944 | 970 | .bind(trigger) |
|
| 945 | 971 | .fetch_one(&app.db) |
|
| 946 | 972 | .await?; |
|
| 947 | - | let outcome = run_into(app, &s, &at).await; |
|
| 973 | + | let outcome = run_into(app, &s, &at, trigger == "now").await; |
|
| 948 | 974 | let (status, detail, sent) = match outcome { |
|
| 949 | 975 | Ok((sent, problems)) if problems.is_empty() => ("ok", String::new(), sent), |
|
| 950 | 976 | Ok((sent, problems)) => ("partial", problems.join("; "), sent), |
|
| … | 17 unchanged lines |
||
| 968 | 994 | Ok(r) |
|
| 969 | 995 | } |
|
| 970 | 996 | ||
| 971 | - | async fn run_into(app: &Shared, s: &Setting, at: &str) -> Result<((i64, Sent), Vec<String>)> { |
|
| 997 | + | async fn run_into(app: &Shared, s: &Setting, at: &str, by_hand: bool) -> Result<((i64, Sent), Vec<String>)> { |
|
| 972 | 998 | let dest: std::sync::Arc<dyn Destination> = destination(app, s)?.into(); |
|
| 973 | 999 | let known = { |
|
| 974 | 1000 | let d = dest.clone(); |
|
| 975 | 1001 | tokio::task::spawn_blocking(move || last_manifests(d.as_ref())).await? |
|
| 976 | 1002 | }; |
|
| 977 | - | let mine: Vec<db::repos::RepoRow> = db::repos::list(&app.db).await?.into_iter().filter(|r| r.owner == s.owner).collect(); |
|
| 1003 | + | // Its repositories, but those left out (§5). |
|
| 1004 | + | let mine: Vec<db::repos::RepoRow> = |
|
| 1005 | + | db::repos::list(&app.db).await?.into_iter().filter(|r| r.owner == s.owner && included(s, r, by_hand)).collect(); |
|
| 978 | 1006 | let mut problems = Vec::new(); |
|
| 979 | 1007 | let mut sent = Sent::default(); |
|
| 980 | 1008 | let mut repos = 0i64; |
|
| … | 216 unchanged lines |
||
| 1197 | 1225 | } |
|
| 1198 | 1226 | } |
|
| 1199 | 1227 | ||
| 1200 | - | /// The owners whose next run is due now. |
|
| 1228 | + | /// The owners whose next run is due now: by their own schedule, or by |
|
| 1229 | + | /// the shortest schedule one of their repositories chose (§5). A run takes |
|
| 1230 | + | /// every included repository and sends only what changed, so each run |
|
| 1231 | + | /// stays whole and a restore point has everything. |
|
| 1201 | 1232 | pub async fn due(pool: &Pool) -> sqlx::Result<Vec<Setting>> { |
|
| 1202 | - | let all: Vec<Setting> = sqlx::query_as("SELECT * FROM backup_settings WHERE every != 'off'").fetch_all(pool).await?; |
|
| 1233 | + | let all: Vec<Setting> = sqlx::query_as("SELECT * FROM backup_settings WHERE updated_ms > 0").fetch_all(pool).await?; |
|
| 1234 | + | let repos = db::repos::list(pool).await?; |
|
| 1203 | 1235 | let now = db::now_ms(); |
|
| 1204 | 1236 | let mut out = Vec::new(); |
|
| 1205 | 1237 | for s in all { |
|
| 1206 | - | let Some(period) = s.period_s() else { continue }; |
|
| 1238 | + | let theirs: Vec<db::repos::RepoRow> = repos.iter().filter(|r| r.owner == s.owner).cloned().collect(); |
|
| 1239 | + | let Some(period) = period_with(&s, &theirs) else { continue }; |
|
| 1207 | 1240 | let last: Option<i64> = |
|
| 1208 | 1241 | sqlx::query_scalar("SELECT MAX(started_ms) FROM backup_runs WHERE owner = ?").bind(&s.owner).fetch_one(pool).await?; |
|
| 1209 | 1242 | if last.is_none_or(|l| now - l >= period * 1000) { |
|
| … | 139 unchanged lines |
modified crates/hub/src/db/repos.rs +9 −0
| … | 34 unchanged lines |
||
| 35 | 35 | /// (migration 0061). |
|
| 36 | 36 | #[sqlx(default)] |
|
| 37 | 37 | pub diffusion_land: String, |
|
| 38 | + | /// Backups (migration 0062): empty with its owner's, `off`, or its own |
|
| 39 | + | /// `hourly` or `daily`. |
|
| 40 | + | #[sqlx(default)] |
|
| 41 | + | pub backup: String, |
|
| 38 | 42 | /// Read-only, with why (migration 0040, #108). |
|
| 39 | 43 | #[sqlx(default)] |
|
| 40 | 44 | pub archived: bool, |
|
| … | 262 unchanged lines |
||
| 303 | 307 | /// `on`, `off`, or `` (follow hub.toml's `[flow] repos`). |
|
| 304 | 308 | pub async fn set_flow(pool: &Pool, id: i64, setting: &str) -> sqlx::Result<()> { |
|
| 305 | 309 | sqlx::query("UPDATE repositories SET flow = ? WHERE id = ?").bind(setting).bind(id).execute(pool).await?; |
|
| 310 | + | Ok(()) |
|
| 311 | + | } |
|
| 312 | + | ||
| 313 | + | pub async fn set_backup(pool: &Pool, id: i64, setting: &str) -> sqlx::Result<()> { |
|
| 314 | + | sqlx::query("UPDATE repositories SET backup = ? WHERE id = ?").bind(setting).bind(id).execute(pool).await?; |
|
| 306 | 315 | Ok(()) |
|
| 307 | 316 | } |
|
| 308 | 317 | ||
| … | 34 unchanged lines |
modified crates/hub/src/web/mod.rs +1 −0
| … | 279 unchanged lines |
||
| 280 | 280 | .route("/{owner}/{name}/settings/mirror", post(repo_settings::mirror)) |
|
| 281 | 281 | .route("/{owner}/{name}/settings/flow", post(repo_settings::flow)) |
|
| 282 | 282 | .route("/{owner}/{name}/settings/diffusion", post(repo_settings::diffusion)) |
|
| 283 | + | .route("/{owner}/{name}/settings/backup", post(repo_settings::backup)) |
|
| 283 | 284 | .route("/{owner}/{name}/settings/storage", post(repo_settings::storage)) |
|
| 284 | 285 | .route("/{owner}/{name}/settings/coverage", post(repo_settings::coverage)) |
|
| 285 | 286 | .route("/{owner}/{name}/settings/runners/token", post(repo_settings::runner_token)) |
|
| … | 480 unchanged lines |
modified crates/hub/src/web/repo_settings.rs +47 −0
| … | 191 unchanged lines |
||
| 192 | 192 | storage: Option<crate::storage::Storage>, |
|
| 193 | 193 | /// The coverage check's rules (#113). |
|
| 194 | 194 | coverage: crate::coverage::Rules, |
|
| 195 | + | /// Its owner's backup, when one is set up (docs/backups.md §5). |
|
| 196 | + | backup_owner: Option<crate::backups::Setting>, |
|
| 195 | 197 | } |
|
| 196 | 198 | ||
| 199 | + | impl RepoSettingsPage { |
|
| 200 | + | fn backup_choices(&self) -> &'static [(&'static str, &'static str)] { |
|
| 201 | + | crate::backups::REPO_CHOICES |
|
| 202 | + | } |
|
| 203 | + | } |
|
| 204 | + | ||
| 197 | 205 | async fn settings_page( |
|
| 198 | 206 | app: &Shared, |
|
| 199 | 207 | shell: Shell, |
|
| … | 37 unchanged lines |
||
| 237 | 245 | let secrets = super::secrets::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 238 | 246 | let storage = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 239 | 247 | let coverage = crate::coverage::rules(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 248 | + | let backup_owner = crate::backups::setting(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 240 | 249 | Ok(Page(RepoSettingsPage { |
|
| 250 | + | backup_owner, |
|
| 241 | 251 | coverage, |
|
| 242 | 252 | storage, |
|
| 243 | 253 | assistants, |
|
| … | 492 unchanged lines |
||
| 736 | 746 | csrf_token: String, |
|
| 737 | 747 | #[serde(default)] |
|
| 738 | 748 | enabled: Option<String>, |
|
| 749 | + | } |
|
| 750 | + | ||
| 751 | + | #[derive(Deserialize)] |
|
| 752 | + | pub struct BackupForm { |
|
| 753 | + | #[serde(rename = "_csrf")] |
|
| 754 | + | csrf_token: String, |
|
| 755 | + | /// Empty (with its owner's), `hourly`, `daily` or `off`. |
|
| 756 | + | #[serde(default)] |
|
| 757 | + | backup: String, |
|
| 758 | + | } |
|
| 759 | + | ||
| 760 | + | /// This repository's backup (docs/backups.md §5): with its owner's, left |
|
| 761 | + | /// out, or at least hourly or daily. Its owners and admins decide. |
|
| 762 | + | pub async fn backup( |
|
| 763 | + | State(app): State<Shared>, |
|
| 764 | + | viewer: Viewer, |
|
| 765 | + | Path((owner, name)): Path<(String, String)>, |
|
| 766 | + | Form(f): Form<BackupForm>, |
|
| 767 | + | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 768 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 769 | + | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 770 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 771 | + | if !crate::backups::REPO_CHOICES.iter().any(|(v, _)| *v == f.backup) { |
|
| 772 | + | return settings_page(&app, shell, row, role, None, Some("Pick how this repository is backed up.".into())).await; |
|
| 773 | + | } |
|
| 774 | + | db::repos::set_backup(&app.db, row.id, &f.backup).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 775 | + | let what = if f.backup.is_empty() { "with its owner's" } else { f.backup.as_str() }; |
|
| 776 | + | accounts::audit(&app.db, viewer.account(), "repo.backup", &format!("{owner}/{name}"), what).await.ok(); |
|
| 777 | + | let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row); |
|
| 778 | + | let notice = match f.backup.as_str() { |
|
| 779 | + | "off" => "This repository is left out of its owner's backups from the next run.".to_string(), |
|
| 780 | + | "" => "This repository is backed up with its owner's.".to_string(), |
|
| 781 | + | every => { |
|
| 782 | + | format!("This repository is backed up at least {} from now on.", if every == "hourly" { "every hour" } else { "every day" }) |
|
| 783 | + | } |
|
| 784 | + | }; |
|
| 785 | + | settings_page(&app, shell, row, role, Some(notice), None).await |
|
| 739 | 786 | } |
|
| 740 | 787 | ||
| 741 | 788 | #[derive(Deserialize)] |
|
| … | 233 unchanged lines |
modified crates/hub/tests/backups.rs +53 −0
| … | 506 unchanged lines |
||
| 507 | 507 | .unwrap(); |
|
| 508 | 508 | assert!(next.number > p.number, "{} after {}", next.number, p.number); |
|
| 509 | 509 | } |
|
| 510 | + | ||
| 511 | + | /// docs/backups.md §5 (BP3): a repository left out is in no run; one with |
|
| 512 | + | /// its own schedule makes its owner's backup run at least that often, and |
|
| 513 | + | /// alone when its owner's is off; a run by hand takes all but those left |
|
| 514 | + | /// out. |
|
| 515 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 516 | + | async fn repositories_choose_their_own_backup() { |
|
| 517 | + | let store = tempfile::tempdir().unwrap(); |
|
| 518 | + | let h = hub(store.path()).await; |
|
| 519 | + | db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", false).await.unwrap(); |
|
| 520 | + | for n in ["game", "notes", "secret"] { |
|
| 521 | + | create(&h.app, "andy", n, hub_api::Visibility::Public).await; |
|
| 522 | + | let work = tempfile::tempdir().unwrap(); |
|
| 523 | + | push(&format!("{}/andy/{n}", h.base), work.path(), &[("README.md", n)]); |
|
| 524 | + | } |
|
| 525 | + | let id = |n: &'static str| { |
|
| 526 | + | let db = h.app.db.clone(); |
|
| 527 | + | async move { db::repos::get(&db, "andy", n).await.unwrap().unwrap().id } |
|
| 528 | + | }; |
|
| 529 | + | db::repos::set_backup(&h.app.db, id("secret").await, "off").await.unwrap(); |
|
| 530 | + | let mut s = Setting::off("andy", "user"); |
|
| 531 | + | s.every = "daily".into(); |
|
| 532 | + | s.target = store.path().display().to_string(); |
|
| 533 | + | backups::save(&h.app.db, &s).await.unwrap(); |
|
| 534 | + | let names = |at: String| { |
|
| 535 | + | let dest = Directory { root: store.path().join("andy") }; |
|
| 536 | + | let mut v = dest.list(&format!("runs/{at}/records")).unwrap(); |
|
| 537 | + | v.sort(); |
|
| 538 | + | v |
|
| 539 | + | }; |
|
| 540 | + | let first = backups::run(&h.app, "andy", "schedule").await.unwrap(); |
|
| 541 | + | assert_eq!((first.status.as_str(), first.repos), ("ok", 2), "{}", first.detail); |
|
| 542 | + | assert_eq!(names(first.at.clone()), vec!["game.json", "notes.json"], "the one left out is not in it"); |
|
| 543 | + | // Daily, and it just ran: not due, until a repository asks for hourly. |
|
| 544 | + | assert!(backups::due(&h.app.db).await.unwrap().is_empty()); |
|
| 545 | + | db::repos::set_backup(&h.app.db, id("game").await, "hourly").await.unwrap(); |
|
| 546 | + | sqlx::query("UPDATE backup_runs SET started_ms = started_ms - 2 * 3600 * 1000").execute(&h.app.db).await.unwrap(); |
|
| 547 | + | assert_eq!(backups::due(&h.app.db).await.unwrap().len(), 1, "hourly for the game"); |
|
| 548 | + | // The owner's schedule off: only the game, which asks for its own. |
|
| 549 | + | s.every = "off".into(); |
|
| 550 | + | backups::save(&h.app.db, &s).await.unwrap(); |
|
| 551 | + | assert_eq!(backups::due(&h.app.db).await.unwrap().len(), 1); |
|
| 552 | + | tokio::time::sleep(std::time::Duration::from_millis(1100)).await; |
|
| 553 | + | let second = backups::run(&h.app, "andy", "schedule").await.unwrap(); |
|
| 554 | + | assert_eq!(names(second.at.clone()), vec!["game.json"]); |
|
| 555 | + | // By hand: everything but the one left out. |
|
| 556 | + | tokio::time::sleep(std::time::Duration::from_millis(1100)).await; |
|
| 557 | + | let third = backups::run(&h.app, "andy", "now").await.unwrap(); |
|
| 558 | + | assert_eq!(names(third.at.clone()), vec!["game.json", "notes.json"]); |
|
| 559 | + | // Admin › Backups counts what the next scheduled run covers. |
|
| 560 | + | let (owners, uncovered) = backups::overview(&h.app.db).await.unwrap(); |
|
| 561 | + | assert_eq!((owners[0].repos, uncovered.len()), (1, 0)); |
|
| 562 | + | } |
modified crates/hub/tests/issues.rs +9 −0
| … | 1019 unchanged lines |
||
| 1020 | 1020 | let missing = &admin.body[admin.body.find("Not backed up").unwrap()..]; |
|
| 1021 | 1021 | assert!(missing.contains("href=\"/bob\"") && missing.contains("1 repository"), "{missing}"); |
|
| 1022 | 1022 | assert!(get(app, "/admin", Some(&andy)).await.body.contains("/admin/backups")); |
|
| 1023 | + | // A repository chooses its own (BP3): its owners decide. |
|
| 1024 | + | let game = get(app, "/bob/game/settings", Some(&bob)).await; |
|
| 1025 | + | assert!(game.body.contains("bob has no backup set up yet") && game.body.contains(r#"name="backup" value="" checked"#), "{}", game.body); |
|
| 1026 | + | let csrf = csrf_of(&game.body); |
|
| 1027 | + | let saved = post(app, "/bob/game/settings/backup", Some(&bob), &format!("_csrf={csrf}&backup=off")).await; |
|
| 1028 | + | assert!(saved.body.contains("left out of its owner"), "{}", saved.body); |
|
| 1029 | + | assert_eq!(db::repos::get(&app.db, "bob", "game").await.unwrap().unwrap().backup, "off"); |
|
| 1030 | + | let bad = post(app, "/bob/game/settings/backup", Some(&bob), &format!("_csrf={csrf}&backup=weekly")).await; |
|
| 1031 | + | assert!(bad.body.contains("Pick how this repository is backed up.")); |
|
| 1023 | 1032 | } |
modified docs/backups.md +11 −4
| … | 5 unchanged lines |
||
| 6 | 6 | and wiki's store byte for byte, the records as JSON, retention, failure |
|
| 7 | 7 | notices, and restore (`oxygenhub admin backup run|restore`). Since |
|
| 8 | 8 | 2026-10-14 (oxygenhub#175) a restore brings back the records too (§7) and |
|
| 9 | - | release assets travel with the stores; Admin › Backups (#176). Next: |
|
| 10 | - | per-repository settings, S3. |
|
| 9 | + | release assets travel with the stores; Admin › Backups (#176); |
|
| 10 | + | per-repository settings (#177). Next: S3. |
|
| 11 | 11 | ||
| 12 | 12 | ## 1. Why |
|
| 13 | 13 | ||
| … | 95 unchanged lines |
||
| 109 | 109 | repositories no backup covers. They may not read an owner's bucket, only |
|
| 110 | 110 | see that it ran. The site's own backup runs on its host, outside the |
|
| 111 | 111 | hub, and the page says so. |
|
| 112 | - | - **A repository** (later): opt a repository out of its owner's backup, |
|
| 113 | - | or give it its own schedule. |
|
| 112 | + | - **A repository** (built 2026-10-14, oxygenhub#177): its Settings › |
|
| 113 | + | Backup, or `PUT /repos/{owner}/{name}/backup {"backup": "owner" | |
|
| 114 | + | "hourly" | "daily" | "off"}`, by its owners and admins (migration |
|
| 115 | + | 0062). Left out, it is in no new run. With its own schedule, its |
|
| 116 | + | owner's backup runs at least that often, and runs even when the owner's |
|
| 117 | + | own schedule is off (taking only the repositories that asked); a run |
|
| 118 | + | sends only what changed and keeps every included repository, so each |
|
| 119 | + | run stays whole and restores everything. A run asked for by hand takes |
|
| 120 | + | every repository not left out. |
|
| 114 | 121 | ||
| 115 | 122 | The API mirrors the pages: `GET/PUT /api/v1/user/backups`, |
|
| 116 | 123 | `GET/PUT /api/v1/orgs/{name}/backups`, the run endpoint above, and |
|
| … | 85 unchanged lines |
modified templates/repo_settings.html +14 −0
| … | 101 unchanged lines |
||
| 102 | 102 | <p class="small mute">A subrepo's <code>.oxymodules</code> entry overrides this with <code>follow = green</code>, <code>any</code> or <code>off</code>; <code>land = auto</code> or <code>propose</code> overrides the landing, <code>target</code> names the bookmark to move (else the one this repository's proposals land on) and <code>watch</code> lists paths whose upstream changes are flagged.</p> |
|
| 103 | 103 | <button type="submit">SAVE</button> |
|
| 104 | 104 | </form> |
|
| 105 | + | <form class="card wide" id="backup" method="post" action="{{ repo.url }}/settings/backup"> |
|
| 106 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 107 | + | <h2>Backup</h2> |
|
| 108 | + | {% if let Some(b) = backup_owner %} |
|
| 109 | + | <p>{{ row.owner }} backs up {% if b.every == "off" %}only by hand or when a repository asks{% else %}{% if b.every == "hourly" %}every hour{% else %}every day{% endif %}{% endif %} to {% if b.destination == "gcs" %}<code>gs://{{ b.target }}</code>{% else %}a directory on this hub's host{% endif %}. Every repository goes with it unless it says otherwise here.</p> |
|
| 110 | + | {% else %} |
|
| 111 | + | <p>{{ row.owner }} has no backup set up yet: a user sets one up in <a href="/settings/backups">Settings › Backups</a>, an organisation's owners in its settings. What you choose here applies once it has.</p> |
|
| 112 | + | {% endif %} |
|
| 113 | + | <div class="choice"> |
|
| 114 | + | {% for (v, label) in backup_choices() %}<label><input type="radio" name="backup" value="{{ v }}"{% if row.backup == *v %} checked{% endif %}> {{ label }}</label>{% endfor %} |
|
| 115 | + | </div> |
|
| 116 | + | <p class="small mute">A repository with its own schedule makes its owner's backup run at least that often; each run sends only what changed and keeps every repository, so any run restores everything. Left out, it is in no new run; earlier runs keep it until they are pruned.</p> |
|
| 117 | + | <button type="submit">SAVE</button> |
|
| 118 | + | </form> |
|
| 105 | 119 | {% if role == crate::auth::Role::Admin %} |
|
| 106 | 120 | <form class="card wide" id="coverage" method="post" action="{{ repo.url }}/settings/coverage"> |
|
| 107 | 121 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| … | 123 unchanged lines |