Backups: a restore brings back the records, and release assets travel 5ef6cf72cd19
A restore brought back each repository's store and row; its issues, proposals and the rest stayed in the run's records, and release assets, which live beside the stores in <data>/assets, were never sent. Records now carry _names: the username, organisation or org/team behind every account, team and organisation id their rows point at. A restore recreates the repository row with its settings, then every table of the records parents first, each row with a new id and every reference following it, accounts found again by name. A reference to an account the hub lacks is blanked where the column allows (the author's name stays in the row) and the row left out where it does not; the restore says how many. Webhooks come back without secrets, each named to be entered again; search is rebuilt. Release assets are sent once each as blobs and written back checked against their hash. Inboxes, webhook deliveries and runners' declines are left out on purpose. Tested end to end: a fresh hub lacking one account gets the issue, its comment, label, milestone and the assignee it has, the proposal with its review and events, the release with its asset's bytes, the webhook named for its secret, search, and numbering that carries on. closes #175
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-14 02:55 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
5ef6cf72cd194dde0ecdaf61058fbe11105e8b7f74cc00ed1eb3fee29c7d783d- Depends on
- 35fea4237cc9 6dbd999ad794 d7d182467382
- Depended on by
- 9041db4e4d92 3b1bc27fe805 5d90e0e045e2 d3b048b27277 1e8a6e8bb91b
- 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
- 14 atoms, 0 tree operations, 3 files
Browse the tree at this change
modified crates/hub/src/backups.rs +318 −7
| … | 607 unchanged lines |
||
| 608 | 608 | Ok(serde_json::Value::Object(out)) |
|
| 609 | 609 | } |
|
| 610 | 610 | ||
| 611 | + | /// The key of a run's records naming the accounts, teams and organisations |
|
| 612 | + | /// their rows point at, by id: `{"accounts": {"7": "andy"}, …}`. |
|
| 613 | + | const NAMES: &str = "_names"; |
|
| 614 | + | ||
| 615 | + | /// Tables a restore leaves out: the search index (rebuilt from what is |
|
| 616 | + | /// restored), and what is passing or personal rather than the |
|
| 617 | + | /// repository's: inboxes, webhook deliveries, runners' declines. |
|
| 618 | + | const NOT_RESTORED: [&str; 5] = ["search", "notifications", "deliveries", "flow_declines", NAMES]; |
|
| 619 | + | ||
| 620 | + | async fn fk_list(pool: &Pool, table: &str) -> Result<Vec<(String, String, String)>> { |
|
| 621 | + | Ok(sqlx::query_as(&format!("SELECT \"table\", \"from\", \"to\" FROM pragma_foreign_key_list('{table}')")).fetch_all(pool).await?) |
|
| 622 | + | } |
|
| 623 | + | ||
| 624 | + | /// The names behind the account, team and organisation ids that `rec`'s |
|
| 625 | + | /// rows point at, so a restore on another hub finds them again by name. |
|
| 626 | + | async fn names_of(pool: &Pool, rec: &serde_json::Value) -> Result<serde_json::Value> { |
|
| 627 | + | let mut ids: HashMap<String, std::collections::BTreeSet<i64>> = HashMap::new(); |
|
| 628 | + | for (table, rows) in rec.as_object().into_iter().flatten() { |
|
| 629 | + | let Some(rows) = rows.as_array() else { continue }; |
|
| 630 | + | for (parent, from, _) in fk_list(pool, table).await? { |
|
| 631 | + | if ["accounts", "teams", "organisations"].contains(&parent.as_str()) { |
|
| 632 | + | ids.entry(parent).or_default().extend(rows.iter().filter_map(|r| r[&from].as_i64())); |
|
| 633 | + | } |
|
| 634 | + | } |
|
| 635 | + | } |
|
| 636 | + | let mut out = serde_json::Map::new(); |
|
| 637 | + | for (kind, sql) in [ |
|
| 638 | + | ("accounts", "SELECT id, username FROM accounts WHERE id = ?"), |
|
| 639 | + | ("organisations", "SELECT id, name FROM organisations WHERE id = ?"), |
|
| 640 | + | ("teams", "SELECT t.id, o.name || '/' || t.name FROM teams t JOIN organisations o ON o.id = t.org_id WHERE t.id = ?"), |
|
| 641 | + | ] { |
|
| 642 | + | let mut m = serde_json::Map::new(); |
|
| 643 | + | for id in ids.get(kind).into_iter().flatten() { |
|
| 644 | + | if let Some((id, name)) = sqlx::query_as::<_, (i64, String)>(sql).bind(id).fetch_optional(pool).await? { |
|
| 645 | + | m.insert(id.to_string(), name.into()); |
|
| 646 | + | } |
|
| 647 | + | } |
|
| 648 | + | out.insert(kind.into(), m.into()); |
|
| 649 | + | } |
|
| 650 | + | Ok(out.into()) |
|
| 651 | + | } |
|
| 652 | + | ||
| 653 | + | /// The id `name` has on this hub as one of `_names`' kinds. |
|
| 654 | + | async fn id_by_name(pool: &Pool, kind: &str, name: &str) -> Result<Option<i64>> { |
|
| 655 | + | Ok(match kind { |
|
| 656 | + | "accounts" => sqlx::query_scalar("SELECT id FROM accounts WHERE username = ?").bind(name).fetch_optional(pool).await?, |
|
| 657 | + | "organisations" => sqlx::query_scalar("SELECT id FROM organisations WHERE name = ?").bind(name).fetch_optional(pool).await?, |
|
| 658 | + | "teams" => match name.split_once('/') { |
|
| 659 | + | Some((org, team)) => { |
|
| 660 | + | sqlx::query_scalar("SELECT t.id FROM teams t JOIN organisations o ON o.id = t.org_id WHERE o.name = ? AND t.name = ?") |
|
| 661 | + | .bind(org) |
|
| 662 | + | .bind(team) |
|
| 663 | + | .fetch_optional(pool) |
|
| 664 | + | .await? |
|
| 665 | + | } |
|
| 666 | + | None => None, |
|
| 667 | + | }, |
|
| 668 | + | _ => None, |
|
| 669 | + | }) |
|
| 670 | + | } |
|
| 671 | + | ||
| 672 | + | /// One column of a table on this hub. |
|
| 673 | + | struct Col { |
|
| 674 | + | name: String, |
|
| 675 | + | ty: String, |
|
| 676 | + | notnull: bool, |
|
| 677 | + | default: Option<String>, |
|
| 678 | + | pk: i64, |
|
| 679 | + | } |
|
| 680 | + | ||
| 681 | + | async fn table_cols(pool: &Pool, table: &str) -> Result<Vec<Col>> { |
|
| 682 | + | let rows: Vec<(String, String, i64, Option<String>, i64)> = |
|
| 683 | + | sqlx::query_as(&format!("SELECT name, type, \"notnull\", dflt_value, pk FROM pragma_table_info('{table}')")) |
|
| 684 | + | .fetch_all(pool) |
|
| 685 | + | .await?; |
|
| 686 | + | Ok(rows.into_iter().map(|(name, ty, notnull, default, pk)| Col { name, ty, notnull: notnull != 0, default, pk }).collect()) |
|
| 687 | + | } |
|
| 688 | + | ||
| 689 | + | /// Bind one value of a backed-up row: blobs were written as hex. |
|
| 690 | + | fn bind_json<'q>( |
|
| 691 | + | q: sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>>, |
|
| 692 | + | v: serde_json::Value, |
|
| 693 | + | blob: bool, |
|
| 694 | + | ) -> sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>> { |
|
| 695 | + | match v { |
|
| 696 | + | serde_json::Value::Null => q.bind(None::<i64>), |
|
| 697 | + | serde_json::Value::Bool(b) => q.bind(b as i64), |
|
| 698 | + | serde_json::Value::Number(n) => match n.as_i64() { |
|
| 699 | + | Some(i) => q.bind(i), |
|
| 700 | + | None => q.bind(n.as_f64().unwrap_or(0.0)), |
|
| 701 | + | }, |
|
| 702 | + | serde_json::Value::String(s) if blob => q.bind(hex::decode(&s).unwrap_or_else(|_| s.into_bytes())), |
|
| 703 | + | serde_json::Value::String(s) => q.bind(s), |
|
| 704 | + | other => q.bind(other.to_string()), |
|
| 705 | + | } |
|
| 706 | + | } |
|
| 707 | + | ||
| 708 | + | /// Restore one repository's records from a run into this hub, the |
|
| 709 | + | /// repository being `repo` here and `old` in the backup (docs/backups.md |
|
| 710 | + | /// §7): its issues, proposals, labels, milestones, releases and their |
|
| 711 | + | /// assets, checks, Flow runs and the rest, each table after the tables it |
|
| 712 | + | /// points at, every id mapped to its new one and every account, team and |
|
| 713 | + | /// organisation found again by name. A row pointing at an account this |
|
| 714 | + | /// hub lacks keeps a blank reference where it may, and is left out where |
|
| 715 | + | /// it may not. What needs re-entering (webhook secrets, never backed up) |
|
| 716 | + | /// and what was left out come back as notes. |
|
| 717 | + | async fn restore_records( |
|
| 718 | + | app: &Shared, |
|
| 719 | + | dest: &std::sync::Arc<dyn Destination>, |
|
| 720 | + | rec: &serde_json::Value, |
|
| 721 | + | old: i64, |
|
| 722 | + | repo: i64, |
|
| 723 | + | ) -> Result<Vec<String>> { |
|
| 724 | + | let pool = &app.db; |
|
| 725 | + | let obj = rec.as_object().context("the records are not an object")?; |
|
| 726 | + | let mut by_name: HashMap<(String, i64), Option<i64>> = HashMap::new(); |
|
| 727 | + | for (kind, m) in obj.get(NAMES).and_then(|n| n.as_object()).into_iter().flatten() { |
|
| 728 | + | for (id, name) in m.as_object().into_iter().flatten() { |
|
| 729 | + | let (Ok(id), Some(name)) = (id.parse::<i64>(), name.as_str()) else { continue }; |
|
| 730 | + | by_name.insert((kind.clone(), id), id_by_name(pool, kind, name).await?); |
|
| 731 | + | } |
|
| 732 | + | } |
|
| 733 | + | let here: std::collections::HashSet<String> = tables(pool).await?.into_iter().collect(); |
|
| 734 | + | let mut notes = Vec::new(); |
|
| 735 | + | let mut todo: Vec<String> = Vec::new(); |
|
| 736 | + | for t in obj.keys().filter(|t| t.as_str() != "repositories" && !NOT_RESTORED.contains(&t.as_str())) { |
|
| 737 | + | if here.contains(t) { |
|
| 738 | + | todo.push(t.clone()); |
|
| 739 | + | } else { |
|
| 740 | + | notes.push(format!("{t}: not a table on this hub, left out")); |
|
| 741 | + | } |
|
| 742 | + | } |
|
| 743 | + | let mut fks: HashMap<String, Vec<(String, String, String)>> = HashMap::new(); |
|
| 744 | + | for t in &todo { |
|
| 745 | + | fks.insert(t.clone(), fk_list(pool, t).await?); |
|
| 746 | + | } |
|
| 747 | + | // Each table after the tables of the records it points at. |
|
| 748 | + | let mut order = Vec::new(); |
|
| 749 | + | while !todo.is_empty() { |
|
| 750 | + | let mut ready: Vec<String> = |
|
| 751 | + | todo.iter().filter(|t| fks[*t].iter().all(|(p, _, _)| p == *t || !todo.contains(p))).cloned().collect(); |
|
| 752 | + | if ready.is_empty() { |
|
| 753 | + | ready.push(todo[0].clone()); |
|
| 754 | + | } |
|
| 755 | + | todo.retain(|t| !ready.contains(t)); |
|
| 756 | + | order.extend(ready); |
|
| 757 | + | } |
|
| 758 | + | let mut maps: HashMap<String, HashMap<i64, i64>> = HashMap::new(); |
|
| 759 | + | maps.insert("repositories".into(), [(old, repo)].into()); |
|
| 760 | + | let mut left_out: std::collections::BTreeMap<String, usize> = Default::default(); |
|
| 761 | + | let mut tx = pool.begin().await?; |
|
| 762 | + | for table in &order { |
|
| 763 | + | let cols = table_cols(pool, table).await?; |
|
| 764 | + | let keys: Vec<&str> = cols.iter().filter(|c| c.pk > 0).map(|c| c.name.as_str()).collect(); |
|
| 765 | + | let by_id = keys == ["id"] && cols.iter().any(|c| c.name == "id" && c.ty.eq_ignore_ascii_case("INTEGER")); |
|
| 766 | + | let verb = if keys == ["repo_id"] { |
|
| 767 | + | "INSERT OR REPLACE" |
|
| 768 | + | } else if by_id { |
|
| 769 | + | "INSERT" |
|
| 770 | + | } else { |
|
| 771 | + | "INSERT OR IGNORE" |
|
| 772 | + | }; |
|
| 773 | + | let mut rows: Vec<&serde_json::Map<String, serde_json::Value>> = |
|
| 774 | + | obj[table].as_array().into_iter().flatten().filter_map(|r| r.as_object()).collect(); |
|
| 775 | + | rows.sort_by_key(|r| r.get("id").and_then(|v| v.as_i64()).unwrap_or(0)); |
|
| 776 | + | 'row: for row in rows { |
|
| 777 | + | let (mut names, mut values) = (Vec::new(), Vec::new()); |
|
| 778 | + | for c in &cols { |
|
| 779 | + | if by_id && c.name == "id" { |
|
| 780 | + | continue; |
|
| 781 | + | } |
|
| 782 | + | let mut v = row.get(&c.name).cloned().filter(|v| !v.is_null()); |
|
| 783 | + | if let Some((parent, _, _)) = fks[table].iter().find(|(_, from, _)| *from == c.name) { |
|
| 784 | + | if let Some(was) = v.as_ref().and_then(|v| v.as_i64()) { |
|
| 785 | + | let now = match parent.as_str() { |
|
| 786 | + | "accounts" | "teams" | "organisations" => by_name.get(&(parent.clone(), was)).copied().flatten(), |
|
| 787 | + | p => maps.get(p).and_then(|m| m.get(&was)).copied(), |
|
| 788 | + | }; |
|
| 789 | + | if now.is_none() && c.notnull { |
|
| 790 | + | *left_out.entry(table.clone()).or_default() += 1; |
|
| 791 | + | continue 'row; |
|
| 792 | + | } |
|
| 793 | + | v = now.map(serde_json::Value::from); |
|
| 794 | + | } |
|
| 795 | + | } |
|
| 796 | + | let blob = c.ty.to_ascii_uppercase().contains("BLOB"); |
|
| 797 | + | match v { |
|
| 798 | + | Some(v) => { |
|
| 799 | + | names.push(c.name.clone()); |
|
| 800 | + | values.push((v, blob)); |
|
| 801 | + | } |
|
| 802 | + | // Never backed up (a secret): blank here, to enter again. |
|
| 803 | + | None if c.notnull && c.default.is_none() => { |
|
| 804 | + | names.push(c.name.clone()); |
|
| 805 | + | let blank = if c.ty.to_ascii_uppercase().contains("INT") { serde_json::json!(0) } else { serde_json::json!("") }; |
|
| 806 | + | values.push((blank, false)); |
|
| 807 | + | } |
|
| 808 | + | None => {} |
|
| 809 | + | } |
|
| 810 | + | } |
|
| 811 | + | let sql = format!( |
|
| 812 | + | "{verb} INTO \"{table}\" ({}) VALUES ({})", |
|
| 813 | + | names.iter().map(|n| format!("\"{n}\"")).collect::<Vec<_>>().join(", "), |
|
| 814 | + | vec!["?"; names.len()].join(", ") |
|
| 815 | + | ); |
|
| 816 | + | let mut q = sqlx::query(&sql); |
|
| 817 | + | for (v, blob) in values { |
|
| 818 | + | q = bind_json(q, v, blob); |
|
| 819 | + | } |
|
| 820 | + | let done = q.execute(&mut *tx).await.with_context(|| format!("restore a row of {table}"))?; |
|
| 821 | + | if let (true, Some(was)) = (by_id, row.get("id").and_then(|v| v.as_i64())) { |
|
| 822 | + | maps.entry(table.clone()).or_default().insert(was, done.last_insert_rowid()); |
|
| 823 | + | } |
|
| 824 | + | } |
|
| 825 | + | } |
|
| 826 | + | tx.commit().await?; |
|
| 827 | + | for (table, n) in left_out { |
|
| 828 | + | notes.push(format!("{table}: {n} row(s) left out: they name accounts or rows this hub lacks")); |
|
| 829 | + | } |
|
| 830 | + | // Search finds what came back. |
|
| 831 | + | for (kind, table) in [("issue", "issues"), ("proposal", "proposals")] { |
|
| 832 | + | let found: Vec<(i64, i64, String, String)> = |
|
| 833 | + | sqlx::query_as(&format!("SELECT id, number, title, body FROM {table} WHERE repo_id = ?")).bind(repo).fetch_all(pool).await?; |
|
| 834 | + | for (id, number, title, body) in found { |
|
| 835 | + | db::notify::index(pool, kind, repo, id, number, &title, &body).await?; |
|
| 836 | + | } |
|
| 837 | + | } |
|
| 838 | + | // Webhook secrets were never written: they are entered again. |
|
| 839 | + | let hooks: Vec<String> = |
|
| 840 | + | sqlx::query_scalar("SELECT url FROM hooks WHERE repo_id = ? AND secret = ''").bind(repo).fetch_all(pool).await?; |
|
| 841 | + | for url in hooks { |
|
| 842 | + | notes.push(format!("webhook {url}: enter its secret again (Settings › Webhooks)")); |
|
| 843 | + | } |
|
| 844 | + | // Release assets, each checked against its name. |
|
| 845 | + | for hash in obj.get("release_assets").and_then(|a| a.as_array()).into_iter().flatten().filter_map(|a| a["hash"].as_str()) { |
|
| 846 | + | if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) { |
|
| 847 | + | continue; |
|
| 848 | + | } |
|
| 849 | + | let path = crate::web::releases::blob_path(app, hash); |
|
| 850 | + | if path.exists() { |
|
| 851 | + | continue; |
|
| 852 | + | } |
|
| 853 | + | let got = tokio::task::block_in_place(|| dest.get(&blob_key(hash))); |
|
| 854 | + | match got { |
|
| 855 | + | Ok(bytes) if blake3::hash(&bytes).to_hex().as_str() == hash => { |
|
| 856 | + | std::fs::create_dir_all(path.parent().context("no parent")?)?; |
|
| 857 | + | std::fs::write(&path, &bytes)?; |
|
| 858 | + | } |
|
| 859 | + | Ok(_) => notes.push(format!("release asset {}: the copy does not match its hash", &hash[..12])), |
|
| 860 | + | Err(_) => notes.push(format!("release asset {}: not in the backup", &hash[..12])), |
|
| 861 | + | } |
|
| 862 | + | } |
|
| 863 | + | Ok(notes) |
|
| 864 | + | } |
|
| 865 | + | ||
| 611 | 866 | // ---------------------------------------------------------------- a run |
|
| 612 | 867 | ||
| 613 | 868 | fn run_name(ms: i64) -> String { |
|
| … | 109 unchanged lines |
||
| 723 | 978 | } |
|
| 724 | 979 | let rec = async { |
|
| 725 | 980 | let mut r = records(&app.db, "repo_id", row.id).await?; |
|
| 726 | - | // The repository's own row, which restore recreates first. |
|
| 981 | + | // The repository's own row, which restore recreates first, and |
|
| 982 | + | // the names behind the accounts, teams and organisations its |
|
| 983 | + | // rows point at, which another hub finds them by. |
|
| 727 | 984 | let own = rows(&app.db, "repositories", &format!("id = {}", row.id)).await?; |
|
| 985 | + | let names = names_of(&app.db, &r).await?; |
|
| 728 | 986 | if let Some(m) = r.as_object_mut() { |
|
| 729 | 987 | m.insert("repositories".into(), own); |
|
| 988 | + | m.insert(NAMES.into(), names); |
|
| 730 | 989 | } |
|
| 731 | 990 | anyhow::Ok(r) |
|
| 732 | 991 | }; |
|
| 733 | 992 | match rec.await { |
|
| 734 | - | Ok(r) => tokio::task::block_in_place(|| dest.put(&format!("runs/{at}/records/{}.json", row.name), &serde_json::to_vec(&r)?))?, |
|
| 993 | + | Ok(r) => { |
|
| 994 | + | tokio::task::block_in_place(|| dest.put(&format!("runs/{at}/records/{}.json", row.name), &serde_json::to_vec(&r)?))?; |
|
| 995 | + | // Release assets live beside the stores, named by their |
|
| 996 | + | // BLAKE3: each sent once, as the stores' files are. |
|
| 997 | + | for hash in r["release_assets"].as_array().into_iter().flatten().filter_map(|a| a["hash"].as_str()) { |
|
| 998 | + | if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) { |
|
| 999 | + | continue; |
|
| 1000 | + | } |
|
| 1001 | + | let path = crate::web::releases::blob_path(app, hash); |
|
| 1002 | + | let key = blob_key(hash); |
|
| 1003 | + | let put = tokio::task::block_in_place(|| -> Result<Option<i64>> { |
|
| 1004 | + | if dest.exists(&key)? { |
|
| 1005 | + | return Ok(None); |
|
| 1006 | + | } |
|
| 1007 | + | let bytes = std::fs::read(&path).with_context(|| format!("read release asset {}", &hash[..12]))?; |
|
| 1008 | + | dest.put(&key, &bytes)?; |
|
| 1009 | + | Ok(Some(bytes.len() as i64)) |
|
| 1010 | + | }); |
|
| 1011 | + | match put { |
|
| 1012 | + | Ok(Some(n)) => { |
|
| 1013 | + | sent.files += 1; |
|
| 1014 | + | sent.sent += 1; |
|
| 1015 | + | sent.bytes += n; |
|
| 1016 | + | } |
|
| 1017 | + | Ok(None) => sent.files += 1, |
|
| 1018 | + | Err(e) => problems.push(format!("{}/{} release asset: {e:#}", row.owner, row.name)), |
|
| 1019 | + | } |
|
| 1020 | + | } |
|
| 1021 | + | } |
|
| 735 | 1022 | Err(e) => problems.push(format!("{}/{} records: {e:#}", row.owner, row.name)), |
|
| 736 | 1023 | } |
|
| 737 | 1024 | } |
|
| … | 174 unchanged lines |
||
| 912 | 1199 | } |
|
| 913 | 1200 | ||
| 914 | 1201 | /// Restore the whole run `at` (the latest when none) into this hub: each |
|
| 915 | - | /// repository's and wiki's store byte for byte, then its row, from the |
|
| 916 | - | /// run's records, so it shows on the hub as it was. Issues, proposals and |
|
| 917 | - | /// the rest stay in the run's records for now (docs/backups.md §7). |
|
| 1202 | + | /// repository's and wiki's store byte for byte, then its row and settings, |
|
| 1203 | + | /// then its records (issues, proposals, releases and the rest), so it shows |
|
| 1204 | + | /// on the hub as it was (docs/backups.md §7). What was restored, then any |
|
| 1205 | + | /// notes (`note: …`) on what needs entering again or was left out. |
|
| 918 | 1206 | pub async fn restore_into_hub(app: &Shared, dest: std::sync::Arc<dyn Destination>, at: Option<String>) -> Result<Vec<String>> { |
|
| 919 | 1207 | let (repos_root, wikis_root) = (app.repos.root().to_path_buf(), app.wikis.root().to_path_buf()); |
|
| 920 | 1208 | let (d, a) = (dest.clone(), at.clone()); |
|
| … | 2 unchanged lines |
||
| 923 | 1211 | Some(a) => a, |
|
| 924 | 1212 | None => String::from_utf8_lossy(&tokio::task::block_in_place(|| dest.get("LATEST"))?).trim().to_string(), |
|
| 925 | 1213 | }; |
|
| 1214 | + | let mut notes = Vec::new(); |
|
| 926 | 1215 | for name in tokio::task::block_in_place(|| dest.list(&format!("runs/{at}/records")))? { |
|
| 927 | 1216 | let rec: serde_json::Value = |
|
| 928 | 1217 | serde_json::from_slice(&tokio::task::block_in_place(|| dest.get(&format!("runs/{at}/records/{name}")))?)?; |
|
| … | 8 unchanged lines |
||
| 937 | 1226 | _ => hub_api::Visibility::Public, |
|
| 938 | 1227 | }; |
|
| 939 | 1228 | let made = db::repos::create(&app.db, owner, repo, row["description"].as_str().unwrap_or_default(), visibility).await?; |
|
| 940 | - | if let Some(b) = row["default_bookmark"].as_str().filter(|b| !b.is_empty()) { |
|
| 941 | - | db::repos::set_default_bookmark(&app.db, made.id, b).await?; |
|
| 1229 | + | // Its settings as they were: every column the row kept but its |
|
| 1230 | + | // name, its id and a fork's parent (another hub's id). |
|
| 1231 | + | let cols = table_cols(&app.db, "repositories").await?; |
|
| 1232 | + | let set: Vec<(&Col, serde_json::Value)> = cols |
|
| 1233 | + | .iter() |
|
| 1234 | + | .filter(|c| !["id", "owner", "name", "forked_from"].contains(&c.name.as_str())) |
|
| 1235 | + | .filter_map(|c| row.get(&c.name).filter(|v| !v.is_null()).map(|v| (c, v.clone()))) |
|
| 1236 | + | .collect(); |
|
| 1237 | + | if !set.is_empty() { |
|
| 1238 | + | let sql = format!( |
|
| 1239 | + | "UPDATE repositories SET {} WHERE id = ?", |
|
| 1240 | + | set.iter().map(|(c, _)| format!("\"{}\" = ?", c.name)).collect::<Vec<_>>().join(", ") |
|
| 1241 | + | ); |
|
| 1242 | + | let mut q = sqlx::query(&sql); |
|
| 1243 | + | for (c, v) in set { |
|
| 1244 | + | q = bind_json(q, v, c.ty.to_ascii_uppercase().contains("BLOB")); |
|
| 1245 | + | } |
|
| 1246 | + | q.bind(made.id).execute(&app.db).await?; |
|
| 1247 | + | } |
|
| 1248 | + | let old = row["id"].as_i64().unwrap_or(-1); |
|
| 1249 | + | for n in restore_records(app, &dest, &rec, old, made.id).await.with_context(|| format!("restore {owner}/{repo}'s records"))? { |
|
| 1250 | + | notes.push(format!("note: {owner}/{repo}: {n}")); |
|
| 942 | 1251 | } |
|
| 943 | 1252 | } |
|
| 1253 | + | let mut done = done; |
|
| 1254 | + | done.extend(notes); |
|
| 944 | 1255 | Ok(done) |
|
| 945 | 1256 | } |
|
| 946 | 1257 | ||
| … | 43 unchanged lines |
modified crates/hub/tests/backups.rs +140 −0
| … | 366 unchanged lines |
||
| 367 | 367 | assert_eq!(refused.status, "failed"); |
|
| 368 | 368 | assert!(refused.detail.contains("roles/storage.objectAdmin") && refused.detail.contains("backup:bob"), "{}", refused.detail); |
|
| 369 | 369 | } |
|
| 370 | + | ||
| 371 | + | /// docs/backups.md §7 (BP1): a restore brings back the records as well as |
|
| 372 | + | /// the stores: issues with their comments, labels, milestone and |
|
| 373 | + | /// assignees, proposals with their reviews and events, releases with their |
|
| 374 | + | /// asset files, and webhooks without their secrets, every id mapped and |
|
| 375 | + | /// every account found again by name; what this hub lacks is said. |
|
| 376 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 377 | + | async fn a_restore_brings_back_issues_proposals_and_releases() { |
|
| 378 | + | let store = tempfile::tempdir().unwrap(); |
|
| 379 | + | let h = hub(store.path()).await; |
|
| 380 | + | let andy = db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", false).await.unwrap(); |
|
| 381 | + | let bob = db::accounts::create_account(&h.app.db, "bob", "[email protected]", "x", "salt", false).await.unwrap(); |
|
| 382 | + | create(&h.app, "andy", "demo", hub_api::Visibility::Public).await; |
|
| 383 | + | let work = tempfile::tempdir().unwrap(); |
|
| 384 | + | push(&format!("{}/andy/demo", h.base), work.path(), &[("README.md", "hello\n")]); |
|
| 385 | + | let row = db::repos::get(&h.app.db, "andy", "demo").await.unwrap().unwrap(); |
|
| 386 | + | db::repos::set_diffusion(&h.app.db, row.id, "green").await.unwrap(); |
|
| 387 | + | // Ids on the first hub start further on than on the fresh one. |
|
| 388 | + | for n in 0..3 { |
|
| 389 | + | db::issues::upsert_label(&h.app.db, row.id, &format!("spare{n}"), "#000000", "").await.unwrap(); |
|
| 390 | + | } |
|
| 391 | + | let bug = db::issues::upsert_label(&h.app.db, row.id, "bug", "#d73a4a", "Something is wrong").await.unwrap(); |
|
| 392 | + | let m = db::issues::upsert_milestone(&h.app.db, row.id, "1.0", "the first", None).await.unwrap(); |
|
| 393 | + | let issue = db::issues::create( |
|
| 394 | + | &h.app.db, |
|
| 395 | + | db::issues::NewIssue { repo_id: row.id, title: "Sprites flicker", body: "on level two", author_id: Some(andy.id), author: "andy" }, |
|
| 396 | + | ) |
|
| 397 | + | .await |
|
| 398 | + | .unwrap(); |
|
| 399 | + | db::issues::add_comment(&h.app.db, issue.id, Some(bob.id), "bob", "seen it too").await.unwrap(); |
|
| 400 | + | db::issues::set_issue_label(&h.app.db, issue.id, bug.id, true).await.unwrap(); |
|
| 401 | + | db::issues::set_milestone(&h.app.db, issue.id, Some(m.id)).await.unwrap(); |
|
| 402 | + | db::issues::set_assignee(&h.app.db, issue.id, andy.id, true).await.unwrap(); |
|
| 403 | + | db::issues::set_assignee(&h.app.db, issue.id, bob.id, true).await.unwrap(); |
|
| 404 | + | let p = db::proposals::create( |
|
| 405 | + | &h.app.db, |
|
| 406 | + | db::proposals::NewProposal { |
|
| 407 | + | repo_id: row.id, |
|
| 408 | + | title: "Double-buffer the sprites", |
|
| 409 | + | body: "fixes the flicker", |
|
| 410 | + | author_id: Some(bob.id), |
|
| 411 | + | author: "bob", |
|
| 412 | + | draft: false, |
|
| 413 | + | target: "main", |
|
| 414 | + | source_kind: "bookmark", |
|
| 415 | + | source_repo_id: Some(row.id), |
|
| 416 | + | source_bookmark: "fix", |
|
| 417 | + | source_changes: "", |
|
| 418 | + | }, |
|
| 419 | + | ) |
|
| 420 | + | .await |
|
| 421 | + | .unwrap(); |
|
| 422 | + | db::proposals::add_event(&h.app.db, p.id, "bob", "opened", "").await.unwrap(); |
|
| 423 | + | db::proposals::add_review(&h.app.db, p.id, andy.id, "andy", "approve", "nice").await.unwrap(); |
|
| 424 | + | let release = db::releases::create(&h.app.db, row.id, "v1.0", "One", "notes", false, Some((andy.id, "andy"))).await.unwrap(); |
|
| 425 | + | let asset = b"a game image".to_vec(); |
|
| 426 | + | let hash = blake3::hash(&asset).to_hex().to_string(); |
|
| 427 | + | let path = h.app.config.data.join("assets").join(&hash[..2]).join(&hash); |
|
| 428 | + | std::fs::create_dir_all(path.parent().unwrap()).unwrap(); |
|
| 429 | + | std::fs::write(&path, &asset).unwrap(); |
|
| 430 | + | db::releases::add_asset(&h.app.db, release.id, "game.prg", asset.len() as i64, &hash, "application/octet-stream").await.unwrap(); |
|
| 431 | + | db::hooks::create_hook(&h.app.db, Some(row.id), None, "https://example.com/hook", "s3cret-value", "push").await.unwrap(); |
|
| 432 | + | ||
| 433 | + | let mut s = Setting::off("andy", "user"); |
|
| 434 | + | s.every = "hourly".into(); |
|
| 435 | + | s.target = store.path().display().to_string(); |
|
| 436 | + | backups::save(&h.app.db, &s).await.unwrap(); |
|
| 437 | + | let run = backups::run(&h.app, "andy", "now").await.unwrap(); |
|
| 438 | + | assert_eq!(run.status, "ok", "{}", run.detail); |
|
| 439 | + | let dest = Directory { root: store.path().join("andy") }; |
|
| 440 | + | let text = String::from_utf8_lossy(&dest.get(&format!("runs/{}/records/demo.json", run.at)).unwrap()).into_owned(); |
|
| 441 | + | assert!(!text.contains("s3cret-value"), "never a secret"); |
|
| 442 | + | assert!(text.contains("\"_names\"") && text.contains("\"bob\""), "accounts by name"); |
|
| 443 | + | ||
| 444 | + | // A fresh hub that knows andy but not bob. |
|
| 445 | + | let empty = tempfile::tempdir().unwrap(); |
|
| 446 | + | let fresh = hub(empty.path()).await; |
|
| 447 | + | let andy2 = db::accounts::create_account(&fresh.app.db, "andy", "[email protected]", "x", "salt", false).await.unwrap(); |
|
| 448 | + | let dest: Arc<dyn Destination> = Arc::new(Directory { root: store.path().join("andy") }); |
|
| 449 | + | let done = backups::restore_into_hub(&fresh.app, dest, None).await.unwrap(); |
|
| 450 | + | assert_eq!(done[0], "andy/demo (repos)"); |
|
| 451 | + | let notes = done[1..].join("\n"); |
|
| 452 | + | assert!(notes.contains("webhook https://example.com/hook: enter its secret again"), "{notes}"); |
|
| 453 | + | assert!(notes.contains("issue_assignees: 1 row(s) left out"), "bob is not on this hub: {notes}"); |
|
| 454 | + | let r2 = db::repos::get(&fresh.app.db, "andy", "demo").await.unwrap().unwrap(); |
|
| 455 | + | assert_eq!(r2.diffusion, "green", "settings come back"); |
|
| 456 | + | // The issue, its comment, label, milestone and the assignee this hub has. |
|
| 457 | + | let issues: Vec<(i64, i64, String, Option<i64>)> = sqlx::query_as("SELECT id, number, title, author_id FROM issues WHERE repo_id = ?") |
|
| 458 | + | .bind(r2.id) |
|
| 459 | + | .fetch_all(&fresh.app.db) |
|
| 460 | + | .await |
|
| 461 | + | .unwrap(); |
|
| 462 | + | assert_eq!(issues.len(), 1); |
|
| 463 | + | let (iid, number, title, author) = issues[0].clone(); |
|
| 464 | + | assert_eq!((number, title.as_str(), author), (issue.number, "Sprites flicker", Some(andy2.id))); |
|
| 465 | + | let comments = db::issues::comments(&fresh.app.db, iid).await.unwrap(); |
|
| 466 | + | assert_eq!(comments.len(), 1); |
|
| 467 | + | assert_eq!((comments[0].author.as_str(), comments[0].body.as_str()), ("bob", "seen it too")); |
|
| 468 | + | let labels: Vec<String> = |
|
| 469 | + | sqlx::query_scalar("SELECT l.name FROM issue_labels il JOIN labels l ON l.id = il.label_id WHERE il.issue_id = ?") |
|
| 470 | + | .bind(iid) |
|
| 471 | + | .fetch_all(&fresh.app.db) |
|
| 472 | + | .await |
|
| 473 | + | .unwrap(); |
|
| 474 | + | assert_eq!(labels, vec!["bug"], "the label, its id mapped"); |
|
| 475 | + | let milestone: Option<String> = |
|
| 476 | + | sqlx::query_scalar("SELECT m.title FROM issues i JOIN milestones m ON m.id = i.milestone_id WHERE i.id = ?") |
|
| 477 | + | .bind(iid) |
|
| 478 | + | .fetch_one(&fresh.app.db) |
|
| 479 | + | .await |
|
| 480 | + | .unwrap(); |
|
| 481 | + | assert_eq!(milestone.as_deref(), Some("1.0")); |
|
| 482 | + | assert_eq!(db::issues::assignees(&fresh.app.db, iid).await.unwrap(), vec!["andy"]); |
|
| 483 | + | // The proposal, its review and its timeline. |
|
| 484 | + | let p2 = db::proposals::get(&fresh.app.db, r2.id, p.number).await.unwrap().expect("the proposal"); |
|
| 485 | + | assert_eq!((p2.title.as_str(), p2.author.as_str(), p2.author_id), ("Double-buffer the sprites", "bob", None)); |
|
| 486 | + | let reviews: Vec<(String, String)> = sqlx::query_as("SELECT author, verdict FROM proposal_reviews WHERE proposal_id = ?") |
|
| 487 | + | .bind(p2.id) |
|
| 488 | + | .fetch_all(&fresh.app.db) |
|
| 489 | + | .await |
|
| 490 | + | .unwrap(); |
|
| 491 | + | assert_eq!(reviews, vec![("andy".to_string(), "approve".to_string())]); |
|
| 492 | + | assert!(db::proposals::events(&fresh.app.db, p2.id).await.unwrap().iter().any(|e| e.event == "opened")); |
|
| 493 | + | // The release and its asset's bytes. |
|
| 494 | + | let rel = db::releases::get(&fresh.app.db, r2.id, "v1.0").await.unwrap().expect("the release"); |
|
| 495 | + | let assets = db::releases::assets(&fresh.app.db, rel.id).await.unwrap(); |
|
| 496 | + | assert_eq!(assets.len(), 1); |
|
| 497 | + | assert_eq!(std::fs::read(fresh.app.config.data.join("assets").join(&hash[..2]).join(&hash)).unwrap(), asset); |
|
| 498 | + | // Search finds what came back. |
|
| 499 | + | let hits = db::notify::search(&fresh.app.db, "flicker", Some(r2.id), 10).await.unwrap(); |
|
| 500 | + | assert!(hits.iter().any(|h| h.kind == "issue" && h.target_id == iid), "{:?}", hits.iter().map(|h| &h.title).collect::<Vec<_>>()); |
|
| 501 | + | // New issues number on from where the old hub was. |
|
| 502 | + | let next = db::issues::create( |
|
| 503 | + | &fresh.app.db, |
|
| 504 | + | db::issues::NewIssue { repo_id: r2.id, title: "After", body: "", author_id: Some(andy2.id), author: "andy" }, |
|
| 505 | + | ) |
|
| 506 | + | .await |
|
| 507 | + | .unwrap(); |
|
| 508 | + | assert!(next.number > p.number, "{} after {}", next.number, p.number); |
|
| 509 | + | } |
modified docs/backups.md +24 −3
| … | 3 unchanged lines |
||
| 4 | 4 | settings, the hourly and daily schedule with "back up now" and TEST, |
|
| 5 | 5 | directory destinations and keyless GCS destinations, every repository's |
|
| 6 | 6 | and wiki's store byte for byte, the records as JSON, retention, failure |
|
| 7 | - | notices, and restore of the stores and repository rows |
|
| 8 | - | (`oxygenhub admin backup run|restore`). Next: restoring issues and |
|
| 9 | - | proposals from the records, Admin › Backups, per-repository settings. |
|
| 7 | + | notices, and restore (`oxygenhub admin backup run|restore`). Since |
|
| 8 | + | 2026-10-14 (oxygenhub#175) a restore brings back the records too (§7) and |
|
| 9 | + | release assets travel with the stores. Next: Admin › Backups, |
|
| 10 | + | per-repository settings, S3. |
|
| 10 | 11 | ||
| 11 | 12 | ## 1. Why |
|
| 12 | 13 | ||
| … | 139 unchanged lines |
||
| 152 | 153 | ||
| 153 | 154 | ## 7. Restore |
|
| 154 | 155 | ||
| 156 | + | **As built (oxygenhub#175).** `oxygenhub admin backup restore --from DIR |
|
| 157 | + | [--run AT]` into a hub that lacks the repositories: each store and wiki |
|
| 158 | + | byte for byte (every file checked against its hash), then the |
|
| 159 | + | repository's row with its settings as they were (visibility, default |
|
| 160 | + | bookmark, Flow and Diffusion settings and the rest), then its records. |
|
| 161 | + | Each run's records carry `_names`, the username, organisation or |
|
| 162 | + | `org/team` behind every account, team and organisation id their rows |
|
| 163 | + | point at, so the restore finds them again by name on any hub. Tables go |
|
| 164 | + | in parents first; every row gets a new id and every reference follows |
|
| 165 | + | it. A reference to an account this hub lacks is left blank where the |
|
| 166 | + | column allows it (the author's name stays in the row) and the row left |
|
| 167 | + | out where it does not (an assignment, a review request); the restore |
|
| 168 | + | says how many. Webhooks come back without their secrets, each named to be |
|
| 169 | + | entered again. Release assets, which live beside the stores rather than |
|
| 170 | + | in them, are sent once each as blobs and written back checked against |
|
| 171 | + | their hash. Search is rebuilt for what came back. Left out on purpose: |
|
| 172 | + | inboxes (notifications), webhook deliveries, runners' declines. |
|
| 173 | + | ||
| 174 | + | The design as first written: |
|
| 175 | + | ||
| 155 | 176 | `oxygenhub admin restore --from gs://BUCKET/PREFIX [--run TIME] [--repo NAME]` |
|
| 156 | 177 | (or a directory) on any hub: |
|
| 157 | 178 | ||
| … | 19 unchanged lines |