Backups API: settings, TEST, back up now and runs 2e9188ca4f98
docs/backups.md §5 said the API mirrors the Backups pages; only the
per-repository PUT existed. Now, with the scope repo:admin, for the
caller's own repositories and for an organisation it owns (any other is
not found):
- GET/PUT /api/v1/user/backups and /api/v1/orgs/{name}/backups: the
setting with where its runs go and the newest runs; PUT takes the
page's fields (keep_days a number) and checks them exactly as the page
does, through the same code;
- POST …/backups/test: write, read and remove a probe;
- POST …/backups/run: back up now, in the background (202);
- GET …/backups/runs.
Tested: refused before a setting exists, without repo:admin, and with a
bad provider; saved, probed and run to ok through the API; an
organisation's set by its owner and hidden from another account.
closes #179
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-14 09:04 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
2e9188ca4f984f24501725d93b43797faf1d7d1810ad150b737612d0eb2069a8- Depends on
- 101740454617 1e8a6e8bb91b 35fea4237cc9 38833ed78707 6dbd999ad794 7b9f4570daf5 b64e2537d702 d23464c0ac60 d3b048b27277 d7d182467382 df71c7892d27
- Depended on by
- f2d2776b2a14 1c52d940b4a0 bff9106edcba
- 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
- 24 atoms, 1 tree operation, 7 files
Browse the tree at this change
added crates/hub/src/api/backups.rs +183 −0
| 1 | + | //! The backups API (oxygenhub#179, docs/backups.md §5): what the Backups |
|
| 2 | + | //! pages do, for a user's own repositories and an organisation's (its |
|
| 3 | + | //! owners only), with the scope `repo:admin`. |
|
| 4 | + | ||
| 5 | + | use super::{caller, not_found, ApiError, Caller}; |
|
| 6 | + | use crate::auth; |
|
| 7 | + | use crate::backups::{self, Run, Setting}; |
|
| 8 | + | use crate::db::{self, accounts::Account}; |
|
| 9 | + | use crate::web::backups::{setting_of, SettingInput}; |
|
| 10 | + | use crate::Shared; |
|
| 11 | + | use axum::extract::{Path, State}; |
|
| 12 | + | use axum::http::{HeaderMap, StatusCode}; |
|
| 13 | + | use axum::Json; |
|
| 14 | + | use serde::{Deserialize, Serialize}; |
|
| 15 | + | ||
| 16 | + | /// A setting as the API takes it: the page's fields, `keep_days` a number. |
|
| 17 | + | #[derive(Deserialize)] |
|
| 18 | + | pub struct Body { |
|
| 19 | + | pub every: String, |
|
| 20 | + | #[serde(default)] |
|
| 21 | + | pub destination: String, |
|
| 22 | + | /// A directory destination: one the hub allows. |
|
| 23 | + | #[serde(default)] |
|
| 24 | + | pub dir: String, |
|
| 25 | + | /// A GCS destination: its bucket and workload identity provider. |
|
| 26 | + | #[serde(default)] |
|
| 27 | + | pub bucket: String, |
|
| 28 | + | #[serde(default)] |
|
| 29 | + | pub provider: String, |
|
| 30 | + | #[serde(default)] |
|
| 31 | + | pub prefix: String, |
|
| 32 | + | #[serde(default = "thirty")] |
|
| 33 | + | pub keep_days: i64, |
|
| 34 | + | /// An S3 destination: its bucket, region and the IAM role's ARN. |
|
| 35 | + | #[serde(default)] |
|
| 36 | + | pub s3_bucket: String, |
|
| 37 | + | #[serde(default)] |
|
| 38 | + | pub s3_region: String, |
|
| 39 | + | #[serde(default)] |
|
| 40 | + | pub s3_role: String, |
|
| 41 | + | } |
|
| 42 | + | ||
| 43 | + | fn thirty() -> i64 { |
|
| 44 | + | 30 |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | #[derive(Serialize)] |
|
| 48 | + | pub struct State_ { |
|
| 49 | + | pub setting: Option<Setting>, |
|
| 50 | + | /// Where its runs go, as the pages say it. |
|
| 51 | + | pub place: Option<String>, |
|
| 52 | + | pub runs: Vec<Run>, |
|
| 53 | + | } |
|
| 54 | + | ||
| 55 | + | /// The owner a call acts for: the caller's own account, or an organisation |
|
| 56 | + | /// it owns. Someone else's is not found. |
|
| 57 | + | async fn owner_of(app: &Shared, c: &Caller, org: Option<&str>) -> Result<(Account, String, &'static str), ApiError> { |
|
| 58 | + | let me = c.require("repo:admin")?.clone(); |
|
| 59 | + | match org { |
|
| 60 | + | None => { |
|
| 61 | + | let name = me.username.clone(); |
|
| 62 | + | Ok((me, name, "user")) |
|
| 63 | + | } |
|
| 64 | + | Some(name) => { |
|
| 65 | + | let o = db::accounts::org_by_name(&app.db, name).await?.ok_or_else(|| not_found("no such organisation"))?; |
|
| 66 | + | if !auth::roles::is_org_owner(&app.db, &me, o.id).await? { |
|
| 67 | + | return Err(not_found("no such organisation")); |
|
| 68 | + | } |
|
| 69 | + | Ok((me, o.name, "org")) |
|
| 70 | + | } |
|
| 71 | + | } |
|
| 72 | + | } |
|
| 73 | + | ||
| 74 | + | async fn state(app: &Shared, owner: &str) -> Result<State_, ApiError> { |
|
| 75 | + | let setting = backups::setting(&app.db, owner).await?; |
|
| 76 | + | let place = setting.as_ref().map(backups::place); |
|
| 77 | + | Ok(State_ { setting, place, runs: backups::runs(&app.db, owner, 10).await? }) |
|
| 78 | + | } |
|
| 79 | + | ||
| 80 | + | async fn get(app: Shared, headers: HeaderMap, org: Option<String>) -> Result<Json<State_>, ApiError> { |
|
| 81 | + | let c = caller(&app, &headers).await?; |
|
| 82 | + | let (_, owner, _) = owner_of(&app, &c, org.as_deref()).await?; |
|
| 83 | + | Ok(Json(state(&app, &owner).await?)) |
|
| 84 | + | } |
|
| 85 | + | ||
| 86 | + | async fn put(app: Shared, headers: HeaderMap, org: Option<String>, b: Body) -> Result<Json<State_>, ApiError> { |
|
| 87 | + | let c = caller(&app, &headers).await?; |
|
| 88 | + | let (me, owner, kind) = owner_of(&app, &c, org.as_deref()).await?; |
|
| 89 | + | let input = SettingInput { |
|
| 90 | + | every: b.every, |
|
| 91 | + | destination: b.destination, |
|
| 92 | + | dir: b.dir, |
|
| 93 | + | bucket: b.bucket, |
|
| 94 | + | provider: b.provider, |
|
| 95 | + | prefix: b.prefix, |
|
| 96 | + | keep_days: b.keep_days.to_string(), |
|
| 97 | + | s3_bucket: b.s3_bucket, |
|
| 98 | + | s3_region: b.s3_region, |
|
| 99 | + | s3_role: b.s3_role, |
|
| 100 | + | }; |
|
| 101 | + | let s = setting_of(&app, &owner, kind, &input).map_err(|e| ApiError(StatusCode::BAD_REQUEST, e))?; |
|
| 102 | + | backups::save(&app.db, &s).await?; |
|
| 103 | + | db::accounts::audit(&app.db, Some(&me), "backup.setting", &owner, &format!("{} to {}", s.every, backups::place(&s))).await.ok(); |
|
| 104 | + | Ok(Json(state(&app, &owner).await?)) |
|
| 105 | + | } |
|
| 106 | + | ||
| 107 | + | async fn test(app: Shared, headers: HeaderMap, org: Option<String>) -> Result<Json<serde_json::Value>, ApiError> { |
|
| 108 | + | let c = caller(&app, &headers).await?; |
|
| 109 | + | let (_, owner, _) = owner_of(&app, &c, org.as_deref()).await?; |
|
| 110 | + | let set = |
|
| 111 | + | backups::setting(&app.db, &owner).await?.ok_or_else(|| ApiError(StatusCode::CONFLICT, "save where backups go first".into()))?; |
|
| 112 | + | let dest = backups::destination(&app, &set).map_err(|e| ApiError(StatusCode::CONFLICT, format!("{e:#}")))?; |
|
| 113 | + | tokio::task::spawn_blocking(move || backups::probe(dest.as_ref())) |
|
| 114 | + | .await |
|
| 115 | + | .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? |
|
| 116 | + | .map_err(|e| ApiError(StatusCode::CONFLICT, format!("TEST failed: {e:#}")))?; |
|
| 117 | + | Ok(Json(serde_json::json!({ "ok": true, "place": backups::place(&set) }))) |
|
| 118 | + | } |
|
| 119 | + | ||
| 120 | + | async fn run(app: Shared, headers: HeaderMap, org: Option<String>) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> { |
|
| 121 | + | let c = caller(&app, &headers).await?; |
|
| 122 | + | let (_, owner, _) = owner_of(&app, &c, org.as_deref()).await?; |
|
| 123 | + | if backups::setting(&app.db, &owner).await?.is_none() { |
|
| 124 | + | return Err(ApiError(StatusCode::CONFLICT, "save where backups go first".into())); |
|
| 125 | + | } |
|
| 126 | + | let (app2, owner2) = (app.clone(), owner.clone()); |
|
| 127 | + | tokio::spawn(async move { |
|
| 128 | + | if let Err(e) = backups::run(&app2, &owner2, "now").await { |
|
| 129 | + | tracing::warn!("backup of {owner2}: {e:#}"); |
|
| 130 | + | } |
|
| 131 | + | }); |
|
| 132 | + | Ok((StatusCode::ACCEPTED, Json(serde_json::json!({ "owner": owner, "started": true })))) |
|
| 133 | + | } |
|
| 134 | + | ||
| 135 | + | async fn runs(app: Shared, headers: HeaderMap, org: Option<String>) -> Result<Json<Vec<Run>>, ApiError> { |
|
| 136 | + | let c = caller(&app, &headers).await?; |
|
| 137 | + | let (_, owner, _) = owner_of(&app, &c, org.as_deref()).await?; |
|
| 138 | + | Ok(Json(backups::runs(&app.db, &owner, 50).await?)) |
|
| 139 | + | } |
|
| 140 | + | ||
| 141 | + | pub async fn user_get(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<State_>, ApiError> { |
|
| 142 | + | get(app, headers, None).await |
|
| 143 | + | } |
|
| 144 | + | pub async fn user_put(State(app): State<Shared>, headers: HeaderMap, Json(b): Json<Body>) -> Result<Json<State_>, ApiError> { |
|
| 145 | + | put(app, headers, None, b).await |
|
| 146 | + | } |
|
| 147 | + | pub async fn user_test(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<serde_json::Value>, ApiError> { |
|
| 148 | + | test(app, headers, None).await |
|
| 149 | + | } |
|
| 150 | + | pub async fn user_run(State(app): State<Shared>, headers: HeaderMap) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> { |
|
| 151 | + | run(app, headers, None).await |
|
| 152 | + | } |
|
| 153 | + | pub async fn user_runs(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<Run>>, ApiError> { |
|
| 154 | + | runs(app, headers, None).await |
|
| 155 | + | } |
|
| 156 | + | pub async fn org_get(State(app): State<Shared>, headers: HeaderMap, Path(name): Path<String>) -> Result<Json<State_>, ApiError> { |
|
| 157 | + | get(app, headers, Some(name)).await |
|
| 158 | + | } |
|
| 159 | + | pub async fn org_put( |
|
| 160 | + | State(app): State<Shared>, |
|
| 161 | + | headers: HeaderMap, |
|
| 162 | + | Path(name): Path<String>, |
|
| 163 | + | Json(b): Json<Body>, |
|
| 164 | + | ) -> Result<Json<State_>, ApiError> { |
|
| 165 | + | put(app, headers, Some(name), b).await |
|
| 166 | + | } |
|
| 167 | + | pub async fn org_test( |
|
| 168 | + | State(app): State<Shared>, |
|
| 169 | + | headers: HeaderMap, |
|
| 170 | + | Path(name): Path<String>, |
|
| 171 | + | ) -> Result<Json<serde_json::Value>, ApiError> { |
|
| 172 | + | test(app, headers, Some(name)).await |
|
| 173 | + | } |
|
| 174 | + | pub async fn org_run( |
|
| 175 | + | State(app): State<Shared>, |
|
| 176 | + | headers: HeaderMap, |
|
| 177 | + | Path(name): Path<String>, |
|
| 178 | + | ) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> { |
|
| 179 | + | run(app, headers, Some(name)).await |
|
| 180 | + | } |
|
| 181 | + | pub async fn org_runs(State(app): State<Shared>, headers: HeaderMap, Path(name): Path<String>) -> Result<Json<Vec<Run>>, ApiError> { |
|
| 182 | + | runs(app, headers, Some(name)).await |
|
| 183 | + | } |
modified crates/hub/src/api/mod.rs +9 −0
| … | 14 unchanged lines |
||
| 15 | 15 | use hub_api::*; |
|
| 16 | 16 | use serde::Deserialize; |
|
| 17 | 17 | ||
| 18 | + | mod backups; |
|
| 18 | 19 | pub mod device; |
|
| 19 | 20 | mod images; |
|
| 20 | 21 | mod projects; |
|
| … | 66 unchanged lines |
||
| 87 | 88 | .route("/repos/{owner}/{name}/consumers", get(consumers)) |
|
| 88 | 89 | .route("/repos/{owner}/{name}/diffusion", get(diffusion).put(set_diffusion)) |
|
| 89 | 90 | .route("/repos/{owner}/{name}/backup", axum::routing::put(set_backup)) |
|
| 91 | + | .route("/user/backups", get(backups::user_get).put(backups::user_put)) |
|
| 92 | + | .route("/user/backups/test", post(backups::user_test)) |
|
| 93 | + | .route("/user/backups/run", post(backups::user_run)) |
|
| 94 | + | .route("/user/backups/runs", get(backups::user_runs)) |
|
| 95 | + | .route("/orgs/{name}/backups", get(backups::org_get).put(backups::org_put)) |
|
| 96 | + | .route("/orgs/{name}/backups/test", post(backups::org_test)) |
|
| 97 | + | .route("/orgs/{name}/backups/run", post(backups::org_run)) |
|
| 98 | + | .route("/orgs/{name}/backups/runs", get(backups::org_runs)) |
|
| 90 | 99 | .route("/repos/{owner}/{name}/history/{r}", get(history)) |
|
| 91 | 100 | .route("/repos/{owner}/{name}/changes/{hash}", get(change)) |
|
| 92 | 101 | .route("/repos/{owner}/{name}/tree/{r}", get(tree_root)) |
|
| … | 2456 unchanged lines |
modified crates/hub/src/backups.rs +12 −8
| … | 157 unchanged lines |
||
| 158 | 158 | impl Overview { |
|
| 159 | 159 | /// Where its runs go, as a person reads it. |
|
| 160 | 160 | pub fn place(&self) -> String { |
|
| 161 | - | let s = &self.setting; |
|
| 162 | - | match s.destination.as_str() { |
|
| 163 | - | "gcs" if s.prefix.is_empty() => format!("gs://{}", s.target), |
|
| 164 | - | "gcs" => format!("gs://{}/{}", s.target, s.prefix), |
|
| 165 | - | "s3" if s.prefix.is_empty() => format!("s3://{} ({})", s.target, s.region), |
|
| 166 | - | "s3" => format!("s3://{}/{} ({})", s.target, s.prefix, s.region), |
|
| 167 | - | _ => format!("{}/{}", s.target, s.owner), |
|
| 168 | - | } |
|
| 161 | + | place(&self.setting) |
|
| 162 | + | } |
|
| 163 | + | } |
|
| 164 | + | ||
| 165 | + | /// Where a setting's runs go, as a person reads it. |
|
| 166 | + | pub fn place(s: &Setting) -> String { |
|
| 167 | + | match s.destination.as_str() { |
|
| 168 | + | "gcs" if s.prefix.is_empty() => format!("gs://{}", s.target), |
|
| 169 | + | "gcs" => format!("gs://{}/{}", s.target, s.prefix), |
|
| 170 | + | "s3" if s.prefix.is_empty() => format!("s3://{} ({})", s.target, s.region), |
|
| 171 | + | "s3" => format!("s3://{}/{} ({})", s.target, s.prefix, s.region), |
|
| 172 | + | _ => format!("{}/{}", s.target, s.owner), |
|
| 169 | 173 | } |
|
| 170 | 174 | } |
|
| 171 | 175 | ||
| … | 1581 unchanged lines |
modified crates/hub/src/web/backups.rs +21 −13
| … | 58 unchanged lines |
||
| 59 | 59 | pub struct BackupForm { |
|
| 60 | 60 | #[serde(rename = "_csrf")] |
|
| 61 | 61 | csrf_token: String, |
|
| 62 | - | every: String, |
|
| 62 | + | #[serde(flatten)] |
|
| 63 | + | input: SettingInput, |
|
| 64 | + | } |
|
| 65 | + | ||
| 66 | + | /// What a backup setting is made from, by the page's form or the API |
|
| 67 | + | /// (oxygenhub#179): checked the same way either way. |
|
| 68 | + | #[derive(Clone, Debug, Default, Deserialize)] |
|
| 69 | + | pub(crate) struct SettingInput { |
|
| 70 | + | pub every: String, |
|
| 63 | 71 | #[serde(default)] |
|
| 64 | - | destination: String, |
|
| 72 | + | pub destination: String, |
|
| 65 | 73 | #[serde(default)] |
|
| 66 | - | dir: String, |
|
| 74 | + | pub dir: String, |
|
| 67 | 75 | #[serde(default)] |
|
| 68 | - | bucket: String, |
|
| 76 | + | pub bucket: String, |
|
| 69 | 77 | #[serde(default)] |
|
| 70 | - | provider: String, |
|
| 78 | + | pub provider: String, |
|
| 71 | 79 | #[serde(default)] |
|
| 72 | - | prefix: String, |
|
| 80 | + | pub prefix: String, |
|
| 73 | 81 | #[serde(default)] |
|
| 74 | - | keep_days: String, |
|
| 82 | + | pub keep_days: String, |
|
| 75 | 83 | /// An S3 destination's bucket, region and the IAM role the hub assumes. |
|
| 76 | 84 | #[serde(default)] |
|
| 77 | - | s3_bucket: String, |
|
| 85 | + | pub s3_bucket: String, |
|
| 78 | 86 | #[serde(default)] |
|
| 79 | - | s3_region: String, |
|
| 87 | + | pub s3_region: String, |
|
| 80 | 88 | #[serde(default)] |
|
| 81 | - | s3_role: String, |
|
| 89 | + | pub s3_role: String, |
|
| 82 | 90 | } |
|
| 83 | 91 | ||
| 84 | 92 | /// The form as a setting, or why it cannot be one. |
|
| 85 | - | fn setting_of(app: &Shared, owner: &str, kind: &str, f: &BackupForm) -> Result<Setting, String> { |
|
| 93 | + | pub(crate) fn setting_of(app: &Shared, owner: &str, kind: &str, f: &SettingInput) -> Result<Setting, String> { |
|
| 86 | 94 | if !backups::EVERY.iter().any(|(v, _)| *v == f.every) { |
|
| 87 | 95 | return Err("Pick how often: off, hourly or daily.".into()); |
|
| 88 | 96 | } |
|
| … | 59 unchanged lines |
||
| 148 | 156 | } |
|
| 149 | 157 | ||
| 150 | 158 | async fn save(app: &Shared, shell: Shell, kind: &'static str, owner: &str, f: &BackupForm) -> Result<Page<BackupsPage>, Error> { |
|
| 151 | - | match setting_of(app, owner, kind, f) { |
|
| 159 | + | match setting_of(app, owner, kind, &f.input) { |
|
| 152 | 160 | Ok(s) => { |
|
| 153 | 161 | backups::save(&app.db, &s).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 154 | 162 | page(app, shell, kind, owner, Some("Saved.".into()), None).await |
|
| … | 29 unchanged lines |
||
| 184 | 192 | }; |
|
| 185 | 193 | match outcome { |
|
| 186 | 194 | Ok(()) => { |
|
| 187 | - | let place = if set.destination == "gcs" { format!("gs://{}", set.target) } else { format!("{}/{owner}", set.target) }; |
|
| 195 | + | let place = backups::place(&set); |
|
| 188 | 196 | page(app, shell, kind, owner, Some(format!("TEST passed: wrote, read and removed a probe in {place}.")), None).await |
|
| 189 | 197 | } |
|
| 190 | 198 | Err(e) => page(app, shell, kind, owner, None, Some(format!("TEST failed: {e:#}"))).await, |
|
| … | 97 unchanged lines |
modified crates/hub/src/web/mod.rs +1 −1
| … | 5 unchanged lines |
||
| 6 | 6 | mod admin; |
|
| 7 | 7 | pub(crate) mod assistants; |
|
| 8 | 8 | mod avatar; |
|
| 9 | - | mod backups; |
|
| 9 | + | pub(crate) mod backups; |
|
| 10 | 10 | pub mod chain; |
|
| 11 | 11 | pub mod coverage; |
|
| 12 | 12 | mod device; |
|
| … | 754 unchanged lines |
modified crates/hub/tests/backups.rs +110 −0
| … | 763 unchanged lines |
||
| 764 | 764 | assert_eq!(refused.status, "failed"); |
|
| 765 | 765 | assert!(refused.detail.contains("s3:PutObject") && refused.detail.contains(ROLE), "{}", refused.detail); |
|
| 766 | 766 | } |
|
| 767 | + | ||
| 768 | + | /// One API call with a bearer token: the status and the JSON answer. |
|
| 769 | + | async fn api( |
|
| 770 | + | app: &Shared, |
|
| 771 | + | method: &str, |
|
| 772 | + | path: &str, |
|
| 773 | + | token: &str, |
|
| 774 | + | body: Option<serde_json::Value>, |
|
| 775 | + | ) -> (axum::http::StatusCode, serde_json::Value) { |
|
| 776 | + | use axum::body::Body; |
|
| 777 | + | use http_body_util::BodyExt; |
|
| 778 | + | use tower::ServiceExt; |
|
| 779 | + | let mut req = |
|
| 780 | + | axum::http::Request::builder().method(method).uri(format!("/api/v1{path}")).header("authorization", format!("Bearer {token}")); |
|
| 781 | + | let body = match body { |
|
| 782 | + | Some(b) => { |
|
| 783 | + | req = req.header("content-type", "application/json"); |
|
| 784 | + | Body::from(b.to_string()) |
|
| 785 | + | } |
|
| 786 | + | None => Body::empty(), |
|
| 787 | + | }; |
|
| 788 | + | let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap(); |
|
| 789 | + | let st = res.status(); |
|
| 790 | + | let bytes = res.into_body().collect().await.unwrap().to_bytes(); |
|
| 791 | + | (st, serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)) |
|
| 792 | + | } |
|
| 793 | + | ||
| 794 | + | /// oxygenhub#179: the API does what the Backups pages do, for the caller's |
|
| 795 | + | /// own repositories and an organisation it owns, with repo:admin: the |
|
| 796 | + | /// setting (checked as the page checks it), TEST, back up now, the runs. |
|
| 797 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 798 | + | async fn backups_are_set_up_and_run_through_the_api() { |
|
| 799 | + | let store = tempfile::tempdir().unwrap(); |
|
| 800 | + | let h = hub(store.path()).await; |
|
| 801 | + | let andy = db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", false).await.unwrap(); |
|
| 802 | + | let bob = db::accounts::create_account(&h.app.db, "bob", "[email protected]", "x", "salt", false).await.unwrap(); |
|
| 803 | + | db::accounts::create_org(&h.app.db, "club", "Club", andy.id).await.unwrap(); |
|
| 804 | + | create(&h.app, "andy", "demo", hub_api::Visibility::Public).await; |
|
| 805 | + | create(&h.app, "club", "game", hub_api::Visibility::Private).await; |
|
| 806 | + | let token = |id: i64, scopes: &'static str| { |
|
| 807 | + | let db = h.app.db.clone(); |
|
| 808 | + | async move { |
|
| 809 | + | let (t, hash) = oxygenhub::auth::new_token(); |
|
| 810 | + | db::accounts::create_token(&db, id, "t", &hash, scopes, "", None).await.unwrap(); |
|
| 811 | + | t |
|
| 812 | + | } |
|
| 813 | + | }; |
|
| 814 | + | let (andy_t, bob_t, weak) = (token(andy.id, "repo:admin").await, token(bob.id, "repo:admin").await, token(andy.id, "repo:read").await); |
|
| 815 | + | let dir = store.path().display().to_string(); |
|
| 816 | + | ||
| 817 | + | let (st, empty) = api(&h.app, "GET", "/user/backups", &andy_t, None).await; |
|
| 818 | + | assert_eq!(st, 200); |
|
| 819 | + | assert!(empty["setting"].is_null() && empty["runs"].as_array().unwrap().is_empty(), "{empty}"); |
|
| 820 | + | let (st, _) = api(&h.app, "POST", "/user/backups/run", &andy_t, None).await; |
|
| 821 | + | assert_eq!(st, 409, "nothing saved yet"); |
|
| 822 | + | let (st, _) = |
|
| 823 | + | api(&h.app, "PUT", "/user/backups", &weak, Some(serde_json::json!({ "every": "hourly", "destination": "dir", "dir": dir }))).await; |
|
| 824 | + | assert_eq!(st, 403, "repo:admin only"); |
|
| 825 | + | let (st, bad) = api( |
|
| 826 | + | &h.app, |
|
| 827 | + | "PUT", |
|
| 828 | + | "/user/backups", |
|
| 829 | + | &andy_t, |
|
| 830 | + | Some(serde_json::json!({ "every": "hourly", "destination": "gcs", "bucket": "andy-backup", "provider": "nope" })), |
|
| 831 | + | ) |
|
| 832 | + | .await; |
|
| 833 | + | assert_eq!(st, 400, "checked as the page checks it: {bad}"); |
|
| 834 | + | let (st, saved) = api( |
|
| 835 | + | &h.app, |
|
| 836 | + | "PUT", |
|
| 837 | + | "/user/backups", |
|
| 838 | + | &andy_t, |
|
| 839 | + | Some(serde_json::json!({ "every": "hourly", "destination": "dir", "dir": dir, "keep_days": 30 })), |
|
| 840 | + | ) |
|
| 841 | + | .await; |
|
| 842 | + | assert_eq!(st, 200, "{saved}"); |
|
| 843 | + | assert_eq!((saved["setting"]["every"].as_str(), saved["place"].as_str()), (Some("hourly"), Some(format!("{dir}/andy").as_str()))); |
|
| 844 | + | let (st, probe) = api(&h.app, "POST", "/user/backups/test", &andy_t, None).await; |
|
| 845 | + | assert_eq!((st.as_u16(), probe["ok"].as_bool()), (200, Some(true)), "{probe}"); |
|
| 846 | + | let (st, started) = api(&h.app, "POST", "/user/backups/run", &andy_t, None).await; |
|
| 847 | + | assert_eq!(st, 202, "{started}"); |
|
| 848 | + | let mut done = serde_json::Value::Null; |
|
| 849 | + | for _ in 0..200 { |
|
| 850 | + | let (_, runs) = api(&h.app, "GET", "/user/backups/runs", &andy_t, None).await; |
|
| 851 | + | if runs[0]["status"].as_str().is_some_and(|s| s != "running") { |
|
| 852 | + | done = runs[0].clone(); |
|
| 853 | + | break; |
|
| 854 | + | } |
|
| 855 | + | tokio::time::sleep(std::time::Duration::from_millis(50)).await; |
|
| 856 | + | } |
|
| 857 | + | assert_eq!((done["status"].as_str(), done["repos"].as_i64(), done["trigger"].as_str()), (Some("ok"), Some(1), Some("now")), "{done}"); |
|
| 858 | + | ||
| 859 | + | // An organisation's, by its owner; another account sees no such thing. |
|
| 860 | + | let (st, _) = |
|
| 861 | + | api(&h.app, "PUT", "/orgs/club/backups", &bob_t, Some(serde_json::json!({ "every": "hourly", "destination": "dir", "dir": dir }))) |
|
| 862 | + | .await; |
|
| 863 | + | assert_eq!(st, 404); |
|
| 864 | + | let (st, org) = api( |
|
| 865 | + | &h.app, |
|
| 866 | + | "PUT", |
|
| 867 | + | "/orgs/club/backups", |
|
| 868 | + | &andy_t, |
|
| 869 | + | Some(serde_json::json!({ "every": "daily", "destination": "dir", "dir": dir, "keep_days": 90 })), |
|
| 870 | + | ) |
|
| 871 | + | .await; |
|
| 872 | + | assert_eq!(st, 200, "{org}"); |
|
| 873 | + | assert_eq!((org["setting"]["kind"].as_str(), org["setting"]["keep_days"].as_i64()), (Some("org"), Some(90))); |
|
| 874 | + | let (st, _) = api(&h.app, "GET", "/orgs/club/backups", &bob_t, None).await; |
|
| 875 | + | assert_eq!(st, 404); |
|
| 876 | + | } |
modified docs/backups.md +6 −2
| … | 125 unchanged lines |
||
| 126 | 126 | run stays whole and restores everything. A run asked for by hand takes |
|
| 127 | 127 | every repository not left out. |
|
| 128 | 128 | ||
| 129 | - | The API mirrors the pages: `GET/PUT /api/v1/user/backups`, |
|
| 130 | - | `GET/PUT /api/v1/orgs/{name}/backups`, the run endpoint above, and |
|
| 129 | + | The API mirrors the pages (built 2026-10-14, oxygenhub#179; scope |
|
| 130 | + | `repo:admin`, an organisation's owners only): `GET/PUT /api/v1/user/backups` |
|
| 131 | + | and `GET/PUT /api/v1/orgs/{name}/backups` (the setting, checked as the page |
|
| 132 | + | checks it, with where its runs go and the newest runs; the body takes the |
|
| 133 | + | page's fields, `keep_days` as a number), `POST …/backups/test` (the probe), |
|
| 134 | + | `POST …/backups/run` (202: back up now, in the background) and |
|
| 131 | 135 | `GET …/backups/runs`. `oh backup status|run|restore` follows. |
|
| 132 | 136 | ||
| 133 | 137 | ## 6. Setting up a GCS destination (for Andy, by hand) |
|
| … | 125 unchanged lines |